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, + }, ]