Skip to content

Commit d0a209d

Browse files
Merge pull request #52 from InstaNode-dev/feat/billing-page-redesign-2months-free-fresh
BillingPage: 4-tier grid + Annual-default + 2 months free + Most Popular badge + collapse promo (per pricing research)
2 parents 2a07206 + ee168c9 commit d0a209d

4 files changed

Lines changed: 1256 additions & 1133 deletions

File tree

src/components/PricingGrid.tsx

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
// PricingGrid — research-backed 4-tier pricing surface.
2+
//
3+
// Renders Free / Hobby / Pro / Team side-by-side as a 4-column grid that
4+
// collapses to 2x2 on tablet (≤ 880px) and a single stack on mobile
5+
// (≤ 540px). Pro gets the "Most Popular" badge + raised treatment.
6+
// The user's current tier shows a "Your plan" pill in place of its CTA;
7+
// every other tier shows a tier-aware action.
8+
//
9+
// Why a separate component:
10+
// - The BillingPage owns the auth/billing/usage state. This is a pure
11+
// pricing-surface presenter: take a current tier + frequency state +
12+
// a CTA handler, render the grid.
13+
// - The marketing /pricing page can (and probably should) compose the
14+
// same component so the in-product and public surfaces don't fork.
15+
// PricingPage.tsx remains its own implementation today — wiring that
16+
// up is a follow-up — but the props shape here is designed for both
17+
// use cases (`embedded` flag toggles the in-product frequency-toggle
18+
// row vs the marketing-page layout).
19+
//
20+
// Research references:
21+
// - 4-tier with Team as anchor → +25–60% mid-tier conversion.
22+
// - Annual default → +19–35% annual adoption.
23+
// - "2 months free" framing → Athenic +342% annual signups, +62% LTV.
24+
// - Most Popular badge → +158% middle-tier conversion in one cited study.
25+
26+
import type { ReactNode } from 'react'
27+
import { TierCard, type Frequency, type TierKey } from './TierCard'
28+
29+
// PLAN_DATA — canonical labels + features for each tier. Mirrors the
30+
// PLANS map in BillingPage (kept for the Usage panel / limits panel)
31+
// but trimmed to what the pricing grid actually renders. Numbers + copy
32+
// stay byte-identical so a hobby user sees the same description on the
33+
// marketing page, the in-product grid, and the limits table.
34+
export interface TierFeature {
35+
text: string
36+
comingSoon?: boolean
37+
}
38+
39+
interface TierDefinition {
40+
key: TierKey
41+
label: string
42+
// Per-frequency price + subtext + savings. Free has no yearly variant
43+
// (the "$0 forever" headline doesn't need a frequency toggle), so we
44+
// store a single block that's reused regardless of toggle state.
45+
monthly: { price: string; sub: string }
46+
yearly?: { price: string; sub: string; savings: string; yearlyTotal: string }
47+
features: TierFeature[]
48+
// The next-tier slug — used when this tier is the *current* one and
49+
// we need a CTA target. Free → hobby, hobby → pro, pro → team, team
50+
// → null. Drives the "Upgrade to Team" copy on the Pro card when the
51+
// user is already on Pro.
52+
upgradesTo?: TierKey
53+
highlight?: boolean
54+
}
55+
56+
// Numbers are USD and come from api/plans.yaml. "2 months free" framing
57+
// is enforced visually: yearly price = monthly * 10, savings = monthly * 2.
58+
// Hobby $9/mo → $90/yr ($7.50/mo) · save $18
59+
// Pro $49/mo → $490/yr ($40.83/mo) · save $98
60+
// Team $199/mo → $1990/yr ($165.83/mo) · save $398
61+
export const PRICING_GRID_TIERS: TierDefinition[] = [
62+
{
63+
key: 'free',
64+
label: 'Free',
65+
monthly: { price: '$0', sub: 'forever' },
66+
// Free has no yearly variant — leave yearly undefined so the
67+
// grid renders the monthly block in both modes (no fake savings).
68+
features: [
69+
{ text: '10 MB Postgres · 24h TTL' },
70+
{ text: '5 MB Redis · 24h TTL' },
71+
{ text: '5 MB MongoDB · 24h TTL' },
72+
{ text: '100 stored webhooks' },
73+
{ text: 'agent-only access' },
74+
],
75+
upgradesTo: 'hobby',
76+
},
77+
{
78+
key: 'hobby',
79+
label: 'Hobby',
80+
monthly: { price: '$9', sub: '/mo' },
81+
yearly: {
82+
price: '$7.50',
83+
sub: '/mo, billed yearly',
84+
savings: '$90/yr · save $18',
85+
yearlyTotal: '$90/yr',
86+
},
87+
features: [
88+
{ text: '1 GB Postgres · 8 conn' },
89+
{ text: '50 MB Redis' },
90+
{ text: '100 MB MongoDB · 5 conn' },
91+
{ text: '1 small deployment' },
92+
{ text: '20 vault entries · production env' },
93+
{ text: '1,000 stored webhooks' },
94+
],
95+
upgradesTo: 'pro',
96+
},
97+
{
98+
key: 'pro',
99+
label: 'Pro',
100+
monthly: { price: '$49', sub: '/mo' },
101+
yearly: {
102+
price: '$40.83',
103+
sub: '/mo, billed yearly',
104+
savings: '$490/yr · save $98',
105+
yearlyTotal: '$490/yr',
106+
},
107+
features: [
108+
{ text: '5 GB Postgres · 20 conn' },
109+
{ text: '256 MB Redis' },
110+
{ text: '2 GB MongoDB · 20 conn' },
111+
{ text: '10 medium deployments' },
112+
{ text: '200 vault entries · multi-env' },
113+
{ text: 'custom domain · 10k stored webhooks' },
114+
],
115+
upgradesTo: 'team',
116+
highlight: true,
117+
},
118+
{
119+
key: 'team',
120+
label: 'Team',
121+
monthly: { price: '$199', sub: '/mo' },
122+
yearly: {
123+
price: '$165.83',
124+
sub: '/mo, billed yearly',
125+
savings: '$1990/yr · save $398',
126+
yearlyTotal: '$1990/yr',
127+
},
128+
features: [
129+
{ text: 'Everything in Pro, with larger limits', comingSoon: true },
130+
{ text: 'Multi-seat · RBAC + audit log', comingSoon: true },
131+
{ text: 'SSO / SAML · 99.9% SLA', comingSoon: true },
132+
{ text: 'Dedicated node pools', comingSoon: true },
133+
],
134+
},
135+
]
136+
137+
// Tier-aware CTA copy. Returns the label the user sees on each tier's
138+
// button, given the user's current tier and the active frequency.
139+
// - Free + current: no CTA (returns null).
140+
// - Free + not-current (e.g. anonymous viewing pricing page): "Start Free".
141+
// - Hobby: "Start Hobby — $7.50/mo" (annual) or "$9/mo" (monthly).
142+
// - Pro: "Get Pro — $40.83/mo" (annual) or "$49/mo" (monthly).
143+
// - Team: "Contact sales" (always — the surface is comingSoon).
144+
//
145+
// Returns null when the tier is the current one (the card renders the
146+
// "Your plan" pill instead) or when there's no sensible CTA target.
147+
export function buildCtaLabel(
148+
tier: TierKey,
149+
currentTier: TierKey,
150+
frequency: Frequency,
151+
): string | null {
152+
if (tier === currentTier) return null
153+
if (tier === 'free') return 'Stay on Free'
154+
if (tier === 'team') return 'Contact sales'
155+
const def = PRICING_GRID_TIERS.find((t) => t.key === tier)
156+
if (!def) return null
157+
const useYearly = frequency === 'yearly' && !!def.yearly
158+
const price = useYearly ? def.yearly!.price : def.monthly.price
159+
// "Get Pro — $7.50/mo" / "Start Hobby — $9/mo"
160+
// "Get" reads as the action you take on an upgrade you actively want
161+
// (Pro). "Start" reads gentler for the cheapest paid step-up (Hobby).
162+
// Both end in the price so research's "verb + outcome + anchored price"
163+
// pattern is preserved.
164+
const verb = tier === 'pro' ? 'Get' : 'Start'
165+
return `${verb} ${def.label}${price}/mo`
166+
}
167+
168+
// Map a tier definition + frequency to the TierCard.pricing prop shape.
169+
function pricingFor(t: TierDefinition, frequency: Frequency) {
170+
if (frequency === 'yearly' && t.yearly) {
171+
return {
172+
headline: t.yearly.price,
173+
subtext: t.yearly.sub,
174+
savings: t.yearly.savings,
175+
}
176+
}
177+
return { headline: t.monthly.price, subtext: t.monthly.sub }
178+
}
179+
180+
export interface PricingGridProps {
181+
currentTier: TierKey
182+
frequency: Frequency
183+
onFrequencyChange: (f: Frequency) => void
184+
// Called when a non-current tier's CTA fires. Pro's CTA is special-cased
185+
// (composes UpgradeButton via `proCtaSlot`); every other CTA routes here.
186+
onSelectTier: (target: TierKey) => void
187+
// Per-tier disabled flag (e.g. while checkout is in flight).
188+
ctaDisabled?: boolean
189+
// Optional slot for the Pro tier's CTA. Lets the parent inject the
190+
// P1 A/B-variant UpgradeButton without forking this component to
191+
// know about experiments. When omitted, Pro falls back to the same
192+
// <button> as the other tiers.
193+
proCtaSlot?: ReactNode
194+
// Heading + intro renderer hook — used so embedded BillingPage and
195+
// standalone PricingPage can each provide their own copy. Defaults
196+
// to a compact in-product heading.
197+
heading?: ReactNode
198+
// Show / hide the frequency toggle. Defaults to true — set false on
199+
// surfaces that drive the toggle externally.
200+
showFrequencyToggle?: boolean
201+
// Optional testid override on the wrapper.
202+
testId?: string
203+
}
204+
205+
export function PricingGrid({
206+
currentTier,
207+
frequency,
208+
onFrequencyChange,
209+
onSelectTier,
210+
ctaDisabled = false,
211+
proCtaSlot,
212+
heading,
213+
showFrequencyToggle = true,
214+
testId = 'pricing-grid',
215+
}: PricingGridProps) {
216+
return (
217+
<section data-testid={testId} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
218+
<PricingGridStyles />
219+
{heading}
220+
{showFrequencyToggle && (
221+
<FrequencyToggle frequency={frequency} onChange={onFrequencyChange} />
222+
)}
223+
<div className="pricing-grid-cards" data-testid="pricing-grid-cards">
224+
{PRICING_GRID_TIERS.map((t) => {
225+
const isCurrent = t.key === currentTier
226+
const ctaLabel = isCurrent ? null : buildCtaLabel(t.key, currentTier, frequency)
227+
return (
228+
<TierCard
229+
key={t.key}
230+
tier={t.key}
231+
label={t.label}
232+
pricing={pricingFor(t, frequency)}
233+
features={t.features}
234+
highlight={!!t.highlight}
235+
isCurrent={isCurrent}
236+
ctaLabel={ctaLabel ?? undefined}
237+
onCta={() => onSelectTier(t.key)}
238+
ctaDisabled={ctaDisabled}
239+
ctaSlot={t.key === 'pro' && !isCurrent ? proCtaSlot : undefined}
240+
ctaTestId={`tier-cta-${t.key}`}
241+
/>
242+
)
243+
})}
244+
</div>
245+
</section>
246+
)
247+
}
248+
249+
/**
250+
* FrequencyToggle — segmented [Monthly] [Annual — 2 months free] control.
251+
* Annual is shown as the right-hand option and carries the "2 months free"
252+
* inline copy per the Athenic finding. Visually identical to the marketing
253+
* page's toggle so in-product and public surfaces feel continuous.
254+
*/
255+
function FrequencyToggle({
256+
frequency,
257+
onChange,
258+
}: {
259+
frequency: Frequency
260+
onChange: (f: Frequency) => void
261+
}) {
262+
return (
263+
<div
264+
data-testid="billing-frequency-toggle"
265+
style={{
266+
display: 'flex',
267+
alignItems: 'center',
268+
gap: 12,
269+
margin: '4px 0 2px',
270+
flexWrap: 'wrap',
271+
}}
272+
>
273+
<span
274+
style={{
275+
fontFamily: 'var(--font-mono)',
276+
fontSize: 10.5,
277+
letterSpacing: '0.06em',
278+
textTransform: 'uppercase',
279+
color: 'var(--text-faint)',
280+
}}
281+
>
282+
billing cycle
283+
</span>
284+
<div
285+
role="radiogroup"
286+
aria-label="Billing cycle"
287+
style={{
288+
display: 'inline-flex',
289+
border: '1px solid var(--border-hi, var(--border))',
290+
borderRadius: 999,
291+
padding: 2,
292+
background: 'var(--elevated, var(--surface))',
293+
}}
294+
>
295+
<button
296+
type="button"
297+
role="radio"
298+
aria-checked={frequency === 'monthly'}
299+
data-testid="frequency-monthly"
300+
onClick={() => onChange('monthly')}
301+
style={{
302+
borderRadius: 999,
303+
padding: '5px 14px',
304+
fontSize: 12,
305+
background: frequency === 'monthly' ? 'var(--accent)' : 'transparent',
306+
color: frequency === 'monthly' ? 'var(--ink)' : 'var(--text)',
307+
border: 'none',
308+
cursor: 'pointer',
309+
}}
310+
>
311+
Monthly
312+
</button>
313+
<button
314+
type="button"
315+
role="radio"
316+
aria-checked={frequency === 'yearly'}
317+
data-testid="frequency-yearly"
318+
onClick={() => onChange('yearly')}
319+
style={{
320+
borderRadius: 999,
321+
padding: '5px 14px',
322+
fontSize: 12,
323+
background: frequency === 'yearly' ? 'var(--accent)' : 'transparent',
324+
color: frequency === 'yearly' ? 'var(--ink)' : 'var(--text)',
325+
border: 'none',
326+
cursor: 'pointer',
327+
display: 'inline-flex',
328+
alignItems: 'center',
329+
gap: 6,
330+
}}
331+
>
332+
<span>Annual</span>
333+
<span
334+
data-testid="frequency-2-months-free"
335+
style={{
336+
fontFamily: 'var(--font-mono)',
337+
fontSize: 10.5,
338+
letterSpacing: '0.04em',
339+
color: frequency === 'yearly' ? 'var(--ink)' : 'var(--accent)',
340+
opacity: frequency === 'yearly' ? 0.9 : 1,
341+
}}
342+
>
343+
— 2 months free
344+
</span>
345+
</button>
346+
</div>
347+
</div>
348+
)
349+
}
350+
351+
/**
352+
* Grid layout styles — kept inline as a <style> tag so the BillingPage and
353+
* a hypothetical marketing reuse don't both need to pull a global stylesheet.
354+
* Breakpoints:
355+
* - default: 4 columns
356+
* - ≤ 1100px: 2x2
357+
* - ≤ 640px: single stack
358+
*/
359+
function PricingGridStyles() {
360+
return (
361+
<style>{`
362+
.pricing-grid-cards {
363+
display: grid;
364+
grid-template-columns: repeat(4, minmax(0, 1fr));
365+
gap: 12px;
366+
}
367+
@media (max-width: 1100px) {
368+
.pricing-grid-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
369+
}
370+
@media (max-width: 640px) {
371+
.pricing-grid-cards { grid-template-columns: 1fr; }
372+
}
373+
`}</style>
374+
)
375+
}

0 commit comments

Comments
 (0)