mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-06 04:24:14 +00:00
Add trial system
This commit is contained in:
@@ -21,6 +21,7 @@ import { Layout } from "@app/components/Layout";
|
||||
import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect";
|
||||
import SubscriptionViolation from "@app/components/SubscriptionViolation";
|
||||
|
||||
|
||||
export default async function OrgLayout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -110,6 +111,7 @@ export default async function OrgLayout(props: {
|
||||
<ApplyInternalRedirect orgId={orgId} />
|
||||
{props.children}
|
||||
{build === "saas" && <SubscriptionViolation />}
|
||||
|
||||
<SetLastOrgCookie orgId={orgId} />
|
||||
</SubscriptionStatusProvider>
|
||||
);
|
||||
|
||||
@@ -219,6 +219,7 @@ export default function BillingPage() {
|
||||
);
|
||||
|
||||
const [hasSubscription, setHasSubscription] = useState(false);
|
||||
const [isTrial, setIsTrial] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentTier, setCurrentTier] = useState<Tier | null>(null);
|
||||
|
||||
@@ -263,6 +264,7 @@ export default function BillingPage() {
|
||||
setHasSubscription(
|
||||
tierSub.subscription.status === "active"
|
||||
);
|
||||
setIsTrial(tierSub.subscription.expiresAt != null);
|
||||
}
|
||||
|
||||
// Find license subscription
|
||||
@@ -558,7 +560,7 @@ export default function BillingPage() {
|
||||
// Get button label and action for each plan
|
||||
const getPlanAction = (plan: PlanOption) => {
|
||||
if (plan.id === "enterprise") {
|
||||
if (plan.id === currentPlanId) {
|
||||
if (plan.id === currentPlanId && !isTrial) {
|
||||
return {
|
||||
label: "Manage Current Plan",
|
||||
action: handleModifySubscription,
|
||||
@@ -597,6 +599,19 @@ export default function BillingPage() {
|
||||
disabled: false
|
||||
};
|
||||
}
|
||||
// If this is a trial subscription, show an upgrade button that starts a real checkout
|
||||
if (isTrial) {
|
||||
return {
|
||||
label: "Upgrade",
|
||||
action: () => {
|
||||
if (plan.tierType) {
|
||||
handleStartSubscription(plan.tierType);
|
||||
}
|
||||
},
|
||||
variant: "default" as const,
|
||||
disabled: isProblematicState
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Manage Current Plan",
|
||||
action: handleModifySubscription,
|
||||
@@ -610,7 +625,8 @@ export default function BillingPage() {
|
||||
);
|
||||
const planIndex = planOptions.findIndex((p) => p.id === plan.id);
|
||||
|
||||
if (planIndex < currentIndex) {
|
||||
// During a trial, never show a downgrade option — all non-current plans are upgrades
|
||||
if (!isTrial && planIndex < currentIndex) {
|
||||
return {
|
||||
label: "Downgrade",
|
||||
action: () => {
|
||||
@@ -642,18 +658,23 @@ export default function BillingPage() {
|
||||
label: "Upgrade",
|
||||
action: () => {
|
||||
if (plan.tierType) {
|
||||
showTierConfirmation(
|
||||
plan.tierType,
|
||||
"upgrade",
|
||||
plan.name,
|
||||
plan.price + (" " + plan.priceDetail || "")
|
||||
);
|
||||
// During a trial, go straight to checkout instead of the tier-change flow
|
||||
if (isTrial) {
|
||||
handleStartSubscription(plan.tierType);
|
||||
} else {
|
||||
showTierConfirmation(
|
||||
plan.tierType,
|
||||
"upgrade",
|
||||
plan.name,
|
||||
plan.price + (" " + plan.priceDetail || "")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
handleModifySubscription();
|
||||
}
|
||||
},
|
||||
variant: "outline" as const,
|
||||
disabled: isProblematicState
|
||||
disabled: isProblematicState || (isTrial && plan.id == "basic")
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { approvalQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
@@ -31,6 +32,10 @@ const ProductUpdates = dynamic(() => import("./ProductUpdates"), {
|
||||
ssr: false
|
||||
});
|
||||
|
||||
const ShowTrialCard = dynamic(() => import("./ShowTrialCard"), {
|
||||
ssr: false
|
||||
});
|
||||
|
||||
interface LayoutSidebarProps {
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
@@ -55,6 +60,7 @@ export function LayoutSidebar({
|
||||
const { user } = useUserContext();
|
||||
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
|
||||
const { env } = useEnvContext();
|
||||
const subscriptionContext = useSubscriptionStatusContext();
|
||||
const t = useTranslations();
|
||||
|
||||
// Fetch pending approval count if we have an orgId and it's not an admin page
|
||||
@@ -122,6 +128,11 @@ export function LayoutSidebar({
|
||||
const canShowProductUpdates =
|
||||
user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin);
|
||||
|
||||
const showTrial =
|
||||
build === "saas" &&
|
||||
Boolean(orgId) &&
|
||||
subscriptionContext?.isTrial
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -227,6 +238,12 @@ export function LayoutSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTrial && (
|
||||
<div className="px-4">
|
||||
<ShowTrialCard isCollapsed={isSidebarCollapsed} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{build === "enterprise" && (
|
||||
<div className="px-4">
|
||||
<SidebarLicenseButton
|
||||
@@ -246,6 +263,7 @@ export function LayoutSidebar({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="px-4 space-y-2 pb-4">
|
||||
{loadFooterLinks() ? (
|
||||
|
||||
@@ -10,7 +10,8 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
|
||||
// const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
|
||||
const TIER_ORDER: Tier[] = ["tier2", "tier3", "enterprise"];
|
||||
|
||||
const TIER_TRANSLATION_KEYS: Record<
|
||||
Tier,
|
||||
|
||||
98
src/components/ShowTrialCard.tsx
Normal file
98
src/components/ShowTrialCard.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ClockIcon, ArrowRight } from "lucide-react";
|
||||
import { ProgressBackwards } from "@app/components/ui/progress-backwards";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const TRIAL_DURATION_DAYS = 14;
|
||||
|
||||
export default function ShowTrialCard({
|
||||
isCollapsed
|
||||
}: {
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const context = useSubscriptionStatusContext();
|
||||
const params = useParams();
|
||||
const orgId = params?.orgId as string | undefined;
|
||||
const t = useTranslations();
|
||||
|
||||
const trialExpiresAt = context?.trialExpiresAt ?? null;
|
||||
|
||||
if (trialExpiresAt == null) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const remainingMs = trialExpiresAt - now;
|
||||
const remainingDays = Math.max(0, Math.ceil(remainingMs / (1000 * 60 * 60 * 24)));
|
||||
const totalMs = TRIAL_DURATION_DAYS * 24 * 60 * 60 * 1000;
|
||||
const progressPct = Math.min(100, Math.max(0, ((now - (trialExpiresAt - totalMs)) / totalMs) * 100));
|
||||
// Inverted: full bar at start, drains to empty as trial ends
|
||||
const displayPct = 100 - progressPct;
|
||||
|
||||
const billingHref = orgId ? `/${orgId}/settings/billing` : "/";
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={billingHref}
|
||||
className="flex items-center justify-center rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<ClockIcon className="h-4 w-4 flex-none" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>
|
||||
{remainingDays === 0
|
||||
? t("trialExpired")
|
||||
: t("trialDaysLeftShort", { days: remainingDays })}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={billingHref}
|
||||
className={cn(
|
||||
"group cursor-pointer block",
|
||||
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm",
|
||||
"transition duration-200 ease-in-out hover:bg-secondary/80 dark:hover:bg-secondary/60"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="flex-none size-4 text-muted-foreground" />
|
||||
<p className="font-medium flex-1 leading-tight">
|
||||
{remainingDays === 0
|
||||
? t("trialExpired")
|
||||
: t("trialActive")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<ProgressBackwards value={displayPct} className="h-1.5" />
|
||||
<small className="text-muted-foreground">
|
||||
{remainingDays === 0
|
||||
? t("trialHasEnded")
|
||||
: t("trialDaysRemaining", { count: remainingDays })}
|
||||
</small>
|
||||
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
<span>{t("trialGoToBilling")}</span>
|
||||
<ArrowRight className="flex-none size-3" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
58
src/components/ui/progress-backwards.tsx
Normal file
58
src/components/ui/progress-backwards.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
const progressVariants = cva(
|
||||
"border relative h-2 w-full overflow-hidden rounded-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
success: "bg-muted",
|
||||
warning: "bg-muted",
|
||||
danger: "bg-muted"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const indicatorVariants = cva("h-full w-full flex-1 transition-all", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary",
|
||||
success: "bg-green-500",
|
||||
warning: "bg-yellow-500",
|
||||
danger: "bg-red-500"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default"
|
||||
}
|
||||
});
|
||||
|
||||
type ProgressProps = React.ComponentProps<typeof ProgressPrimitive.Root> &
|
||||
VariantProps<typeof progressVariants>;
|
||||
|
||||
function ProgressBackwards({ className, value, variant, ...props }: ProgressProps) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(progressVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn(indicatorVariants({ variant }))}
|
||||
style={{ transform: `translateX(${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { ProgressBackwards };
|
||||
@@ -10,6 +10,10 @@ type SubscriptionStatusContextType = {
|
||||
subscribed: boolean;
|
||||
/** True when org has exceeded plan limits (sites, users, etc.). Only set when build === saas. */
|
||||
limitsExceeded: boolean;
|
||||
/** Unix timestamp (ms) when the trial expires, or null if not in trial. */
|
||||
trialExpiresAt: number | null;
|
||||
/** True if the organization is currently in a trial period. */
|
||||
isTrial: boolean;
|
||||
};
|
||||
|
||||
const SubscriptionStatusContext = createContext<
|
||||
|
||||
@@ -71,6 +71,19 @@ export function SubscriptionStatusProvider({
|
||||
|
||||
const limitsExceeded = subscriptionStatusState?.limitsExceeded ?? false;
|
||||
|
||||
const trialExpiresAt = (() => {
|
||||
if (subscriptionStatusState?.subscriptions) {
|
||||
for (const { subscription } of subscriptionStatusState.subscriptions) {
|
||||
if (subscription.expiresAt != null) {
|
||||
return subscription.expiresAt * 1000; // convert seconds to ms
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const isTrial = subscriptionStatusState?.subscriptions?.some(({ subscription }) => subscription.trial) ?? false;
|
||||
|
||||
return (
|
||||
<SubscriptionStatusContext.Provider
|
||||
value={{
|
||||
@@ -79,7 +92,9 @@ export function SubscriptionStatusProvider({
|
||||
getTier,
|
||||
isSubscribed,
|
||||
subscribed,
|
||||
limitsExceeded
|
||||
limitsExceeded,
|
||||
trialExpiresAt,
|
||||
isTrial: isTrial
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user