feat: launch Stripe billing flows with lifecycle hardening and analytics
add Stripe checkout, portal, webhook ingestion, and idempotent event persistence add billing lifecycle state (grace/sync/timeline/admin visibility) and stronger entitlement handling add analytics event tracking and admin summary APIs plus account/pricing UI integration
This commit is contained in:
+19
-9
@@ -24,7 +24,7 @@ import { PricingComparisonTable } from './components/PricingComparisonTable';
|
||||
import { ResearchWorkspace } from './components/ResearchWorkspace';
|
||||
import { ResultsWorkspace } from './components/ResultsWorkspace';
|
||||
import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui';
|
||||
import type { BillingInterval } from '../shared/billing/plans';
|
||||
import type { BillingInterval, PlanCode } from '../shared/billing/plans';
|
||||
import type { SessionUser } from '../shared/types';
|
||||
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
|
||||
import { hasApiConfig } from './lib/api';
|
||||
@@ -42,6 +42,7 @@ export default function App() {
|
||||
const [authNotice, setAuthNotice] = useState<string | null>(null);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const [authMode, setAuthMode] = useState<'sign_in' | 'sign_up'>('sign_in');
|
||||
const [billingIntentPlanCode, setBillingIntentPlanCode] = useState<PlanCode | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
@@ -132,6 +133,9 @@ export default function App() {
|
||||
|
||||
setUser(nextUser);
|
||||
setAuthNotice('Account created and signed in.');
|
||||
if (billingIntentPlanCode) {
|
||||
setActiveTab('account');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,6 +145,9 @@ export default function App() {
|
||||
});
|
||||
|
||||
setUser(nextUser);
|
||||
if (billingIntentPlanCode) {
|
||||
setActiveTab('account');
|
||||
}
|
||||
navigatePublicPage('landing', setPublicPage);
|
||||
} catch (error) {
|
||||
setAuthError(error instanceof Error ? error.message : 'Authentication failed.');
|
||||
@@ -217,13 +224,14 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LandingPage
|
||||
onGoToAuth={(mode) => {
|
||||
handleSetAuthMode(mode);
|
||||
navigatePublicPage('auth', setPublicPage);
|
||||
}}
|
||||
/>
|
||||
return (
|
||||
<LandingPage
|
||||
onGoToAuth={(mode, planCode) => {
|
||||
handleSetAuthMode(mode);
|
||||
setBillingIntentPlanCode(planCode ?? null);
|
||||
navigatePublicPage('auth', setPublicPage);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -279,6 +287,8 @@ export default function App() {
|
||||
<AccountPage
|
||||
user={user}
|
||||
onUserUpdated={(nextUser) => setUser((currentUser) => (currentUser ? { ...nextUser, sessionId: currentUser.sessionId } : currentUser))}
|
||||
initialCheckoutPlanCode={billingIntentPlanCode}
|
||||
onConsumeInitialCheckoutPlanCode={() => setBillingIntentPlanCode(null)}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
@@ -305,7 +315,7 @@ function navigatePublicPage(page: 'landing' | 'auth', setPublicPage: (page: 'lan
|
||||
}
|
||||
|
||||
function LandingPage(props: {
|
||||
onGoToAuth: (mode: 'sign_in' | 'sign_up') => void;
|
||||
onGoToAuth: (mode: 'sign_in' | 'sign_up', planCode?: PlanCode) => void;
|
||||
}) {
|
||||
const { onGoToAuth } = props;
|
||||
const [pricingInterval, setPricingInterval] = useState<Extract<BillingInterval, 'monthly' | 'annual'>>('monthly');
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { AlertCircle, ArrowUpRight, Building2, CreditCard, Loader2, Shield, Users } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getEligibleAddonsForPlan } from '../../shared/billing/addons';
|
||||
import { getPlanByCode } from '../../shared/billing/plans';
|
||||
import type { AccountPageData, AppUser } from '../../shared/types';
|
||||
import { getAccountPageData, updateAccountProfile } from '../lib/account';
|
||||
import { formatLifecycleDate } from '../../shared/billing/lifecycle';
|
||||
import { getPlanByCode, getPublicPricingPlans, type PlanCode } from '../../shared/billing/plans';
|
||||
import type { AccountPageData, AppUser, BillingAdminWorkspaceDetail, BillingAdminWorkspaceSummary } from '../../shared/types';
|
||||
import {
|
||||
createAddonCheckout,
|
||||
createBillingPortalSession,
|
||||
createSubscriptionCheckout,
|
||||
getAccountPageData,
|
||||
getAdminBillingWorkspaceDetail,
|
||||
listAdminBillingWorkspaces,
|
||||
updateAccountProfile,
|
||||
} from '../lib/account';
|
||||
import {
|
||||
formatBillingIntervalLabel,
|
||||
formatBillingStatusLabel,
|
||||
formatDateLabel,
|
||||
formatPlanPeriod,
|
||||
formatPlanPrice,
|
||||
formatQuantity,
|
||||
formatUsageResourceName,
|
||||
getBillingStatusBadgeVariant,
|
||||
@@ -17,22 +28,32 @@ import {
|
||||
getUsageWarningMessage,
|
||||
getUsageWarningState,
|
||||
} from '../lib/billing-ui';
|
||||
import { sendAnalyticsEvent } from '../lib/analytics';
|
||||
import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader, StatCard } from './ui';
|
||||
|
||||
const SALES_EMAIL = 'sales@localescope.app';
|
||||
|
||||
interface AccountPageProps {
|
||||
user: AppUser;
|
||||
onUserUpdated: (user: AppUser) => void;
|
||||
initialCheckoutPlanCode?: PlanCode | null;
|
||||
onConsumeInitialCheckoutPlanCode?: () => void;
|
||||
}
|
||||
|
||||
export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = null, onConsumeInitialCheckoutPlanCode }: AccountPageProps) {
|
||||
const [account, setAccount] = useState<AccountPageData | null>(null);
|
||||
const [displayName, setDisplayName] = useState(user.displayName);
|
||||
const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl ?? '');
|
||||
const [workspaceName, setWorkspaceName] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [billingAction, setBillingAction] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [adminQuery, setAdminQuery] = useState('');
|
||||
const [adminWorkspaces, setAdminWorkspaces] = useState<BillingAdminWorkspaceSummary[]>([]);
|
||||
const [adminWorkspaceDetail, setAdminWorkspaceDetail] = useState<BillingAdminWorkspaceDetail | null>(null);
|
||||
const [adminLoading, setAdminLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -52,6 +73,16 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
setDisplayName(nextAccount.profile.displayName);
|
||||
setAvatarUrl(nextAccount.profile.avatarUrl ?? '');
|
||||
setWorkspaceName(nextAccount.workspace.name);
|
||||
setNotice(getBillingReturnNotice());
|
||||
|
||||
if (nextAccount.isBillingAdmin) {
|
||||
setAdminLoading(true);
|
||||
const adminResponse = await listAdminBillingWorkspaces();
|
||||
if (isMounted) {
|
||||
setAdminWorkspaces(adminResponse.workspaces);
|
||||
}
|
||||
setAdminLoading(false);
|
||||
}
|
||||
} catch (nextError) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
@@ -72,6 +103,28 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has('billing')) {
|
||||
url.searchParams.delete('billing');
|
||||
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
}, [notice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!account || !initialCheckoutPlanCode || billingAction !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handlePlanAction(initialCheckoutPlanCode).finally(() => {
|
||||
onConsumeInitialCheckoutPlanCode?.();
|
||||
});
|
||||
}, [account, initialCheckoutPlanCode, billingAction, onConsumeInitialCheckoutPlanCode]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -94,6 +147,122 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscriptionCheckout = async (planCode: string) => {
|
||||
setBillingAction(`plan:${planCode}`);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
|
||||
try {
|
||||
sendAnalyticsEvent({
|
||||
eventName: 'checkout_started',
|
||||
planCode,
|
||||
});
|
||||
const response = await createSubscriptionCheckout(planCode);
|
||||
window.location.assign(response.checkoutUrl);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to start checkout.');
|
||||
} finally {
|
||||
setBillingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddonCheckout = async (addonCode: string) => {
|
||||
setBillingAction(`addon:${addonCode}`);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
|
||||
try {
|
||||
sendAnalyticsEvent({
|
||||
eventName: 'addon_checkout_started',
|
||||
addonCode,
|
||||
});
|
||||
const response = await createAddonCheckout(addonCode);
|
||||
window.location.assign(response.checkoutUrl);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to start add-on checkout.');
|
||||
} finally {
|
||||
setBillingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingPortal = async () => {
|
||||
setBillingAction('portal');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
sendAnalyticsEvent({
|
||||
eventName: 'portal_opened',
|
||||
});
|
||||
const response = await createBillingPortalSession();
|
||||
window.location.assign(response.url);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to open billing portal.');
|
||||
} finally {
|
||||
setBillingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlanAction = async (planCode: PlanCode) => {
|
||||
const targetPlan = getPlanByCode(planCode);
|
||||
|
||||
if (!targetPlan) {
|
||||
setError('Selected plan is unavailable. Please refresh and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetPlan.contactSalesRequired || !targetPlan.isSelfServe) {
|
||||
setError(null);
|
||||
setNotice(`This plan is available through sales. Contact ${SALES_EMAIL} to coordinate your rollout.`);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.assign(`mailto:${SALES_EMAIL}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (account.billing.planCode === planCode && !account.billing.cancelAtPeriodEnd) {
|
||||
setError(null);
|
||||
setNotice('You are already on this plan.');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasExternalPaidSubscription = Boolean(account.billing.externalCustomerRef && account.billing.externalSubscriptionRef);
|
||||
if (hasExternalPaidSubscription && account.billing.planCode !== planCode) {
|
||||
setNotice('Use the billing portal to change plans for your existing subscription.');
|
||||
await handleBillingPortal();
|
||||
return;
|
||||
}
|
||||
|
||||
await handleSubscriptionCheckout(planCode);
|
||||
};
|
||||
|
||||
const handleLoadAdminWorkspace = async (workspaceId: string) => {
|
||||
setAdminLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getAdminBillingWorkspaceDetail(workspaceId);
|
||||
setAdminWorkspaceDetail(response.workspace);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load billing workspace detail.');
|
||||
} finally {
|
||||
setAdminLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchAdminWorkspaces = async () => {
|
||||
setAdminLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await listAdminBillingWorkspaces(adminQuery);
|
||||
setAdminWorkspaces(response.workspaces);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to search billing workspaces.');
|
||||
} finally {
|
||||
setAdminLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -118,6 +287,8 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
const suggestedUpgradePlanCode = getSuggestedUpgradePlanCode(account.billing.planCode, account.billing.billingInterval);
|
||||
const suggestedUpgradePlan = suggestedUpgradePlanCode ? getPlanByCode(suggestedUpgradePlanCode) : null;
|
||||
const eligibleAddons = activePlan ? getEligibleAddonsForPlan(activePlan.code, { includeComingSoon: true }) : [];
|
||||
const publicPricingPlans = getPublicPricingPlans();
|
||||
const hasExternalPaidSubscription = Boolean(account.billing.externalCustomerRef && account.billing.externalSubscriptionRef);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -233,8 +404,39 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
<span>Current period ends</span>
|
||||
<span className="font-medium text-stone-900">{formatDateLabel(account.billing.currentPeriodEndsAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>Grace period ends</span>
|
||||
<span className="font-medium text-stone-900">{formatDateLabel(account.billing.gracePeriodEndsAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>Stripe sync</span>
|
||||
<span className="font-medium text-stone-900">{account.billing.billingSyncStatus}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-stone-600">{account.billing.message}</p>
|
||||
{account.billing.pendingPlanCode && account.billing.pendingPlanEffectiveAt ? (
|
||||
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
Pending change to <span className="font-semibold">{account.billing.pendingPlanCode}</span> on {formatLifecycleDate(account.billing.pendingPlanEffectiveAt)}.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{account.billing.externalCustomerRef ? (
|
||||
<Button type="button" variant="secondary" onClick={() => void handleBillingPortal()} disabled={billingAction !== null}>
|
||||
{billingAction === 'portal' ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Manage billing
|
||||
</Button>
|
||||
) : null}
|
||||
{suggestedUpgradePlan ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handlePlanAction(suggestedUpgradePlan.code)}
|
||||
disabled={billingAction !== null}
|
||||
>
|
||||
{billingAction === `plan:${suggestedUpgradePlan.code}` ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUpRight className="h-4 w-4" />}
|
||||
Upgrade to {suggestedUpgradePlan.name}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{suggestedUpgradePlan ? (
|
||||
<div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<p className="text-sm font-semibold text-emerald-900">Suggested next plan</p>
|
||||
@@ -243,7 +445,14 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
<p className="text-base font-semibold text-emerald-950">{suggestedUpgradePlan.name}</p>
|
||||
<p className="text-sm text-emerald-800">Step up when you need more usage headroom or premium workflows.</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" className="shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => void handlePlanAction(suggestedUpgradePlan.code)}
|
||||
disabled={billingAction !== null}
|
||||
>
|
||||
{billingAction === `plan:${suggestedUpgradePlan.code}` ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Upgrade
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -252,6 +461,50 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Plan Options</p>
|
||||
<h3 className="text-lg font-semibold text-stone-950">Choose the right subscription</h3>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{publicPricingPlans.map((plan) => {
|
||||
const isCurrentPlan = account.billing.planCode === plan.code && !account.billing.cancelAtPeriodEnd;
|
||||
const buttonLabel = isCurrentPlan
|
||||
? 'Current plan'
|
||||
: plan.contactSalesRequired
|
||||
? 'Contact sales'
|
||||
: hasExternalPaidSubscription
|
||||
? 'Change in billing portal'
|
||||
: `Choose ${plan.name}`;
|
||||
|
||||
return (
|
||||
<div key={plan.code} className="rounded-2xl border border-stone-200 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-900">{plan.name}</p>
|
||||
<p className="mt-1 text-xs text-stone-500">
|
||||
{formatPlanPrice(plan.priceCents, plan.currencyCode)} {formatPlanPeriod(plan.billingInterval, plan.contactSalesRequired)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isCurrentPlan ? 'secondary' : 'primary'}
|
||||
onClick={() => void handlePlanAction(plan.code)}
|
||||
disabled={isCurrentPlan || billingAction !== null}
|
||||
>
|
||||
{billingAction === `plan:${plan.code}` || (billingAction === 'portal' && hasExternalPaidSubscription && !plan.contactSalesRequired && !isCurrentPlan)
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: null}
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -347,6 +600,20 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
<Badge variant="info">{addon.purchaseMode === 'one_time' ? 'One-time' : 'Recurring'}</Badge>
|
||||
<Badge>{addon.quantity === null ? 'Feature add-on' : `${formatQuantity(addon.quantity)} units`}</Badge>
|
||||
</div>
|
||||
{addon.availability === 'active' ? (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void handleAddonCheckout(addon.code)}
|
||||
disabled={billingAction !== null}
|
||||
>
|
||||
{billingAction === `addon:${addon.code}` ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Buy add-on
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -365,6 +632,72 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-stone-600">{account.team.message}</p>
|
||||
</Card>
|
||||
|
||||
{account.isBillingAdmin ? (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Admin Billing</p>
|
||||
<h3 className="text-lg font-semibold text-stone-950">Support visibility</h3>
|
||||
</div>
|
||||
{adminLoading ? <Loader2 className="h-4 w-4 animate-spin text-stone-500" /> : null}
|
||||
</div>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<Input value={adminQuery} onChange={(event) => setAdminQuery(event.target.value)} placeholder="Search workspace or Stripe id" />
|
||||
<Button type="button" variant="secondary" onClick={() => void handleSearchAdminWorkspaces()} disabled={adminLoading}>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{adminWorkspaces.map((workspace) => (
|
||||
<button
|
||||
key={workspace.workspaceId}
|
||||
type="button"
|
||||
onClick={() => void handleLoadAdminWorkspace(workspace.workspaceId)}
|
||||
className="w-full rounded-2xl border border-stone-200 p-4 text-left transition hover:border-stone-300 hover:bg-stone-50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">{workspace.workspaceName}</p>
|
||||
<p className="text-xs text-stone-500">{workspace.planCode || 'No plan'} · {workspace.status}</p>
|
||||
</div>
|
||||
<Badge variant={workspace.billingSyncStatus === 'error' ? 'danger' : workspace.billingSyncStatus === 'stale' ? 'warning' : 'success'}>
|
||||
{workspace.billingSyncStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{adminWorkspaceDetail ? (
|
||||
<div className="mt-6 space-y-4 rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm">
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">{adminWorkspaceDetail.summary.workspaceName}</p>
|
||||
<p className="text-stone-600">{adminWorkspaceDetail.summary.planCode || 'No plan'} · {adminWorkspaceDetail.summary.status}</p>
|
||||
</div>
|
||||
<div className="grid gap-2 text-stone-600">
|
||||
<div>Renewal: <span className="font-medium text-stone-900">{formatDateLabel(adminWorkspaceDetail.billing.currentPeriodEndsAt)}</span></div>
|
||||
<div>Grace ends: <span className="font-medium text-stone-900">{formatDateLabel(adminWorkspaceDetail.billing.gracePeriodEndsAt)}</span></div>
|
||||
<div>Subscription ref: <span className="font-medium text-stone-900">{adminWorkspaceDetail.summary.externalSubscriptionRef || 'Not set'}</span></div>
|
||||
<div>Usage period id: <span className="font-medium text-stone-900">{adminWorkspaceDetail.usagePeriodId || 'Not active'}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">Recent timeline</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{adminWorkspaceDetail.timeline.map((entry) => (
|
||||
<div key={entry.id} className="rounded-xl border border-stone-200 bg-white px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium text-stone-900">{entry.eventType}</span>
|
||||
<span className="text-xs text-stone-500">{formatDateLabel(entry.occurredAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -377,3 +710,22 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function getBillingReturnNotice() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const billing = new URL(window.location.href).searchParams.get('billing');
|
||||
|
||||
switch (billing) {
|
||||
case 'success':
|
||||
return 'Billing checkout completed. Subscription sync should appear here shortly.';
|
||||
case 'addon-success':
|
||||
return 'Add-on purchase completed. New balance should appear here shortly.';
|
||||
case 'cancelled':
|
||||
return 'Checkout was canceled before completion.';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import { getPlanCardBullets, getPlanDisplayMeta, getPublicPricingPlansForInterval, type BillingInterval } from '../../shared/billing/plans';
|
||||
import { getPlanCardBullets, getPlanDisplayMeta, getPublicPricingPlansForInterval, type BillingInterval, type PlanCode } from '../../shared/billing/plans';
|
||||
import { Button } from './ui';
|
||||
import { formatPlanPeriod, formatPlanPrice } from '../lib/billing-ui';
|
||||
import { sendAnalyticsEvent } from '../lib/analytics';
|
||||
|
||||
interface PricingCardsProps {
|
||||
billingInterval: Extract<BillingInterval, 'monthly' | 'annual'>;
|
||||
onGoToAuth: (mode: 'sign_in' | 'sign_up') => void;
|
||||
onGoToAuth: (mode: 'sign_in' | 'sign_up', planCode?: PlanCode) => void;
|
||||
}
|
||||
|
||||
export function PricingCards({ billingInterval, onGoToAuth }: PricingCardsProps) {
|
||||
@@ -45,7 +46,16 @@ export function PricingCards({ billingInterval, onGoToAuth }: PricingCardsProps)
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onGoToAuth(display.ctaMode)}
|
||||
onClick={() => {
|
||||
sendAnalyticsEvent({
|
||||
eventName: 'pricing_plan_selected',
|
||||
planCode: plan.code,
|
||||
metadata: {
|
||||
billingInterval,
|
||||
},
|
||||
});
|
||||
onGoToAuth(display.ctaMode, plan.code);
|
||||
}}
|
||||
className={`mt-8 w-full rounded-2xl ${isFeatured ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
|
||||
variant={isFeatured ? 'primary' : 'secondary'}
|
||||
>
|
||||
|
||||
+42
-1
@@ -1,4 +1,12 @@
|
||||
import type { AccountPageData, UpdateAccountProfileRequest } from '../../shared/types';
|
||||
import type {
|
||||
AccountPageData,
|
||||
BillingAdminWorkspaceDetailResponse,
|
||||
BillingAdminWorkspaceListResponse,
|
||||
BillingCheckoutSessionResponse,
|
||||
BillingDebugData,
|
||||
BillingPortalSessionResponse,
|
||||
UpdateAccountProfileRequest,
|
||||
} from '../../shared/types';
|
||||
import { apiRequest } from './api';
|
||||
|
||||
export async function getAccountPageData() {
|
||||
@@ -14,3 +22,36 @@ export async function updateAccountProfile(payload: UpdateAccountProfileRequest)
|
||||
|
||||
return response.account;
|
||||
}
|
||||
|
||||
export async function createSubscriptionCheckout(planCode: string) {
|
||||
return apiRequest<BillingCheckoutSessionResponse>('/billing/checkout/subscription', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ planCode }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAddonCheckout(addonCode: string) {
|
||||
return apiRequest<BillingCheckoutSessionResponse>('/billing/checkout/addon', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ addonCode }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createBillingPortalSession() {
|
||||
return apiRequest<BillingPortalSessionResponse>('/billing/portal', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBillingDebugData() {
|
||||
return apiRequest<BillingDebugData>('/billing/debug');
|
||||
}
|
||||
|
||||
export async function listAdminBillingWorkspaces(query?: string) {
|
||||
const search = query?.trim() ? `?query=${encodeURIComponent(query.trim())}` : '';
|
||||
return apiRequest<BillingAdminWorkspaceListResponse>(`/admin/billing/workspaces${search}`);
|
||||
}
|
||||
|
||||
export async function getAdminBillingWorkspaceDetail(workspaceId: string) {
|
||||
return apiRequest<BillingAdminWorkspaceDetailResponse>(`/admin/billing/workspaces/${workspaceId}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AnalyticsEventInput } from '../../shared/analytics/events';
|
||||
import { apiRequest } from './api';
|
||||
|
||||
export function sendAnalyticsEvent(event: Omit<AnalyticsEventInput, 'eventSource'> & { eventSource?: AnalyticsEventInput['eventSource'] }) {
|
||||
void apiRequest<{ ok: boolean }>('/analytics/events', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...event,
|
||||
eventSource: event.eventSource ?? 'web_app',
|
||||
}),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user