#116 adds the public landing page that the franchise model relies on
to convert voucher distribution into Catalyst signups (per
docs/FRANCHISE-MODEL.md §3, "redemption flow end-to-end").
New page core/marketplace/src/pages/redeem.astro:
- Reads ?code=... from the URL (or accepts manual entry if absent).
- POSTs to /api/billing/vouchers/redeem-preview (added in #117) — does
NOT consume the voucher, just validates it.
- Renders one of four states:
* Valid (200): "X OMR credit" + description + "Sign up to redeem"
CTA. The CTA stashes the code in localStorage under
`sme-pending-voucher` and routes to /plans (the start of the
existing signup wizard).
* Campaign ended (410): inactive or capped — shows the credit that
was offered + a path to sign up without a voucher.
* Not valid (404): never existed or soft-deleted (#91 tombstone-leak
protection — the two are indistinguishable on the public surface).
* No code present: a manual input form so a redeemer who landed on
/redeem without a query string can paste their code.
CheckoutStep wiring (core/marketplace/src/components/CheckoutStep.svelte):
- The `promoCode` $state now hydrates from `sme-pending-voucher` so a
redeemer arriving via /redeem reaches /checkout with the field
pre-filled. They can still edit or clear it.
- After submitting to /billing/checkout, we clear the localStorage
stash. This prevents a second signup on the same browser from
silently carrying over the previous voucher.
The actual redemption (insert into promo_redemptions, increment
times_redeemed, credit_ledger entry) still happens transactionally
inside POST /billing/checkout — splitting it out would risk a
partially-redeemed code with no Order to show for it (the same
class of bug #91 fixed).
Per docs/INVIOLABLE-PRINCIPLES.md §1: target-state shape, not MVP.
The page handles all four observable backend states; manual-entry
fallback is included; the "campaign ended" path keeps the user moving
into signup rather than dead-ending.
Closes#116.