From 5d438668472e6ccb6f09b13ceda92c3d540dcb61 Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 19 Jul 2026 02:48:12 +0300 Subject: [PATCH] fix: show every plan in onboarding and let finished steps be reopened The plan step hardcoded Free and Pro inline, so Scale never appeared no matter what the pricing page offered. Tiers now come from lib/plans, which mirrors the marketing pricing section, with a test pinning the values so the two cannot drift silently. Kept static rather than fetched from /billing/plans. That endpoint returns Plan documents (limits and cents), not customer-facing copy, and an environment with no plans rows left the step showing "plans could not be loaded" with nothing to choose. Reopening a completed step showed an empty row. Two things caused it: the body was gated on the step not being done, and an effect cleared the selection whenever the selected step was done, so clicking a finished step deselected it on the next render. That effect exists to advance you when the step you are sitting on completes underneath you, so it now fires only on that transition. Both paths are covered: reopening the API key step offers "Generate another API key", and a step completing while selected still moves the selection on. Skip is hidden on an already-finished step, where it would mean nothing. Also corrects the Plan type, which declared amount, currency and recurringInterval. The plans endpoint has never sent those; they belong to Subscription. Anything reading plan.amount saw undefined and rendered a paid plan as free. The fixture encoded the same wrong shape, so tests would have passed while production broke. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../(components)/generate-api-key.tsx | 16 +- .../(components)/get-started/index.tsx | 12 +- .../get-started/onboarding-states.test.tsx | 67 +++++ .../get-started/plan-picker.test.tsx | 94 +++++++ .../(components)/get-started/plan-picker.tsx | 251 ++++++++++++------ .../(components)/get-started/step-actions.tsx | 35 ++- .../(components)/get-started/steps.ts | 9 + .../get-started/use-onboarding.ts | 24 +- web/lib/api/types.ts | 11 +- web/lib/plans.test.ts | 67 +++++ web/lib/plans.ts | 94 +++++++ web/test/fixtures.ts | 35 ++- 12 files changed, 601 insertions(+), 114 deletions(-) create mode 100644 web/app/(app)/dashboard/(components)/get-started/plan-picker.test.tsx create mode 100644 web/lib/plans.test.ts create mode 100644 web/lib/plans.ts diff --git a/web/app/(app)/dashboard/(components)/generate-api-key.tsx b/web/app/(app)/dashboard/(components)/generate-api-key.tsx index e9a47e6..6c5de78 100644 --- a/web/app/(app)/dashboard/(components)/generate-api-key.tsx +++ b/web/app/(app)/dashboard/(components)/generate-api-key.tsx @@ -22,10 +22,20 @@ export type GenerateApiKeyHandle = { type GenerateApiKeyProps = { showTrigger?: boolean + // Lets a caller say "Generate another API key" where a key already exists. + triggerLabel?: string + triggerVariant?: 'default' | 'outline' } const GenerateApiKey = forwardRef( - function GenerateApiKey({ showTrigger = true }, ref) { + function GenerateApiKey( + { + showTrigger = true, + triggerLabel = 'Generate API Key', + triggerVariant = 'default', + }, + ref + ) { const [isGenerateKeyModalOpen, setIsGenerateKeyModalOpen] = useState(false) const [isConfirmGenerateKeyModalOpen, setIsConfirmGenerateKeyModalOpen] = useState(false) @@ -71,9 +81,9 @@ const GenerateApiKey = forwardRef( return ( <> {showTrigger ? ( - ) : null} diff --git a/web/app/(app)/dashboard/(components)/get-started/index.tsx b/web/app/(app)/dashboard/(components)/get-started/index.tsx index fa72891..14fd028 100644 --- a/web/app/(app)/dashboard/(components)/get-started/index.tsx +++ b/web/app/(app)/dashboard/(components)/get-started/index.tsx @@ -264,14 +264,22 @@ export default function GetStartedCard() { )} - {isActive && !step.isDone && ( + {/* Rendered whenever the step is selected, done or not. It + used to require !isDone, so opening a completed step + showed an empty row: someone who wanted to generate a + replacement API key had no way back to that action. The + tick stays; only the body comes back. */} + {isActive && (

- {step.description} + {step.isDone + ? step.doneDescription ?? step.description + : step.description}

{ await waitFor(() => expect(screen.getByText('6 of 6')).toBeInTheDocument()) expect(screen.getByText('All steps complete!')).toBeInTheDocument() }) + + // Reopening a finished step used to render an empty row: the body was gated + // on the step NOT being done. Someone who wanted to replace a leaked or lost + // API key had no route back to that action from the checklist. + it('lets a completed API key step be reopened to generate another key', async () => { + const user = userEvent.setup() + renderWithProviders() + + await waitFor(() => expect(screen.getByText('6 of 6')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Generate an API key' })) + + // The action is back, and says plainly that it makes an additional key. + expect( + await screen.findByRole('button', { name: /generate another api key/i }) + ).toBeInTheDocument() + expect(screen.getByText(/You already have a key/)).toBeInTheDocument() + }) + + // The counterpart to the test above. Allowing a done step to stay selected + // must not break the original behaviour: if the step you are sitting on + // completes underneath you (the card polls), the selection should move on + // rather than stranding you on a finished step. + it('advances off a step that completes while you are sitting on it', async () => { + const user = userEvent.setup() + let apiKeyCount = 0 + server.use( + http.get(`${API_BASE_URL}/gateway/stats`, () => + HttpResponse.json({ + data: { + totalSentSMSCount: 0, + totalReceivedSMSCount: 0, + totalDeviceCount: 0, + totalApiKeyCount: apiKeyCount, + }, + }) + ) + ) + + renderWithProviders() + + // Done with these stats: verify_email (fixture user is verified) and + // choose_plan (fixture subscription is a paid plan). + await waitFor(() => expect(screen.getByText('2 of 6')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Generate an API key' })) + expect( + await screen.findByRole('button', { name: /^generate api key$/i }) + ).toBeInTheDocument() + + // The key now exists; the next poll completes the step under the user. + apiKeyCount = 3 + + await waitFor( + () => expect(screen.getByText('3 of 6')).toBeInTheDocument(), + { timeout: 15000 } + ) + + // The selection moved on rather than stranding the user on a finished + // step, so that step's action is no longer the one being offered. + await waitFor(() => + expect( + screen.queryByRole('button', { name: /^generate api key$/i }) + ).not.toBeInTheDocument() + ) + }, 20000) }) diff --git a/web/app/(app)/dashboard/(components)/get-started/plan-picker.test.tsx b/web/app/(app)/dashboard/(components)/get-started/plan-picker.test.tsx new file mode 100644 index 0000000..c3a4aa3 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/get-started/plan-picker.test.tsx @@ -0,0 +1,94 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import PlanPicker from './plan-picker' + +const useSubscription = vi.fn() + +vi.mock('@/lib/api', () => ({ + useSubscription: () => useSubscription(), +})) + +beforeEach(() => { + useSubscription.mockReturnValue({ data: { plan: { name: 'Free' } } }) +}) + +describe('PlanPicker', () => { + const renderPicker = ( + props?: Partial> + ) => + render( + {}} {...props} /> + ) + + // The picker hardcoded Free and Pro inline, so Scale was invisible here no + // matter what the pricing page offered. + it('offers every self-serve tier, including Scale', () => { + renderPicker() + + // By heading, since "Free" is also the Free tier's price. + expect( + screen.getAllByRole('heading').map((h) => h.textContent) + ).toEqual(['Free', 'Pro', 'Scale']) + }) + + it('shows the marketing prices', () => { + renderPicker() + + expect(screen.getByText('$9.99')).toBeInTheDocument() + expect(screen.getByText('$29.99')).toBeInTheDocument() + }) + + it('links each paid tier at its own checkout route', () => { + renderPicker() + + expect(screen.getByRole('link', { name: /Upgrade to Pro/ })).toHaveAttribute( + 'href', + '/checkout/pro' + ) + expect( + screen.getByRole('link', { name: /Upgrade to Scale/ }) + ).toHaveAttribute('href', '/checkout/scale') + }) + + it('marks the subscribed tier as current and does not sell it again', () => { + useSubscription.mockReturnValue({ data: { plan: { name: 'Pro' } } }) + renderPicker() + + expect(screen.getByText('Current')).toBeInTheDocument() + expect( + screen.queryByRole('link', { name: /Upgrade to Pro/ }) + ).not.toBeInTheDocument() + // Other tiers stay available. + expect( + screen.getByRole('link', { name: /Upgrade to Scale/ }) + ).toBeInTheDocument() + }) + + it('matches the current plan regardless of casing or padding', () => { + useSubscription.mockReturnValue({ data: { plan: { name: ' SCALE ' } } }) + renderPicker() + + expect(screen.getByText('Current')).toBeInTheDocument() + expect( + screen.queryByRole('link', { name: /Upgrade to Scale/ }) + ).not.toBeInTheDocument() + }) + + it('renders without a subscription rather than blanking the step', () => { + useSubscription.mockReturnValue({ data: undefined }) + renderPicker() + + expect(screen.getByRole('heading', { name: 'Scale' })).toBeInTheDocument() + expect(screen.queryByText('Current')).not.toBeInTheDocument() + }) + + it('hides Skip once the step is done', () => { + renderPicker({ isDone: true }) + expect(screen.queryByText(/Skip for now/)).not.toBeInTheDocument() + }) + + it('offers Skip while the step is unfinished', () => { + renderPicker({ isDone: false }) + expect(screen.getByText(/Skip for now/)).toBeInTheDocument() + }) +}) diff --git a/web/app/(app)/dashboard/(components)/get-started/plan-picker.tsx b/web/app/(app)/dashboard/(components)/get-started/plan-picker.tsx index 972bfe9..6528e92 100644 --- a/web/app/(app)/dashboard/(components)/get-started/plan-picker.tsx +++ b/web/app/(app)/dashboard/(components)/get-started/plan-picker.tsx @@ -14,108 +14,183 @@ import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { Check, ExternalLink } from 'lucide-react' import { Routes } from '@/config/routes' +import { useSubscription } from '@/lib/api' +import { PLAN_TIERS, formatPlanPrice, type PlanTier } from '@/lib/plans' +import { cn } from '@/lib/utils' type PlanPickerProps = { isLoading: boolean isSaving: boolean + isDone?: boolean onSkip: () => void } -// Free-vs-Pro chooser shown inside the "Choose your plan" onboarding step. -export default function PlanPicker({ isLoading, isSaving, onSkip }: PlanPickerProps) { +function PlanCard({ + tier, + isCurrent, + isSaving, + onSkip, +}: { + tier: PlanTier + isCurrent: boolean + isSaving: boolean + onSkip: () => void +}) { + const free = tier.monthlyPrice <= 0 + const highlight = tier.isPopular && !isCurrent + + return ( + + {(highlight || isCurrent) && ( + + {isCurrent ? 'Current' : 'Most popular'} + + )} + + + {tier.name} + + + {formatPlanPrice(tier.monthlyPrice)} + + {' / month'} + + + + +
    + {tier.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ + + {isCurrent ? ( + + ) : free ? ( + + ) : ( + + )} + +
+ ) +} + +/** + * Plan chooser for the "Choose your plan" onboarding step. + * + * Tiers come from lib/plans, which mirrors the marketing pricing page. The + * previous version hardcoded Free and Pro inline, so Scale never appeared + * here at all. + * + * Custom is intentionally left out: it is a talk-to-us tier with no + * self-serve checkout, and "Compare all plans" already links to it. + */ +export default function PlanPicker({ + isLoading, + isSaving, + isDone = false, + onSkip, +}: PlanPickerProps) { + const { data: subscription } = useSubscription() + + // Only the current-plan badge needs the subscription, so this is the one + // thing worth waiting on. if (isLoading) { return ( -
- - +
+ + +
) } + const currentPlan = subscription?.plan?.name?.trim().toLowerCase() + return ( - <> -
- - - Free - $0/month - - -

- - 1 device -

-

- - 50 SMS / day, 300 / month -

-
- - - -
- - Recommended - - Pro - $10/month - - -

- - Up to 5 devices -

-

- - No daily limit -

-

- - 5,000 SMS / month -

-

- - Priority support -

-
- - - - -
+
+ {/* Stacked on a phone, two up on a tablet, all three from lg. */} +
+ {PLAN_TIERS.map((tier) => ( + + ))}
- - + +
+ + {/* Skipping a step that is already settled would mean nothing. */} + {!isDone && ( + + )} +
+
) } diff --git a/web/app/(app)/dashboard/(components)/get-started/step-actions.tsx b/web/app/(app)/dashboard/(components)/get-started/step-actions.tsx index e18f451..977a95a 100644 --- a/web/app/(app)/dashboard/(components)/get-started/step-actions.tsx +++ b/web/app/(app)/dashboard/(components)/get-started/step-actions.tsx @@ -73,15 +73,20 @@ function VerifyEmailActions({ email }: { email?: string }) { type StepActionsProps = { stepId: string + // A completed step can still be reopened. Its actions stay available (you + // may want a second device or a replacement key), but anything that only + // makes sense for an unfinished step, like Skip, is dropped. + isDone?: boolean isSaving: boolean subLoading: boolean userEmail?: string onSkipStep: (stepId: string) => void } -// Call-to-action block for the currently active onboarding step. +// Call-to-action block for the currently selected onboarding step. export default function StepActions({ stepId, + isDone = false, isSaving, subLoading, userEmail, @@ -101,19 +106,26 @@ export default function StepActions({ Download APK - + {!isDone && ( + + )} ) case 'api_key': - return + return ( + + ) case 'register_device': return case 'choose_plan': @@ -121,6 +133,7 @@ export default function StepActions({ onSkipStep('choose_plan')} /> ) diff --git a/web/app/(app)/dashboard/(components)/get-started/steps.ts b/web/app/(app)/dashboard/(components)/get-started/steps.ts index 96e8d8b..7a270e5 100644 --- a/web/app/(app)/dashboard/(components)/get-started/steps.ts +++ b/web/app/(app)/dashboard/(components)/get-started/steps.ts @@ -24,6 +24,9 @@ export type StepDef = { id: string label: string description: string + // Shown instead of `description` when the step is already done and the user + // has reopened it, so the copy does not tell them to do what they have done. + doneDescription?: string optional: boolean // Shown as a small chip; sets expectations and reduces abandonment. timeEstimate: string @@ -48,6 +51,7 @@ export const STEPS: StepDef[] = [ id: 'download_app', label: 'Download the Android app', description: 'Install TextBee on the Android phone that will send your SMS.', + doneDescription: 'Installed. Download it again if you are setting up another phone.', optional: true, timeEstimate: '~1 min', checkDone: (_u, stats, _s, skipped) => @@ -57,6 +61,8 @@ export const STEPS: StepDef[] = [ id: 'api_key', label: 'Generate an API key', description: 'Your key connects the app and authenticates API calls.', + doneDescription: + 'You already have a key. Generate another if you need to replace it or set up a second device.', optional: false, timeEstimate: '~10 sec', checkDone: (_u, stats) => (stats?.totalApiKeyCount ?? 0) > 0, @@ -65,6 +71,7 @@ export const STEPS: StepDef[] = [ id: 'register_device', label: 'Register your device', description: 'Turn your phone into your SMS gateway: scan the QR code below with the TextBee app.', + doneDescription: 'Device registered. Register another the same way.', optional: false, timeEstimate: '~1 min', checkDone: (_u, stats) => (stats?.totalDeviceCount ?? 0) > 0, @@ -73,6 +80,7 @@ export const STEPS: StepDef[] = [ id: 'choose_plan', label: 'Choose your plan', description: 'Pick the plan that fits your usage.', + doneDescription: 'Your plan is set. Change it any time.', optional: true, timeEstimate: '~30 sec', checkDone: (_u, _stats, sub, skipped) => @@ -83,6 +91,7 @@ export const STEPS: StepDef[] = [ id: 'first_message', label: 'Send your first message', description: 'The moment it all works: send an SMS from the dashboard.', + doneDescription: 'You have sent your first message. Send another any time.', optional: false, timeEstimate: '~30 sec', checkDone: (_u, stats) => (stats?.totalSentSMSCount ?? 0) > 0, diff --git a/web/app/(app)/dashboard/(components)/get-started/use-onboarding.ts b/web/app/(app)/dashboard/(components)/get-started/use-onboarding.ts index 0137a0c..1c5e702 100644 --- a/web/app/(app)/dashboard/(components)/get-started/use-onboarding.ts +++ b/web/app/(app)/dashboard/(components)/get-started/use-onboarding.ts @@ -153,12 +153,28 @@ export function useOnboarding() { return firstIncomplete ?? STEPS[STEPS.length - 1].id }, [stepStates, userData?.onboarding?.currentStepId, selectedStepId, canNavigateToStep]) - // When the previously selected step gets completed (e.g. the poll detected a - // registered device), advance the selection to the next incomplete step. + // When the step you are sitting on gets completed (e.g. the poll detected a + // registered device), advance the selection to the next incomplete one. + // + // Only on that transition, though. This used to clear the selection whenever + // the selected step was done, which also meant deliberately opening an + // already-completed step deselected it on the very next render, so it looked + // like clicking it did nothing at all. + const selectionRef = useRef<{ id: string; isDone: boolean } | null>(null) useEffect(() => { - if (!selectedStepId) return + if (!selectedStepId) { + selectionRef.current = null + return + } const selected = stepStates.find((s) => s.id === selectedStepId) - if (selected?.isDone) { + if (!selected) return + + const previous = selectionRef.current + selectionRef.current = { id: selectedStepId, isDone: selected.isDone } + + const completedWhileSelected = + previous?.id === selectedStepId && !previous.isDone && selected.isDone + if (completedWhileSelected) { setSelectedStepId(null) } }, [selectedStepId, stepStates]) diff --git a/web/lib/api/types.ts b/web/lib/api/types.ts index a2be5d6..d06eee3 100644 --- a/web/lib/api/types.ts +++ b/web/lib/api/types.ts @@ -53,9 +53,14 @@ export interface Plan { monthlyLimit?: number bulkSendLimit?: number deviceLimit?: number - amount?: number - currency?: string - recurringInterval?: string + // Prices are in cents and live on the plan as monthlyPrice/yearlyPrice. + // This type previously declared amount/currency/recurringInterval, which the + // plans endpoint has never sent: those belong to Subscription, populated + // from the payment provider. Anything reading plan.amount silently saw + // undefined and rendered a paid plan as free. + monthlyPrice?: number + yearlyPrice?: number + isActive?: boolean } export interface SubscriptionUsage { diff --git a/web/lib/plans.test.ts b/web/lib/plans.test.ts new file mode 100644 index 0000000..614ffdf --- /dev/null +++ b/web/lib/plans.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { PLAN_TIERS, findPlanTier, formatPlanPrice } from './plans' + +// These values mirror the marketing pricing page. They are pinned here so that +// if the two drift apart, this fails instead of quietly showing a customer a +// price we do not charge. +describe('PLAN_TIERS', () => { + it('matches the marketing pricing page', () => { + expect( + PLAN_TIERS.map((t) => [t.id, t.monthlyPrice, t.yearlyPrice]) + ).toEqual([ + ['free', 0, undefined], + ['pro', 9.99, 99.99], + ['scale', 29.99, 299.99], + ]) + }) + + it('offers the three self-serve tiers, Scale included', () => { + expect(PLAN_TIERS.map((t) => t.name)).toEqual(['Free', 'Pro', 'Scale']) + }) + + it('highlights exactly one tier', () => { + expect(PLAN_TIERS.filter((t) => t.isPopular)).toHaveLength(1) + expect(PLAN_TIERS.find((t) => t.isPopular)?.id).toBe('pro') + }) + + it('gives every tier features to show', () => { + for (const tier of PLAN_TIERS) { + expect(tier.features.length).toBeGreaterThan(0) + expect(tier.description).not.toBe('') + } + }) + + // The id doubles as the /checkout/{id} segment, so it has to stay + // URL-clean. + it('uses lowercase ids safe for the checkout route', () => { + for (const tier of PLAN_TIERS) { + expect(tier.id).toMatch(/^[a-z][a-z0-9-]*$/) + } + }) +}) + +describe('formatPlanPrice', () => { + // "$0" not "Free", so the price does not just repeat the tier name above it. + it('renders the free tier as a price', () => { + expect(formatPlanPrice(0)).toBe('$0') + }) + + it('always shows cents on a paid plan', () => { + expect(formatPlanPrice(9.99)).toBe('$9.99') + expect(formatPlanPrice(30)).toBe('$30.00') + }) +}) + +describe('findPlanTier', () => { + it('is forgiving about casing and whitespace', () => { + expect(findPlanTier('Pro')?.id).toBe('pro') + expect(findPlanTier(' SCALE ')?.id).toBe('scale') + }) + + it('returns nothing for an unknown or missing name', () => { + expect(findPlanTier('enterprise')).toBeUndefined() + expect(findPlanTier(undefined)).toBeUndefined() + expect(findPlanTier(null)).toBeUndefined() + expect(findPlanTier('')).toBeUndefined() + }) +}) diff --git a/web/lib/plans.ts b/web/lib/plans.ts new file mode 100644 index 0000000..69a247c --- /dev/null +++ b/web/lib/plans.ts @@ -0,0 +1,94 @@ +/** + * Plan definitions for the dashboard, mirroring the marketing site's pricing + * section (textbee-marketing pricing-section.tsx). + * + * Deliberately static rather than fetched. The billing plans endpoint returns + * raw Plan documents with limits and cents, not the customer-facing copy, and + * an environment whose plans collection is empty left the onboarding step + * showing "plans could not be loaded" with nothing to choose. Pricing is + * marketing copy, so it comes from the same place the pricing page does. + * + * If the marketing pricing changes, change it here too. plans.test.ts pins the + * values so a silent drift shows up as a failing test rather than a wrong + * price in front of a customer. + */ + +export type PlanTier = { + /** Matches the checkout route segment: /checkout/{id}. */ + id: string + name: string + description: string + monthlyPrice: number + yearlyPrice?: number + features: string[] + /** The tier the picker highlights. */ + isPopular?: boolean +} + +export const PLAN_TIERS: PlanTier[] = [ + { + id: 'free', + name: 'Free', + description: 'Get started with basic SMS gateway features', + monthlyPrice: 0, + features: [ + 'Send and receive SMS Messages', + 'Register 1 active device', + 'Max 50 messages per day', + 'Up to 300 messages per month', + 'Up to 50 recipients in bulk', + 'Webhook notifications', + 'Basic support', + ], + }, + { + id: 'pro', + name: 'Pro', + description: 'For growing projects that send every day', + monthlyPrice: 9.99, + yearlyPrice: 99.99, + isPopular: true, + features: [ + 'Everything in Free plan', + 'Register up to 5 active devices', + 'Unlimited daily messages', + 'Up to 5,000 messages per month', + 'No bulk SMS recipient limits', + 'Message templates with variables', + 'Priority support', + ], + }, + { + id: 'scale', + name: 'Scale', + description: 'For higher volume and more devices', + monthlyPrice: 29.99, + yearlyPrice: 299.99, + features: [ + 'Everything in Pro plan', + 'Register up to 15 active devices', + 'Unlimited daily messages', + 'Up to 25,000 messages per month', + 'No bulk SMS recipient limits', + 'Message templates with variables', + 'Priority support', + ], + }, +] + +/** + * Prices are dollar amounts here, not the cents the API deals in. + * + * Free renders as "$0" rather than "Free" so it does not simply repeat the + * tier name directly above it, matching how the pricing page reads. + */ +export function formatPlanPrice(price: number): string { + if (price <= 0) return '$0' + return `$${price.toFixed(2)}` +} + +export function findPlanTier(name: string | undefined | null) { + if (!name) return undefined + const key = name.trim().toLowerCase() + return PLAN_TIERS.find((tier) => tier.id === key) +} diff --git a/web/test/fixtures.ts b/web/test/fixtures.ts index f66cd00..7700768 100644 --- a/web/test/fixtures.ts +++ b/web/test/fixtures.ts @@ -146,8 +146,37 @@ export const mockMessages = { meta: { total: 2, page: 1, limit: 20, totalPages: 1 }, } +// Shaped like the real /billing/plans response, which returns Plan documents: +// monthlyPrice in cents plus the limits, and no currency or amount. The old +// fixture used amount/currency, fields that endpoint has never sent, so a +// component reading them looked correct in tests and rendered every paid plan +// as free in production. export const mockBillingPlans = [ - { name: 'Free', amount: 0, currency: 'usd', recurringInterval: 'month' }, - { name: 'Pro', amount: 1900, currency: 'usd', recurringInterval: 'month' }, - { name: 'Scale', amount: 4900, currency: 'usd', recurringInterval: 'month' }, + { + name: 'Free', + monthlyPrice: 0, + dailyLimit: 50, + monthlyLimit: 500, + bulkSendLimit: 50, + deviceLimit: 1, + isActive: true, + }, + { + name: 'Pro', + monthlyPrice: 1900, + dailyLimit: 5000, + monthlyLimit: 100000, + bulkSendLimit: 500, + deviceLimit: 5, + isActive: true, + }, + { + name: 'Scale', + monthlyPrice: 4900, + dailyLimit: -1, + monthlyLimit: -1, + bulkSendLimit: 2000, + deviceLimit: -1, + isActive: true, + }, ]