diff --git a/messages/en-US.json b/messages/en-US.json index 5bb1af511..ee895be9f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -23,6 +23,14 @@ "componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.", "dismiss": "Dismiss", "subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.", + "trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.", + "trialBannerExpired": "Your trial has expired. Upgrade now to restore access.", + "trialActive": "Free Trial Active", + "trialExpired": "Trial Expired", + "trialHasEnded": "Your trial has ended.", + "trialDaysRemaining": "{count, plural, one {# day remaining} other {# days remaining}}", + "trialDaysLeftShort": "{days}d left in trial", + "trialGoToBilling": "Go to billing page", "subscriptionViolationViewBilling": "View billing", "componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.", "componentsSupporterMessage": "Thank you for supporting Pangolin as a {tier}!", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index fd9c02e93..89ccd7e37 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -123,6 +123,7 @@ export enum ActionsEnum { deleteOrgDomain = "deleteOrgDomain", restartOrgDomain = "restartOrgDomain", sendUsageNotification = "sendUsageNotification", + sendTrialNotification = "sendTrialNotification", createRemoteExitNode = "createRemoteExitNode", updateRemoteExitNode = "updateRemoteExitNode", getRemoteExitNode = "getRemoteExitNode", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 9007013b1..598127af8 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -90,6 +90,8 @@ export const subscriptions = pgTable("subscriptions", { updatedAt: bigint("updatedAt", { mode: "number" }), version: integer("version"), billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), + expiresAt: bigint("expiresAt", { mode: "number" }), + trial: boolean("trial").default(false), type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license }); diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 318a094dd..e6c904485 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -84,6 +84,8 @@ export const subscriptions = sqliteTable("subscriptions", { createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt"), version: integer("version"), + expiresAt: integer("expiresAt"), + trial: integer("trial", { mode: "boolean" }).default(false), billingCycleAnchor: integer("billingCycleAnchor"), type: text("type") // tier1, tier2, tier3, or license }); diff --git a/server/emails/templates/NotifyTrialExpiring.tsx b/server/emails/templates/NotifyTrialExpiring.tsx new file mode 100644 index 000000000..5e8f0e6a8 --- /dev/null +++ b/server/emails/templates/NotifyTrialExpiring.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailLetterHead, + EmailSignature, + EmailText +} from "./components/Email"; + +interface Props { + email: string; + orgName: string; + trialEndsAt: string; + daysRemaining: number | null; + billingLink: string; +} + +export const NotifyTrialExpiring = ({ + email, + orgName, + trialEndsAt, + daysRemaining, + billingLink +}: Props) => { + const hasEnded = daysRemaining === null || daysRemaining === 0; + const isLastDay = daysRemaining === 1; + + const previewText = hasEnded + ? `Your trial for ${orgName} has ended.` + : isLastDay + ? `Your trial for ${orgName} ends tomorrow.` + : `Your trial for ${orgName} ends in ${daysRemaining} days.`; + + const heading = hasEnded + ? "Your Trial Has Ended" + : "Your Trial Is Ending Soon"; + + return ( + + + {previewText} + + + + + + {heading} + + Hi there, + + {hasEnded ? ( + <> + + Your free trial for{" "} + {orgName} ended on{" "} + {trialEndsAt}. Your account + has been moved to the free plan, which + includes limited functionality. + + + + Some features and resources may now be + restricted or disconnected. To restore full + access and continue using all the features + you had during your trial, please upgrade to + a paid plan. + + + + You can{" "} + + upgrade your plan here + {" "} + to get back up and running right away. + + + ) : ( + <> + + Just a reminder that your free trial for{" "} + {orgName} will end on{" "} + {trialEndsAt} + {isLastDay + ? " — that's tomorrow!" + : `, in ${daysRemaining} days`} + . + + + + After your trial ends, your account will be + moved to the free plan and some + functionality may be restricted or your + sites may disconnect. + + + + To avoid any interruption to your service, + we encourage you to upgrade before your + trial expires. You can{" "} + + upgrade your plan here + + . + + + )} + + + If you have any questions or need assistance, please + don't hesitate to reach out to our support team. + + + + + + + + + + ); +}; + +export default NotifyTrialExpiring; \ No newline at end of file diff --git a/server/private/routers/billing/hooks/handleCustomerCreated.ts b/server/private/routers/billing/hooks/handleCustomerCreated.ts index 11405f392..66ad3a4fa 100644 --- a/server/private/routers/billing/hooks/handleCustomerCreated.ts +++ b/server/private/routers/billing/hooks/handleCustomerCreated.ts @@ -12,9 +12,10 @@ */ import Stripe from "stripe"; -import { customers, db } from "@server/db"; +import { customers, db, subscriptions } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; +import { generateId } from "@server/auth/sessions/app"; export async function handleCustomerCreated( customer: Stripe.Customer @@ -38,14 +39,31 @@ export async function handleCustomerCreated( return; } - await db.insert(customers).values({ - customerId: customer.id, - orgId: customer.metadata.orgId, - email: customer.email || null, - name: customer.name || null, - createdAt: customer.created, - updatedAt: customer.created + await db.transaction(async (trx) => { + await trx.insert(customers).values({ + customerId: customer.id, + orgId: customer.metadata.orgId, + email: customer.email || null, + name: customer.name || null, + createdAt: customer.created, + updatedAt: customer.created + }); + + // Insert a 14-day trial subscription at tier3 + const now = Math.floor(Date.now() / 1000); + const trialExpiresAt = now + 10 * 24 * 60 * 60; + const subscriptionId = `trial-${generateId(15)}`; + await trx.insert(subscriptions).values({ + subscriptionId, + customerId: customer.id, + status: "active", + type: "tier3", + createdAt: now, + expiresAt: trialExpiresAt, + trial: true + }); }); + logger.info(`Customer with ID ${customer.id} created successfully.`); } catch (error) { logger.error( diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 8c1ce4d46..0fa526bc0 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -48,6 +48,14 @@ authenticated.post( org.sendUsageNotification ); +authenticated.post( + `/org/:orgId/send-trial-notification`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.sendTrialNotification), + logActionAudit(ActionsEnum.sendTrialNotification), + org.sendTrialNotification +); + authenticated.delete( "/idp/:idpId", verifyApiKeyIsRoot, diff --git a/server/private/routers/org/index.ts b/server/private/routers/org/index.ts index 7a23be693..5dc0faed8 100644 --- a/server/private/routers/org/index.ts +++ b/server/private/routers/org/index.ts @@ -12,3 +12,4 @@ */ export * from "./sendUsageNotifications"; +export * from "./sendTrialNotification"; diff --git a/server/private/routers/org/sendTrialNotification.ts b/server/private/routers/org/sendTrialNotification.ts new file mode 100644 index 000000000..c3b7f6518 --- /dev/null +++ b/server/private/routers/org/sendTrialNotification.ts @@ -0,0 +1,224 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userOrgs, userOrgRoles, users, roles, orgs } from "@server/db"; +import { eq, and, or } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { sendEmail } from "@server/emails"; +import NotifyTrialExpiring from "@server/emails/templates/NotifyTrialExpiring"; +import config from "@server/lib/config"; + +const sendTrialNotificationParamsSchema = z.object({ + orgId: z.string() +}); + +const sendTrialNotificationBodySchema = z.object({ + notificationType: z.enum(["trial_ending_5d", "trial_ending_24h", "trial_ended"]), + orgName: z.string(), + trialEndsAt: z.number(), + billingLink: z.string().optional() +}); + +export type SendTrialNotificationResponse = { + success: boolean; + emailsSent: number; + adminEmails: string[]; +}; + +async function getOrgAdmins(orgId: string) { + const admins = await db + .select({ + userId: users.userId, + email: users.email, + name: users.name, + isOwner: userOrgs.isOwner, + roleName: roles.name, + isAdminRole: roles.isAdmin + }) + .from(userOrgs) + .innerJoin(users, eq(userOrgs.userId, users.userId)) + .leftJoin( + userOrgRoles, + and( + eq(userOrgs.userId, userOrgRoles.userId), + eq(userOrgs.orgId, userOrgRoles.orgId) + ) + ) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgs.orgId, orgId), + or(eq(userOrgs.isOwner, true), eq(roles.isAdmin, true)) + ) + ); + + const byUserId = new Map( + admins.map((a) => [a.userId, a]) + ); + const orgAdmins = Array.from(byUserId.values()).filter( + (admin) => admin.email && admin.email.length > 0 + ); + + return orgAdmins; +} + +export async function sendTrialNotification( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = sendTrialNotificationParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = sendTrialNotificationBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { notificationType, orgName, trialEndsAt, billingLink: bodyBillingLink } = + parsedBody.data; + + // Verify organization exists + const org = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (org.length === 0) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Organization not found") + ); + } + + // Get all admin users for this organization + const orgAdmins = await getOrgAdmins(orgId); + + if (orgAdmins.length === 0) { + logger.warn(`No admin users found for organization ${orgId}`); + return response(res, { + data: { + success: true, + emailsSent: 0, + adminEmails: [] + }, + success: true, + error: false, + message: "No admin users found to notify", + status: HttpCode.OK + }); + } + + const billingLink = + bodyBillingLink ?? + `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`; + + const trialEndsAtFormatted = new Date(trialEndsAt * 1000).toLocaleDateString( + "en-US", + { year: "numeric", month: "long", day: "numeric" } + ); + + let daysRemaining: number | null; + let subject: string; + + if (notificationType === "trial_ending_5d") { + daysRemaining = 5; + subject = "Your trial ends in 5 days"; + } else if (notificationType === "trial_ending_24h") { + daysRemaining = 1; + subject = "Your trial ends tomorrow"; + } else { + daysRemaining = null; + subject = "Your trial has ended"; + } + + let emailsSent = 0; + const adminEmails: string[] = []; + + for (const admin of orgAdmins) { + if (!admin.email) continue; + + try { + const template = NotifyTrialExpiring({ + email: admin.email, + orgName, + trialEndsAt: trialEndsAtFormatted, + daysRemaining, + billingLink + }); + + await sendEmail(template, { + to: admin.email, + from: config.getNoReplyEmail(), + subject + }); + + emailsSent++; + adminEmails.push(admin.email); + + logger.info( + `Trial notification sent to admin ${admin.email} for org ${orgId}` + ); + } catch (emailError) { + logger.error( + `Failed to send trial notification to ${admin.email}:`, + emailError + ); + // Continue with other admins even if one fails + } + } + + return response(res, { + data: { + success: true, + emailsSent, + adminEmails + }, + success: true, + error: false, + message: `Trial notifications sent to ${emailsSent} administrators`, + status: HttpCode.OK + }); + } catch (error) { + logger.error("Error sending trial notifications:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to send trial notifications" + ) + ); + } +} \ No newline at end of file diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 88f76c29c..5fccbcd1f 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -12,7 +12,9 @@ import { userOrgRoles, userOrgs, users, - actions + actions, + customers, + subscriptions } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -31,6 +33,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor import { doCidrsOverlap } from "@server/lib/ip"; import { generateCA } from "@server/lib/sshCA"; import { encrypt } from "@server/lib/crypto"; +import { generateId } from "@server/auth/sessions/app"; const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/; diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index 8dc28001e..fe0077427 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -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: { {props.children} {build === "saas" && } + ); diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 150725e32..8f714336a 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -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(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") }; }; diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 779d5eb74..7c3ade008 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -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 (
)} + {showTrial && ( +
+ +
+ )} + {build === "enterprise" && (
)} + {!isSidebarCollapsed && (
{loadFooterLinks() ? ( diff --git a/src/components/PaidFeaturesAlert.tsx b/src/components/PaidFeaturesAlert.tsx index 95179ea78..933a9f5b9 100644 --- a/src/components/PaidFeaturesAlert.tsx +++ b/src/components/PaidFeaturesAlert.tsx @@ -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, diff --git a/src/components/ShowTrialCard.tsx b/src/components/ShowTrialCard.tsx new file mode 100644 index 000000000..1cc8e79f1 --- /dev/null +++ b/src/components/ShowTrialCard.tsx @@ -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 ( + + + + + + + + +

+ {remainingDays === 0 + ? t("trialExpired") + : t("trialDaysLeftShort", { days: remainingDays })} +

+
+
+
+ ); + } + + return ( + +
+ +

+ {remainingDays === 0 + ? t("trialExpired") + : t("trialActive")} +

+
+
+ + + {remainingDays === 0 + ? t("trialHasEnded") + : t("trialDaysRemaining", { count: remainingDays })} + +
+ {t("trialGoToBilling")} + +
+
+ + ); +} diff --git a/src/components/ui/progress-backwards.tsx b/src/components/ui/progress-backwards.tsx new file mode 100644 index 000000000..e2482f0e2 --- /dev/null +++ b/src/components/ui/progress-backwards.tsx @@ -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 & + VariantProps; + +function ProgressBackwards({ className, value, variant, ...props }: ProgressProps) { + return ( + + + + ); +} + +export { ProgressBackwards }; \ No newline at end of file diff --git a/src/contexts/subscriptionStatusContext.ts b/src/contexts/subscriptionStatusContext.ts index 73503da4f..6ba30fe42 100644 --- a/src/contexts/subscriptionStatusContext.ts +++ b/src/contexts/subscriptionStatusContext.ts @@ -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< diff --git a/src/providers/SubscriptionStatusProvider.tsx b/src/providers/SubscriptionStatusProvider.tsx index a105e5d58..e1e781193 100644 --- a/src/providers/SubscriptionStatusProvider.tsx +++ b/src/providers/SubscriptionStatusProvider.tsx @@ -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 ( {children}