Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 41 additions & 30 deletions src/app/[locale]/(main)/pro/checkout/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
resolveProOfferCheckoutSelection,
type ProOfferCheckoutInput,
} from '@/lib/pro-offer-catalog'
import { CheckoutPrototypeGate } from '@/components/pro/prototype/checkout/gate'

export const metadata = {
title: 'Checkout',
Expand Down Expand Up @@ -110,38 +111,48 @@ export default async function CheckoutPage({
const { locale } = await params
setRequestLocale(locale)

return (
<main className="min-h-screen bg-gradient-to-b from-background to-muted/20">
<div className="container mx-auto px-4 py-8">
<Link
href="/pro"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-8"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to pricing
</Link>
const production = (
<div className="container mx-auto px-4 py-8">
<Link
href="/pro"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-8"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to pricing
</Link>

<h1 className="text-3xl font-bold mb-8 text-center">Checkout</h1>

<h1 className="text-3xl font-bold mb-8 text-center">Checkout</h1>
<Suspense
fallback={
<div className="mx-auto grid max-w-4xl gap-8 md:grid-cols-2">
{[1, 2].map((panel) => (
<div
key={panel}
className="space-y-3 rounded-md border bg-card p-6"
>
<Skeleton className="h-6 w-40" />
<Skeleton className="h-20" />
<Skeleton className="h-20" />
</div>
))}
</div>
}
>
<CheckoutContent locale={locale} searchParamsPromise={searchParams} />
</Suspense>
</div>
)

<Suspense
fallback={
<div className="mx-auto grid max-w-4xl gap-8 md:grid-cols-2">
{[1, 2].map((panel) => (
<div
key={panel}
className="space-y-3 rounded-md border bg-card p-6"
>
<Skeleton className="h-6 w-40" />
<Skeleton className="h-20" />
<Skeleton className="h-20" />
</div>
))}
</div>
}
>
<CheckoutContent locale={locale} searchParamsPromise={searchParams} />
</Suspense>
</div>
return (
<main className="min-h-screen bg-gradient-to-b from-background to-muted/20">
{/* PROTOTYPE: dev-only ?variant= switcher; renders the real checkout in production */}
<Suspense fallback={null}>
<CheckoutPrototypeGate
searchParams={searchParams}
production={production}
/>
</Suspense>
</main>
)
}
6 changes: 6 additions & 0 deletions src/app/[locale]/(main)/pro/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ vi.mock('@/components/pro/pricing-card', () => ({
PricingCard: () => <div data-testid="pricing-card" />,
}))

// PROTOTYPE: pass-through so tests exercise the real pricing section
vi.mock('@/components/pro/prototype/gate', () => ({
PricingPrototypeGate: ({ production }: { production: React.ReactNode }) =>
production,
}))

vi.mock('@/components/ui/section', () => ({
Section: ({
children,
Expand Down
9 changes: 8 additions & 1 deletion src/app/[locale]/(main)/pro/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '@/lib/pro-offer-catalog'
import { Section } from '@/components/ui/section'
import { SectionHeading } from '@/components/ui/section-heading'
import { PricingPrototypeGate } from '@/components/pro/prototype/gate'

export async function generateMetadata({
params,
Expand Down Expand Up @@ -124,8 +125,10 @@ async function ProProductJsonLd() {

export default async function ProPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>
searchParams?: Promise<Record<string, string | string[] | undefined>>
}) {
const { locale } = await params
setRequestLocale(locale)
Expand All @@ -147,9 +150,13 @@ export default async function ProPage({
</Section>

{/* Pricing Section - Dynamic */}
{/* PROTOTYPE: dev-only ?variant= switcher; renders the real section in production */}
<Section tone="default" spacing="compact">
<Suspense fallback={<PricingSkeleton />}>
<PricingSectionWithExperiment />
<PricingPrototypeGate
searchParams={searchParams ?? Promise.resolve({})}
production={<PricingSectionWithExperiment />}
/>
</Suspense>
</Section>

Expand Down
126 changes: 126 additions & 0 deletions src/components/pro/prototype/NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# /pro pricing + checkout prototypes — NOTES

## Part 1 — pricing section

**Question:** What should the /pro pricing section look like, and how little of
it needs to suspend while Medusa prices load?

**Finding (applies to whichever variant wins):** the only datum that needs
Medusa is the **price text**. Titles, descriptions, feature lists, and badges
are static copy (`OFFER_COPY` in `src/lib/pro-offer-catalog.ts`), and the
checkout CTA works with just the product handle
(`/pro/checkout?product=wcpos-pro-yearly`) because checkout re-validates the
selection against the live catalog anyway. So the production fix is: render the
cards statically and wrap only the price in Suspense — verified by streaming
inspection that the full card markup arrives in the first chunk with
number-sized price skeletons.

## How to view

```
pnpm dev # then open:
http://localhost:3000/pro?variant=a # A — static-first cards (minimal change)
http://localhost:3000/pro?variant=b # B — comparison panel (features listed once)
http://localhost:3000/pro?variant=c # C — one product, pick a term
http://localhost:3000/pro # current production design (baseline)
```

Flip variants with the floating bottom bar or ←/→ keys. `?delay=<ms>` tunes the
simulated Medusa latency (default 1500; `?delay=0` disables).

Dev-only: in production builds the gate renders the real pricing section.

## Part 1b — pricing round 2 (research-driven)

Round 1 (a/b/c) was rejected as lacklustre. Round 2 patterns come from
researching high-converting license/pricing pages (Sketch, WP Rocket, Tower,
plus data-backed roundups):

- **Use the free tier as the anchor** — three tiers convert best; our free
plugin IS the missing third column (none of round 1 used it).
- **Dollar framing beats percentages** — "about 3 years of Yearly, then $0
forever", not "save 24%".
- **Proof adjacent to price** — 5,000+ active stores, open source since 2014,
14-day no-reason refund (all factual: homepage + /refunds).
- **Risk-reversal at the CTA** — never auto-renews; Yearly credits toward
Lifetime (from the live FAQ).
- **≤8 specific features, one CTA per tier, generous whitespace.**

New variants (switcher now cycles current + d/e/f; a/b/c still URL-reachable):

```
http://localhost:3000/pro?variant=d # D — Free anchors Pro (3-col, research-correct)
http://localhost:3000/pro?variant=e # E — The zero column (dark value pitch vs POS industry, term toggle)
http://localhost:3000/pro?variant=f # F — The receipt (till-receipt pricing, brand-native)
```

All still render static-first with only the price suspending (PriceSlot).

## Part 1c — pricing round 3 ("features once")

Round 2 (d/e/f) also rejected. Owner's articulated rule: **don't reproduce the
same checklist for yearly and lifetime** — the feature list appears once; the
yearly/lifetime choice is price + term facts only.

```
http://localhost:3000/pro?variant=g # G — One panel, two prices (feature grid once → two compact price tiles)
http://localhost:3000/pro?variant=h # H — The buy box (product-page pattern: features left, sticky radio buy box right)
```

Both use the real feature descriptions from messages/en.json (not bare
checkmarks), IconTile brand tiles, and keep the round-2 trust elements
(guarantee at CTA, proof strip, dollar-framed lifetime math, never-auto-renews).

## Part 2 — checkout flow

**Question:** What does a modern, minimal checkout look like given the two hard
requirements (account + billing address)?

Current flow pain points the variants attack:

1. **The /login bounce** — middleware redirects signed-out buyers to a separate
login/register page and back. All three variants create the account inline
from the checkout email field (license + sign-in link delivered after
purchase), so a new customer never leaves the page.
2. **The blocking init spinner** — "Preparing checkout..." blocks on three
sequential API calls before any UI appears. The variants render the full
form instantly (in the real implementation the cart would be created in the
background while the customer types).
3. **No express path** — wallet payments (Apple/Google Pay via Stripe) provide
name, email, and billing address from the wallet in one tap, which is the
real answer to "quick and painless" for most buyers.
4. **No explicit billing address** — today only the bare Stripe PaymentElement
is shown; the variants use one compact block (name / country / address /
city / postal) and wallet payments skip it entirely.

Checkout variants (fully stubbed — no Medusa/Stripe/auth; middleware has a
dev-only bypass for `?variant=a|b|c`):

```
http://localhost:3000/pro/checkout?variant=a # A — one column, express-first
http://localhost:3000/pro/checkout?variant=b # B — three steps that collapse
http://localhost:3000/pro/checkout?variant=c # C — split-screen paywall
http://localhost:3000/pro/checkout # current production checkout
```

`?signedin=1` (or the toggle in the floating bar) previews the signed-in state
— B starts on step 2 with Account already ticked. Pay buttons fake a 1.2s
processing state then show a success screen.

## Verdict

- **Checkout: Variant B wins** (owner, 2026-07-01) — three collapsing steps
with sticky summary. Payment step reworked to the owner's spec: wallet
buttons (Apple/Google Pay) on top, then a radio-accordion where **Card is
the default and expanded** and **PayPal and Bitcoin are equally visible
rows** (not a buried text link); the pay button label follows the selection
("Pay $129 now" / "Continue to PayPal" / "Continue to Bitcoin payment").
See `checkout/payment-method-selector.tsx`.
- **Pricing section: Variant H wins** (owner, 2026-07-02) — "the buy box":
feature list once (icons + real descriptions) with a sticky radio term
selector; feature checklist never repeats per plan. Owner caveat: page still
"not very strong visually" — a visual-polish pass is deferred, the structure
is approved. Rounds 1 (a/b/c) and 2 (d/e/f) rejected.

When both are decided: fold the winners into the real components and delete
this directory (including the middleware bypass)._
56 changes: 56 additions & 0 deletions src/components/pro/prototype/checkout/gate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* PROTOTYPE — throwaway code, do not ship.
*
* Plan: three checkout mockups on the existing /pro/checkout route,
* switchable via ?variant= (plus 'current' baseline), dev-only, fully
* stubbed (no Medusa/Stripe/auth). ?signedin=1 previews the signed-in
* state of each variant.
*/
import type { ReactNode } from 'react'
import { CheckoutVariantA } from './variant-a'
import { CheckoutVariantB } from './variant-b'
import { CheckoutVariantC } from './variant-c'
import { PrototypeSwitcher } from '../switcher'
import { SignedInToggle } from './signed-in-toggle'

const CHECKOUT_VARIANTS = [
{ key: 'current', label: 'Current production checkout' },
{ key: 'a', label: 'One column, express-first' },
{ key: 'b', label: 'Three steps that collapse' },
{ key: 'c', label: 'Split-screen paywall' },
] as const

export async function CheckoutPrototypeGate({
searchParams,
production,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
/** The real production checkout, shown as the 'current' baseline. */
production: ReactNode
}) {
if (process.env.NODE_ENV === 'production') {
return production
}

const params = await searchParams
const variant =
typeof params.variant === 'string' ? params.variant : 'current'
const signedIn = params.signedin === '1'

return (
<>
{variant === 'a' ? (
<CheckoutVariantA signedIn={signedIn} />
) : variant === 'b' ? (
<CheckoutVariantB signedIn={signedIn} />
) : variant === 'c' ? (
<CheckoutVariantC signedIn={signedIn} />
) : (
production
)}
<PrototypeSwitcher variants={CHECKOUT_VARIANTS} current={variant}>
<SignedInToggle signedIn={signedIn} />
</PrototypeSwitcher>
</>
)
}
Loading
Loading