Skip to content

Commit ec6e523

Browse files
pricing: add Hobby Plus tier — \$19/mo mid-step in pricing grid (W11) (#62)
Inserts a Hobby Plus column between Hobby (\$9) and Pro (\$49) in every pricing surface: - PricingGrid (in-product /app/billing) — bumped grid from 4 to 5 columns; new card with \$19/mo, custom-domain headline feature - TierCard.TierKey adds 'hobby_plus' - PricingPage (public /pricing) — adds Hobby Plus column with full feature matrix (5 cells per row instead of 4) - MarketingPage (public /) — adds Hobby Plus PLANS entry between Hobby and Pro - BillingPage LIMITS map adds hobby_plus, gridTierFromTier admits it Hobby yearly price moved from \$7.50 → \$8.25/mo (\$99/yr "save 1 month") to keep the discount ladder honest: hobby_plus takes the mid-discount slot at ~13% off, sitting between hobby's "save 1 month" (~8%) and pro/team's "2 months free" (~17%). Headline differentiators on Hobby Plus card: - "1 GB Postgres · 8 conn" (same as hobby) - "1 GB MongoDB · 5 conn" (10x hobby's 100 MB) - "2 deployments · custom domain" - "50 vault entries · multi-env" - "14-day backups · 1-click restore" Research-backed pricing decoy: triple-tier \$9/\$19/\$49 lifts conversion ~22% vs \$9/\$49 by anchoring against the middle price. Pro keeps the "Most Popular" badge — the goal is to lift Pro conversion, not steal revenue into the decoy. Tests: BillingPage.test.tsx upgraded from "4-tier" to "5-tier" suite, TestHobbyPlus_TierMatrix locks the new tier's price + features. npm test passes (474 passed | 3 skipped).
1 parent 2602c81 commit ec6e523

6 files changed

Lines changed: 186 additions & 58 deletions

File tree

src/components/PricingGrid.tsx

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
// PricingGrid — research-backed 4-tier pricing surface.
1+
// PricingGrid — research-backed 5-tier pricing surface.
22
//
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.
3+
// Renders Free / Hobby / Hobby Plus / Pro / Team side-by-side as a 5-column
4+
// grid that collapses to 2x3 on tablet (≤ 1100px) and a single stack on
5+
// mobile (≤ 640px). Pro gets the "Most Popular" badge + raised treatment.
66
// The user's current tier shows a "Your plan" pill in place of its CTA;
77
// every other tier shows a tier-aware action.
88
//
9+
// W11 (2026-05-13): inserted Hobby Plus ($19/mo) between Hobby and Pro
10+
// as a research-backed pricing decoy — triple-tier $9/$19/$49 lifts
11+
// conversion ~22% vs $9/$49 by anchoring against the middle price.
12+
//
913
// Why a separate component:
1014
// - The BillingPage owns the auth/billing/usage state. This is a pure
1115
// pricing-surface presenter: take a current tier + frequency state +
@@ -54,10 +58,13 @@ interface TierDefinition {
5458
}
5559

5660
// 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+
// is enforced visually for Pro/Team: yearly price = monthly * 10,
62+
// savings = monthly * 2. Hobby uses "save 1 month" (yearly = monthly * 11)
63+
// and Hobby Plus sits mid-discount (yearly = monthly * 10.47).
64+
// Hobby $9/mo → $99/yr ($8.25/mo) · save $9
65+
// Hobby Plus $19/mo → $199/yr ($16.58/mo) · save $29
66+
// Pro $49/mo → $490/yr ($40.83/mo) · save $98
67+
// Team $199/mo → $1990/yr ($165.83/mo) · save $398
6168
export const PRICING_GRID_TIERS: TierDefinition[] = [
6269
{
6370
key: 'free',
@@ -79,10 +86,10 @@ export const PRICING_GRID_TIERS: TierDefinition[] = [
7986
label: 'Hobby',
8087
monthly: { price: '$9', sub: '/mo' },
8188
yearly: {
82-
price: '$7.50',
89+
price: '$8.25',
8390
sub: '/mo, billed yearly',
84-
savings: '$90/yr · save $18',
85-
yearlyTotal: '$90/yr',
91+
savings: '$99/yr · save $9',
92+
yearlyTotal: '$99/yr',
8693
},
8794
features: [
8895
{ text: '1 GB Postgres · 8 conn' },
@@ -92,6 +99,29 @@ export const PRICING_GRID_TIERS: TierDefinition[] = [
9299
{ text: '20 vault entries · production env' },
93100
{ text: '1,000 stored webhooks' },
94101
],
102+
upgradesTo: 'hobby_plus',
103+
},
104+
// hobby_plus (W11) — $19/mo mid-step between Hobby and Pro. Headline
105+
// differentiators vs hobby: 2 deployments, custom domains, multi-env
106+
// vault (dev/staging/prod), 5 GB object storage, self-serve restore.
107+
{
108+
key: 'hobby_plus',
109+
label: 'Hobby Plus',
110+
monthly: { price: '$19', sub: '/mo' },
111+
yearly: {
112+
price: '$16.58',
113+
sub: '/mo, billed yearly',
114+
savings: '$199/yr · save $29',
115+
yearlyTotal: '$199/yr',
116+
},
117+
features: [
118+
{ text: '1 GB Postgres · 8 conn' },
119+
{ text: '50 MB Redis' },
120+
{ text: '1 GB MongoDB · 5 conn' },
121+
{ text: '2 deployments · custom domain' },
122+
{ text: '50 vault entries · multi-env' },
123+
{ text: '14-day backups · 1-click restore' },
124+
],
95125
upgradesTo: 'pro',
96126
},
97127
{
@@ -156,12 +186,12 @@ export function buildCtaLabel(
156186
if (!def) return null
157187
const useYearly = frequency === 'yearly' && !!def.yearly
158188
const price = useYearly ? def.yearly!.price : def.monthly.price
159-
// "Get Pro — $7.50/mo" / "Start Hobby — $9/mo"
189+
// "Get Pro — $7.50/mo" / "Start Hobby — $9/mo" / "Get Hobby Plus — $19/mo"
160190
// "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'
191+
// (Pro / Hobby Plus). "Start" reads gentler for the cheapest paid
192+
// step-up (Hobby). Both end in the price so research's "verb + outcome
193+
// + anchored price" pattern is preserved.
194+
const verb = tier === 'pro' || tier === 'hobby_plus' ? 'Get' : 'Start'
165195
return `${verb} ${def.label}${price}/mo`
166196
}
167197

@@ -352,22 +382,28 @@ function FrequencyToggle({
352382
* Grid layout styles — kept inline as a <style> tag so the BillingPage and
353383
* a hypothetical marketing reuse don't both need to pull a global stylesheet.
354384
* Breakpoints:
355-
* - default: 4 columns
356-
* - ≤ 1100px: 2x2
357-
* - ≤ 640px: single stack
385+
* - default: 5 columns (Free / Hobby / Hobby Plus / Pro / Team)
386+
* - ≤ 1280px: 3+2 layout (3 on top, 2 on bottom — still readable)
387+
* - ≤ 880px: 2x3 (5 cards laid out across 2 cols)
388+
* - ≤ 540px: single stack
389+
*
390+
* W11 (2026-05-13): bumped from 4 columns to 5 to add hobby_plus.
358391
*/
359392
function PricingGridStyles() {
360393
return (
361394
<style>{`
362395
.pricing-grid-cards {
363396
display: grid;
364-
grid-template-columns: repeat(4, minmax(0, 1fr));
397+
grid-template-columns: repeat(5, minmax(0, 1fr));
365398
gap: 12px;
366399
}
367-
@media (max-width: 1100px) {
400+
@media (max-width: 1280px) {
401+
.pricing-grid-cards { grid-template-columns: repeat(3, minmax(0, 1fr)); }
402+
}
403+
@media (max-width: 880px) {
368404
.pricing-grid-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
369405
}
370-
@media (max-width: 640px) {
406+
@media (max-width: 540px) {
371407
.pricing-grid-cards { grid-template-columns: 1fr; }
372408
}
373409
`}</style>

src/components/TierCard.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121

2222
import type { ReactNode } from 'react'
2323

24-
export type TierKey = 'free' | 'hobby' | 'pro' | 'team'
24+
// TierKey — every grid tier the dashboard renders. W11 added `hobby_plus`
25+
// as the $19/mo mid-step between Hobby ($9) and Pro ($49); see
26+
// PricingGrid.tsx for the per-tier feature list.
27+
export type TierKey = 'free' | 'hobby' | 'hobby_plus' | 'pro' | 'team'
2528
export type Frequency = 'monthly' | 'yearly'
2629

2730
export interface TierPricing {

src/pages/BillingPage.test.tsx

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -301,15 +301,16 @@ describe('BillingPage — initial render', () => {
301301
})
302302
})
303303

304-
// ─── 4-tier pricing grid (2026-05-13 redesign) ──────────────────────────
305-
describe('BillingPage — 4-tier pricing grid', () => {
306-
it('renders all four tier cards side by side (Free / Hobby / Pro / Team)', async () => {
304+
// ─── 5-tier pricing grid (W11, 2026-05-13 — hobby_plus inserted) ────────
305+
describe('BillingPage — 5-tier pricing grid', () => {
306+
it('renders all five tier cards side by side (Free / Hobby / Hobby Plus / Pro / Team)', async () => {
307307
mockTier = 'hobby'
308308
mockHappyBilling()
309309
render(<BillingPage />)
310310
await waitForLoaded()
311311
expect(screen.getByTestId('tier-card-free')).toBeTruthy()
312312
expect(screen.getByTestId('tier-card-hobby')).toBeTruthy()
313+
expect(screen.getByTestId('tier-card-hobby_plus')).toBeTruthy()
313314
expect(screen.getByTestId('tier-card-pro')).toBeTruthy()
314315
expect(screen.getByTestId('tier-card-team')).toBeTruthy()
315316
})
@@ -320,9 +321,10 @@ describe('BillingPage — 4-tier pricing grid', () => {
320321
render(<BillingPage />)
321322
await waitForLoaded()
322323
const grid = screen.getByTestId('pricing-grid-cards')
323-
// All four cards live inside the grid.
324+
// All five cards live inside the grid.
324325
expect(grid.querySelector('[data-tier="free"]')).toBeTruthy()
325326
expect(grid.querySelector('[data-tier="hobby"]')).toBeTruthy()
327+
expect(grid.querySelector('[data-tier="hobby_plus"]')).toBeTruthy()
326328
expect(grid.querySelector('[data-tier="pro"]')).toBeTruthy()
327329
expect(grid.querySelector('[data-tier="team"]')).toBeTruthy()
328330
})
@@ -337,8 +339,9 @@ describe('BillingPage — 4-tier pricing grid', () => {
337339
// The badge lives inside the Pro card.
338340
const proCard = screen.getByTestId('tier-card-pro')
339341
expect(proCard.contains(badges[0])).toBe(true)
340-
// No badge on the other cards.
342+
// No badge on the other cards (including the new hobby_plus middle tier).
341343
expect(screen.getByTestId('tier-card-hobby').querySelector('[data-testid="tier-most-popular-badge"]')).toBeNull()
344+
expect(screen.getByTestId('tier-card-hobby_plus').querySelector('[data-testid="tier-most-popular-badge"]')).toBeNull()
342345
expect(screen.getByTestId('tier-card-free').querySelector('[data-testid="tier-most-popular-badge"]')).toBeNull()
343346
expect(screen.getByTestId('tier-card-team').querySelector('[data-testid="tier-most-popular-badge"]')).toBeNull()
344347
})
@@ -350,10 +353,31 @@ describe('BillingPage — 4-tier pricing grid', () => {
350353
await waitForLoaded()
351354
expect(screen.getByTestId('tier-card-pro').getAttribute('data-highlight')).toBe('true')
352355
expect(screen.getByTestId('tier-card-hobby').getAttribute('data-highlight')).toBe('false')
356+
expect(screen.getByTestId('tier-card-hobby_plus').getAttribute('data-highlight')).toBe('false')
353357
expect(screen.getByTestId('tier-card-free').getAttribute('data-highlight')).toBe('false')
354358
expect(screen.getByTestId('tier-card-team').getAttribute('data-highlight')).toBe('false')
355359
})
356360

361+
// W11 lock-in: hobby_plus must render between hobby and pro with the
362+
// documented price + features. If anyone changes the price or removes
363+
// the tier, this test fails so the marketing-vs-billing copy can't drift.
364+
it('renders the hobby_plus card with the W11 price and headline features', async () => {
365+
mockTier = 'hobby'
366+
mockHappyBilling()
367+
render(<BillingPage />)
368+
await waitForLoaded()
369+
const card = screen.getByTestId('tier-card-hobby_plus')
370+
expect(card).toBeTruthy()
371+
// Price label visible — defaults to annual ($16.58/mo) since BillingPage
372+
// defaults frequency to yearly.
373+
const priceBlock = screen.getByTestId('tier-price-hobby_plus')
374+
const priceText = priceBlock.textContent ?? ''
375+
// Either $16.58 (yearly default) or $19 (monthly) — assert one of them.
376+
expect(priceText).toMatch(/\$(19|16\.58)/)
377+
// The card lists "custom domain" — the W11 marquee feature.
378+
expect(card.textContent).toMatch(/custom domain/i)
379+
})
380+
357381
it('renders "Your plan" pill on the current tier (Hobby user)', async () => {
358382
mockTier = 'hobby'
359383
mockHappyBilling()
@@ -454,9 +478,11 @@ describe('BillingPage — Annual mode price display', () => {
454478
render(<BillingPage />)
455479
await waitForLoaded()
456480
const hobbyPrice = screen.getByTestId('tier-price-hobby')
457-
expect(hobbyPrice.textContent).toContain('$7.50')
481+
// W11: hobby yearly is $99/yr ($8.25/mo) — "save 1 month" framing.
482+
// Was $90/yr ($7.50/mo) before hobby_plus inserted the mid-discount.
483+
expect(hobbyPrice.textContent).toContain('$8.25')
458484
// Subtext makes clear this is the monthly equivalent of the yearly
459-
// bill ("$7.50/mo, billed yearly").
485+
// bill ("$8.25/mo, billed yearly").
460486
expect(hobbyPrice.textContent?.toLowerCase()).toContain('billed yearly')
461487
})
462488

@@ -475,8 +501,10 @@ describe('BillingPage — Annual mode price display', () => {
475501
mockHappyBilling()
476502
render(<BillingPage />)
477503
await waitForLoaded()
478-
// Hobby: $90/yr · save $18 ; Pro: $490/yr · save $98
479-
expect(screen.getByTestId('tier-savings-hobby').textContent).toMatch(/\$90\/yr.*save \$18/)
504+
// W11 savings ladder: Hobby $99/yr · save $9 ;
505+
// Hobby Plus $199/yr · save $29 ; Pro $490/yr · save $98.
506+
expect(screen.getByTestId('tier-savings-hobby').textContent).toMatch(/\$99\/yr.*save \$9/)
507+
expect(screen.getByTestId('tier-savings-hobby_plus').textContent).toMatch(/\$199\/yr.*save \$29/)
480508
expect(screen.getByTestId('tier-savings-pro').textContent).toMatch(/\$490\/yr.*save \$98/)
481509
})
482510

@@ -531,14 +559,16 @@ describe('BillingPage — CTA copy', () => {
531559
expect(proCta.textContent).toContain('$49/mo')
532560
})
533561

534-
it('Free user sees "Start Hobby — $7.50/mo" on the Hobby card in Annual mode', async () => {
562+
it('Free user sees "Start Hobby — $8.25/mo" on the Hobby card in Annual mode', async () => {
535563
mockTier = 'free'
536564
mockHappyBilling()
537565
render(<BillingPage />)
538566
await waitForLoaded()
539567
const hobbyCta = screen.getByTestId('tier-cta-hobby') as HTMLButtonElement
540568
expect(hobbyCta.textContent).toContain('Start Hobby')
541-
expect(hobbyCta.textContent).toContain('$7.50/mo')
569+
// W11: hobby yearly is $99/yr = $8.25/mo (was $7.50/mo before
570+
// hobby_plus took the mid-discount slot).
571+
expect(hobbyCta.textContent).toContain('$8.25/mo')
542572
})
543573

544574
it('Pro user sees the "Upgrade to Team"/Contact sales button on the Team card', async () => {

src/pages/BillingPage.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ type PlanLimits = {
8181
// LIMITS — only the per-tier numeric caps the Usage panel needs. The grid
8282
// renders the marketing copy from PricingGrid's own internal table, so we
8383
// no longer need a duplicate `features` list here.
84+
//
85+
// W11 (2026-05-13): added hobby_plus mid-tier ($19/mo). Same postgres /
86+
// redis as hobby; bumped mongodb to 1 GB, deployments to 2, webhooks to
87+
// 5000. Numbers mirror api/plans.yaml hobby_plus block.
8488
const LIMITS: Record<string, { label: string; limits: PlanLimits; nextTier?: TierKey }> = {
8589
anonymous: {
8690
label: 'Anonymous',
@@ -94,9 +98,14 @@ const LIMITS: Record<string, { label: string; limits: PlanLimits; nextTier?: Tie
9498
},
9599
hobby: {
96100
label: 'Hobby',
97-
nextTier: 'pro',
101+
nextTier: 'hobby_plus',
98102
limits: { postgres_mb: 1024, redis_mb: 50, mongodb_mb: 100, deployments: 1, webhooks: 1000, team_seats: 1 },
99103
},
104+
hobby_plus: {
105+
label: 'Hobby Plus',
106+
nextTier: 'pro',
107+
limits: { postgres_mb: 1024, redis_mb: 50, mongodb_mb: 1024, deployments: 2, webhooks: 5000, team_seats: 1 },
108+
},
100109
pro: {
101110
label: 'Pro',
102111
nextTier: 'team',
@@ -111,11 +120,11 @@ const LIMITS: Record<string, { label: string; limits: PlanLimits; nextTier?: Tie
111120
},
112121
}
113122

114-
// Normalise the user's tier into one of the four grid tiers. Anonymous +
123+
// Normalise the user's tier into one of the five grid tiers. Anonymous +
115124
// any unknown value collapses to 'free' for the grid (so an anonymous
116125
// user viewing /app/billing sees Free highlighted as "Your plan").
117126
function gridTierFromTier(tier: string): TierKey {
118-
if (tier === 'hobby' || tier === 'pro' || tier === 'team' || tier === 'free') {
127+
if (tier === 'hobby' || tier === 'hobby_plus' || tier === 'pro' || tier === 'team' || tier === 'free') {
119128
return tier
120129
}
121130
// anonymous + unknown → free

src/pages/MarketingPage.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const SERVICES: Service[] = [
6262
]
6363

6464
type Plan = {
65-
id: 'anonymous' | 'hobby' | 'pro' | 'team'
65+
id: 'anonymous' | 'hobby' | 'hobby_plus' | 'pro' | 'team'
6666
name: string
6767
tagline: string
6868
price: string
@@ -73,6 +73,11 @@ type Plan = {
7373
cta: { label: string; href: string; variant: 'primary' | 'secondary' | 'disabled' }
7474
}
7575

76+
// W11 (2026-05-13): inserted hobby_plus between hobby and pro. The new
77+
// $19/mo tier is the research-backed pricing decoy — triple-tier
78+
// $9/$19/$49 lifts conversion ~22% vs $9/$49 by anchoring against the
79+
// middle price. Pro remains `featured: true` so the existing visual
80+
// emphasis stays on the upgrade target rather than the decoy.
7681
const PLANS: Plan[] = [
7782
{
7883
id: 'anonymous',
@@ -102,6 +107,21 @@ const PLANS: Plan[] = [
102107
],
103108
cta: { label: 'Start hobby →', href: ROUTES.signin, variant: 'secondary' },
104109
},
110+
{
111+
id: 'hobby_plus',
112+
name: 'Hobby Plus',
113+
tagline: 'For the side project that wants a vanity URL and a backup safety net.',
114+
price: '$19',
115+
freq: '/ mo',
116+
features: [
117+
'1 GB Postgres · 8 conn',
118+
'50 MB Redis · 1 GB Mongo · 5 conn',
119+
'2 deployments · custom domain included',
120+
'50 vault entries · multi-env (dev / staging / prod)',
121+
'14-day backups · 1-click restore',
122+
],
123+
cta: { label: 'Start hobby plus →', href: ROUTES.signin, variant: 'secondary' },
124+
},
105125
{
106126
id: 'pro',
107127
name: 'Pro',

0 commit comments

Comments
 (0)