feat: add billing foundation and entitlement enforcement
- add workspace-scoped billing storage, usage tracking, and add-on catalog - enforce plan entitlements for search and deep research routes - expand pricing and account UI around billing state, usage, and upgrades
This commit is contained in:
+23
-75
@@ -19,10 +19,12 @@ import { Layout, type AppTab } from './components/Layout';
|
||||
import { AccountPage } from './components/AccountPage';
|
||||
import { Dashboard } from './components/Dashboard';
|
||||
import { MapView } from './components/MapView';
|
||||
import { PricingCards } from './components/PricingCards';
|
||||
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 { getPlanCardBullets, getPlanDisplayMeta, getPublicPricingPlans } from '../shared/billing/plans';
|
||||
import type { BillingInterval } from '../shared/billing/plans';
|
||||
import type { SessionUser } from '../shared/types';
|
||||
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
|
||||
import { hasApiConfig } from './lib/api';
|
||||
@@ -302,30 +304,11 @@ function navigatePublicPage(page: 'landing' | 'auth', setPublicPage: (page: 'lan
|
||||
setPublicPage(page);
|
||||
}
|
||||
|
||||
function formatPlanPrice(priceCents: number | null, currencyCode: string) {
|
||||
if (priceCents === null) {
|
||||
return 'Custom';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(priceCents / 100);
|
||||
}
|
||||
|
||||
function formatPlanPeriod(billingInterval: 'monthly' | 'annual' | 'custom', contactSalesRequired: boolean) {
|
||||
if (contactSalesRequired || billingInterval === 'custom') {
|
||||
return 'pricing';
|
||||
}
|
||||
|
||||
return billingInterval === 'annual' ? '/year' : '/month';
|
||||
}
|
||||
|
||||
function LandingPage(props: {
|
||||
onGoToAuth: (mode: 'sign_in' | 'sign_up') => void;
|
||||
}) {
|
||||
const { onGoToAuth } = props;
|
||||
const [pricingInterval, setPricingInterval] = useState<Extract<BillingInterval, 'monthly' | 'annual'>>('monthly');
|
||||
|
||||
const featureCards = [
|
||||
{
|
||||
@@ -368,8 +351,6 @@ function LandingPage(props: {
|
||||
},
|
||||
] as const;
|
||||
|
||||
const pricingPlans = getPublicPricingPlans();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.08),_transparent_28%),linear-gradient(180deg,#fafaf9_0%,#f5f5f4_100%)] text-stone-900">
|
||||
<div className="mx-auto max-w-7xl px-4 pb-16 pt-4 sm:px-6 lg:px-8 lg:pb-24">
|
||||
@@ -516,62 +497,29 @@ function LandingPage(props: {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-5 xl:grid-cols-3">
|
||||
{pricingPlans.map((plan) => {
|
||||
const display = getPlanDisplayMeta(plan.code);
|
||||
const isFeatured = display.badgeLabel === 'Best Value';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.code}
|
||||
className={`rounded-[2rem] border p-7 shadow-sm ${
|
||||
isFeatured ? 'border-emerald-300 bg-emerald-50/60 shadow-emerald-100' : 'border-stone-200 bg-white'
|
||||
<div className="mt-8 flex justify-center">
|
||||
<div className="inline-flex rounded-full border border-stone-200 bg-white p-1 shadow-sm">
|
||||
{(['monthly', 'annual'] as const).map((interval) => (
|
||||
<button
|
||||
key={interval}
|
||||
type="button"
|
||||
onClick={() => setPricingInterval(interval)}
|
||||
className={`rounded-full px-5 py-2 text-sm font-semibold transition ${
|
||||
pricingInterval === interval ? 'bg-stone-900 text-white' : 'text-stone-600 hover:bg-stone-100 hover:text-stone-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xl font-bold tracking-tight text-stone-900">{plan.name}</p>
|
||||
<p className="mt-2 text-sm text-stone-600">{display.audience}</p>
|
||||
</div>
|
||||
{display.badgeLabel ? (
|
||||
<span className="rounded-full bg-emerald-600 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
|
||||
{display.badgeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{interval === 'monthly' ? 'Monthly' : 'Annual'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-end gap-1">
|
||||
<span className="text-4xl font-bold tracking-tight text-stone-950">{formatPlanPrice(plan.priceCents, plan.currencyCode)}</span>
|
||||
<span className="pb-1 text-sm text-stone-500">{formatPlanPeriod(plan.billingInterval, plan.contactSalesRequired)}</span>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<PricingCards billingInterval={pricingInterval} onGoToAuth={onGoToAuth} />
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600">{display.summary}</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onGoToAuth(display.ctaMode)}
|
||||
className={`mt-8 inline-flex w-full items-center justify-center rounded-2xl px-4 py-3 text-sm font-semibold transition ${
|
||||
isFeatured
|
||||
? 'bg-emerald-600 text-white hover:bg-emerald-700'
|
||||
: 'border border-stone-200 bg-white text-stone-800 hover:bg-stone-50'
|
||||
}`}
|
||||
>
|
||||
{display.ctaLabel}
|
||||
</button>
|
||||
|
||||
<div className="mt-8 space-y-3">
|
||||
{getPlanCardBullets(plan.code).map((item) => (
|
||||
<div key={item} className="flex items-start gap-3 text-sm text-stone-700">
|
||||
<div className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-8">
|
||||
<PricingComparisonTable billingInterval={pricingInterval} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { Building2, CreditCard, Loader2, Shield, Users } from 'lucide-react';
|
||||
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 {
|
||||
formatBillingIntervalLabel,
|
||||
formatBillingStatusLabel,
|
||||
formatDateLabel,
|
||||
formatQuantity,
|
||||
formatUsageResourceName,
|
||||
getBillingStatusBadgeVariant,
|
||||
getSuggestedUpgradePlanCode,
|
||||
getUsageProgressPercent,
|
||||
getUsageWarningBarClass,
|
||||
getUsageWarningMessage,
|
||||
getUsageWarningState,
|
||||
} from '../lib/billing-ui';
|
||||
import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader, StatCard } from './ui';
|
||||
|
||||
interface AccountPageProps {
|
||||
@@ -101,6 +115,9 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
}
|
||||
|
||||
const activePlan = account.billing.planCode ? getPlanByCode(account.billing.planCode) : null;
|
||||
const suggestedUpgradePlanCode = getSuggestedUpgradePlanCode(account.billing.planCode, account.billing.billingInterval);
|
||||
const suggestedUpgradePlan = suggestedUpgradePlanCode ? getPlanByCode(suggestedUpgradePlanCode) : null;
|
||||
const eligibleAddons = activePlan ? getEligibleAddonsForPlan(activePlan.code, { includeComingSoon: true }) : [];
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -202,7 +219,138 @@ export function AccountPage({ user, onUserUpdated }: AccountPageProps) {
|
||||
<h3 className="text-lg font-semibold text-stone-950">{activePlan ? activePlan.name : 'Subscription foundation in progress'}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Badge variant={getBillingStatusBadgeVariant(account.billing.status)}>{formatBillingStatusLabel(account.billing.status)}</Badge>
|
||||
<Badge>{formatBillingIntervalLabel(account.billing.billingInterval)}</Badge>
|
||||
{account.billing.cancelAtPeriodEnd ? <Badge variant="warning">Cancels at period end</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 rounded-2xl bg-stone-50 p-4 text-sm text-stone-600">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>Current period starts</span>
|
||||
<span className="font-medium text-stone-900">{formatDateLabel(account.billing.currentPeriodStartsAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>Current period ends</span>
|
||||
<span className="font-medium text-stone-900">{formatDateLabel(account.billing.currentPeriodEndsAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-stone-600">{account.billing.message}</p>
|
||||
{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>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<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">
|
||||
Upgrade
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<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">Usage This Period</p>
|
||||
<h3 className="text-lg font-semibold text-stone-950">Quota visibility</h3>
|
||||
</div>
|
||||
{account.billing.usage.some((usage) => {
|
||||
const state = getUsageWarningState(usage);
|
||||
return state === 'warning' || state === 'critical';
|
||||
}) ? <Badge variant="warning">Needs attention</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
{account.billing.usage.map((usage) => {
|
||||
const warningMessage = getUsageWarningMessage(usage);
|
||||
const progressPercent = getUsageProgressPercent(usage);
|
||||
|
||||
return (
|
||||
<div key={usage.resource} 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">{formatUsageResourceName(usage.resource)}</p>
|
||||
<p className="mt-1 text-xs text-stone-500">
|
||||
Included: {formatQuantity(usage.included)} · Consumed: {formatQuantity(usage.consumed)} · Remaining: {formatQuantity(usage.remaining)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={usage.availability === 'not_available' ? 'neutral' : getUsageWarningState(usage) === 'critical' ? 'danger' : getUsageWarningState(usage) === 'warning' ? 'warning' : 'success'}>
|
||||
{usage.availability === 'not_available' ? 'Not included' : usage.availability === 'custom' ? 'Custom' : usage.availability === 'unlimited' ? 'Unlimited' : 'Tracked'}
|
||||
</Badge>
|
||||
</div>
|
||||
{progressPercent !== null ? (
|
||||
<div className="mt-3 h-2 rounded-full bg-stone-100">
|
||||
<div className={`h-2 rounded-full ${getUsageWarningBarClass(usage)}`} style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
{warningMessage ? (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-xl bg-stone-50 px-3 py-2 text-xs text-stone-600">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{warningMessage}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Add-On Balances</p>
|
||||
<h3 className="text-lg font-semibold text-stone-950">Extra capacity</h3>
|
||||
</div>
|
||||
{account.billing.addonBalances.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-stone-600">No add-on balances are active yet.</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-3">
|
||||
{account.billing.addonBalances.map((balance) => (
|
||||
<div key={`${balance.addonCode}-${balance.resource}`} className="rounded-2xl border border-stone-200 p-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">{balance.addonCode}</p>
|
||||
<p className="text-xs text-stone-500">{formatUsageResourceName(balance.resource)}</p>
|
||||
</div>
|
||||
<Badge variant="info">{formatQuantity(balance.remainingQuantity)} remaining</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-stone-500">Expires: {formatDateLabel(balance.expiresAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Available Add-Ons</p>
|
||||
<h3 className="text-lg font-semibold text-stone-950">Optional capacity and feature packs</h3>
|
||||
</div>
|
||||
{eligibleAddons.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-stone-600">No add-ons are configured for this plan yet.</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-3">
|
||||
{eligibleAddons.map((addon) => (
|
||||
<div key={addon.code} className="rounded-2xl border border-stone-200 p-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">{addon.name}</p>
|
||||
<p className="mt-1 text-xs text-stone-500">{addon.description}</p>
|
||||
</div>
|
||||
<Badge variant={addon.availability === 'active' ? 'success' : 'warning'}>
|
||||
{addon.availability === 'active' ? 'Available' : 'Coming soon'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="info">{addon.purchaseMode === 'one_time' ? 'One-time' : 'Recurring'}</Badge>
|
||||
<Badge>{addon.quantity === null ? 'Feature add-on' : `${formatQuantity(addon.quantity)} units`}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import { getPlanCardBullets, getPlanDisplayMeta, getPublicPricingPlansForInterval, type BillingInterval } from '../../shared/billing/plans';
|
||||
import { Button } from './ui';
|
||||
import { formatPlanPeriod, formatPlanPrice } from '../lib/billing-ui';
|
||||
|
||||
interface PricingCardsProps {
|
||||
billingInterval: Extract<BillingInterval, 'monthly' | 'annual'>;
|
||||
onGoToAuth: (mode: 'sign_in' | 'sign_up') => void;
|
||||
}
|
||||
|
||||
export function PricingCards({ billingInterval, onGoToAuth }: PricingCardsProps) {
|
||||
const pricingPlans = getPublicPricingPlansForInterval(billingInterval);
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 xl:grid-cols-4">
|
||||
{pricingPlans.map((plan) => {
|
||||
const display = getPlanDisplayMeta(plan.code);
|
||||
const isFeatured = display.badgeLabel === 'Best Value';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.code}
|
||||
className={`rounded-[2rem] border p-7 shadow-sm ${
|
||||
isFeatured ? 'border-emerald-300 bg-emerald-50/60 shadow-emerald-100' : 'border-stone-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xl font-bold tracking-tight text-stone-900">{plan.name}</p>
|
||||
<p className="mt-2 text-sm text-stone-600">{display.audience}</p>
|
||||
</div>
|
||||
{display.badgeLabel ? (
|
||||
<span className="rounded-full bg-emerald-600 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
|
||||
{display.badgeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-end gap-1">
|
||||
<span className="text-4xl font-bold tracking-tight text-stone-950">{formatPlanPrice(plan.priceCents, plan.currencyCode)}</span>
|
||||
<span className="pb-1 text-sm text-stone-500">{formatPlanPeriod(plan.billingInterval, plan.contactSalesRequired)}</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600">{display.summary}</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onGoToAuth(display.ctaMode)}
|
||||
className={`mt-8 w-full rounded-2xl ${isFeatured ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
|
||||
variant={isFeatured ? 'primary' : 'secondary'}
|
||||
>
|
||||
{display.ctaLabel}
|
||||
</Button>
|
||||
|
||||
<div className="mt-8 space-y-3">
|
||||
{getPlanCardBullets(plan.code).map((item) => (
|
||||
<div key={item} className="flex items-start gap-3 text-sm text-stone-700">
|
||||
<div className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Check, Minus } from 'lucide-react';
|
||||
import { getFeatureGate } from '../../shared/billing/feature-gates';
|
||||
import { getPlanByCode, getPublicPricingPlansForInterval, type ActivePlanCode, type BillingInterval, type PlanFeatures } from '../../shared/billing/plans';
|
||||
import { Card, Badge } from './ui';
|
||||
import { formatQuantity } from '../lib/billing-ui';
|
||||
|
||||
const FEATURE_ROWS: Array<{ feature: keyof PlanFeatures; label: string }> = [
|
||||
{ feature: 'csvExport', label: 'CSV export' },
|
||||
{ feature: 'mapSearch', label: 'Map search' },
|
||||
{ feature: 'radiusSearch', label: 'Radius search' },
|
||||
{ feature: 'advancedFilters', label: 'Advanced filtering' },
|
||||
{ feature: 'savedSearches', label: 'Saved searches' },
|
||||
{ feature: 'territoryMapping', label: 'Territory mapping' },
|
||||
{ feature: 'deduplication', label: 'Deduplication' },
|
||||
{ feature: 'exportHistory', label: 'Export history' },
|
||||
{ feature: 'taggingNotes', label: 'Tagging & notes' },
|
||||
{ feature: 'sharedLists', label: 'Shared lists' },
|
||||
{ feature: 'scheduledResearch', label: 'Scheduled research' },
|
||||
{ feature: 'bulkExports', label: 'Bulk exports' },
|
||||
{ feature: 'crmIntegrations', label: 'CRM integrations' },
|
||||
{ feature: 'apiAccess', label: 'API access' },
|
||||
{ feature: 'webhooks', label: 'Webhooks' },
|
||||
{ feature: 'collaboration', label: 'Collaboration' },
|
||||
{ feature: 'enrichments', label: 'Enrichments' },
|
||||
{ feature: 'prioritySupport', label: 'Priority support' },
|
||||
{ feature: 'sso', label: 'SSO' },
|
||||
{ feature: 'sla', label: 'SLA' },
|
||||
{ feature: 'whiteLabel', label: 'White-labeling' },
|
||||
];
|
||||
|
||||
interface PricingComparisonTableProps {
|
||||
billingInterval: Extract<BillingInterval, 'monthly' | 'annual'>;
|
||||
}
|
||||
|
||||
export function PricingComparisonTable({ billingInterval }: PricingComparisonTableProps) {
|
||||
const plans = getPublicPricingPlansForInterval(billingInterval);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="border-b border-stone-200 px-6 py-5">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Plan Comparison</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-tight text-stone-950">Compare capabilities across plans</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-stone-600">Included-but-not-ready capabilities are labeled as coming soon so the table stays honest with the current rollout phase.</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border-collapse text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-200 bg-stone-50">
|
||||
<th className="px-6 py-4 font-semibold text-stone-500">Capability</th>
|
||||
{plans.map((plan) => (
|
||||
<th key={plan.code} className="px-6 py-4 font-semibold text-stone-900">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{plan.name}</span>
|
||||
{plan.code === 'growth_monthly' || plan.code === 'growth_annual' ? <Badge variant="primary">Best Value</Badge> : null}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ComparisonValueRow
|
||||
label="Research runs / month"
|
||||
values={plans.map((plan) => (plan.limits.researchRunsPerMonth === null ? 'Custom' : formatQuantity(plan.limits.researchRunsPerMonth)))}
|
||||
/>
|
||||
<ComparisonValueRow
|
||||
label="Exports / month"
|
||||
values={plans.map((plan) => (plan.limits.exportsPerMonth === null ? 'Custom' : formatQuantity(plan.limits.exportsPerMonth)))}
|
||||
/>
|
||||
<ComparisonValueRow
|
||||
label="Users included"
|
||||
values={plans.map((plan) => (plan.limits.usersIncluded === null ? 'Custom' : formatQuantity(plan.limits.usersIncluded)))}
|
||||
/>
|
||||
<ComparisonValueRow
|
||||
label="Workspaces included"
|
||||
values={plans.map((plan) => (plan.limits.workspacesIncluded === null ? 'Unlimited' : formatQuantity(plan.limits.workspacesIncluded)))}
|
||||
/>
|
||||
{FEATURE_ROWS.map(({ feature, label }) => (
|
||||
<ComparisonFeatureRow key={feature} label={label} planCodes={plans.map((plan) => plan.code)} feature={feature} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonValueRow({ label, values }: { label: string; values: string[] }) {
|
||||
return (
|
||||
<tr className="border-b border-stone-100 align-top">
|
||||
<td className="px-6 py-4 font-medium text-stone-700">{label}</td>
|
||||
{values.map((value, index) => (
|
||||
<td key={`${label}-${index}`} className="px-6 py-4 text-stone-600">{value}</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonFeatureRow({ label, planCodes, feature }: { label: string; planCodes: ActivePlanCode[]; feature: keyof PlanFeatures }) {
|
||||
return (
|
||||
<tr className="border-b border-stone-100 align-top">
|
||||
<td className="px-6 py-4 font-medium text-stone-700">{label}</td>
|
||||
{planCodes.map((planCode) => (
|
||||
<td key={`${label}-${planCode}`} className="px-6 py-4">
|
||||
<FeatureGateCell planCode={planCode} feature={feature} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureGateCell({ planCode, feature }: { planCode: ActivePlanCode; feature: keyof PlanFeatures }) {
|
||||
const plan = getPlanByCode(planCode);
|
||||
const gate = getFeatureGate(planCode, feature);
|
||||
|
||||
if (!plan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (gate.state) {
|
||||
case 'available':
|
||||
return <span className="inline-flex items-center gap-2 text-emerald-700"><Check className="h-4 w-4" />Included</span>;
|
||||
case 'coming_soon':
|
||||
return <Badge variant="warning">Coming soon</Badge>;
|
||||
case 'contact_sales':
|
||||
return plan.code === 'enterprise_custom' && plan.features[feature] ? <Badge variant="warning">Coming soon</Badge> : <Badge variant="info">Enterprise</Badge>;
|
||||
case 'upgrade_required':
|
||||
return <span className="text-stone-500">Higher tier</span>;
|
||||
case 'hidden':
|
||||
return <span className="inline-flex items-center gap-2 text-stone-300"><Minus className="h-4 w-4" />Not included</span>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { ActivePlanCode, BillingInterval, PlanCode } from '../../shared/billing/plans';
|
||||
import { getPlanByCode, getPlanVariant } from '../../shared/billing/plans';
|
||||
import type { AccountBillingStatus, BillingUsageResourceSummary } from '../../shared/types';
|
||||
|
||||
export type UsageWarningState = 'healthy' | 'warning' | 'critical' | 'unavailable';
|
||||
|
||||
export function formatPlanPrice(priceCents: number | null, currencyCode: string) {
|
||||
if (priceCents === null) {
|
||||
return 'Custom';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(priceCents / 100);
|
||||
}
|
||||
|
||||
export function formatPlanPeriod(billingInterval: BillingInterval, contactSalesRequired: boolean) {
|
||||
if (contactSalesRequired || billingInterval === 'custom') {
|
||||
return 'pricing';
|
||||
}
|
||||
|
||||
return billingInterval === 'annual' ? '/year' : '/month';
|
||||
}
|
||||
|
||||
export function formatBillingIntervalLabel(billingInterval: BillingInterval | null) {
|
||||
if (!billingInterval) {
|
||||
return 'Not scheduled';
|
||||
}
|
||||
|
||||
switch (billingInterval) {
|
||||
case 'monthly':
|
||||
return 'Monthly';
|
||||
case 'annual':
|
||||
return 'Annual';
|
||||
case 'custom':
|
||||
return 'Custom';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatBillingStatusLabel(status: AccountBillingStatus) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'Active';
|
||||
case 'past_due':
|
||||
return 'Past due';
|
||||
case 'inactive':
|
||||
return 'Inactive';
|
||||
case 'canceled':
|
||||
return 'Canceled';
|
||||
case 'not_configured':
|
||||
return 'Bootstrap';
|
||||
}
|
||||
}
|
||||
|
||||
export function getBillingStatusBadgeVariant(status: AccountBillingStatus) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'success' as const;
|
||||
case 'past_due':
|
||||
return 'warning' as const;
|
||||
case 'inactive':
|
||||
case 'canceled':
|
||||
return 'danger' as const;
|
||||
case 'not_configured':
|
||||
return 'info' as const;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatUsageResourceName(resource: BillingUsageResourceSummary['resource']) {
|
||||
switch (resource) {
|
||||
case 'research_credits':
|
||||
return 'Research credits';
|
||||
case 'exports':
|
||||
return 'Exports';
|
||||
case 'enrichments':
|
||||
return 'Enrichments';
|
||||
case 'api_requests':
|
||||
return 'API requests';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatQuantity(value: number | null) {
|
||||
if (value === null) {
|
||||
return 'Custom';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('en-US').format(value);
|
||||
}
|
||||
|
||||
export function getUsageWarningState(summary: BillingUsageResourceSummary): UsageWarningState {
|
||||
if (summary.availability === 'not_available') {
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
if (summary.included === null || summary.remaining === null || summary.included <= 0) {
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
const remainingRatio = summary.remaining / summary.included;
|
||||
|
||||
if (summary.remaining === 0 || remainingRatio <= 0.1) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if (remainingRatio <= 0.2) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
export function getUsageWarningMessage(summary: BillingUsageResourceSummary) {
|
||||
const warningState = getUsageWarningState(summary);
|
||||
const resourceName = formatUsageResourceName(summary.resource);
|
||||
|
||||
switch (warningState) {
|
||||
case 'unavailable':
|
||||
return `${resourceName} are not included on this plan.`;
|
||||
case 'critical':
|
||||
return `You are close to exhausting your ${resourceName.toLowerCase()}.`;
|
||||
case 'warning':
|
||||
return `${resourceName} are running low for this period.`;
|
||||
case 'healthy':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getUsageProgressPercent(summary: BillingUsageResourceSummary) {
|
||||
if (summary.included === null || summary.included <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const consumedRatio = summary.consumed / summary.included;
|
||||
return Math.max(0, Math.min(consumedRatio * 100, 100));
|
||||
}
|
||||
|
||||
export function getUsageWarningBarClass(summary: BillingUsageResourceSummary) {
|
||||
switch (getUsageWarningState(summary)) {
|
||||
case 'critical':
|
||||
return 'bg-red-500';
|
||||
case 'warning':
|
||||
return 'bg-amber-500';
|
||||
case 'unavailable':
|
||||
return 'bg-stone-300';
|
||||
case 'healthy':
|
||||
return 'bg-emerald-500';
|
||||
}
|
||||
}
|
||||
|
||||
export function getSuggestedUpgradePlanCode(planCode: PlanCode | null, billingInterval: BillingInterval | null) {
|
||||
if (!planCode) {
|
||||
return 'starter_monthly' as const;
|
||||
}
|
||||
|
||||
const plan = getPlanByCode(planCode);
|
||||
if (!plan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetInterval = billingInterval === 'annual' ? 'annual' : 'monthly';
|
||||
|
||||
switch (plan.planFamily) {
|
||||
case 'starter':
|
||||
return getPlanVariant('growth', targetInterval)?.code ?? null;
|
||||
case 'growth':
|
||||
return getPlanVariant('pro', targetInterval)?.code ?? null;
|
||||
case 'pro':
|
||||
return 'enterprise_custom';
|
||||
case 'enterprise':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateLabel(value: string | null) {
|
||||
if (!value) {
|
||||
return 'Not set';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
Reference in New Issue
Block a user