OctoWiki

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:

  1. One full JSX pageLandingPage.tsx (+ the unused ErrorPage.tsx), rendered by hono/jsx server-side at GET / (index.tsx:86).
  2. 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 gridbrand.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

AttributeWherePurpose
hx-post="/api/claim/verify-code"LandingPage.tsx:393step 1: code + captcha
hx-post="/api/claim/submit-direct"claim.ts:633step 2: PIN + dynamic fields
hx-get="/api/claim/status/<token>?n=<n>"poll divspolling
hx-target="#claim-container"both formsdrawer swap target
hx-swap="innerHTML" / "outerHTML"forms / poll divsreplace drawer / self-replace poll div
hx-swap-oob="true"|"innerHTML"many fragmentsupdate card header/title/desc, hero FAQs, hide code form
hx-trigger="load delay:<n>s"poll divsauto-fire polling with backoff
hx-indicator="#claim-container"submit formloading 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 and preventDefault()s a bad submit.
  • htmx:responseError (:434-441) — injects a generic error + reload button.
  • initFlipHeightSync (:448-469) — a ResizeObserver keeps #card-flipper-inner tall enough for the absolutely-positioned back face when .flipped toggles.
  • Plus: PIN-box concatenation → #pinValue, a progressively-enhanced searchable <select>, the landing code auto-formatter (XXXX-XXX-XXX), a delegated data-action click 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).

StateTriggerFragment
PIN formfresh valid voucher (verify-code)plan details + 6 .pin-box inputs + hidden code/pin/nonce + dynamic fields + OOB card header/title/FAQs (:630)
Processing / pollaccepted order or ambiguous timeout"Processing…"/"Activating eSIM…" + self-replacing #status-poll div (:280,931,1073)
Recharge successpoll terminal DELIVERED/RECHARGED (non-eSIM)green back-of-card, order-details box, "what's next" checklist, conditional SMS line (:1513)
Gift-card successplain card, no topup productinline redeemedHtml, no flip (:152)
eSIM revealeSIM delivered (poll or returning-user re-verify)QR (or "installed" pill), ICCID copy, 3 install steps, enlarge modal (:307,1361)
Failure + notifydefinite 4xx (submit) or poll FAILED/CANCELLEDred card, vendor reason, "Notify Me" email box, Try-Again if user-fixable (:975,1584)
Terminal alertsnot-found / expired / already-redeemed / bad PIN / captcha / rate-limited / nonce-expirederrorHtml 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 HTMX hx-trigger backoff.

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): per field_typeselect (searchable), phone (with dialing_prefix prefix span, pattern="[0-9]{4,15}"), number, or text — each named input_<field_name>, carrying data-extra-field + optional data-validation-regex. Fallback when no input_fields_json (:574): MOBILE/PAYOUT → phone, GAMING → Player ID, ESIM → none.
  • Validate twice: client-side (claim.js updatePinValue + htmx:configRequest) and server-side (submit-direct, :761-813: required / ≤500 chars / phone ^[0-9]{4,15}$ / number / select membership / validation_regex via ReDoS-guarded safeRegexTest).
  • Dial-prefix: phone fields are prefixed to E.164 with dialing_prefix before 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-supplied validation_regex; fails open (returns pass) for patterns >200 chars, nested-quantifier heuristics, or invalid regex.

Escaping gaps worth an audit

  1. esc() does not escape single quotes ', yet escaped values are placed inside single-quoted JS string literals in inline onclick (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.
  2. voucherInfoBar and redeemedHtml interpolate name/denom/currency without esc() in some branches (:58,152). These are DB-controlled (not end-user) values, but it's inconsistent with the escape-everywhere pattern.
  3. safeRegexTest failing open means a hostile validation_regex skips 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.0 public/js/htmx.min.js
  • Design system: public/css/brand.css (+ local Bootstrap/FontAwesome)
  • Asset model: wrangler.jsonc:19; SEO public/robots.txt, public/sitemap.xml

On this page