Add subscription upgrade/change/cancel UX and account self-delete (#75)
Unit Tests / test (pull_request) Successful in 11s
Unit Tests / test (pull_request) Successful in 11s
Surface Upgrade, Change plan, and Cancel on Account billing (portal-first), plan picker when multiple public plans exist, complimentary-safe messaging, and Danger Zone self-delete against DELETE /user/ (backend #34).
This commit is contained in:
+7
-1
@@ -20,7 +20,13 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta.
|
|||||||
| Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` |
|
| Conversation Created | `CONVERSATION_CREATED` | MessageContext | `{ conversationId }` |
|
||||||
| Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` |
|
| Message Sent | `MESSAGE_SENT` | AsyncDashboard2 | `{ hasConversation, hasAttachment }` |
|
||||||
| ToS Acknowledged | `TOS_ACKNOWLEDGED` | TermsOfService | — |
|
| ToS Acknowledged | `TOS_ACKNOWLEDGED` | TermsOfService | — |
|
||||||
| Billing Portal Opened | `BILLING_PORTAL_OPENED` | BillingSection (Account) | — |
|
| Billing Portal Opened | `BILLING_PORTAL_OPENED` | BillingSection (Account) | `{ intent? }` |
|
||||||
|
| Subscription Upgrade Started | `SUBSCRIPTION_UPGRADE_STARTED` | BillingSection, UsageSummaryCard | `{ source, plan_slug? }` |
|
||||||
|
| Plan Change Started | `PLAN_CHANGE_STARTED` | BillingSection | `{ source, plan_slug? }` |
|
||||||
|
| Subscription Cancel Started | `SUBSCRIPTION_CANCEL_STARTED` | BillingSection | `{ source: 'portal' }` |
|
||||||
|
| Account Delete Started | `ACCOUNT_DELETE_STARTED` | DeleteAccountSection | — |
|
||||||
|
| Account Delete Success | `ACCOUNT_DELETE_SUCCESS` | DeleteAccountSection | — |
|
||||||
|
| Account Delete Failed | `ACCOUNT_DELETE_FAILED` | DeleteAccountSection | — |
|
||||||
|
|
||||||
## Identify
|
## Identify
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ jest.mock('../../utils/analytics', () => ({
|
|||||||
AnalyticsEvents: {
|
AnalyticsEvents: {
|
||||||
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
||||||
CHECKOUT_STARTED: 'Checkout Started',
|
CHECKOUT_STARTED: 'Checkout Started',
|
||||||
|
SUBSCRIPTION_UPGRADE_STARTED: 'Subscription Upgrade Started',
|
||||||
|
PLAN_CHANGE_STARTED: 'Plan Change Started',
|
||||||
|
SUBSCRIPTION_CANCEL_STARTED: 'Subscription Cancel Started',
|
||||||
},
|
},
|
||||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||||
}));
|
}));
|
||||||
@@ -52,8 +55,7 @@ const paidInvoice = {
|
|||||||
last_modified: '2026-07-01T12:00:00Z',
|
last_modified: '2026-07-01T12:00:00Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
const foundersSubscription = {
|
const foundersPlan = {
|
||||||
plan: {
|
|
||||||
slug: 'founders',
|
slug: 'founders',
|
||||||
name: 'Founders',
|
name: 'Founders',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -71,11 +73,16 @@ const foundersSubscription = {
|
|||||||
prompt_window_hours: 6,
|
prompt_window_hours: 6,
|
||||||
monthly_token_quota: null,
|
monthly_token_quota: null,
|
||||||
sort_order: 10,
|
sort_order: 10,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const foundersSubscription = {
|
||||||
|
plan: foundersPlan,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
source: 'stripe',
|
source: 'stripe',
|
||||||
needs_checkout: false,
|
needs_checkout: false,
|
||||||
stripe_subscription_id: 'sub_test',
|
stripe_subscription_id: 'sub_test',
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
current_period_end: '2026-08-01T00:00:00Z',
|
||||||
usage: {
|
usage: {
|
||||||
prompts_in_window: 2,
|
prompts_in_window: 2,
|
||||||
prompt_quota: 300,
|
prompt_quota: 300,
|
||||||
@@ -92,12 +99,20 @@ const foundersSubscription = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const complimentarySubscription = {
|
||||||
|
...foundersSubscription,
|
||||||
|
source: 'backer',
|
||||||
|
stripe_subscription_id: '',
|
||||||
|
};
|
||||||
|
|
||||||
const emptySubscription = {
|
const emptySubscription = {
|
||||||
plan: null,
|
plan: null,
|
||||||
status: 'none',
|
status: 'none',
|
||||||
source: 'none',
|
source: 'none',
|
||||||
needs_checkout: true,
|
needs_checkout: true,
|
||||||
stripe_subscription_id: '',
|
stripe_subscription_id: '',
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
current_period_end: null,
|
||||||
usage: {
|
usage: {
|
||||||
prompts_in_window: 0,
|
prompts_in_window: 0,
|
||||||
prompt_quota: null,
|
prompt_quota: null,
|
||||||
@@ -120,10 +135,12 @@ const mockFinanceGets = ({
|
|||||||
invoices = [] as unknown[],
|
invoices = [] as unknown[],
|
||||||
payments = [] as unknown[],
|
payments = [] as unknown[],
|
||||||
subscription = emptySubscription as SubscriptionMock,
|
subscription = emptySubscription as SubscriptionMock,
|
||||||
|
plans = [foundersPlan] as unknown[],
|
||||||
}: {
|
}: {
|
||||||
invoices?: unknown[];
|
invoices?: unknown[];
|
||||||
payments?: unknown[];
|
payments?: unknown[];
|
||||||
subscription?: SubscriptionMock;
|
subscription?: SubscriptionMock;
|
||||||
|
plans?: unknown[];
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
mockGet.mockImplementation((url: string) => {
|
mockGet.mockImplementation((url: string) => {
|
||||||
if (url === '/finance/invoices/') {
|
if (url === '/finance/invoices/') {
|
||||||
@@ -135,6 +152,9 @@ const mockFinanceGets = ({
|
|||||||
if (url === '/finance/subscription/') {
|
if (url === '/finance/subscription/') {
|
||||||
return Promise.resolve({ data: subscription });
|
return Promise.resolve({ data: subscription });
|
||||||
}
|
}
|
||||||
|
if (url === '/finance/plans/') {
|
||||||
|
return Promise.resolve({ data: plans });
|
||||||
|
}
|
||||||
return Promise.reject(new Error(`unexpected GET ${url}`));
|
return Promise.reject(new Error(`unexpected GET ${url}`));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -171,7 +191,7 @@ describe('BillingSection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders plan summary and invoice history from finance APIs', async () => {
|
it('renders plan summary and paid subscription management CTAs', async () => {
|
||||||
mockFinanceGets({
|
mockFinanceGets({
|
||||||
invoices: [paidInvoice],
|
invoices: [paidInvoice],
|
||||||
subscription: foundersSubscription,
|
subscription: foundersSubscription,
|
||||||
@@ -179,7 +199,10 @@ describe('BillingSection', () => {
|
|||||||
|
|
||||||
renderBilling();
|
renderBilling();
|
||||||
|
|
||||||
expect(await screen.findByRole('button', { name: /Manage subscription/i })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: /^Upgrade$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /Change plan/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^Cancel$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /Manage subscription/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText('Founders')).toBeInTheDocument();
|
expect(screen.getByText('Founders')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/298 \/ 300/)).toBeInTheDocument();
|
expect(screen.getByText(/298 \/ 300/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/in — · out —/)).toBeInTheDocument();
|
expect(screen.getByText(/in — · out —/)).toBeInTheDocument();
|
||||||
@@ -189,10 +212,11 @@ describe('BillingSection', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects to Stripe Customer Portal on manage billing', async () => {
|
it('opens Stripe portal for change plan when no alternate public plans', async () => {
|
||||||
mockFinanceGets({
|
mockFinanceGets({
|
||||||
invoices: [paidInvoice],
|
invoices: [paidInvoice],
|
||||||
subscription: foundersSubscription,
|
subscription: foundersSubscription,
|
||||||
|
plans: [foundersPlan],
|
||||||
});
|
});
|
||||||
mockPost.mockResolvedValue({
|
mockPost.mockResolvedValue({
|
||||||
data: { portal_url: 'https://billing.stripe.com/p/session/test' },
|
data: { portal_url: 'https://billing.stripe.com/p/session/test' },
|
||||||
@@ -201,20 +225,67 @@ describe('BillingSection', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderBilling();
|
renderBilling();
|
||||||
|
|
||||||
await screen.findByRole('button', { name: /Manage subscription/i });
|
await screen.findByRole('button', { name: /Change plan/i });
|
||||||
await user.click(screen.getByRole('button', { name: /Manage subscription/i }));
|
await user.click(screen.getByRole('button', { name: /Change plan/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockPost).toHaveBeenCalledWith('/finance/portal/', {
|
expect(mockPost).toHaveBeenCalledWith('/finance/portal/', {
|
||||||
return_url: 'http://localhost/account/',
|
return_url: 'http://localhost/account/',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(mockTrackEvent).toHaveBeenCalledWith('Billing Portal Opened');
|
expect(mockTrackEvent).toHaveBeenCalledWith('Plan Change Started', {
|
||||||
|
source: 'account_billing',
|
||||||
|
});
|
||||||
|
expect(mockTrackEvent).toHaveBeenCalledWith('Billing Portal Opened', {
|
||||||
|
intent: 'change',
|
||||||
|
});
|
||||||
expect(assignMock).toHaveBeenCalledWith(
|
expect(assignMock).toHaveBeenCalledWith(
|
||||||
'https://billing.stripe.com/p/session/test'
|
'https://billing.stripe.com/p/session/test'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('confirms cancel then opens portal', async () => {
|
||||||
|
mockFinanceGets({
|
||||||
|
invoices: [paidInvoice],
|
||||||
|
subscription: foundersSubscription,
|
||||||
|
});
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { portal_url: 'https://billing.stripe.com/p/session/cancel' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBilling();
|
||||||
|
|
||||||
|
await screen.findByRole('button', { name: /^Cancel$/i });
|
||||||
|
await user.click(screen.getByRole('button', { name: /^Cancel$/i }));
|
||||||
|
expect(await screen.findByTestId('cancel-confirm-modal')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /Continue to cancel/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockTrackEvent).toHaveBeenCalledWith('Subscription Cancel Started', {
|
||||||
|
source: 'portal',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(assignMock).toHaveBeenCalledWith(
|
||||||
|
'https://billing.stripe.com/p/session/cancel'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows complimentary messaging without cancel', async () => {
|
||||||
|
mockFinanceGets({
|
||||||
|
invoices: [],
|
||||||
|
subscription: complimentarySubscription,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderBilling();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByTestId('complimentary-billing-message')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /^Cancel$/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /^Upgrade$/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows empty unpaid state and starts checkout', async () => {
|
it('shows empty unpaid state and starts checkout', async () => {
|
||||||
mockFinanceGets();
|
mockFinanceGets();
|
||||||
mockPost.mockResolvedValue({
|
mockPost.mockResolvedValue({
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ import {
|
|||||||
formatBillingDate,
|
formatBillingDate,
|
||||||
formatMoneyCents,
|
formatMoneyCents,
|
||||||
formatTokenCount,
|
formatTokenCount,
|
||||||
|
higherSelectablePlans,
|
||||||
humanizeStatus,
|
humanizeStatus,
|
||||||
|
isComplimentarySubscription,
|
||||||
|
otherSelectablePlans,
|
||||||
pickPrimaryInvoice,
|
pickPrimaryInvoice,
|
||||||
SubscriptionMe,
|
SubscriptionMe,
|
||||||
|
SubscriptionPlanInfo,
|
||||||
} from '../../utils/finance';
|
} from '../../utils/finance';
|
||||||
|
|
||||||
const GlassCard = styled.div`
|
const GlassCard = styled.div`
|
||||||
@@ -69,6 +73,14 @@ const BodyText = styled.p`
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const NoticeText = styled.p`
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.85;
|
||||||
|
margin: 1rem 0 0 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
`;
|
||||||
|
|
||||||
const ErrorText = styled.p`
|
const ErrorText = styled.p`
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
margin: 0.75rem 0 0 0;
|
margin: 0.75rem 0 0 0;
|
||||||
@@ -116,6 +128,81 @@ const SecondaryButton = styled(StyledButton)`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const DangerButton = styled(SecondaryButton)`
|
||||||
|
border-color: #ff6b6b66;
|
||||||
|
color: #ff6b6b;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlanList = styled.ul`
|
||||||
|
list-style: none;
|
||||||
|
margin: 1rem 0 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlanOption = styled.li`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlanMeta = styled.div`
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlanName = styled.div`
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PlanDesc = styled.div`
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.7;
|
||||||
|
line-height: 1.4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ModalBackdrop = styled.div`
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1.5rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ModalCard = styled.div`
|
||||||
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.75rem;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ModalTitle = styled.h3`
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
font-size: 1.35rem;
|
||||||
|
`;
|
||||||
|
|
||||||
const StyledTable = styled.table`
|
const StyledTable = styled.table`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -154,32 +241,42 @@ function apiErrorMessage(error: unknown, fallback: string): string {
|
|||||||
return axiosError.response?.data?.detail || axiosError.message || fallback;
|
return axiosError.response?.data?.detail || axiosError.message || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PortalIntent = 'manage' | 'upgrade' | 'change' | 'cancel';
|
||||||
|
|
||||||
const BillingSection = (): JSX.Element => {
|
const BillingSection = (): JSX.Element => {
|
||||||
const [invoices, setInvoices] = useState<FinanceInvoice[]>([]);
|
const [invoices, setInvoices] = useState<FinanceInvoice[]>([]);
|
||||||
const [payments, setPayments] = useState<FinancePayment[]>([]);
|
const [payments, setPayments] = useState<FinancePayment[]>([]);
|
||||||
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
||||||
|
const [plans, setPlans] = useState<SubscriptionPlanInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [listError, setListError] = useState('');
|
const [listError, setListError] = useState('');
|
||||||
const [actionError, setActionError] = useState('');
|
const [actionError, setActionError] = useState('');
|
||||||
const [portalLoading, setPortalLoading] = useState(false);
|
const [portalLoading, setPortalLoading] = useState(false);
|
||||||
const [checkoutLoading, setCheckoutLoading] = useState(false);
|
const [checkoutLoadingSlug, setCheckoutLoadingSlug] = useState<string | null>(null);
|
||||||
|
const [showPlanPicker, setShowPlanPicker] = useState(false);
|
||||||
|
const [planPickerMode, setPlanPickerMode] = useState<'upgrade' | 'change'>('change');
|
||||||
|
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
|
||||||
|
|
||||||
const loadBilling = useCallback(async () => {
|
const loadBilling = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setListError('');
|
setListError('');
|
||||||
try {
|
try {
|
||||||
const [invoiceResponse, paymentResponse, subscriptionResponse] = await Promise.all([
|
const [invoiceResponse, paymentResponse, subscriptionResponse, plansResponse] =
|
||||||
|
await Promise.all([
|
||||||
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
|
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
|
||||||
axiosInstance.get<FinancePayment[]>('/finance/payments/'),
|
axiosInstance.get<FinancePayment[]>('/finance/payments/'),
|
||||||
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
|
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
|
||||||
|
axiosInstance.get<SubscriptionPlanInfo[]>('/finance/plans/'),
|
||||||
]);
|
]);
|
||||||
setInvoices(Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []);
|
setInvoices(Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []);
|
||||||
setPayments(Array.isArray(paymentResponse.data) ? paymentResponse.data : []);
|
setPayments(Array.isArray(paymentResponse.data) ? paymentResponse.data : []);
|
||||||
setSubscription(subscriptionResponse.data || null);
|
setSubscription(subscriptionResponse.data || null);
|
||||||
|
setPlans(Array.isArray(plansResponse.data) ? plansResponse.data : []);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setInvoices([]);
|
setInvoices([]);
|
||||||
setPayments([]);
|
setPayments([]);
|
||||||
setSubscription(null);
|
setSubscription(null);
|
||||||
|
setPlans([]);
|
||||||
setListError(apiErrorMessage(error, 'Could not load billing information.'));
|
setListError(apiErrorMessage(error, 'Could not load billing information.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -192,6 +289,23 @@ const BillingSection = (): JSX.Element => {
|
|||||||
|
|
||||||
const primaryInvoice = useMemo(() => pickPrimaryInvoice(invoices), [invoices]);
|
const primaryInvoice = useMemo(() => pickPrimaryInvoice(invoices), [invoices]);
|
||||||
const hasPortalAccess = useMemo(() => canOpenBillingPortal(invoices), [invoices]);
|
const hasPortalAccess = useMemo(() => canOpenBillingPortal(invoices), [invoices]);
|
||||||
|
const complimentary = useMemo(
|
||||||
|
() => isComplimentarySubscription(subscription, hasPortalAccess),
|
||||||
|
[subscription, hasPortalAccess]
|
||||||
|
);
|
||||||
|
const upgradePlans = useMemo(
|
||||||
|
() => higherSelectablePlans(plans, subscription?.plan),
|
||||||
|
[plans, subscription?.plan]
|
||||||
|
);
|
||||||
|
const changePlans = useMemo(
|
||||||
|
() => otherSelectablePlans(plans, subscription?.plan?.slug),
|
||||||
|
[plans, subscription?.plan?.slug]
|
||||||
|
);
|
||||||
|
const periodEndLabel = useMemo(() => {
|
||||||
|
const end =
|
||||||
|
subscription?.current_period_end || primaryInvoice?.period_end || null;
|
||||||
|
return formatBillingDate(end);
|
||||||
|
}, [subscription?.current_period_end, primaryInvoice?.period_end]);
|
||||||
|
|
||||||
const historyRows = useMemo(() => {
|
const historyRows = useMemo(() => {
|
||||||
if (invoices.length) {
|
if (invoices.length) {
|
||||||
@@ -217,7 +331,7 @@ const BillingSection = (): JSX.Element => {
|
|||||||
}));
|
}));
|
||||||
}, [invoices, payments]);
|
}, [invoices, payments]);
|
||||||
|
|
||||||
const handleManageBilling = async () => {
|
const openPortal = async (intent: PortalIntent) => {
|
||||||
setActionError('');
|
setActionError('');
|
||||||
setPortalLoading(true);
|
setPortalLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -231,7 +345,7 @@ const BillingSection = (): JSX.Element => {
|
|||||||
setActionError('Billing portal could not be opened. Try again.');
|
setActionError('Billing portal could not be opened. Try again.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED);
|
trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED, { intent });
|
||||||
window.location.assign(portalUrl);
|
window.location.assign(portalUrl);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setActionError(
|
setActionError(
|
||||||
@@ -242,15 +356,22 @@ const BillingSection = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartCheckout = async () => {
|
const handleStartCheckout = async (planSlug?: string, source = 'account_billing') => {
|
||||||
setActionError('');
|
setActionError('');
|
||||||
setCheckoutLoading(true);
|
setCheckoutLoadingSlug(planSlug || '__default__');
|
||||||
try {
|
try {
|
||||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||||
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'account_billing' });
|
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, {
|
||||||
|
source,
|
||||||
|
...(planSlug ? { plan_slug: planSlug } : {}),
|
||||||
|
});
|
||||||
const response = await axiosInstance.post<{ checkout_url: string }>(
|
const response = await axiosInstance.post<{ checkout_url: string }>(
|
||||||
'/finance/checkout/',
|
'/finance/checkout/',
|
||||||
{ success_url, cancel_url }
|
{
|
||||||
|
success_url,
|
||||||
|
cancel_url,
|
||||||
|
...(planSlug ? { plan_slug: planSlug } : {}),
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const checkoutUrl = response.data?.checkout_url;
|
const checkoutUrl = response.data?.checkout_url;
|
||||||
if (!checkoutUrl) {
|
if (!checkoutUrl) {
|
||||||
@@ -261,10 +382,59 @@ const BillingSection = (): JSX.Element => {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setActionError(apiErrorMessage(error, 'Could not start checkout. Try again.'));
|
setActionError(apiErrorMessage(error, 'Could not start checkout. Try again.'));
|
||||||
} finally {
|
} finally {
|
||||||
setCheckoutLoading(false);
|
setCheckoutLoadingSlug(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpgradeClick = () => {
|
||||||
|
trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, {
|
||||||
|
source: 'account_billing',
|
||||||
|
});
|
||||||
|
if (upgradePlans.length > 0) {
|
||||||
|
setPlanPickerMode('upgrade');
|
||||||
|
setShowPlanPicker(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void openPortal('upgrade');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePlanClick = () => {
|
||||||
|
trackEvent(AnalyticsEvents.PLAN_CHANGE_STARTED, { source: 'account_billing' });
|
||||||
|
if (changePlans.length > 0) {
|
||||||
|
setPlanPickerMode('change');
|
||||||
|
setShowPlanPicker(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void openPortal('change');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmCancel = async () => {
|
||||||
|
trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'portal' });
|
||||||
|
setCancelConfirmOpen(false);
|
||||||
|
await openPortal('cancel');
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickerPlans = planPickerMode === 'upgrade' ? upgradePlans : changePlans;
|
||||||
|
|
||||||
|
const selectPlanFromPicker = async (plan: SubscriptionPlanInfo) => {
|
||||||
|
if (planPickerMode === 'upgrade') {
|
||||||
|
trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, {
|
||||||
|
source: 'plan_picker',
|
||||||
|
plan_slug: plan.slug,
|
||||||
|
});
|
||||||
|
// New higher tier via Checkout when selectable; portal otherwise.
|
||||||
|
await handleStartCheckout(plan.slug, 'account_upgrade');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackEvent(AnalyticsEvents.PLAN_CHANGE_STARTED, {
|
||||||
|
source: 'plan_picker',
|
||||||
|
plan_slug: plan.slug,
|
||||||
|
});
|
||||||
|
// Existing subscribers change plans in the Stripe portal (proration / PCI).
|
||||||
|
setShowPlanPicker(false);
|
||||||
|
await openPortal('change');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GlassCard data-testid="billing-section">
|
<GlassCard data-testid="billing-section">
|
||||||
@@ -331,13 +501,19 @@ const BillingSection = (): JSX.Element => {
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{primaryInvoice ? (
|
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingLabel>Period end</SettingLabel>
|
<SettingLabel>Period end</SettingLabel>
|
||||||
<SettingValue>
|
<SettingValue>{periodEndLabel}</SettingValue>
|
||||||
{formatBillingDate(primaryInvoice.period_end)}
|
|
||||||
</SettingValue>
|
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
{subscription?.cancel_at_period_end ? (
|
||||||
|
<NoticeText data-testid="cancel-scheduled-notice">
|
||||||
|
Cancellation scheduled. You keep access until {periodEndLabel}.
|
||||||
|
</NoticeText>
|
||||||
|
) : null}
|
||||||
|
{subscription?.status === 'canceled' ? (
|
||||||
|
<NoticeText>
|
||||||
|
Subscription canceled. Renew via Complete payment when you are ready.
|
||||||
|
</NoticeText>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -349,25 +525,52 @@ const BillingSection = (): JSX.Element => {
|
|||||||
|
|
||||||
{!loading && !listError && (
|
{!loading && !listError && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
{hasPortalAccess ? (
|
{hasPortalAccess && !complimentary ? (
|
||||||
|
<>
|
||||||
<StyledButton
|
<StyledButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleManageBilling}
|
onClick={handleUpgradeClick}
|
||||||
|
disabled={portalLoading || Boolean(checkoutLoadingSlug)}
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</StyledButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleChangePlanClick}
|
||||||
|
disabled={portalLoading || Boolean(checkoutLoadingSlug)}
|
||||||
|
>
|
||||||
|
Change plan
|
||||||
|
</SecondaryButton>
|
||||||
|
{!subscription?.cancel_at_period_end &&
|
||||||
|
subscription?.status !== 'canceled' ? (
|
||||||
|
<DangerButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCancelConfirmOpen(true)}
|
||||||
|
disabled={portalLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</DangerButton>
|
||||||
|
) : null}
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => openPortal('manage')}
|
||||||
disabled={portalLoading}
|
disabled={portalLoading}
|
||||||
>
|
>
|
||||||
{portalLoading ? 'Opening…' : 'Manage subscription'}
|
{portalLoading ? 'Opening…' : 'Manage subscription'}
|
||||||
</StyledButton>
|
</SecondaryButton>
|
||||||
) : subscription?.needs_checkout === false ? (
|
</>
|
||||||
<BodyText style={{ margin: 0 }}>
|
) : complimentary ? (
|
||||||
Complimentary access — no payment required.
|
<BodyText style={{ margin: 0 }} data-testid="complimentary-billing-message">
|
||||||
|
Complimentary access — no payment required. Plan changes and
|
||||||
|
cancellation are not available for this account.
|
||||||
</BodyText>
|
</BodyText>
|
||||||
) : (
|
) : (
|
||||||
<StyledButton
|
<StyledButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleStartCheckout}
|
onClick={() => handleStartCheckout()}
|
||||||
disabled={checkoutLoading}
|
disabled={Boolean(checkoutLoadingSlug)}
|
||||||
>
|
>
|
||||||
{checkoutLoading ? 'Starting…' : 'Complete payment'}
|
{checkoutLoadingSlug ? 'Starting…' : 'Complete payment'}
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
)}
|
)}
|
||||||
<SecondaryButton type="button" onClick={loadBilling} disabled={loading}>
|
<SecondaryButton type="button" onClick={loadBilling} disabled={loading}>
|
||||||
@@ -375,6 +578,52 @@ const BillingSection = (): JSX.Element => {
|
|||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</ButtonRow>
|
</ButtonRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showPlanPicker && pickerPlans.length > 0 ? (
|
||||||
|
<div data-testid="plan-picker">
|
||||||
|
<BodyText style={{ marginTop: '1.25rem', marginBottom: 0 }}>
|
||||||
|
{planPickerMode === 'upgrade'
|
||||||
|
? 'Choose a higher plan. Checkout opens securely in Stripe.'
|
||||||
|
: 'Select another plan, then confirm the change in the Stripe customer portal (price and quotas update there).'}
|
||||||
|
</BodyText>
|
||||||
|
<PlanList>
|
||||||
|
{pickerPlans.map((plan) => (
|
||||||
|
<PlanOption key={plan.slug}>
|
||||||
|
<PlanMeta>
|
||||||
|
<PlanName>{plan.name}</PlanName>
|
||||||
|
<PlanDesc>
|
||||||
|
{formatMoneyCents(plan.price_cents, plan.currency)}
|
||||||
|
{plan.interval ? ` / ${plan.interval}` : ''}
|
||||||
|
{plan.description ? ` — ${plan.description}` : ''}
|
||||||
|
{` · ${plan.prompt_quota_per_window} prompts / ${plan.prompt_window_hours}h`}
|
||||||
|
</PlanDesc>
|
||||||
|
</PlanMeta>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectPlanFromPicker(plan)}
|
||||||
|
disabled={
|
||||||
|
portalLoading ||
|
||||||
|
checkoutLoadingSlug === plan.slug ||
|
||||||
|
checkoutLoadingSlug === '__default__'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{checkoutLoadingSlug === plan.slug
|
||||||
|
? 'Starting…'
|
||||||
|
: planPickerMode === 'upgrade'
|
||||||
|
? 'Upgrade'
|
||||||
|
: 'Select'}
|
||||||
|
</StyledButton>
|
||||||
|
</PlanOption>
|
||||||
|
))}
|
||||||
|
</PlanList>
|
||||||
|
<ButtonRow>
|
||||||
|
<SecondaryButton type="button" onClick={() => setShowPlanPicker(false)}>
|
||||||
|
Close
|
||||||
|
</SecondaryButton>
|
||||||
|
</ButtonRow>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null}
|
{actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
@@ -421,6 +670,44 @@ const BillingSection = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
|
{cancelConfirmOpen ? (
|
||||||
|
<ModalBackdrop
|
||||||
|
role="presentation"
|
||||||
|
onClick={() => setCancelConfirmOpen(false)}
|
||||||
|
data-testid="cancel-confirm-modal"
|
||||||
|
>
|
||||||
|
<ModalCard
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="cancel-subscription-title"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ModalTitle id="cancel-subscription-title">Cancel subscription?</ModalTitle>
|
||||||
|
<BodyText>
|
||||||
|
You will finish canceling in the Stripe customer portal. Access typically
|
||||||
|
continues until the end of the current billing period
|
||||||
|
{periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.
|
||||||
|
</BodyText>
|
||||||
|
<ButtonRow>
|
||||||
|
<DangerButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirmCancel}
|
||||||
|
disabled={portalLoading}
|
||||||
|
>
|
||||||
|
{portalLoading ? 'Opening…' : 'Continue to cancel'}
|
||||||
|
</DangerButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCancelConfirmOpen(false)}
|
||||||
|
disabled={portalLoading}
|
||||||
|
>
|
||||||
|
Keep subscription
|
||||||
|
</SecondaryButton>
|
||||||
|
</ButtonRow>
|
||||||
|
</ModalCard>
|
||||||
|
</ModalBackdrop>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ThemeProvider } from 'styled-components';
|
||||||
|
import { AccountContext } from '../../contexts/AccountContext';
|
||||||
|
import { AuthContext } from '../../contexts/AuthContext';
|
||||||
|
import DeleteAccountSection from './DeleteAccountSection';
|
||||||
|
|
||||||
|
const mockDelete = jest.fn();
|
||||||
|
const mockPost = jest.fn();
|
||||||
|
const mockClearTokens = jest.fn();
|
||||||
|
const mockGetRefreshToken = jest.fn();
|
||||||
|
const mockApplyAccessToken = jest.fn();
|
||||||
|
const mockNavigate = jest.fn();
|
||||||
|
const mockSetAccount = jest.fn();
|
||||||
|
const mockSetAuthentication = jest.fn();
|
||||||
|
const mockTrackEvent = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('../../../axiosApi', () => ({
|
||||||
|
axiosInstance: {
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
},
|
||||||
|
applyAccessToken: (...args: unknown[]) => mockApplyAccessToken(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../auth/tokenStorage', () => ({
|
||||||
|
clearTokens: (...args: unknown[]) => mockClearTokens(...args),
|
||||||
|
getRefreshToken: (...args: unknown[]) => mockGetRefreshToken(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../utils/analytics', () => ({
|
||||||
|
AnalyticsEvents: {
|
||||||
|
ACCOUNT_DELETE_STARTED: 'Account Delete Started',
|
||||||
|
ACCOUNT_DELETE_SUCCESS: 'Account Delete Success',
|
||||||
|
ACCOUNT_DELETE_FAILED: 'Account Delete Failed',
|
||||||
|
},
|
||||||
|
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
main: '#4a90e2',
|
||||||
|
darkMode: true,
|
||||||
|
colors: {
|
||||||
|
text: '#ffffff',
|
||||||
|
cardBackground: 'rgba(0,0,0,0.4)',
|
||||||
|
cardBorder: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSection = () =>
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
authenticated: true,
|
||||||
|
setAuthentication: mockSetAuthentication,
|
||||||
|
needsNewPassword: false,
|
||||||
|
setNeedsNewPassword: () => {},
|
||||||
|
loading: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccountContext.Provider
|
||||||
|
value={{
|
||||||
|
account: { email: 'user@example.com' } as never,
|
||||||
|
setAccount: mockSetAccount,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteAccountSection />
|
||||||
|
</AccountContext.Provider>
|
||||||
|
</AuthContext.Provider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('DeleteAccountSection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDelete.mockReset();
|
||||||
|
mockPost.mockReset();
|
||||||
|
mockClearTokens.mockReset();
|
||||||
|
mockGetRefreshToken.mockReset();
|
||||||
|
mockApplyAccessToken.mockReset();
|
||||||
|
mockNavigate.mockReset();
|
||||||
|
mockSetAccount.mockReset();
|
||||||
|
mockSetAuthentication.mockReset();
|
||||||
|
mockTrackEvent.mockReset();
|
||||||
|
mockGetRefreshToken.mockReturnValue('refresh-token');
|
||||||
|
mockClearTokens.mockResolvedValue(undefined);
|
||||||
|
mockDelete.mockResolvedValue({ data: { deleted: true } });
|
||||||
|
mockPost.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires email confirmation then deletes and signs out', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderSection();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /Delete my account/i }));
|
||||||
|
expect(await screen.findByTestId('delete-account-modal')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/Confirm email/i), 'user@example.com');
|
||||||
|
await user.click(screen.getByRole('button', { name: /^Delete account$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/user/', {
|
||||||
|
data: { refresh_token: 'refresh-token' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(mockTrackEvent).toHaveBeenCalledWith('Account Delete Started');
|
||||||
|
expect(mockTrackEvent).toHaveBeenCalledWith('Account Delete Success');
|
||||||
|
expect(mockClearTokens).toHaveBeenCalled();
|
||||||
|
expect(mockApplyAccessToken).toHaveBeenCalledWith(null);
|
||||||
|
expect(mockSetAuthentication).toHaveBeenCalledWith(false);
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/signin/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import React, { useContext, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||||
|
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
||||||
|
import { AccountContext } from '../../contexts/AccountContext';
|
||||||
|
import { AuthContext } from '../../contexts/AuthContext';
|
||||||
|
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
|
||||||
|
|
||||||
|
const GlassCard = styled.div`
|
||||||
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CardTitle = styled.h2`
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
border-bottom: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BodyText = styled.p`
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
opacity: 0.75;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ErrorText = styled.p`
|
||||||
|
color: #ff6b6b;
|
||||||
|
margin: 0.75rem 0 0 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ButtonRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DangerButton = styled.button`
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #ff6b6b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #ff6b6b;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #ff6b6b22;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SecondaryButton = styled.button`
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ConfirmInput = styled.input`
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: ${({ theme }) =>
|
||||||
|
theme.darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'};
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ModalBackdrop = styled.div`
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1.5rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ModalCard = styled.div`
|
||||||
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.75rem;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ModalTitle = styled.h3`
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
color: ${({ theme }) => theme.colors.text};
|
||||||
|
font-size: 1.35rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function apiErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: { data?: { detail?: string } };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
return axiosError.response?.data?.detail || axiosError.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteAccountSection = (): JSX.Element => {
|
||||||
|
const { account, setAccount } = useContext(AccountContext);
|
||||||
|
const { setAuthentication } = useContext(AuthContext);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [confirmEmail, setConfirmEmail] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const email = account?.email || '';
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
if (loading) return;
|
||||||
|
setConfirmOpen(false);
|
||||||
|
setConfirmEmail('');
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!email || confirmEmail.trim().toLowerCase() !== email.toLowerCase()) {
|
||||||
|
setError('Type your account email exactly to confirm.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_STARTED);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = getRefreshToken();
|
||||||
|
await axiosInstance.delete('/user/', {
|
||||||
|
data: refreshToken ? { refresh_token: refreshToken } : {},
|
||||||
|
});
|
||||||
|
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_SUCCESS);
|
||||||
|
try {
|
||||||
|
if (refreshToken) {
|
||||||
|
await axiosInstance.post('blacklist/', { refresh_token: refreshToken });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Account already deleted; local cleanup is enough.
|
||||||
|
}
|
||||||
|
await clearTokens();
|
||||||
|
applyAccessToken(null);
|
||||||
|
setAuthentication(false);
|
||||||
|
setAccount(undefined);
|
||||||
|
navigate('/signin/');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_FAILED);
|
||||||
|
setError(apiErrorMessage(err, 'Could not delete account. Try again.'));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GlassCard data-testid="delete-account-section">
|
||||||
|
<CardTitle>Delete account</CardTitle>
|
||||||
|
<BodyText>
|
||||||
|
Permanently deactivate your account. Conversations are hidden and you will
|
||||||
|
be signed out. This cannot be undone from the app.
|
||||||
|
</BodyText>
|
||||||
|
<DangerButton type="button" onClick={() => setConfirmOpen(true)}>
|
||||||
|
Delete my account
|
||||||
|
</DangerButton>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
{confirmOpen ? (
|
||||||
|
<ModalBackdrop
|
||||||
|
role="presentation"
|
||||||
|
onClick={closeModal}
|
||||||
|
data-testid="delete-account-modal"
|
||||||
|
>
|
||||||
|
<ModalCard
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="delete-account-title"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ModalTitle id="delete-account-title">Delete your account?</ModalTitle>
|
||||||
|
<BodyText>
|
||||||
|
Type <strong>{email || 'your email'}</strong> to confirm. You will lose
|
||||||
|
access immediately after deletion.
|
||||||
|
</BodyText>
|
||||||
|
<ConfirmInput
|
||||||
|
type="email"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Confirm email"
|
||||||
|
value={confirmEmail}
|
||||||
|
onChange={(event) => setConfirmEmail(event.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
aria-label="Confirm email"
|
||||||
|
/>
|
||||||
|
{error ? <ErrorText role="alert">{error}</ErrorText> : null}
|
||||||
|
<ButtonRow>
|
||||||
|
<DangerButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={loading || !confirmEmail}
|
||||||
|
>
|
||||||
|
{loading ? 'Deleting…' : 'Delete account'}
|
||||||
|
</DangerButton>
|
||||||
|
<SecondaryButton type="button" onClick={closeModal} disabled={loading}>
|
||||||
|
Keep account
|
||||||
|
</SecondaryButton>
|
||||||
|
</ButtonRow>
|
||||||
|
</ModalCard>
|
||||||
|
</ModalBackdrop>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteAccountSection;
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { axiosInstance } from '../../../axiosApi';
|
import { axiosInstance } from '../../../axiosApi';
|
||||||
|
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
|
||||||
import {
|
import {
|
||||||
|
canOpenBillingPortal,
|
||||||
formatBillingDate,
|
formatBillingDate,
|
||||||
formatTokenCount,
|
formatTokenCount,
|
||||||
|
isComplimentarySubscription,
|
||||||
SubscriptionMe,
|
SubscriptionMe,
|
||||||
} from '../../utils/finance';
|
} from '../../utils/finance';
|
||||||
|
import type { FinanceInvoice } from '../../utils/finance';
|
||||||
|
|
||||||
const GlassCard = styled.div`
|
const GlassCard = styled.div`
|
||||||
background: ${({ theme }) => theme.colors.cardBackground};
|
background: ${({ theme }) => theme.colors.cardBackground};
|
||||||
@@ -106,6 +110,27 @@ const UpgradeHint = styled.p`
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const UpgradeButton = styled.button`
|
||||||
|
margin-top: 1rem;
|
||||||
|
background: ${({ theme }) => theme.main};
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.7rem 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
function clampPct(used: number, quota: number): number {
|
function clampPct(used: number, quota: number): number {
|
||||||
if (quota <= 0) return 0;
|
if (quota <= 0) return 0;
|
||||||
return Math.min(100, Math.round((used / quota) * 1000) / 10);
|
return Math.min(100, Math.round((used / quota) * 1000) / 10);
|
||||||
@@ -141,15 +166,26 @@ const UsageMeter = ({ label, meta, usedLabel, pct }: MeterProps): JSX.Element =>
|
|||||||
|
|
||||||
const UsageSummaryCard = (): JSX.Element => {
|
const UsageSummaryCard = (): JSX.Element => {
|
||||||
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
||||||
|
const [hasPortalAccess, setHasPortalAccess] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [upgradeLoading, setUpgradeLoading] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await axiosInstance.get<SubscriptionMe>('/finance/subscription/');
|
const [subscriptionResponse, invoiceResponse] = await Promise.all([
|
||||||
setSubscription(response.data || null);
|
axiosInstance.get<SubscriptionMe>('/finance/subscription/'),
|
||||||
|
axiosInstance.get<FinanceInvoice[]>('/finance/invoices/'),
|
||||||
|
]);
|
||||||
|
setSubscription(subscriptionResponse.data || null);
|
||||||
|
setHasPortalAccess(
|
||||||
|
canOpenBillingPortal(
|
||||||
|
Array.isArray(invoiceResponse.data) ? invoiceResponse.data : []
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
setSubscription(null);
|
setSubscription(null);
|
||||||
|
setHasPortalAccess(false);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -174,6 +210,39 @@ const UsageSummaryCard = (): JSX.Element => {
|
|||||||
? 0
|
? 0
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const complimentary = isComplimentarySubscription(subscription, hasPortalAccess);
|
||||||
|
const showUpgrade = Boolean(subscription?.plan) && hasPortalAccess && !complimentary;
|
||||||
|
|
||||||
|
const handleUpgrade = async () => {
|
||||||
|
trackEvent(AnalyticsEvents.SUBSCRIPTION_UPGRADE_STARTED, {
|
||||||
|
source: 'usage_card',
|
||||||
|
});
|
||||||
|
const billing = document.querySelector('[data-testid="billing-section"]');
|
||||||
|
if (billing) {
|
||||||
|
billing.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
const buttons = Array.from(billing.querySelectorAll('button'));
|
||||||
|
const upgrade = buttons.find((btn) =>
|
||||||
|
/upgrade/i.test(btn.textContent || '')
|
||||||
|
);
|
||||||
|
upgrade?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUpgradeLoading(true);
|
||||||
|
try {
|
||||||
|
const returnUrl = `${window.location.origin}/account/`;
|
||||||
|
const response = await axiosInstance.post<{ portal_url: string }>(
|
||||||
|
'/finance/portal/',
|
||||||
|
{ return_url: returnUrl }
|
||||||
|
);
|
||||||
|
if (response.data?.portal_url) {
|
||||||
|
trackEvent(AnalyticsEvents.BILLING_PORTAL_OPENED, { intent: 'upgrade' });
|
||||||
|
window.location.assign(response.data.portal_url);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUpgradeLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard data-testid="usage-summary-card">
|
<GlassCard data-testid="usage-summary-card">
|
||||||
<CardTitle>Usage limit</CardTitle>
|
<CardTitle>Usage limit</CardTitle>
|
||||||
@@ -224,9 +293,27 @@ const UsageSummaryCard = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showUpgrade ? (
|
||||||
|
<>
|
||||||
|
<UpgradeHint>
|
||||||
|
Need more capacity? Upgrade your plan in Billing, or open upgrade
|
||||||
|
below.
|
||||||
|
</UpgradeHint>
|
||||||
|
<UpgradeButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleUpgrade}
|
||||||
|
disabled={upgradeLoading}
|
||||||
|
>
|
||||||
|
{upgradeLoading ? 'Opening…' : 'Upgrade'}
|
||||||
|
</UpgradeButton>
|
||||||
|
</>
|
||||||
|
) : complimentary ? (
|
||||||
|
<UpgradeHint>Complimentary access — usage limits follow your granted plan.</UpgradeHint>
|
||||||
|
) : (
|
||||||
<UpgradeHint>
|
<UpgradeHint>
|
||||||
Need more capacity? Manage or upgrade your plan in Billing below.
|
Need more capacity? Manage or upgrade your plan in Billing below.
|
||||||
</UpgradeHint>
|
</UpgradeHint>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import styled from "styled-components";
|
|||||||
import ThemeSettingsCard from "../../components/ThemeSettingsCard/ThemeSettingsCard";
|
import ThemeSettingsCard from "../../components/ThemeSettingsCard/ThemeSettingsCard";
|
||||||
import UsageSummaryCard from "../../components/UsageSummaryCard/UsageSummaryCard";
|
import UsageSummaryCard from "../../components/UsageSummaryCard/UsageSummaryCard";
|
||||||
import BillingSection from "../../components/BillingSection/BillingSection";
|
import BillingSection from "../../components/BillingSection/BillingSection";
|
||||||
|
import DeleteAccountSection from "../../components/DeleteAccountSection/DeleteAccountSection";
|
||||||
|
|
||||||
// Styled Components
|
// Styled Components
|
||||||
const PageContainer = styled.div`
|
const PageContainer = styled.div`
|
||||||
@@ -419,6 +420,7 @@ const AccountPage = (): JSX.Element => {
|
|||||||
<p style={{ color: 'rgba(255,255,255,0.7)' }}>Account and prompt information will be available soon</p>
|
<p style={{ color: 'rgba(255,255,255,0.7)' }}>Account and prompt information will be available soon</p>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
|
<DeleteAccountSection />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ const queue: QueuedCall[] = [];
|
|||||||
* | Conversation Created | New chat id assigned over WS |
|
* | Conversation Created | New chat id assigned over WS |
|
||||||
* | Message Sent | User submits prompt (no content) |
|
* | Message Sent | User submits prompt (no content) |
|
||||||
* | ToS Acknowledged | POST acknowledge_tos succeeds |
|
* | ToS Acknowledged | POST acknowledge_tos succeeds |
|
||||||
* | Billing Portal Opened | When #33 portal CTA ships |
|
* | Billing Portal Opened | Account manage / portal CTAs |
|
||||||
|
* | Subscription Upgrade Started | Upgrade intent (#75) |
|
||||||
|
* | Plan Change Started | Change-plan intent (#75) |
|
||||||
|
* | Subscription Cancel Started | Cancel intent (#75) |
|
||||||
|
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
|
||||||
*/
|
*/
|
||||||
export const AnalyticsEvents = {
|
export const AnalyticsEvents = {
|
||||||
LOGIN_SUCCESS: 'Login Success',
|
LOGIN_SUCCESS: 'Login Success',
|
||||||
@@ -36,6 +40,12 @@ export const AnalyticsEvents = {
|
|||||||
MESSAGE_SENT: 'Message Sent',
|
MESSAGE_SENT: 'Message Sent',
|
||||||
TOS_ACKNOWLEDGED: 'ToS Acknowledged',
|
TOS_ACKNOWLEDGED: 'ToS Acknowledged',
|
||||||
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
||||||
|
SUBSCRIPTION_UPGRADE_STARTED: 'Subscription Upgrade Started',
|
||||||
|
PLAN_CHANGE_STARTED: 'Plan Change Started',
|
||||||
|
SUBSCRIPTION_CANCEL_STARTED: 'Subscription Cancel Started',
|
||||||
|
ACCOUNT_DELETE_STARTED: 'Account Delete Started',
|
||||||
|
ACCOUNT_DELETE_SUCCESS: 'Account Delete Success',
|
||||||
|
ACCOUNT_DELETE_FAILED: 'Account Delete Failed',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
||||||
|
|||||||
@@ -74,9 +74,44 @@ export type SubscriptionMe = {
|
|||||||
source: string;
|
source: string;
|
||||||
needs_checkout: boolean;
|
needs_checkout: boolean;
|
||||||
stripe_subscription_id: string;
|
stripe_subscription_id: string;
|
||||||
|
cancel_at_period_end?: boolean;
|
||||||
|
current_period_end?: string | null;
|
||||||
usage: SubscriptionUsage;
|
usage: SubscriptionUsage;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SubscriptionSource = 'none' | 'stripe' | 'backer' | 'admin' | string;
|
||||||
|
|
||||||
|
/** Complimentary / admin-granted access — no Stripe cancel/change. */
|
||||||
|
export function isComplimentarySubscription(
|
||||||
|
subscription: Pick<SubscriptionMe, 'source' | 'needs_checkout'> | null | undefined,
|
||||||
|
hasPortalAccess: boolean
|
||||||
|
): boolean {
|
||||||
|
if (!subscription) return false;
|
||||||
|
if (subscription.source === 'backer' || subscription.source === 'admin') return true;
|
||||||
|
return !hasPortalAccess && subscription.needs_checkout === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Higher-priced selectable plans relative to the current plan. */
|
||||||
|
export function higherSelectablePlans(
|
||||||
|
plans: SubscriptionPlanInfo[],
|
||||||
|
current: SubscriptionPlanInfo | null | undefined
|
||||||
|
): SubscriptionPlanInfo[] {
|
||||||
|
const selectable = plans.filter((p) => p.is_selectable);
|
||||||
|
if (!current) return selectable;
|
||||||
|
return selectable.filter(
|
||||||
|
(p) =>
|
||||||
|
p.slug !== current.slug &&
|
||||||
|
(p.price_cents > current.price_cents || p.sort_order > current.sort_order)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function otherSelectablePlans(
|
||||||
|
plans: SubscriptionPlanInfo[],
|
||||||
|
currentSlug: string | null | undefined
|
||||||
|
): SubscriptionPlanInfo[] {
|
||||||
|
return plans.filter((p) => p.is_selectable && p.slug !== currentSlug);
|
||||||
|
}
|
||||||
|
|
||||||
/** Display provider-reported token counts; never invent 0 for missing usage. */
|
/** Display provider-reported token counts; never invent 0 for missing usage. */
|
||||||
export function formatTokenCount(value: number | null | undefined): string {
|
export function formatTokenCount(value: number | null | undefined): string {
|
||||||
if (value === null || value === undefined) return '—';
|
if (value === null || value === undefined) return '—';
|
||||||
|
|||||||
Reference in New Issue
Block a user