From 3d088aa4f28fa41def8e3626ed43a9d32b54495a Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Thu, 2 Jul 2026 00:34:08 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(pro):=20buy-box=20pricing=20=E2=80=94?= =?UTF-8?q?=20feature=20list=20once,=20term=20choice=20as=20radio=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the winning prototype (PR #194 variant H). The /pro pricing section becomes a product page: the Pro feature list renders once (icons + descriptions from pro.features messages) beside a sticky buy box where yearly vs lifetime is price + term facts only — the feature checklist never repeats per plan. Static-first: the page is now partially prerendered — hero, features and FAQ are static HTML; only the buy box suspends (Medusa prices + checkout experiment from cookies). Buy box keeps the pro_checkout_v1 experiment (CTA label + exp params) and the click_start_checkout event, and adds trust facts sourced from live pages: 14-day refund (linked to /refunds), never-auto-renews, yearly-credits-toward-lifetime, 5,000+ stores. PricingCard and its test are deleted (only consumer was this page). The old standalone features section is subsumed by the new layout. --- e2e/pro.spec.ts | 14 +- src/app/[locale]/(main)/pro/page.test.tsx | 38 ++++- src/app/[locale]/(main)/pro/page.tsx | 165 ++++++++++++---------- src/components/pro/pricing-card.test.tsx | 108 -------------- src/components/pro/pricing-card.tsx | 93 ------------ src/components/pro/pro-buy-box.test.tsx | 132 +++++++++++++++++ src/components/pro/pro-buy-box.tsx | 143 +++++++++++++++++++ src/components/pro/pro-features.tsx | 63 +++++++++ 8 files changed, 468 insertions(+), 288 deletions(-) delete mode 100644 src/components/pro/pricing-card.test.tsx delete mode 100644 src/components/pro/pricing-card.tsx create mode 100644 src/components/pro/pro-buy-box.test.tsx create mode 100644 src/components/pro/pro-buy-box.tsx create mode 100644 src/components/pro/pro-features.tsx diff --git a/e2e/pro.spec.ts b/e2e/pro.spec.ts index 41e7347b..89b97e7d 100644 --- a/e2e/pro.spec.ts +++ b/e2e/pro.spec.ts @@ -36,10 +36,10 @@ test.describe('Pro Page', () => { }) test.describe('Product Display', () => { - test('displays product pricing cards', async ({ page }) => { + test('displays the buy box', async ({ page }) => { await skipIfPricingUnavailable(page) - const pricingCard = page.locator('[data-testid="pricing-card"]').first() - await expect(pricingCard).toBeVisible({ timeout: 10000 }) + const buyBox = page.locator('[data-testid="pro-buy-box"]') + await expect(buyBox).toBeVisible({ timeout: 10000 }) }) test('does NOT show "pricing unavailable" message when products are available', async ({ page }) => { @@ -52,15 +52,15 @@ test.describe('Pro Page', () => { await expect(errorMessage).not.toBeVisible() }) - test('displays yearly and lifetime products', async ({ page }) => { + test('displays yearly and lifetime term options', async ({ page }) => { await skipIfPricingUnavailable(page) await page.waitForLoadState('networkidle') await page.waitForTimeout(2000) - await expect(page.getByText('Pro Yearly')).toBeVisible() - await expect(page.getByText('Pro Lifetime')).toBeVisible() + await expect(page.getByRole('radio', { name: /Yearly/ })).toBeVisible() + await expect(page.getByRole('radio', { name: /Lifetime/ })).toBeVisible() }) - test('pricing cards have checkout CTAs with experiment query params', async ({ page }) => { + test('buy box CTA carries experiment query params', async ({ page }) => { await skipIfPricingUnavailable(page) await page.waitForLoadState('networkidle') await page.waitForTimeout(2000) diff --git a/src/app/[locale]/(main)/pro/page.test.tsx b/src/app/[locale]/(main)/pro/page.test.tsx index 6255a525..8eaa887f 100644 --- a/src/app/[locale]/(main)/pro/page.test.tsx +++ b/src/app/[locale]/(main)/pro/page.test.tsx @@ -66,13 +66,31 @@ vi.mock('@/services/core/analytics/posthog-service', () => ({ resolveProCheckoutVariant: vi.fn(async () => 'control'), })) +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: { + children: React.ReactNode + href: string + [key: string]: unknown + }) => ( + + {children} + + ), +})) + +vi.mock('@/lib/analytics/client-events', () => ({ + trackClientEvent: vi.fn(), +})) + vi.mock('@/lib/pro-offer-catalog', () => ({ getProOfferCatalog: vi.fn(async () => ({ offers: [] })), buildProOfferSchemaOffers: vi.fn(() => []), -})) - -vi.mock('@/components/pro/pricing-card', () => ({ - PricingCard: () =>
, + buildProCheckoutHref: vi.fn(() => '/pro/checkout?product=test'), + getProCheckoutCtaLabel: vi.fn(() => 'Get Started'), })) vi.mock('@/components/ui/section', () => ({ @@ -115,7 +133,7 @@ vi.mock('@/components/ui/section-heading', () => ({ import ProPage from './page' describe('ProPage', () => { - it('uses the canonical page-section seam for hero, pricing, features, and FAQ', async () => { + it('renders hero and features statically with only the buy box suspending', async () => { render(await ProPage({ params: Promise.resolve({ locale: 'en' }) })) const heroHeading = screen.getByRole('heading', { @@ -128,14 +146,20 @@ describe('ProPage', () => { const sections = [...document.querySelectorAll('[data-section-tone]')] expect( sections.map((section) => section.getAttribute('data-section-tone')) - ).toEqual(['default', 'default', 'muted', 'default']) + ).toEqual(['default', 'default', 'muted']) expect( sections.map((section) => section.getAttribute('data-section-spacing')) - ).toEqual(['hero', 'compact', 'default', 'default']) + ).toEqual(['hero', 'compact', 'default']) + // Feature list renders statically (exactly once, next to the buy box). expect(screen.getByText('Pro features')).toBeInTheDocument() expect(screen.getByText('Payment terminals')).toBeInTheDocument() expect(screen.getByText('Stock and price edits')).toBeInTheDocument() + + // The buy box is the only suspended region (Suspense is mocked to + // render its fallback), so the skeleton stands in for it here. + expect(screen.getByTestId('pro-buy-box-skeleton')).toBeInTheDocument() + expect(screen.getByText('Questions')).toBeInTheDocument() expect(screen.getByText('Do I need the free plugin?')).toBeInTheDocument() expect(screen.getAllByText('Yes.')).toHaveLength(2) diff --git a/src/app/[locale]/(main)/pro/page.tsx b/src/app/[locale]/(main)/pro/page.tsx index 2101bf1f..fb8e65ed 100644 --- a/src/app/[locale]/(main)/pro/page.tsx +++ b/src/app/[locale]/(main)/pro/page.tsx @@ -2,7 +2,11 @@ import { getTranslations, setRequestLocale } from 'next-intl/server' import { Suspense } from 'react' import { cacheLife, cacheTag } from 'next/cache' import { cookies } from 'next/headers' -import { PricingCard } from '@/components/pro/pricing-card' +import { ProBuyBox, type ProBuyBoxOption } from '@/components/pro/pro-buy-box' +import { + PRO_FEATURE_KEYS, + ProFeatureList, +} from '@/components/pro/pro-features' import { Skeleton } from '@/components/ui/skeleton' import type { ProCheckoutVariant } from '@/services/core/analytics/posthog-service' import { resolveProCheckoutVariant } from '@/services/core/analytics/posthog-service' @@ -11,9 +15,12 @@ import { getAnalyticsConfig } from '@/lib/analytics/config' import type { Metadata } from 'next' import { marketingMetadata } from '@/lib/seo' import { + buildProCheckoutHref, buildProOfferSchemaOffers, + getProCheckoutCtaLabel, getProOfferCatalog, } from '@/lib/pro-offer-catalog' +import type { PlanId } from '@/lib/plans' import { Section } from '@/components/ui/section' import { SectionHeading } from '@/components/ui/section-heading' @@ -33,9 +40,40 @@ export async function generateMetadata({ } /** - * Dynamic component that fetches products from Medusa + * Buy-box copy: one product, two terms. Price + term facts only — the + * feature list renders once, outside the box. + */ +const BUY_BOX_COPY: Record< + PlanId, + { + title: string + subtitle: string + badgeLabel: string | null + priceSuffix: string + ctaNote: string + } +> = { + yearly: { + title: 'Yearly', + subtitle: 'Updates & support for 1 year', + badgeLabel: 'Most Popular', + priceSuffix: '/yr', + ctaNote: 'One-time payment — never auto-renews.', + }, + lifetime: { + title: 'Lifetime', + subtitle: 'Updates forever', + badgeLabel: null, + priceSuffix: ' once', + ctaNote: 'About 3 years of Yearly — then $0 forever.', + }, +} + +/** + * Dynamic component that fetches offers from Medusa. Only this box + * suspends; the rest of the page renders statically. */ -async function PricingSection({ +async function BuyBoxSection({ experimentVariant, }: { experimentVariant: ProCheckoutVariant @@ -48,7 +86,7 @@ async function PricingSection({ if (offers.length === 0) { return ( -
+

Pricing information is currently unavailable. Please try again later. @@ -57,29 +95,46 @@ async function PricingSection({ ) } + const options: ProBuyBoxOption[] = offers.map((offer) => { + const copy = BUY_BOX_COPY[offer.planId] + return { + planId: offer.planId, + handle: offer.handle, + title: copy.title, + subtitle: copy.subtitle, + badgeLabel: copy.badgeLabel, + priceText: offer.price.compact, + priceSuffix: copy.priceSuffix, + ctaNote: copy.ctaNote, + checkoutHref: buildProCheckoutHref(offer, experimentVariant), + } + }) + return ( -

- {offers.map((offer) => ( - - ))} -
+ ) } -function PricingSkeleton() { +function BuyBoxSkeleton() { return ( -
- - +
+ + + + +
) } -async function PricingSectionWithExperiment() { +async function BuyBoxWithExperiment() { const cookieStore = await cookies() const distinctId = cookieStore.get(ANALYTICS_DISTINCT_ID_COOKIE)?.value const analyticsConfig = getAnalyticsConfig(process.env) @@ -90,10 +145,9 @@ async function PricingSectionWithExperiment() { }) : 'control' - return + return } - async function ProProductJsonLd() { 'use cache' cacheLife('products') @@ -131,6 +185,12 @@ export default async function ProPage({ setRequestLocale(locale) const t = await getTranslations({ locale, namespace: 'pro' }) + const features = PRO_FEATURE_KEYS.map(({ key, Icon }) => ({ + Icon, + title: t(`features.${key}.title`), + description: t(`features.${key}.description`), + })) + return (
@@ -146,50 +206,24 @@ export default async function ProPage({ /> - {/* Pricing Section - Dynamic */} + {/* Features render statically; only the buy box waits on Medusa */}
- }> - - -
- - {/* Features Section */} -
- -
- - - - - - + +
+ }> + + +
{/* FAQ Section */} -
+
-

{title}

-

{description}

-
- ) -} - function FaqItem({ question, answer }: { question: string; answer: string }) { return (
diff --git a/src/components/pro/pricing-card.test.tsx b/src/components/pro/pricing-card.test.tsx deleted file mode 100644 index 517345c8..00000000 --- a/src/components/pro/pricing-card.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import { PricingCard } from './pricing-card' -import type { ProOffer } from '@/lib/pro-offer-catalog' - -vi.mock('@/i18n/navigation', () => ({ - Link: ({ - children, - href, - ...props - }: { - children: React.ReactNode - href: string - [key: string]: unknown - }) => ( - - {children} - - ), -})) - -const yearlyOffer: ProOffer = { - planId: 'yearly', - handle: 'wcpos-pro-yearly', - variantId: 'variant_123', - title: 'WCPOS Pro Yearly', - description: 'One-year license', - featured: true, - badgeLabel: 'Most Popular', - price: { - amount: 129, - currencyCode: 'usd', - formatted: '$129.00', - compact: '$129', - schemaPrice: '129', - }, - priceSuffix: '/year', - features: [ - 'All Pro features included', - 'Unlimited orders & products', - 'Priority email support', - 'Automatic updates for 1 year', - 'Manual renewal — no automatic billing', - ], - checkoutPath: '/pro/checkout?product=wcpos-pro-yearly&variant=variant_123', -} - -const lifetimeOffer: ProOffer = { - ...yearlyOffer, - planId: 'lifetime', - handle: 'wcpos-pro-lifetime', - title: 'WCPOS Pro Lifetime', - description: 'One-time purchase', - featured: false, - badgeLabel: null, - price: { - amount: 399, - currencyCode: 'usd', - formatted: '$399.00', - compact: '$399', - schemaPrice: '399', - }, - priceSuffix: null, - features: [ - 'All Pro features included', - 'Unlimited orders & products', - 'Priority email support', - 'Lifetime updates forever', - 'One-time payment', - 'Best value for long-term use', - ], - variantId: 'variant_456', - checkoutPath: '/pro/checkout?product=wcpos-pro-lifetime&variant=variant_456', -} - -describe('PricingCard', () => { - it('renders lifetime offer copy from the offer catalog', () => { - render() - - expect(screen.getByText('One-time purchase')).toBeInTheDocument() - expect(screen.getByText('Lifetime updates forever')).toBeInTheDocument() - expect(screen.queryByText('/year')).not.toBeInTheDocument() - expect(screen.queryByText('One-year license')).not.toBeInTheDocument() - }) - - it('renders yearly offer copy from the offer catalog', () => { - render() - - expect(screen.getByText('One-year license')).toBeInTheDocument() - expect(screen.getByText('/year')).toBeInTheDocument() - expect(screen.getByText('Manual renewal — no automatic billing')).toBeInTheDocument() - expect(screen.queryByText('Cancel anytime')).not.toBeInTheDocument() - expect(screen.queryByText('Annual subscription')).not.toBeInTheDocument() - }) - - it('includes experiment metadata in checkout link', () => { - render() - - const cta = screen.getByRole('link', { name: 'Get Instant Access' }) - const href = cta.getAttribute('href') - - expect(href).toContain('/pro/checkout?') - expect(href).toContain('product=wcpos-pro-yearly') - expect(href).toContain('variant=variant_123') - expect(href).toContain('exp=pro_checkout_v1') - expect(href).toContain('exp_variant=value_copy') - }) -}) diff --git a/src/components/pro/pricing-card.tsx b/src/components/pro/pricing-card.tsx deleted file mode 100644 index e769775f..00000000 --- a/src/components/pro/pricing-card.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Check } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import type { ProCheckoutVariant } from '@/services/core/analytics/posthog-service' -import { TrackedLocaleLink } from '@/components/analytics/tracked-locale-link' -import { - buildProCheckoutHref, - getProCheckoutCtaLabel, - type ProOffer, -} from '@/lib/pro-offer-catalog' - -interface PricingCardProps { - offer: ProOffer - experimentVariant?: ProCheckoutVariant -} - -export function PricingCard({ - offer, - experimentVariant = 'control', -}: PricingCardProps) { - const checkoutHref = buildProCheckoutHref(offer, experimentVariant) - const ctaLabel = getProCheckoutCtaLabel(experimentVariant) - - return ( - - {offer.badgeLabel && ( - - {offer.badgeLabel} - - )} - - {offer.title} - - {offer.description} - - - -
- - {offer.price.formatted} - - {offer.priceSuffix && ( - {offer.priceSuffix} - )} -
-
    - {offer.features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
-
- - - -
- ) -} diff --git a/src/components/pro/pro-buy-box.test.tsx b/src/components/pro/pro-buy-box.test.tsx new file mode 100644 index 00000000..7cfca11d --- /dev/null +++ b/src/components/pro/pro-buy-box.test.tsx @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { ProBuyBox, type ProBuyBoxOption } from './pro-buy-box' + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: { + children: React.ReactNode + href: string + [key: string]: unknown + }) => ( + + {children} + + ), +})) + +vi.mock('@/lib/analytics/client-events', () => ({ + trackClientEvent: vi.fn(), +})) + +import { trackClientEvent } from '@/lib/analytics/client-events' + +const options: ProBuyBoxOption[] = [ + { + planId: 'yearly', + handle: 'wcpos-pro-yearly', + title: 'Yearly', + subtitle: 'Updates & support for 1 year', + badgeLabel: 'Most Popular', + priceText: '$129', + priceSuffix: '/yr', + ctaNote: 'One-time payment — never auto-renews.', + checkoutHref: + '/pro/checkout?product=wcpos-pro-yearly&variant=variant_123&exp=pro_checkout_v1&exp_variant=control', + }, + { + planId: 'lifetime', + handle: 'wcpos-pro-lifetime', + title: 'Lifetime', + subtitle: 'Updates forever', + badgeLabel: null, + priceText: '$399', + priceSuffix: ' once', + ctaNote: 'About 3 years of Yearly — then $0 forever.', + checkoutHref: + '/pro/checkout?product=wcpos-pro-lifetime&variant=variant_456&exp=pro_checkout_v1&exp_variant=control', + }, +] + +describe('ProBuyBox', () => { + it('selects the first option by default and links its checkout href', () => { + render( + + ) + + const radios = screen.getAllByRole('radio') + expect(radios[0]).toHaveAttribute('aria-checked', 'true') + expect(radios[1]).toHaveAttribute('aria-checked', 'false') + + const cta = screen.getByRole('link', { name: 'Get Started' }) + expect(cta.getAttribute('href')).toContain('product=wcpos-pro-yearly') + expect( + screen.getByText('One-time payment — never auto-renews.') + ).toBeInTheDocument() + }) + + it('switches CTA href and note when the other term is selected', () => { + render( + + ) + + fireEvent.click(screen.getByRole('radio', { name: /Lifetime/ })) + + expect( + screen.getByRole('radio', { name: /Lifetime/ }) + ).toHaveAttribute('aria-checked', 'true') + const cta = screen.getByRole('link', { name: 'Get Started' }) + expect(cta.getAttribute('href')).toContain('product=wcpos-pro-lifetime') + expect( + screen.getByText('About 3 years of Yearly — then $0 forever.') + ).toBeInTheDocument() + expect( + screen.queryByText('One-time payment — never auto-renews.') + ).not.toBeInTheDocument() + }) + + it('tracks the checkout click with the selected plan', () => { + render( + + ) + + fireEvent.click(screen.getByRole('radio', { name: /Lifetime/ })) + fireEvent.click(screen.getByRole('link', { name: 'Get Started' })) + + expect(trackClientEvent).toHaveBeenCalledWith('click_start_checkout', { + experiment: 'pro_checkout_v1', + variant: 'value_copy', + product: 'wcpos-pro-lifetime', + plan: 'lifetime', + }) + }) + + it('links the guarantee to the refunds policy', () => { + render( + + ) + + expect( + screen.getByRole('link', { name: '14-day money-back guarantee' }) + ).toHaveAttribute('href', '/refunds') + }) +}) diff --git a/src/components/pro/pro-buy-box.tsx b/src/components/pro/pro-buy-box.tsx new file mode 100644 index 00000000..e73da619 --- /dev/null +++ b/src/components/pro/pro-buy-box.tsx @@ -0,0 +1,143 @@ +'use client' + +import { useState } from 'react' +import { Bitcoin, CreditCard, Shield } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Link } from '@/i18n/navigation' +import { TrackedLocaleLink } from '@/components/analytics/tracked-locale-link' +import type { ProCheckoutVariant } from '@/services/core/analytics/posthog-service' +import type { PlanId } from '@/lib/plans' + +/** + * ProBuyBox — the purchase decision on /pro, product-page style. + * + * The feature list lives outside this box and appears exactly once; the + * yearly/lifetime choice is deliberately reduced to price + term facts + * (see docs/adr — pricing must never repeat the feature checklist per + * plan). Selection is client state; prices arrive resolved as strings so + * the box streams in as one unit behind Suspense. + */ +export interface ProBuyBoxOption { + planId: PlanId + handle: string + title: string + subtitle: string + badgeLabel: string | null + priceText: string + priceSuffix: string + ctaNote: string + checkoutHref: string +} + +interface ProBuyBoxProps { + options: ProBuyBoxOption[] + ctaLabel: string + experimentVariant: ProCheckoutVariant +} + +export function ProBuyBox({ + options, + ctaLabel, + experimentVariant, +}: ProBuyBoxProps) { + const [selected, setSelected] = useState(options[0].planId) + const current = options.find((option) => option.planId === selected)! + + return ( +
+

Get Pro

+

+ One license, all features. Just pick how long you want updates. +

+ +
+ {options.map((option) => { + const isSelected = option.planId === selected + return ( + + ) + })} +
+ + +

+ {current.ctaNote} +

+ +
+

+ + + + 14-day money-back guarantee + + , no reason required + +

+

+ + + Card, PayPal or + Bitcoin + +

+
+ +

+ 5,000+ active stores · Yearly credits toward Lifetime +

+
+ ) +} diff --git a/src/components/pro/pro-features.tsx b/src/components/pro/pro-features.tsx new file mode 100644 index 00000000..c941b1ba --- /dev/null +++ b/src/components/pro/pro-features.tsx @@ -0,0 +1,63 @@ +import { + BarChart3, + ClipboardList, + CreditCard, + PencilRuler, + Plug, + Users, + type LucideIcon, +} from 'lucide-react' +import { IconTile } from '@/components/ui/icon-tile' + +/** + * The Pro feature list — appears exactly once on /pro, beside the buy box. + * Copy comes from the `pro.features` message namespace via the page; only + * the icon pairing lives here. Sync on purpose: the page must render this + * statically while the buy box suspends. + */ +export const PRO_FEATURE_KEYS = [ + { key: 'terminal', Icon: CreditCard }, + { key: 'stockPrice', Icon: PencilRuler }, + { key: 'orders', Icon: ClipboardList }, + { key: 'customers', Icon: Users }, + { key: 'reports', Icon: BarChart3 }, + { key: 'gateways', Icon: Plug }, +] as const + +export interface ProFeature { + Icon: LucideIcon + title: string + description: string +} + +export function ProFeatureList({ + heading, + subtitle, + features, +}: { + heading: string + subtitle: string + features: ProFeature[] +}) { + return ( +
+

{heading}

+

{subtitle}

+
+ {features.map(({ Icon, title, description }) => ( +
+ + + +
+

{title}

+

+ {description} +

+
+
+ ))} +
+
+ ) +} From 570adbc3976694aeb8b10344360076d35c8dad51 Mon Sep 17 00:00:00 2001 From: "wcpos-agents[bot]" <2860316+wcpos-agents[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:53:39 +0000 Subject: [PATCH 2/6] fix: localize pro buy box copy --- messages/de.json | 15 +++ messages/en.json | 15 +++ messages/es.json | 15 +++ messages/fr.json | 15 +++ messages/it.json | 15 +++ messages/ja.json | 15 +++ messages/ko.json | 15 +++ messages/nl.json | 15 +++ messages/pt.json | 15 +++ messages/zh.json | 15 +++ src/app/[locale]/(main)/pro/page.test.tsx | 121 +++++++++++++++++++++- src/app/[locale]/(main)/pro/page.tsx | 80 ++++++++------ 12 files changed, 314 insertions(+), 37 deletions(-) diff --git a/messages/de.json b/messages/de.json index e44611e9..b422cb51 100644 --- a/messages/de.json +++ b/messages/de.json @@ -61,6 +61,21 @@ "description": "Take payments with any WooCommerce-compatible gateway, not just the built-in options." } }, + "buyBox": { + "yearly": { + "title": "Jährlich", + "subtitle": "Updates & Support für 1 Jahr", + "badgeLabel": "Am beliebtesten", + "priceSuffix": "/Jahr", + "ctaNote": "Einmalige Zahlung — keine automatische Verlängerung." + }, + "lifetime": { + "title": "Lebenslang", + "subtitle": "Updates für immer", + "priceSuffix": " einmalig", + "ctaNote": "Etwa 3 Jahre Jährlich — danach für immer $0." + } + }, "faq": { "title": "Frequently Asked Questions", "freePlugin": { diff --git a/messages/en.json b/messages/en.json index 9b3d0b5e..ee0ebc13 100644 --- a/messages/en.json +++ b/messages/en.json @@ -61,6 +61,21 @@ "description": "Take payments with any WooCommerce-compatible gateway, not just the built-in options." } }, + "buyBox": { + "yearly": { + "title": "Yearly", + "subtitle": "Updates & support for 1 year", + "badgeLabel": "Most Popular", + "priceSuffix": "/yr", + "ctaNote": "One-time payment — never auto-renews." + }, + "lifetime": { + "title": "Lifetime", + "subtitle": "Updates forever", + "priceSuffix": " once", + "ctaNote": "About 3 years of Yearly — then $0 forever." + } + }, "faq": { "title": "Frequently Asked Questions", "freePlugin": { diff --git a/messages/es.json b/messages/es.json index 84396bce..80133cf0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -61,6 +61,21 @@ "description": "Take payments with any WooCommerce-compatible gateway, not just the built-in options." } }, + "buyBox": { + "yearly": { + "title": "Anual", + "subtitle": "Actualizaciones y soporte durante 1 año", + "badgeLabel": "Más popular", + "priceSuffix": "/año", + "ctaNote": "Pago único — nunca se renueva automáticamente." + }, + "lifetime": { + "title": "De por vida", + "subtitle": "Actualizaciones para siempre", + "priceSuffix": " una vez", + "ctaNote": "Aproximadamente 3 años de Anual — después $0 para siempre." + } + }, "faq": { "title": "Frequently Asked Questions", "freePlugin": { diff --git a/messages/fr.json b/messages/fr.json index 202395ec..05a85fb3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -61,6 +61,21 @@ "description": "Take payments with any WooCommerce-compatible gateway, not just the built-in options." } }, + "buyBox": { + "yearly": { + "title": "Annuel", + "subtitle": "Mises à jour et support pendant 1 an", + "badgeLabel": "Le plus populaire", + "priceSuffix": "/an", + "ctaNote": "Paiement unique — jamais renouvelé automatiquement." + }, + "lifetime": { + "title": "À vie", + "subtitle": "Mises à jour pour toujours", + "priceSuffix": " une fois", + "ctaNote": "Environ 3 ans d'Annuel — puis $0 pour toujours." + } + }, "faq": { "title": "Frequently Asked Questions", "freePlugin": { diff --git a/messages/it.json b/messages/it.json index 8ae16d59..6e1cc977 100644 --- a/messages/it.json +++ b/messages/it.json @@ -61,6 +61,21 @@ "description": "Take payments with any WooCommerce-compatible gateway, not just the built-in options." } }, + "buyBox": { + "yearly": { + "title": "Annuale", + "subtitle": "Aggiornamenti e supporto per 1 anno", + "badgeLabel": "Più popolare", + "priceSuffix": "/anno", + "ctaNote": "Pagamento unico — non si rinnova mai automaticamente." + }, + "lifetime": { + "title": "A vita", + "subtitle": "Aggiornamenti per sempre", + "priceSuffix": " una tantum", + "ctaNote": "Circa 3 anni di Annuale — poi $0 per sempre." + } + }, "faq": { "title": "Frequently Asked Questions", "freePlugin": { diff --git a/messages/ja.json b/messages/ja.json index 56b03bc8..da4afb33 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -61,6 +61,21 @@ "description": "Take payments with any WooCommerce-compatible gateway, not just the built-in options." } }, + "buyBox": { + "yearly": { + "title": "年間", + "subtitle": "1年間のアップデートとサポート", + "badgeLabel": "最も人気", + "priceSuffix": "/年", + "ctaNote": "一回払い — 自動更新はありません。" + }, + "lifetime": { + "title": "買い切り", + "subtitle": "アップデートは永久", + "priceSuffix": " 一回払い", + "ctaNote": "年間約3年分 — その後は永久に$0。" + } + }, "faq": { "title": "Frequently Asked Questions", "freePlugin": { diff --git a/messages/ko.json b/messages/ko.json index cac2e20a..80f5d9cf 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -61,6 +61,21 @@ "description": "Take payments with any WooCommerce-compatible gateway, not just the built-in options." } }, + "buyBox": { + "yearly": { + "title": "연간", + "subtitle": "1년간 업데이트 및 지원", + "badgeLabel": "가장 인기", + "priceSuffix": "/년", + "ctaNote": "일회성 결제 — 자동 갱신되지 않습니다." + }, + "lifetime": { + "title": "평생", + "subtitle": "영구 업데이트", + "priceSuffix": " 1회", + "ctaNote": "연간 약 3년치 — 이후 영구적으로 $0." + } + }, "faq": { "title": "Frequently Asked Questions", "freePlugin": { diff --git a/messages/nl.json b/messages/nl.json index 745851ee..7a900fb1 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -61,6 +61,21 @@ "description": "Take payments with any WooCommerce-compatible gateway, not just the built-in options." } }, + "buyBox": { + "yearly": { + "title": "Jaarlijks", + "subtitle": "Updates en support voor 1 jaar", + "badgeLabel": "Meest populair", + "priceSuffix": "/jaar", + "ctaNote": "Eenmalige betaling — wordt nooit automatisch verlengd." + }, + "lifetime": { + "title": "Levenslang", + "subtitle": "Altijd updates", + "priceSuffix": " eenmalig", + "ctaNote": "Ongeveer 3 jaar Jaarlijks — daarna voor altijd $0." + } + }, "faq": { "title": "Frequently Asked Questions", "freePlugin": { diff --git a/messages/pt.json b/messages/pt.json index 7451786f..f28f5132 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -61,6 +61,21 @@ "description": "Take payments with any WooCommerce-compatible gateway, not just the built-in options." } }, + "buyBox": { + "yearly": { + "title": "Anual", + "subtitle": "Atualizações e suporte por 1 ano", + "badgeLabel": "Mais popular", + "priceSuffix": "/ano", + "ctaNote": "Pagamento único — nunca renova automaticamente." + }, + "lifetime": { + "title": "Vitalício", + "subtitle": "Atualizações para sempre", + "priceSuffix": " uma vez", + "ctaNote": "Cerca de 3 anos do Anual — depois $0 para sempre." + } + }, "faq": { "title": "Frequently Asked Questions", "freePlugin": { diff --git a/messages/zh.json b/messages/zh.json index aec8dae7..2adab955 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -61,6 +61,21 @@ "description": "Take payments with any WooCommerce-compatible gateway, not just the built-in options." } }, + "buyBox": { + "yearly": { + "title": "年付", + "subtitle": "1年更新和支持", + "badgeLabel": "最受欢迎", + "priceSuffix": "/年", + "ctaNote": "一次性付款 — 永不自动续订。" + }, + "lifetime": { + "title": "终身", + "subtitle": "永久更新", + "priceSuffix": " 一次性", + "ctaNote": "约等于3年年付 — 之后永久$0。" + } + }, "faq": { "title": "Frequently Asked Questions", "freePlugin": { diff --git a/src/app/[locale]/(main)/pro/page.test.tsx b/src/app/[locale]/(main)/pro/page.test.tsx index 8eaa887f..fb685b6a 100644 --- a/src/app/[locale]/(main)/pro/page.test.tsx +++ b/src/app/[locale]/(main)/pro/page.test.tsx @@ -1,16 +1,25 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { render, screen } from '@testing-library/react' +import type { ProBuyBoxOption } from '@/components/pro/pro-buy-box' +import type { ProOffer } from '@/lib/pro-offer-catalog' + +const mocks = vi.hoisted(() => ({ + suspense: { renderChildren: false }, + getProOfferCatalog: vi.fn(async () => ({ offers: [] as ProOffer[] })), + buildProCheckoutHref: vi.fn(() => '/pro/checkout?product=test'), +})) vi.mock('react', async (importActual) => { const actual = await importActual() return { ...actual, Suspense: ({ + children, fallback, }: { children?: React.ReactNode fallback?: React.ReactNode - }) => <>{fallback}, + }) => <>{mocks.suspense.renderChildren ? children : fallback}, } }) @@ -31,6 +40,15 @@ vi.mock('next-intl/server', () => ({ 'features.reports.description': 'Run reports.', 'features.gateways.title': 'Gateways', 'features.gateways.description': 'Use custom gateways.', + 'buyBox.yearly.title': 'Translated Yearly', + 'buyBox.yearly.subtitle': 'Translated yearly support', + 'buyBox.yearly.badgeLabel': 'Translated Popular', + 'buyBox.yearly.priceSuffix': '/translated-year', + 'buyBox.yearly.ctaNote': 'Translated yearly note.', + 'buyBox.lifetime.title': 'Translated Lifetime', + 'buyBox.lifetime.subtitle': 'Translated lifetime updates', + 'buyBox.lifetime.priceSuffix': ' translated-once', + 'buyBox.lifetime.ctaNote': 'Translated lifetime note.', 'faq.title': 'Questions', 'faq.freePlugin.question': 'Do I need the free plugin?', 'faq.freePlugin.answer': 'Yes.', @@ -87,12 +105,35 @@ vi.mock('@/lib/analytics/client-events', () => ({ })) vi.mock('@/lib/pro-offer-catalog', () => ({ - getProOfferCatalog: vi.fn(async () => ({ offers: [] })), + getProOfferCatalog: mocks.getProOfferCatalog, buildProOfferSchemaOffers: vi.fn(() => []), - buildProCheckoutHref: vi.fn(() => '/pro/checkout?product=test'), + buildProCheckoutHref: mocks.buildProCheckoutHref, getProCheckoutCtaLabel: vi.fn(() => 'Get Started'), })) +vi.mock('@/components/pro/pro-buy-box', () => ({ + ProBuyBox: ({ + options, + ctaLabel, + }: { + options: ProBuyBoxOption[] + ctaLabel: string + }) => ( +
+ {ctaLabel} + {options.map((option) => ( +
+ {option.title} + {option.subtitle} + {option.badgeLabel && {option.badgeLabel}} + {option.priceSuffix} + {option.ctaNote} +
+ ))} +
+ ), +})) + vi.mock('@/components/ui/section', () => ({ Section: ({ children, @@ -130,9 +171,15 @@ vi.mock('@/components/ui/section-heading', () => ({ ), })) -import ProPage from './page' +import ProPage, { BuyBoxSection } from './page' describe('ProPage', () => { + beforeEach(() => { + mocks.suspense.renderChildren = false + mocks.getProOfferCatalog.mockResolvedValue({ offers: [] }) + mocks.buildProCheckoutHref.mockReturnValue('/pro/checkout?product=test') + }) + it('renders hero and features statically with only the buy box suspending', async () => { render(await ProPage({ params: Promise.resolve({ locale: 'en' }) })) @@ -164,4 +211,68 @@ describe('ProPage', () => { expect(screen.getByText('Do I need the free plugin?')).toBeInTheDocument() expect(screen.getAllByText('Yes.')).toHaveLength(2) }) + + it('passes translated buy-box option copy to the buy box', async () => { + mocks.getProOfferCatalog.mockResolvedValue({ + offers: [ + { + planId: 'yearly', + handle: 'wcpos-pro-yearly', + variantId: 'variant_yearly', + title: 'Pro Yearly', + description: 'One-year license', + featured: true, + badgeLabel: 'Most Popular', + price: { + amount: 129, + currencyCode: 'usd', + formatted: '$129.00', + compact: '$129', + schemaPrice: '129', + }, + priceSuffix: '/year', + features: [], + checkoutPath: '/pro/checkout?product=wcpos-pro-yearly', + }, + { + planId: 'lifetime', + handle: 'wcpos-pro-lifetime', + variantId: 'variant_lifetime', + title: 'Pro Lifetime', + description: 'Lifetime license', + featured: false, + badgeLabel: null, + price: { + amount: 399, + currencyCode: 'usd', + formatted: '$399.00', + compact: '$399', + schemaPrice: '399', + }, + priceSuffix: null, + features: [], + checkoutPath: '/pro/checkout?product=wcpos-pro-lifetime', + }, + ], + }) + + render( + await BuyBoxSection({ + experimentVariant: 'control', + locale: 'en', + }) + ) + + expect(await screen.findByText('Translated Yearly')).toBeInTheDocument() + expect(screen.getByText('Translated yearly support')).toBeInTheDocument() + expect(screen.getByText('Translated Popular')).toBeInTheDocument() + expect(screen.getByText('/translated-year')).toBeInTheDocument() + expect(screen.getByText('Translated yearly note.')).toBeInTheDocument() + expect(screen.getByText('Translated Lifetime')).toBeInTheDocument() + expect(screen.getByText('Translated lifetime updates')).toBeInTheDocument() + expect( + screen.getByText((_, element) => element?.textContent === ' translated-once') + ).toBeInTheDocument() + expect(screen.getByText('Translated lifetime note.')).toBeInTheDocument() + }) }) diff --git a/src/app/[locale]/(main)/pro/page.tsx b/src/app/[locale]/(main)/pro/page.tsx index fb8e65ed..af19d0ae 100644 --- a/src/app/[locale]/(main)/pro/page.tsx +++ b/src/app/[locale]/(main)/pro/page.tsx @@ -40,48 +40,62 @@ export async function generateMetadata({ } /** - * Buy-box copy: one product, two terms. Price + term facts only — the - * feature list renders once, outside the box. + * Buy-box copy: one product, two terms. Price + term facts only; the + * values are resolved through the locale messages in BuyBoxSection. */ -const BUY_BOX_COPY: Record< - PlanId, - { - title: string - subtitle: string - badgeLabel: string | null - priceSuffix: string - ctaNote: string - } -> = { +type BuyBoxMessageKey = + | 'buyBox.yearly.title' + | 'buyBox.yearly.subtitle' + | 'buyBox.yearly.badgeLabel' + | 'buyBox.yearly.priceSuffix' + | 'buyBox.yearly.ctaNote' + | 'buyBox.lifetime.title' + | 'buyBox.lifetime.subtitle' + | 'buyBox.lifetime.priceSuffix' + | 'buyBox.lifetime.ctaNote' + +const BUY_BOX_COPY_KEYS = { yearly: { - title: 'Yearly', - subtitle: 'Updates & support for 1 year', - badgeLabel: 'Most Popular', - priceSuffix: '/yr', - ctaNote: 'One-time payment — never auto-renews.', + title: 'buyBox.yearly.title', + subtitle: 'buyBox.yearly.subtitle', + badgeLabel: 'buyBox.yearly.badgeLabel', + priceSuffix: 'buyBox.yearly.priceSuffix', + ctaNote: 'buyBox.yearly.ctaNote', }, lifetime: { - title: 'Lifetime', - subtitle: 'Updates forever', + title: 'buyBox.lifetime.title', + subtitle: 'buyBox.lifetime.subtitle', badgeLabel: null, - priceSuffix: ' once', - ctaNote: 'About 3 years of Yearly — then $0 forever.', + priceSuffix: 'buyBox.lifetime.priceSuffix', + ctaNote: 'buyBox.lifetime.ctaNote', }, -} +} as const satisfies Record< + PlanId, + { + title: BuyBoxMessageKey + subtitle: BuyBoxMessageKey + badgeLabel: BuyBoxMessageKey | null + priceSuffix: BuyBoxMessageKey + ctaNote: BuyBoxMessageKey + } +> /** * Dynamic component that fetches offers from Medusa. Only this box * suspends; the rest of the page renders statically. */ -async function BuyBoxSection({ +export async function BuyBoxSection({ experimentVariant, + locale, }: { experimentVariant: ProCheckoutVariant + locale: string }) { 'use cache' cacheLife('products') cacheTag('products') + const t = await getTranslations({ locale, namespace: 'pro' }) const { offers } = await getProOfferCatalog() if (offers.length === 0) { @@ -96,16 +110,16 @@ async function BuyBoxSection({ } const options: ProBuyBoxOption[] = offers.map((offer) => { - const copy = BUY_BOX_COPY[offer.planId] + const copy = BUY_BOX_COPY_KEYS[offer.planId] return { planId: offer.planId, handle: offer.handle, - title: copy.title, - subtitle: copy.subtitle, - badgeLabel: copy.badgeLabel, + title: t(copy.title), + subtitle: t(copy.subtitle), + badgeLabel: copy.badgeLabel ? t(copy.badgeLabel) : null, priceText: offer.price.compact, - priceSuffix: copy.priceSuffix, - ctaNote: copy.ctaNote, + priceSuffix: t(copy.priceSuffix), + ctaNote: t(copy.ctaNote), checkoutHref: buildProCheckoutHref(offer, experimentVariant), } }) @@ -134,7 +148,7 @@ function BuyBoxSkeleton() { ) } -async function BuyBoxWithExperiment() { +async function BuyBoxWithExperiment({ locale }: { locale: string }) { const cookieStore = await cookies() const distinctId = cookieStore.get(ANALYTICS_DISTINCT_ID_COOKIE)?.value const analyticsConfig = getAnalyticsConfig(process.env) @@ -145,7 +159,9 @@ async function BuyBoxWithExperiment() { }) : 'control' - return + return ( + + ) } async function ProProductJsonLd() { @@ -216,7 +232,7 @@ export default async function ProPage({ />
}> - +
From 57043525566f12ea698554e96e25353c45444388 Mon Sep 17 00:00:00 2001 From: "wcpos-agents[bot]" <2860316+wcpos-agents[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:53:57 +0000 Subject: [PATCH 3/6] fix: support keyboard navigation in pro buy box --- src/components/pro/pro-buy-box.test.tsx | 29 +++++++++++++++++++++++++ src/components/pro/pro-buy-box.tsx | 28 ++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/components/pro/pro-buy-box.test.tsx b/src/components/pro/pro-buy-box.test.tsx index 7cfca11d..ba3a39e9 100644 --- a/src/components/pro/pro-buy-box.test.tsx +++ b/src/components/pro/pro-buy-box.test.tsx @@ -96,6 +96,35 @@ describe('ProBuyBox', () => { ).not.toBeInTheDocument() }) + it('moves selection with arrow keys and keeps only the selected row tabbable', () => { + render( + + ) + + const yearly = screen.getByRole('radio', { name: /Yearly/ }) + const lifetime = screen.getByRole('radio', { name: /Lifetime/ }) + + expect(yearly).toHaveAttribute('tabIndex', '0') + expect(lifetime).toHaveAttribute('tabIndex', '-1') + + yearly.focus() + fireEvent.keyDown(yearly, { key: 'ArrowDown' }) + + expect(lifetime).toHaveAttribute('aria-checked', 'true') + expect(yearly).toHaveAttribute('tabIndex', '-1') + expect(lifetime).toHaveAttribute('tabIndex', '0') + expect(lifetime).toHaveFocus() + + fireEvent.keyDown(lifetime, { key: 'ArrowUp' }) + + expect(yearly).toHaveAttribute('aria-checked', 'true') + expect(yearly).toHaveFocus() + }) + it('tracks the checkout click with the selected plan', () => { render( (options[0].planId) + const optionRefs = useRef>([]) const current = options.find((option) => option.planId === selected)! return ( @@ -55,15 +56,38 @@ export function ProBuyBox({

- {options.map((option) => { + {options.map((option, index) => { const isSelected = option.planId === selected + const moveSelection = (direction: 1 | -1) => { + const nextIndex = + (index + direction + options.length) % options.length + const nextOption = options[nextIndex] + setSelected(nextOption.planId) + optionRefs.current[nextIndex]?.focus() + } + return (