From 97ae0ddd443004695d3d32e4ee2e91e25c11bbd2 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Mon, 12 Jan 2026 17:49:15 +0900 Subject: [PATCH] feat: add trial status and days remaining to billing UI Extend JWT claims with subscription_status and trial_end to display trial information in both desktop and web apps without additional API calls. --- apps/desktop/src/billing.tsx | 53 +++++++++++++- .../components/settings/general/account.tsx | 24 ++++++- apps/web/src/functions/billing-access.ts | 72 +++++++++++++++++++ apps/web/src/hooks/use-billing-access.ts | 8 +++ apps/web/src/routes/_view/app/account.tsx | 59 ++++++++------- apps/web/src/routes/_view/app/route.tsx | 10 ++- ...0006_auth_hook_add_subscription_claims.sql | 56 +++++++++++++++ 7 files changed, 250 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/functions/billing-access.ts create mode 100644 apps/web/src/hooks/use-billing-access.ts create mode 100644 supabase/migrations/20250101000006_auth_hook_add_subscription_claims.sql diff --git a/apps/desktop/src/billing.tsx b/apps/desktop/src/billing.tsx index 5ba6f482af..4c16fc85d0 100644 --- a/apps/desktop/src/billing.tsx +++ b/apps/desktop/src/billing.tsx @@ -11,18 +11,41 @@ import { import { useAuth } from "./auth"; import { env } from "./env"; +type JwtClaims = { + entitlements?: string[]; + subscription_status?: "trialing" | "active"; + trial_end?: number; +}; + export function getEntitlementsFromToken(accessToken: string): string[] { try { - const decoded = jwtDecode<{ entitlements?: string[] }>(accessToken); + const decoded = jwtDecode(accessToken); return decoded.entitlements ?? []; } catch { return []; } } +export function getSubscriptionInfoFromToken(accessToken: string): { + status: "trialing" | "active" | null; + trialEnd: number | null; +} { + try { + const decoded = jwtDecode(accessToken); + return { + status: decoded.subscription_status ?? null, + trialEnd: decoded.trial_end ?? null, + }; + } catch { + return { status: null, trialEnd: null }; + } +} + type BillingContextValue = { entitlements: string[]; isPro: boolean; + isTrialing: boolean; + trialDaysRemaining: number | null; upgradeToPro: () => void; }; @@ -40,11 +63,35 @@ export function BillingProvider({ children }: { children: ReactNode }) { return getEntitlementsFromToken(auth.session.access_token); }, [auth?.session?.access_token]); + const subscriptionInfo = useMemo(() => { + if (!auth?.session?.access_token) { + return { status: null, trialEnd: null }; + } + return getSubscriptionInfoFromToken(auth.session.access_token); + }, [auth?.session?.access_token]); + const isPro = useMemo( () => entitlements.includes("hyprnote_pro"), [entitlements], ); + const isTrialing = useMemo( + () => subscriptionInfo.status === "trialing", + [subscriptionInfo.status], + ); + + const trialDaysRemaining = useMemo(() => { + if (!subscriptionInfo.trialEnd) { + return null; + } + const now = Math.floor(Date.now() / 1000); + const secondsRemaining = subscriptionInfo.trialEnd - now; + if (secondsRemaining <= 0) { + return 0; + } + return Math.ceil(secondsRemaining / (24 * 60 * 60)); + }, [subscriptionInfo.trialEnd]); + const upgradeToPro = useCallback(() => { void openUrl(`${env.VITE_APP_URL}/app/checkout?period=monthly`); }, []); @@ -53,9 +100,11 @@ export function BillingProvider({ children }: { children: ReactNode }) { () => ({ entitlements, isPro, + isTrialing, + trialDaysRemaining, upgradeToPro, }), - [entitlements, isPro, upgradeToPro], + [entitlements, isPro, isTrialing, trialDaysRemaining, upgradeToPro], ); return ( diff --git a/apps/desktop/src/components/settings/general/account.tsx b/apps/desktop/src/components/settings/general/account.tsx index f84fa84552..4f13301ce0 100644 --- a/apps/desktop/src/components/settings/general/account.tsx +++ b/apps/desktop/src/components/settings/general/account.tsx @@ -24,9 +24,29 @@ import { useTrialBeginModal } from "../../devtool/trial-begin-modal"; const WEB_APP_BASE_URL = env.VITE_APP_URL ?? "http://localhost:3000"; +function getPlanDescription( + isPro: boolean, + isTrialing: boolean, + trialDaysRemaining: number | null, +): string { + if (!isPro) { + return "Your current plan is FREE."; + } + if (isTrialing && trialDaysRemaining !== null) { + if (trialDaysRemaining === 0) { + return "Your trial ends today. Add a payment method to continue."; + } + if (trialDaysRemaining === 1) { + return "Your trial ends tomorrow. Add a payment method to continue."; + } + return `Your trial ends in ${trialDaysRemaining} days.`; + } + return "Your current plan is PRO."; +} + export function AccountSettings() { const auth = useAuth(); - const { isPro } = useBillingAccess(); + const { isPro, isTrialing, trialDaysRemaining } = useBillingAccess(); const isAuthenticated = !!auth?.session; const [isPending, setIsPending] = useState(false); @@ -156,7 +176,7 @@ export function AccountSettings() { } >

diff --git a/apps/web/src/functions/billing-access.ts b/apps/web/src/functions/billing-access.ts new file mode 100644 index 0000000000..5f56522631 --- /dev/null +++ b/apps/web/src/functions/billing-access.ts @@ -0,0 +1,72 @@ +import { createServerFn } from "@tanstack/react-start"; + +import { getSupabaseServerClient } from "@/functions/supabase"; + +const PRO_ENTITLEMENT = "hyprnote_pro"; + +type JwtClaims = { + entitlements?: string[]; + subscription_status?: "trialing" | "active"; + trial_end?: number; +}; + +function decodeJwtPayload(accessToken: string): JwtClaims { + try { + const [, payloadBase64] = accessToken.split("."); + if (!payloadBase64) return {}; + + return JSON.parse( + Buffer.from(payloadBase64, "base64url").toString("utf-8"), + ); + } catch { + return {}; + } +} + +export type BillingAccess = { + entitlements: string[]; + isPro: boolean; + isTrialing: boolean; + trialDaysRemaining: number | null; +}; + +export const fetchBillingAccess = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const supabase = getSupabaseServerClient(); + const { data } = await supabase.auth.getSession(); + + if (!data.session?.access_token) { + return { + entitlements: [], + isPro: false, + isTrialing: false, + trialDaysRemaining: null, + }; + } + + const claims = decodeJwtPayload(data.session.access_token); + const entitlements = Array.isArray(claims.entitlements) + ? claims.entitlements + : []; + const isPro = entitlements.includes(PRO_ENTITLEMENT); + const isTrialing = claims.subscription_status === "trialing"; + + let trialDaysRemaining: number | null = null; + if (claims.trial_end) { + const now = Math.floor(Date.now() / 1000); + const secondsRemaining = claims.trial_end - now; + if (secondsRemaining <= 0) { + trialDaysRemaining = 0; + } else { + trialDaysRemaining = Math.ceil(secondsRemaining / (24 * 60 * 60)); + } + } + + return { + entitlements, + isPro, + isTrialing, + trialDaysRemaining, + }; + }, +); diff --git a/apps/web/src/hooks/use-billing-access.ts b/apps/web/src/hooks/use-billing-access.ts new file mode 100644 index 0000000000..ac50da4191 --- /dev/null +++ b/apps/web/src/hooks/use-billing-access.ts @@ -0,0 +1,8 @@ +import { useMatch } from "@tanstack/react-router"; + +import type { BillingAccess } from "@/functions/billing-access"; + +export function useBillingAccess(): BillingAccess { + const match = useMatch({ from: "/_view/app", shouldThrow: true }); + return match.context.billingAccess; +} diff --git a/apps/web/src/routes/_view/app/account.tsx b/apps/web/src/routes/_view/app/account.tsx index 5a071ceeae..257d7a36ae 100644 --- a/apps/web/src/routes/_view/app/account.tsx +++ b/apps/web/src/routes/_view/app/account.tsx @@ -6,12 +6,15 @@ import { canStartTrial, createPortalSession, createTrialCheckoutSession, - syncAfterSuccess, } from "@/functions/billing"; +import { useBillingAccess } from "@/hooks/use-billing-access"; export const Route = createFileRoute("/_view/app/account")({ component: Component, - loader: async ({ context }) => ({ user: context.user }), + loader: async ({ context }) => ({ + user: context.user, + billingAccess: context.billingAccess, + }), }); function Component() { @@ -52,15 +55,33 @@ function Component() { ); } +function getPlanDescription( + isPro: boolean, + isTrialing: boolean, + trialDaysRemaining: number | null, +): string { + if (!isPro) { + return "Free"; + } + if (isTrialing && trialDaysRemaining !== null) { + if (trialDaysRemaining === 0) { + return "Trial (ends today)"; + } + if (trialDaysRemaining === 1) { + return "Trial (ends tomorrow)"; + } + return `Trial (${trialDaysRemaining} days left)`; + } + return "Pro"; +} + function AccountSettingsCard() { - const billingQuery = useQuery({ - queryKey: ["billing"], - queryFn: () => syncAfterSuccess(), - }); + const { isPro, isTrialing, trialDaysRemaining } = useBillingAccess(); const canTrialQuery = useQuery({ queryKey: ["canStartTrial"], queryFn: () => canStartTrial(), + enabled: !isPro, }); const manageBillingMutation = useMutation({ @@ -81,18 +102,8 @@ function AccountSettingsCard() { }, }); - const currentPlan = (() => { - if (!billingQuery.data || billingQuery.data.status === "none") { - return "free"; - } - const status = billingQuery.data.status; - if (status === "trialing") return "trial"; - if (status === "active") return "pro"; - return "free"; - })(); - const renderPlanButton = () => { - if (billingQuery.isLoading || canTrialQuery.isLoading) { + if (canTrialQuery.isLoading) { return (

Loading... @@ -100,7 +111,7 @@ function AccountSettingsCard() { ); } - if (currentPlan === "free") { + if (!isPro) { if (canTrialQuery.data) { return (