apps/checkout is what a buyer sees. It is a separate Nuxt application from the
console on purpose: a different person, a different surface, a different session
sealed with a different secret. One secret doing both jobs would let one
cookie be the other.
It is public. There is no operator token anywhere in it, and no control-plane route is reachable from it.
#Why it wears each provider's face
A developer integrating a gateway has to recognise what their buyer will see. A single generic form with the provider's name in the corner teaches nothing about the redirect, the method list or the flow — and this project exists so that an integration built against the emulator survives contact with the real thing.
So a provider has two halves, and they never import each other:
| Half | Lives in | Holds |
|---|---|---|
| Behaviour | apps/api/src/providers/<id>/ |
Routes, validation, state vocabulary, signing |
| Identity | apps/checkout/providers/<id>/ |
The visual kit: the component and its theme |
What the checkout needs to know about a provider — display name, payment methods, canonical states — it reads from the API at runtime. Never from a shared type, because a shared type would make the split a formality.
#Its shape
app/
pages/
mercadopago/checkout/[resourceId].vue the paths the real providers use
paypal/checkoutnow.vue
stripe/c/pay/[resourceId].vue
components/ the shell: summary, methods, payer, outcome
core/gateway.ts what the API says about this checkout
core/kits.ts provider id → visual kit
providers/
mercadopago/ Checkout.vue + theme.css
stripe/ Checkout.vue + theme.css + logo.svg
paypal/ Checkout.vue + theme.css + logo.svg
core/kits.ts is the registry, and it is the reason no shared component ever
branches on a provider id. A provider with no kit falls back to the shell's own
plain form — a usable checkout, not a blank page.
These are local emulator routes inspired by provider paths. A Stripe PaymentIntent
is not a Checkout Session, and a Mercado Pago Order is not a checkout preference.
Neither page proves compatibility with the provider's hosted SDK or redirect flow.
PayPal has a review and approval kit at /paypal/checkoutnow?token=...; approval
moves no money. Return and cancel links come from the API. See its
fidelity report for the supported flow and limits.
Stripe uses a two-column merchant summary and card form, stacked on mobile. Mercado Pago uses a blue/white header and stacked payment choices. Test-card data is read-only; technical IDs and rejection controls live in expandable test details. Link and ACH are not offered by Stripe; regional Mercado Pago choices follow the configured site. The session form appears only for a method that spends internal balance. No kit claims pixel equality with every country/account variant.
#Rules
- No PrimeVue here. PrimeVue is the console's design system. A buyer-facing page that inherited an admin panel's component library would look like an admin panel, and each provider's kit could not be itself. The checkout styles itself.
- The API is the only contract. Payment methods, currency, amounts and state
come from
GETting the checkout; nothing is hard-coded per provider outside its kit. - Copy is available in Spanish and English, in the checkout's own typed catalog. Code and comments are in English.
- Accessibility is not optional. The method list is a
role="radiogroup"driven with the arrow keys, every control has a real<label>, focus is visible, and touch targets are at least 44px. A payment form that cannot be completed with a keyboard is a broken payment form.
#The buyer's session
A buyer signs in with their own email and password against the API, exactly as a
person does in the console — the difference is the role. A customer reaches
the checkout and not the console; the API decides that, not the interface.
Paying from a wallet balance debits the ledger; paying by card, ticket or
transfer brings money in from outside it. Which one a method does is the
funding_source recorded with the payment, so a refund months later does not
depend on a plugin's method list still saying the same thing today.
#Related
- Critical request flows — the checkout confirmation path end to end.
- Provider model — what a plugin owns.
- Fake bank and ledger — what paying actually posts.
The three kits bundle the SVG beside their llms source. The icon workflow also covers the console cards and selectors.
#Language and simulation notice
app/core/i18n/checkout.ts owns ES/EN copy for all three kits, methods, optional
sign-in, outcomes, errors and titles. Brand names, references, amounts and
technical statuses received from the API retain their original values.
Language is resolved on the server: ?lang=es|en → the
emulator_checkout_locale cookie → Accept-Language (regions and q weights)
→ es. Nuxt serializes the result so hydration uses the same language.
The ES/EN control writes a one-year, SameSite=Lax, path / cookie and updates
lang while preserving other parameters, including PayPal's token. The cookie
is independent of the console and the HTML lang attribute follows the choice.
CheckoutFrame places a static <aside> before the kit, using --caution-*
tokens: “These payments are fictitious.” and an instruction not to enter real
data. It does not use role="alert". Index and error pages also show the notice.
The watermark is a two-line SVG, rotated −30°, fully encoded with
encodeURIComponent and exposed as --wm. Its tile starts at 420 × 260 px and
grows with the translation length. It repeats as a CSS mask on
.checkout::before, with opacity 0.07 and --wm-color ink: this lets CSS control
the color, which an SVG in background-image cannot inherit. It adds no DOM
nodes, intercepts no events and stays behind opaque panels. A kit can tint it
using .checkout:has(.kit-<id>) { --wm-color: ... } in its own theme.css.
On mobile the visible notice carries the message when the card covers the tile.
Validation: npm run test:checkout covers negotiation, catalog parity and SVG
encoding. scripts/checkout-browser.mjs checks SSR, hydration, language switching,
overflow and axe; it saves all three kits in both languages at desktop and mobile
sizes. It requires Playwright, axe and a running local stack.
npm run smoke:stack validates the complete payment flow.