Public Access
1
0

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:
pguerrerox
2026-05-22 22:55:04 +00:00
parent 94b8c357b4
commit 5508e15da1
35 changed files with 2851 additions and 50 deletions
+19 -9
View File
@@ -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');
+357 -5
View File
@@ -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;
}
}
+13 -3
View File
@@ -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
View File
@@ -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}`);
}
+12
View File
@@ -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);
}