Grasshopper — UI & HTMX
How the claim UI is actually rendered and driven — the single JSX landing page, the template-literal HTML fragments, the HTMX 2.0 swap/OOB machinery, the full fragment-state catalog, the CSS card-flip, data-URI QR rendering, inline JS, the dynamic input-field system, and the hand-rolled XSS escaping with its gaps.
Grasshopper has no client framework. The "app" is three things stitched together: one server-rendered JSX page (LandingPage), a pile of hand-built HTML-string fragments returned from claim.ts via c.html(\…`)`, and HTMX 2.0 swapping those fragments in and out. This page documents that drive layer end-to-end. For the request/response contracts see Grasshopper API; for the flow logic see Grasshopper → the claim flow.
Rendering model
Two rendering styles coexist:
- One full JSX page —
LandingPage.tsx(+ the unusedErrorPage.tsx), rendered byhono/jsxserver-side atGET /(index.tsx:86). - Template-literal fragments — every claim interaction returns a hand-assembled HTML string from
src/api/claim.ts. No JSX, no vdom — just backtick strings with${…}interpolation, which is why escaping is hand-rolled (§XSS).
Static assets bypass the worker entirely. wrangler.jsonc:19 binds "assets": { "directory": "./public" }, so /css/*, /js/*, /img/*, /favicon.*, /robots.txt, /sitemap.xml, /webfonts/* are served straight from public/ by Cloudflare with no worker code. Only two page routes run worker code: GET / and GET /status.
The landing page
LandingPage.tsx — one FC, props { turnstileSiteKey, trustpilot }, injected server-side (index.tsx:87-88): the Turnstile site key from env.TURNSTILE_SITE_KEY and a fetchTrustpilotScore() result (server-fetched, 1h in-memory cache).
Structure, top to bottom: navbar with theme toggle → hero (headline + #hero-features block + a server-rendered Trustpilot SVG badge + the claim card) → "How It Works" → a CSS-marquee brand strip → product-category cards → 4 static FAQ <details> accordions → footer. The <head> carries a full OpenGraph set, theme-color #FF8426, favicons, and an anti-flash theme script (:56) that stamps data-theme before paint.
Styling: Bootstrap + a token stylesheet, not Tailwind
Styling is local Bootstrap 5 + FontAwesome + public/css/brand.css (a 1097-line CSS-custom-property design system: --o-vivid:#FF8426 brand orange, --b-vivid blue, neutral/semantic tokens, DM Serif/Nunito/DM Mono fonts). Dark mode is token-based via [data-theme="dark"] overrides, toggled by a nav button (claim.js toggleTheme), persisted to localStorage, with an OS-preference listener. Responsive is 100% Bootstrap grid — brand.css has zero @media queries; mobile reordering uses Bootstrap order-* classes so the claim card floats to the top. Page-scoped layout CSS (hero, card-flip 3D, drawer) is a large inline <style> in the head (LandingPage.tsx:67-312). Fonts load from Google Fonts via @import in brand.css:3 (the local webfonts/ are FontAwesome glyphs, not body fonts) — note this is one of the few off-origin dependencies, allowed by the CSP font-src/style-src.
Turnstile mounts only if a site key is present (:413-415): an inline script sets window.__tsSiteKey and an __tsOnload explicit-render callback into #cf-turnstile-host, and the CF API script is loaded ?onload=__tsOnload&render=explicit async defer (:63-65). Theme changes re-render the widget; error fragments reset it via [data-reset-captcha].
The claim card (:375-438) is the interactive core: a flip container (#card-flipper-inner) with a front face holding the code form and a back face (#card-back-content) for the reveal. The front body owns #claim-container — the drawer that every verify-code/submit-direct response swaps into.
The HTMX drive layer
HTMX 2.0.0, bundled locally (public/js/htmx.min.js, loaded LandingPage.tsx:60) — not a CDN. The whole UI advances by swapping one fragment for the next inside #claim-container, while out-of-band (OOB) tags update chrome outside the drawer (card header, title, hero FAQs).
Attributes in use
| Attribute | Where | Purpose |
|---|---|---|
hx-post="/api/claim/verify-code" | LandingPage.tsx:393 | step 1: code + captcha |
hx-post="/api/claim/submit-direct" | claim.ts:633 | step 2: PIN + dynamic fields |
hx-get="/api/claim/status/<token>?n=<n>" | poll divs | polling |
hx-target="#claim-container" | both forms | drawer swap target |
hx-swap="innerHTML" / "outerHTML" | forms / poll divs | replace drawer / self-replace poll div |
hx-swap-oob="true"|"innerHTML" | many fragments | update card header/title/desc, hero FAQs, hide code form |
hx-trigger="load delay:<n>s" | poll divs | auto-fire polling with backoff |
hx-indicator="#claim-container" | submit form | loading state during submit |
There are no hx-vals/hx-headers — fields are native form inputs; the captcha token and HMAC nonce ride as hidden inputs. Two response headers matter: HX-Refresh: true on nonce failure (claim.ts:717) and poll-token expiry (claim.ts:1271) forces a full reload. No HX-Trigger header is used.
The swap model
Client-side lifecycle hooks (public/js/claim.js, 469 lines — the only bundled app script)
htmx:afterSwap(:273-292) — the central re-init: rebuilds PIN boxes, field validation and searchable-selects on the freshly-swapped fragment, opens the drawer, hides the code form when PIN boxes/[data-final]appear, and resets Turnstile when[data-reset-captcha]is present.htmx:configRequest(:221-270) — a pre-submit validation gate: validates phone/number/select fields andpreventDefault()s a bad submit.htmx:responseError(:434-441) — injects a generic error + reload button.initFlipHeightSync(:448-469) — a ResizeObserver keeps#card-flipper-innertall enough for the absolutely-positioned back face when.flippedtoggles.- Plus: PIN-box concatenation →
#pinValue, a progressively-enhanced searchable<select>, the landing code auto-formatter (XXXX-XXX-XXX), a delegateddata-actionclick handler (reload/show-code-form/toggle-theme— CSP-friendly, replaces some inline onclick), and the theme system.
Fragment-state catalog
Every distinct fragment claim.ts can return. Shared builders: errorHtml(msg, showRetry) (:41), renderPlanDetails (:64), voucherInfoBar (:51), redeemedHtml (:152), renderHeroFaqs (:131).
| State | Trigger | Fragment |
|---|---|---|
| PIN form | fresh valid voucher (verify-code) | plan details + 6 .pin-box inputs + hidden code/pin/nonce + dynamic fields + OOB card header/title/FAQs (:630) |
| Processing / poll | accepted order or ambiguous timeout | "Processing…"/"Activating eSIM…" + self-replacing #status-poll div (:280,931,1073) |
| Recharge success | poll terminal DELIVERED/RECHARGED (non-eSIM) | green back-of-card, order-details box, "what's next" checklist, conditional SMS line (:1513) |
| Gift-card success | plain card, no topup product | inline redeemedHtml, no flip (:152) |
| eSIM reveal | eSIM delivered (poll or returning-user re-verify) | QR (or "installed" pill), ICCID copy, 3 install steps, enlarge modal (:307,1361) |
| Failure + notify | definite 4xx (submit) or poll FAILED/CANCELLED | red card, vendor reason, "Notify Me" email box, Try-Again if user-fixable (:975,1584) |
| Terminal alerts | not-found / expired / already-redeemed / bad PIN / captcha / rate-limited / nonce-expired | errorHtml alert, showRetry=false on terminal states |
Errors are HTTP 200 fragments
Claim-router "errors" (bad code, expired, redeemed, captcha, rate limit) return HTTP 200 with an HTML alert fragment — HTMX swaps them into the drawer. They are not HTTP error statuses. Only /status (non-HTMX) and /notify emit real 4xx/429. See Grasshopper API.
The card-flip
Pure CSS 3D flip (LandingPage.tsx:247-311): .claim-card-flipper-inner { transform-style:preserve-3d; transition:transform .7s }, .flipped { transform:rotateY(180deg) }, both faces backface-visibility:hidden. The flip is triggered from the server fragment — every success/eSIM/fail response injects an inline <script> that setTimeouts .classList.add('flipped') ~200-300 ms after the OOB swap lands in #card-back-content (:458,1502,1574,1626).
QR rendering (qrcode-generator)
qrcode(0,'M') → qr.createImgTag(2,0) small / (5,0) large (claim.ts:346,1390). createImgTag emits an <img> with a base64 GIF data: URI — not SVG, not a table — which is exactly why the CSP has img-src 'self' data:. Dark mode inverts it via filter:invert(1). The enlarge modal (#qr-modal) is a fixed overlay opened by an inline openQrModal(), holding the large QR + ICCID + install steps.
Inline JavaScript inventory
Because fragments are template strings, meaningful logic ships as inline <script>/onclick (hence CSP 'unsafe-inline'):
- In
LandingPage.tsx: anti-flash theme, Turnstile onload, Turnstile API loader, scroll-reveal IntersectionObserver. - In fragments (
claim.ts):openQrModal/closeQrModal, the four flip triggers,submitNotifyEmail(token)(validates +fetch('/api/claim/notify')), copy-to-clipboard ICCID buttons (navigator.clipboard.writeText(...)), and the fail-card un-flip. No JS timers — all "delays" are HTMXhx-triggerbackoff.
Dynamic input fields
input_fields_json on a voucher_type drives per-product form fields; submitted values become the encrypted input_data_json. Schema (both sides agree): { field_name, field_label, field_type, is_required, validation_regex?, placeholder?, help_text?, options?[] }. Populated from Octopus's product input-field table during provisioning — see Grasshopper API → dynamic fields.
- Render (
verify-code,claim.ts:503-612): perfield_type→select(searchable),phone(withdialing_prefixprefix span,pattern="[0-9]{4,15}"),number, or text — each namedinput_<field_name>, carryingdata-extra-field+ optionaldata-validation-regex. Fallback when noinput_fields_json(:574):MOBILE/PAYOUT→ phone,GAMING→ Player ID,ESIM→ none. - Validate twice: client-side (
claim.jsupdatePinValue+htmx:configRequest) and server-side (submit-direct,:761-813: required / ≤500 chars / phone^[0-9]{4,15}$/ number / select membership /validation_regexvia ReDoS-guardedsafeRegexTest). - Dial-prefix: phone fields are prefixed to E.164 with
dialing_prefixbefore the order call (:834-851).
XSS escaping & the gaps
Template-string HTML means escaping is manual:
esc(s, maxLen=500)(claim.ts:24) — truncates, then replaces& " < >. Applied to most DB/user values (names, denominations, ICCID, failure reasons, field labels/options, the entered code, FAQ text, notify email echo).safeRegexTest(pattern, input)(claim.ts:30) — ReDoS guard for DB-suppliedvalidation_regex; fails open (returns pass) for patterns >200 chars, nested-quantifier heuristics, or invalid regex.
Escaping gaps worth an audit
esc()does not escape single quotes', yet escaped values are placed inside single-quoted JS string literals in inlineonclick(ICCID copy buttons,claim.ts:338/380/408/…). ICCID is vendor-supplied and numeric in practice, so low risk — but a'would break out of the JS string.voucherInfoBarandredeemedHtmlinterpolatename/denom/currencywithoutesc()in some branches (:58,152). These are DB-controlled (not end-user) values, but it's inconsistent with the escape-everywhere pattern.safeRegexTestfailing open means a hostilevalidation_regexskips validation rather than blocking — acceptable for a trusted admin-provisioned field, but note the trust assumption.
SEO, accessibility, assets
<html lang="en"> (landing only — ErrorPage.tsx sets none). English-only, no i18n framework; product copy/labels come from the DB. Full OG meta + theme-color, but no canonical, no Twitter card, no JSON-LD. role="alert" on error fragments, role="status" on spinners, aria-label on the theme toggle; PIN boxes and the custom searchable-select have limited ARIA. robots.txt disallows /api/; sitemap.xml lists only the root. Apple-touch icons exist as files but aren't referenced by a <link> in the head.
Key files
- Page + inline CSS/JS:
src/components/LandingPage.tsx,src/components/ErrorPage.tsx(dead) - Fragments:
src/api/claim.ts(builders:24-174, renders throughout) - Client JS:
public/js/claim.js; HTMX 2.0.0public/js/htmx.min.js - Design system:
public/css/brand.css(+ local Bootstrap/FontAwesome) - Asset model:
wrangler.jsonc:19; SEOpublic/robots.txt,public/sitemap.xml
Grasshopper — Claim Worker
The customer-facing voucher/topup/eSIM claim worker end-to-end — Hono+D1 on Cloudflare Workers, the verify→submit→poll reveal flow with sequence diagrams, AES-256-GCM encryption, HMAC nonces & poll tokens, rate limiting, circuit breaker & idempotent recovery, telemetry, deployment, and the full security risk register.
Grasshopper — API & Provisioning
The exact wire contract of every Grasshopper endpoint (request fields, response shapes, every error status), plus how Octopus provisions vouchers and catalog into Grasshopper's D1 — the vendor-adapter push, the admin catalog push vs the worker's self-pull /sync, the SHA-256-vs-SHA-3-256 dual-hash split, and the dynamic input-field pipeline.