Add trial system

This commit is contained in:
Owen
2026-04-18 13:40:50 -07:00
parent b2d5a1ffdf
commit 55989c2019
18 changed files with 631 additions and 20 deletions

View File

@@ -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() ? (

View File

@@ -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,

View 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>
);
}

View 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 };