mirror of
https://github.com/vernu/textbee.git
synced 2026-07-30 08:57:13 -04:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<GenerateApiKeyHandle, GenerateApiKeyProps>(
|
||||
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<GenerateApiKeyHandle, GenerateApiKeyProps>(
|
||||
return (
|
||||
<>
|
||||
{showTrigger ? (
|
||||
<Button onClick={handleConfirmGenerateKey}>
|
||||
<Button variant={triggerVariant} onClick={handleConfirmGenerateKey}>
|
||||
<QrCode className='mr-2 h-4 w-4' />
|
||||
Generate API Key
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -264,14 +264,22 @@ export default function GetStartedCard() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{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 && (
|
||||
<div className='animate-fade-in'>
|
||||
<p className='mt-1 text-sm text-muted-foreground'>
|
||||
{step.description}
|
||||
{step.isDone
|
||||
? step.doneDescription ?? step.description
|
||||
: step.description}
|
||||
</p>
|
||||
<div className='mt-3 flex flex-wrap items-center gap-2'>
|
||||
<StepActions
|
||||
stepId={step.id}
|
||||
isDone={step.isDone}
|
||||
isSaving={savingOnboarding}
|
||||
subLoading={subLoading}
|
||||
userEmail={userData?.email}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders, screen, waitFor } from '@/test/render'
|
||||
import { server } from '@/test/msw/server'
|
||||
import { API_BASE_URL, mockUser } from '@/test/fixtures'
|
||||
@@ -59,4 +60,70 @@ describe('GetStartedCard states', () => {
|
||||
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(<GetStartedCard />)
|
||||
|
||||
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(<GetStartedCard />)
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
@@ -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<React.ComponentProps<typeof PlanPicker>>
|
||||
) =>
|
||||
render(
|
||||
<PlanPicker isLoading={false} isSaving={false} onSkip={() => {}} {...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()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<Card
|
||||
className={cn(
|
||||
'relative flex flex-col',
|
||||
highlight
|
||||
? 'border-2 border-primary shadow-md'
|
||||
: 'border-border shadow-none',
|
||||
isCurrent && 'border-2 border-primary/40'
|
||||
)}
|
||||
>
|
||||
{(highlight || isCurrent) && (
|
||||
<Badge
|
||||
variant={isCurrent ? 'secondary' : 'default'}
|
||||
className='absolute right-3 top-3 text-[10px]'
|
||||
>
|
||||
{isCurrent ? 'Current' : 'Most popular'}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<CardHeader className='pb-2 pr-24 pt-4'>
|
||||
<CardTitle className='text-base'>{tier.name}</CardTitle>
|
||||
<CardDescription>
|
||||
<span className='text-foreground'>
|
||||
{formatPlanPrice(tier.monthlyPrice)}
|
||||
</span>
|
||||
{' / month'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='flex-1 pb-4'>
|
||||
<ul className='space-y-1.5 text-sm'>
|
||||
{tier.features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className={cn(
|
||||
'flex gap-2',
|
||||
free ? 'text-muted-foreground' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4 shrink-0',
|
||||
free ? 'text-muted-foreground' : 'text-primary'
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className='pb-4 pt-0'>
|
||||
{isCurrent ? (
|
||||
<Button variant='outline' className='w-full' disabled>
|
||||
Your current plan
|
||||
</Button>
|
||||
) : free ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
disabled={isSaving}
|
||||
onClick={onSkip}
|
||||
>
|
||||
Continue with Free
|
||||
</Button>
|
||||
) : (
|
||||
<Button className='w-full' asChild>
|
||||
<Link href={`/checkout/${tier.id}`}>Upgrade to {tier.name}</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className='grid w-full gap-3 md:grid-cols-2'>
|
||||
<Skeleton className='h-40 rounded-lg' />
|
||||
<Skeleton className='h-40 rounded-lg' />
|
||||
<div className='grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
<Skeleton className='h-64 rounded-lg' />
|
||||
<Skeleton className='h-64 rounded-lg' />
|
||||
<Skeleton className='h-64 rounded-lg' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentPlan = subscription?.plan?.name?.trim().toLowerCase()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='grid w-full gap-3 md:grid-cols-2'>
|
||||
<Card className='border-border shadow-none'>
|
||||
<CardHeader className='pb-2 pt-4'>
|
||||
<CardTitle className='text-base'>Free</CardTitle>
|
||||
<CardDescription>$0/month</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-2 pb-4 text-sm text-muted-foreground'>
|
||||
<p className='flex gap-2'>
|
||||
<Check className='mt-0.5 h-4 w-4 shrink-0 text-muted-foreground' />
|
||||
1 device
|
||||
</p>
|
||||
<p className='flex gap-2'>
|
||||
<Check className='mt-0.5 h-4 w-4 shrink-0 text-muted-foreground' />
|
||||
50 SMS / day, 300 / month
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className='flex-col gap-2 pb-4 pt-0'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
disabled={isSaving}
|
||||
onClick={onSkip}
|
||||
>
|
||||
Continue with Free
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className='relative border-2 border-primary shadow-md'>
|
||||
<Badge className='absolute right-3 top-3 text-[10px]'>Recommended</Badge>
|
||||
<CardHeader className='pb-2 pt-4 pr-14'>
|
||||
<CardTitle className='text-base'>Pro</CardTitle>
|
||||
<CardDescription>$10/month</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-2 pb-4 text-sm'>
|
||||
<p className='flex gap-2 text-foreground'>
|
||||
<Check className='mt-0.5 h-4 w-4 shrink-0 text-primary' />
|
||||
Up to 5 devices
|
||||
</p>
|
||||
<p className='flex gap-2 text-foreground'>
|
||||
<Check className='mt-0.5 h-4 w-4 shrink-0 text-primary' />
|
||||
No daily limit
|
||||
</p>
|
||||
<p className='flex gap-2 text-foreground'>
|
||||
<Check className='mt-0.5 h-4 w-4 shrink-0 text-primary' />
|
||||
5,000 SMS / month
|
||||
</p>
|
||||
<p className='flex gap-2 text-foreground'>
|
||||
<Check className='mt-0.5 h-4 w-4 shrink-0 text-primary' />
|
||||
Priority support
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className='flex-col gap-2 pb-4 pt-0'>
|
||||
<Button className='w-full' size='sm' asChild>
|
||||
<Link href='/checkout/pro'>Upgrade to Pro</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='h-auto text-xs text-muted-foreground'
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={`${Routes.landingPage}/pricing`}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Compare all plans
|
||||
<ExternalLink className='ml-1 h-3 w-3' />
|
||||
</a>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<div className='w-full space-y-3'>
|
||||
{/* Stacked on a phone, two up on a tablet, all three from lg. */}
|
||||
<div className='grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{PLAN_TIERS.map((tier) => (
|
||||
<PlanCard
|
||||
key={tier.id}
|
||||
tier={tier}
|
||||
isCurrent={currentPlan === tier.id}
|
||||
isSaving={isSaving}
|
||||
onSkip={onSkip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='h-auto px-0 text-muted-foreground'
|
||||
disabled={isSaving}
|
||||
onClick={onSkip}
|
||||
>
|
||||
Skip for now →
|
||||
</Button>
|
||||
</>
|
||||
|
||||
<div className='flex flex-wrap items-center gap-4'>
|
||||
<Button
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='h-auto px-0 text-xs text-muted-foreground'
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={`${Routes.landingPage}/pricing`}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Compare all plans
|
||||
<ExternalLink className='ml-1 h-3 w-3' />
|
||||
</a>
|
||||
</Button>
|
||||
{/* Skipping a step that is already settled would mean nothing. */}
|
||||
{!isDone && (
|
||||
<Button
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='h-auto px-0 text-muted-foreground'
|
||||
disabled={isSaving}
|
||||
onClick={onSkip}
|
||||
>
|
||||
Skip for now →
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 className='h-4 w-4' />
|
||||
Download APK
|
||||
</Button>
|
||||
<Button
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='h-auto px-2 text-muted-foreground'
|
||||
disabled={isSaving}
|
||||
onClick={() => onSkipStep('download_app')}
|
||||
>
|
||||
Skip →
|
||||
</Button>
|
||||
{!isDone && (
|
||||
<Button
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='h-auto px-2 text-muted-foreground'
|
||||
disabled={isSaving}
|
||||
onClick={() => onSkipStep('download_app')}
|
||||
>
|
||||
Skip →
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
case 'api_key':
|
||||
return <GenerateApiKey />
|
||||
return (
|
||||
<GenerateApiKey
|
||||
triggerLabel={isDone ? 'Generate another API key' : 'Generate API Key'}
|
||||
triggerVariant={isDone ? 'outline' : 'default'}
|
||||
/>
|
||||
)
|
||||
case 'register_device':
|
||||
return <InlineRegisterPanel />
|
||||
case 'choose_plan':
|
||||
@@ -121,6 +133,7 @@ export default function StepActions({
|
||||
<PlanPicker
|
||||
isLoading={subLoading}
|
||||
isSaving={isSaving}
|
||||
isDone={isDone}
|
||||
onSkip={() => onSkipStep('choose_plan')}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
67
web/lib/plans.test.ts
Normal file
67
web/lib/plans.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
94
web/lib/plans.ts
Normal file
94
web/lib/plans.ts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user