mirror of
https://github.com/vernu/textbee.git
synced 2026-07-30 17:07:46 -04:00
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>
95 lines
2.8 KiB
TypeScript
95 lines
2.8 KiB
TypeScript
/**
|
|
* 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)
|
|
}
|