Skip to content

feat(upgrade): looser paywall density + short CTA labels + USD→BRL checkout fix - #342

Merged
thomasluizon merged 1 commit into
mainfrom
fix/paywall-density-labels
Jun 27, 2026
Merged

feat(upgrade): looser paywall density + short CTA labels + USD→BRL checkout fix#342
thomasluizon merged 1 commit into
mainfrom
fix/paywall-density-labels

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

Paywall polish + BRL checkout fix

Density + labels (both platforms)

  • Shorter CTA labels (both locales) so they fit one line: "Continuar no plano grátis" → Continuar grátis; "Fazer upgrade para o Pro" → Assinar Pro (trial: Manter o Pro); "Assinar Mensal" → Assinar. (EN: Stay free / Get Pro / Keep Pro / Subscribe.)
  • Looser density — card padding, grid gaps, price/feature spacing opened up (web 3-column grid + mobile card list). Navy-violet anchor, violet hero, ECONOMIZE badge, and the paywall-checkout testid all kept.

Currency fix (web — the USD-at-Stripe bug)

Brazilian users were charged USD despite the paywall showing BRL. Root cause: createCheckoutSession (Server Action) called orbit-api directly, bypassing the geo-forwarding BFF route, so no country header reached the backend → server geo → USD.

  • handleCheckout now fetches the BFF route /api/subscriptions/checkout (forwards x-vercel-ip-country / X-Orbit-Country-Code + handles auth + 401 refresh), matching how /plans already resolves BRL.
  • Removed the now-unused createCheckoutSession Server Action (kept openCustomerPortal). New test asserts checkout POSTs to the geo-forwarding route.

Validation

type-check + lint 6/6, 0 errors; tests shared 1241, mobile 725, web 1752.

🤖 Generated with Claude Code

… geo BFF

Paywall polish (web + mobile):
- Shorten the CTA labels so each fits one line at the column width, in
  both locales (en + pt-BR): convert.stayFree, convert.freeCta,
  convert.trialCta, plans.monthly.cta.
- Loosen the web 3-column card layout (card padding 18->22, grid gap
  12->16, price/sub/heroLine/features rhythm, sub/feature font 12.5->13,
  CTA top padding 16->20, header/notes spacing) so it reads premium.
- Mirror the breathing room on the mobile plan cards (padding 18->20/20/22,
  name + feature gaps, +18 top margin above the card group).
- Keep the navy-violet anchor, violet hero, ECONOMIZE badge, the
  paywall-checkout testid, and all pricing/plan logic unchanged.

Billing fix (web-only):
- Stripe checkout was charging USD even when the paywall showed BRL: the
  createCheckoutSession Server Action called orbit-api directly, bypassing
  the geo-forwarding BFF route, so no country header reached the backend.
- Route handleCheckout through the existing /api/subscriptions/checkout BFF
  route (forwards x-vercel-ip-country / X-Orbit-Country-Code + handles auth
  and 401 refresh), matching how /plans already resolves currency.
- Remove the now-unused createCheckoutSession action; keep openCustomerPortal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
orbit-ui-mobile-web Ignored Ignored Jun 27, 2026 10:40pm

Request Review

@sonarqubecloud

Copy link
Copy Markdown

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #342

Scope: PR #342 in thomasluizon/orbit-ui-mobile — feat(upgrade): looser paywall density + short CTA labels + USD→BRL checkout fix
Recommendation: APPROVE

Summary

PR #342 delivers three things: shorter CTA labels on the paywall (both locales), looser spacing across the web and mobile upgrade components, and a root-cause fix for Brazilian users being charged USD instead of BRL. The currency fix removes the createCheckoutSession Server Action and replaces it with a direct client-side fetch to the existing dedicated BFF route (/api/subscriptions/checkout), which correctly forwards X-Forwarded-For and geo headers that the old Server Action bypassed. All i18n changes are in sync across both locales. No Critical or High findings survived the adversarial pass.

Findings

Critical

None

High

None

Medium

[MEDIUM] handleCheckout BFF-fetch exception not documented in apps/web/CLAUDE.md
· dimension: SOLID / clean architecture (#3) + Comment policy (#4)
· location: apps/web/CLAUDE.md (convention doc) / apps/web/app/(app)/upgrade/page.tsx:60
· issue: apps/web/CLAUDE.md lists exactly two sanctioned exceptions to the "all mutations through Server Actions" rule: cookie-setting auth flows and the SSE chat-stream. The checkout fetch() is a third exception: the dedicated BFF route handler is required because it receives a real NextRequest from which it can extract X-Forwarded-For, x-vercel-ip-country, and the timeZone query param for X-Orbit-Country-Code — none of which a Server Action can reliably supply. The exception is architecturally correct but undocumented.
· risk: A future developer sees "Never call the API from a client component" and refactors the fetch back to a Server Action, silently reintroducing the USD bug.
· fix: Add to the sanctioned-exceptions list in apps/web/CLAUDE.md: (3) POST /api/subscriptions/checkout — dedicated BFF route because it must forward X-Forwarded-For and x-vercel-ip-country from the real NextRequest for geo-based pricing; a Server Action cannot access these reliably.
· reference: CLAUDE.md rule 1 (root-cause, not workaround) + apps/web CLAUDE.md "Sanctioned exceptions" convention

Low / Info

None (signal gate applied).

Subagents

Agent Verdict
parity-checker PAIRED — web spacing/padding tweaks mirrored in mobile; checkout flow change is web-only (mobile uses native Play Billing SDK, not Stripe checkout); no mobile mirror needed for the removed Server Action
i18n-syncer IN SYNC — all 4 changed keys (upgrade.convert.trialCta, freeCta, stayFree, upgrade.plans.monthly.cta) present in both en.json and pt-BR.json; 610 keys each, zero orphans
contract-aligner N/A — no packages/shared/src/types/*.ts schema fields added, renamed, or removed; no orbit-api changes in this PR
security-reviewer N/A — no orbit-api code changed

Validation

Check Result
Lint N/A — toolchain not installed in review environment; PR author reports 0 errors; CI is the authoritative gate
Type check N/A — same; PR author reports 0 errors
Tests N/A — same; PR author reports web 1752 passing
Build (api) N/A — orbit-api not touched

Backward-compat guard

No fields were added, removed, or renamed in packages/shared/src/types/*.ts or any orbit-api DTO. The only packages/shared changes are string value updates in i18n/en.json and i18n/pt-BR.json — purely forward-compatible. No breaking risk to old mobile clients.

Deferred — N/A dimensions & files not verdicted

  • Correctness (#1): Verified end-to-end — client passes ?timeZone= → BFF reads it via request.nextUrl.searchParams.get('timeZone')countryCodeFromTimeZone derives X-Orbit-Country-Code: BR → backend geo-routes to BRL. No gap.
  • Dead/stale code (#2): createCheckoutSession and its CheckoutResponse type fully removed; zero remaining references confirmed.
  • Comment policy (#4): The /** BFF: POST ... */ JSDoc in route.ts is on an exported handler — permitted. No violations in changed files.
  • No-workaround (#5): This change IS the root-cause fix. The old workaround (Server Action bypassing geo) is deleted.
  • Type safety (#6): (await response.json()) as { url?: string }response.json() returns any in the Fetch API types; the cast is idiomatic narrowing. No as any or escape hatches.
  • No console.log (#7): None in changed files.
  • DESIGN.md / AI-slop (#8): Spacing adjustments use numeric literals only. No new colors, no raw --slate-*, no hardcoded violet rgba, no transition-all, no h-screen. Font size bumps (12.5→13) stay within spec.
  • Contract drift + backward-compat (#11): N/A — no type schema or DTO changes.
  • Security (#12): fetch goes to same-origin BFF, not orbit-api directly. Auth handled server-side by resolveServerSession() with 401-refresh retry. No credentials exposed client-side.
  • Backend hard rules (#13): N/A — orbit-api not touched.

What's good

  • Root-cause fix, not a patch. The old Server Action bypassed the geo-BFF; instead of adding a header workaround, the PR deletes the Server Action entirely and routes through the purpose-built BFF route that already handles all geo forwarding.
  • The dedicated checkout route is well-engineered. resolveForwardedClientHeaders handles Vercel, Cloudflare, CloudFront, and Accept-Language as geo fallbacks — including timezone-to-country derivation for Brazilian timezones as a last resort.
  • The new test is behavioral, not implementation-bound. It asserts fetch was called with the BFF URL and POST method — exactly the contract that matters — without coupling to internal state.
  • vi.unstubAllGlobals() in afterEach. Correct cleanup preventing location stub from leaking across tests.
  • i18n label changes are purposeful and atomic. Both locales updated in the same commit; shorter labels solve a real overflow problem.

Recommendation

Approve. The one Medium finding (undocumented sanctioned exception in apps/web/CLAUDE.md) is low-risk and can be addressed as a follow-up or squashed before merge. No blocking issues.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review - PR 342

Recommendation: APPROVE

Severity Count
Critical (incl. old-client breaks) 0
High 0
Medium 1
Low / Info 0

Everything is clean. Parity is correct (mobile uses Play Billing natively - no mirror needed for the Server Action removal), i18n is in sync (610 keys in both locales, all 4 changed keys present and matching), no contract/DTO changes, and the backward-compat guard found nothing (only string values changed in i18n files).

Medium finding (non-blocking): The client-side fetch() to /api/subscriptions/checkout in apps/web/app/(app)/upgrade/page.tsx is architecturally correct - the dedicated BFF route handler is required because it needs the real NextRequest to extract X-Forwarded-For and x-vercel-ip-country for geo-based pricing (BRL vs. USD). A Server Action cannot reliably supply those headers. However, this exception is not documented in apps/web/CLAUDE.md sanctioned-exceptions list, which risks a future developer cleaning it up and reintroducing the USD bug. Suggested fix: add a third bullet noting that POST /api/subscriptions/checkout is a dedicated BFF route because it must forward geo headers from the real NextRequest for geo-based pricing. Non-blocking - the implementation is correct.

@thomasluizon
thomasluizon merged commit ed40ecc into main Jun 27, 2026
10 checks passed
@thomasluizon
thomasluizon deleted the fix/paywall-density-labels branch June 27, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant