import type { ICatalogAgent, ICatalogPlan, IPlansCatalog, } from '../service/plans/plans.interface'; import { PlanPeriod, PlanType } from './plan'; export const formatUSD = (amount: number): string => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: amount % 1 === 0 ? 0 : 2, }).format(amount); /** * The plan selector's model, the same shape recomaze.ai/pricing draws from: * one stop per plan on the slider, the six agents, what each limit row tops * out at per plan, and the compare table. Derived from the catalog, so the * page and the site can never disagree on a figure. */ /** One agent row on the plan panel and one checkbox in the picker. */ export interface PricingAgent { key: string; name: string; blurb: string; /** Limit rows this agent unlocks on the bundled plans. */ rows: string[]; /** Limit rows this agent unlocks when bought on its own (Starter). */ solo: string[]; } /** The all-agents deal on a per-agent plan. */ export interface PricingBundle { units: number; period: PlanPeriod; /** Per-month figure on the deal. */ monthly: number; /** What the same agents cost per month without the deal. */ was: number; } /** One stop on the slider and the panel it opens. */ export interface PricingPlan { key: PlanType; name: string; badge: string; fit: string; /** Annual price per month, and the monthly-billing price; per agent on Starter. */ yearly: number; monthly: number; billYearly: string; billMonthly: string; /** Headline of the 'a month on this plan' panel. */ month: string; /** Filled dots (out of 10) per agent row. */ fill: number; /** Filled dots on the always-on 'Audit and Plan' row. */ auditFill: number; /** Credits a month; per agent on a per-agent plan. */ credits: number; perAgent: boolean; bundle?: PricingBundle; /** Short line under the slider stop. */ stop: string; /** Sold on this period; Free is sold on neither. */ soldOn: Record; } export type CompareCell = 'check' | 'dash' | string; export interface CompareGroup { title: string; rows: { feature: string; cells: CompareCell[] }[]; } export interface PricingModel { plans: PricingPlan[]; agents: PricingAgent[]; /** Agent limit rows in listing order. */ limitRows: string[]; /** Rows that name a fixed number rather than a ceiling. */ fixedLimitRows: string[]; /** What each limit row tops out at, per plan index; '' when not on the plan. */ rowLimits: Record; /** Human label per limit row key. */ limitLabels: Record; compareColumns: { name: string; note: string }[]; compareGroups: CompareGroup[]; } /** The short line under each slider stop; the catalog's blurb otherwise. */ const STOP_LINES: Partial> = { [PlanType.FREE]: 'Know what to do next', [PlanType.STARTER]: 'Start with what you need', [PlanType.FAST_GROWING]: 'Get customers every week', [PlanType.SCALING]: 'Grow on every channel', [PlanType.HYPER_SCALING]: 'Grow at scale', }; const FREE_BILL = 'No card. No time limit.'; /** * @param {number} value - A count. * @returns {string} The count with thousands separators. */ export const formatCount = (value: number): string => value.toLocaleString('en-US'); /** * The plans the slider walks, in catalog order, without the contact-sales * tier: that one is a conversation, not a stop. * * @param {ICatalogPlan[]} plans - The catalog's plans. * @returns {ICatalogPlan[]} The slider's plans. */ const sliderPlans = (plans: ICatalogPlan[]): ICatalogPlan[] => [...plans] .filter(plan => !plan.contact_sales) .sort((a, b) => a.order - b.order); /** * One catalog plan as a slider stop with its panel. * * @param {ICatalogPlan} plan - The catalog plan. * @returns {PricingPlan} The stop. */ function pricingPlan(plan: ICatalogPlan): PricingPlan { const monthly = plan.prices.MONTHLY?.monthly_equivalent ?? 0; const yearly = plan.prices.YEARLY?.monthly_equivalent ?? 0; const isFree = !monthly && !yearly; const perAgent = !!plan.unit; return { key: plan.code, name: plan.name, badge: plan.popular ? 'Most chosen' : '', fit: plan.blurb, yearly, monthly, billYearly: isFree ? FREE_BILL : perAgent ? 'Per agent, on the annual plan.' : 'With an annual commitment.', billMonthly: isFree ? FREE_BILL : perAgent ? 'Per agent, billed monthly.' : 'Billed monthly. Cancel any month.', month: plan.summary, fill: plan.intensity.agents, auditFill: plan.intensity.audit, credits: perAgent ? (plan.unit?.credits ?? 0) : plan.credits, perAgent, bundle: plan.bundle && plan.unit ? { units: plan.bundle.units, period: plan.bundle.period, monthly: plan.bundle.monthly_equivalent, was: (plan.bundle.period === PlanPeriod.YEARLY ? yearly : monthly) * plan.bundle.units, } : undefined, stop: STOP_LINES[plan.code] ?? plan.blurb, soldOn: { [PlanPeriod.MONTHLY]: !!plan.prices.MONTHLY?.price_id, [PlanPeriod.YEARLY]: !!plan.prices.YEARLY?.price_id, }, }; } /** * A limit value as the plan panel prints it. * * @param {number | string | boolean | null | undefined} value - The catalog value. * @returns {string} '2,000', 'Unlimited', or '' when the row is not on the plan. */ function limitText( value: number | string | boolean | null | undefined ): string { if (value === undefined || value === false) return ''; if (value === null) return 'Unlimited'; if (value === true) return 'Included'; return typeof value === 'number' ? formatCount(value) : value; } /** * A limit value as the compare table prints it: ceilings read 'up to'. * * @param {number | string | boolean | null | undefined} value - The catalog value. * @param {boolean} fixed - Whether the row names a fixed number. * @returns {CompareCell} The cell. */ function compareCell( value: number | string | boolean | null | undefined, fixed: boolean ): CompareCell { if (value === undefined || value === false) return 'dash'; if (value === true) return 'check'; if (value === null) return 'Unlimited'; if (typeof value === 'number') return fixed ? formatCount(value) : `up to ${formatCount(value)}`; return value; } /** * The credits cell of the compare table. * * @param {ICatalogPlan} plan - The catalog plan. * @returns {string} '10,000', or '1,000 per agent'. */ const creditsCell = (plan: ICatalogPlan): string => plan.unit ? `${formatCount(plan.unit.credits)} per agent` : formatCount(plan.credits); /** * The selector's model, from the catalog. * * @param {IPlansCatalog} catalog - What GET /v2/plans answered. * @returns {PricingModel} Plans, agents, limits and the compare table. */ export function pricingModel(catalog: IPlansCatalog): PricingModel { const plans = sliderPlans(catalog.plans); const agents: PricingAgent[] = catalog.agents.map((agent: ICatalogAgent) => ({ key: agent.key, name: agent.name, blurb: agent.blurb, rows: agent.card_limits, solo: agent.limits, })); const fixed = new Set(catalog.fixed_limit_rows); const label = (row: string): string => catalog.limit_labels[row] ?? row; const rowLimits: Record = {}; for (const row of catalog.limit_rows) { rowLimits[row] = plans.map(plan => limitText(plan.limits[row])); } const platformRows = catalog.platform_rows.flatMap(row => { const entry = { feature: label(row), cells: plans.map(plan => compareCell(plan.limits[row], true)), }; // The credits row sits right before the support row, as the page reads it. return row === 'support' ? [ { feature: 'AI credits a month', cells: plans.map(creditsCell) }, entry, ] : [entry]; }); return { plans: plans.map(pricingPlan), agents, limitRows: catalog.limit_rows, fixedLimitRows: catalog.fixed_limit_rows, rowLimits, limitLabels: catalog.limit_labels, compareColumns: plans.map(plan => ({ name: plan.name, note: plan.unit ? `per ${plan.unit.name}` : '', })), compareGroups: [ { title: 'Platform', rows: platformRows }, ...catalog.agents.map(agent => ({ title: agent.name, rows: agent.limits.map(row => ({ feature: label(row), cells: plans.map(plan => compareCell(plan.limits[row], fixed.has(row)) ), })), })), ], }; } /** * The per-month figure a stop shows for a period and a number of agents: * every agent priced, except all of them on the bundle's period. * * @param {PricingPlan} plan - The stop. * @param {PlanPeriod} period - Billing period. * @param {number} units - Agents picked; ignored on a plan without agents. * @returns {number} USD per month. */ export function stopMonthlyPrice( plan: PricingPlan, period: PlanPeriod, units: number ): number { const perUnit = period === PlanPeriod.YEARLY ? plan.yearly : plan.monthly; if (!plan.perAgent) return perUnit; if ( plan.bundle && plan.bundle.period === period && units === plan.bundle.units ) { return plan.bundle.monthly; } return perUnit * Math.max(units, 1); } /** * What the price line says under the figure. * * @param {PricingPlan} plan - The stop. * @param {PlanPeriod} period - Billing period. * @param {number} units - Agents picked. * @param {boolean} deal - The all-agents deal is on. * @returns {string} One sentence. */ export function stopBillNote( plan: PricingPlan, period: PlanPeriod, units: number, deal: boolean ): string { const yearly = period === PlanPeriod.YEARLY; if (deal) return 'All six agents, on the annual plan.'; if (plan.perAgent && units > 1) { return yearly ? `${units} agents bundled, ${formatUSD(plan.yearly)} each on the annual plan.` : `${units} agents bundled, ${formatUSD(plan.monthly)} each, billed monthly.`; } return yearly ? plan.billYearly : plan.billMonthly; } /** * A limit row's name as it reads after its number: lowercased unless it is * an acronym, and singular when the number is exactly one, so Starter reads * '1 engine' rather than '1 Engines'. * * @param {string} label - The row's label. * @param {string} value - The plan's limit for that row. * @returns {string} The words to print. */ export function limitWords(label: string, value: string): string { const acronym = label.length > 1 && label[1] === label[1].toUpperCase(); const words = acronym ? label : label.charAt(0).toLowerCase() + label.slice(1); return value === '1' && words.endsWith('s') ? words.slice(0, -1) : words; }