Member portal
apps/member-portal is the platform’s member-facing app — a phone-only, installable, tenant-branded
PWA a merchant hands out to their own customers. It’s the eighth app in this repository, and
architecturally the simplest: it holds no business logic of its own and is a pure client of a small
consumer-only slice of the public API (/v1/me* plus one unauthenticated branding read), the same
way the merchant dashboard and developer portal are pure clients of the rest of /v1. Like every
other Pages app here, it makes only relative /api//v1 fetches — no API base URL is baked in at
build time, so the same build works behind whatever domain it’s deployed to.
Getting in: /?t=<org-slug>
The portal is multi-tenant but has no tenant of its own baked into a build — a member reaches it
through a URL that names one: https://<portal-domain>/?t=<org-slug>, where org-slug is the
merchant’s own organization slug (the same slug Better Auth already uses for the organization). The
root route reads that t query parameter and does two different things with it:
- If a
tis present, it first tries a liveGET /v1/me?t=<slug>call — best-effort, on the chance a still-valid session cookie exists from an earlier visit (sessions last 30 minutes). If that succeeds, the member is sent straight to/home, skipping login entirely. If it fails for any reason — no session, an expired one, or simply a first visit — the app falls through to the login screen. There’s no special-casing between those failure reasons; all of them just mean “show login.” - If
tis missing entirely, there’s no tenant to even ask about, so the app shows an honest dead-end screen explaining that the link is missing its program reference, rather than guessing or erroring obscurely.
Once a t is established, every other route in the app (/home, /challenges, /rewards,
/wallet, /profile) carries it forward automatically — a root-level search-param retention rule
means navigating between tabs never drops it, so a member never has to re-supply it mid-session.
Logging in: phone + one-time code
The portal is phone-only — there’s no email/password surface anywhere in this app, matching
CLAUDE.md’s member-portal auth model. The flow is the platform’s existing consumer session type
(phone-OTP via Better Auth’s phoneNumber plugin), used here for the first time end-to-end:
- A member types a phone number; the client normalizes it to strict E.164 (
+<country><number>) before sending anything — the exact string that goes tosend-otpis the same stringverifysends and the same string the session ends up carrying, so it must byte-match the phone number the member is actually enrolled under. authClient.phoneNumber.sendOtprequests a 6-digit code, valid for 5 minutes.- The member enters the code;
authClient.phoneNumber.verifychecks it and, on success, sets the consumer session cookie. Sessions are short — 30 minutes — and the portal says so on the login screen; expiry just means logging in again, not a partial or degraded state.
Two independent limits guard OTP sending: Better Auth’s own endpoint-level rate limit, and a separate per-phone counter (3 sends per hour, tracked in Workers KV) that caps a single phone number regardless of who’s asking — IP-based limiting alone doesn’t stop phone-enumeration abuse, and every OTP sent through a real provider costs money.
Local development / testing: setting DEV_OTP_ECHO=1 on the edge-api worker makes it log the
generated code to the console instead of sending anything, so the whole login flow is exercisable
with no SMS provider at all — this is the same dev-echo flag the deploy runbook already documents as
“never set in production” (it always wins over a configured provider while set, so it’s harmless to
leave real provider config sitting alongside it in a non-production environment). Live delivery:
OTP sends ride the platform’s existing SP11 SMS adapters — the same Twilio/MSG91/Plivo integrations
that already deliver SMS campaigns — configured through three platform-level (not per-tenant)
edge-api values: OTP_SMS_PROVIDER, OTP_SMS_CONFIG_JSON, and the secret
OTP_SMS_CREDENTIALS_JSON. Platform-level rather than a tenant’s own channel_connections row
because better-auth’s phoneNumber plugin has no tenant context at send time. With DEV_OTP_ECHO
unset and none of those three configured, a send fails outright with an honest “no SMS provider
configured” error rather than silently succeeding. Actually receiving real SMS still needs an
operator-owned SMS aggregator account, so treat this the same as every other live-provider feature
in this platform: gated on infrastructure the operator connects, not something a fresh deploy gets
for free. See infra/DEPLOY.md’s edge-api section for exactly how to set the three values and
workers/edge-api/src/auth/otp.ts for the send-order logic.
The pages
Five tabs, phone-first, bottom tab bar navigation:
| Tab | What it shows |
|---|---|
| Home | Points balance, current tier (name + the same endowed-progress label wallet passes show — e.g. “120 points to Gold”), and a streak card (current/best consecutive weeks, plus a teaser for the next milestone if the program has one configured). |
| Challenges | Every active challenge with a live progress bar (computed the same way the merchant-side view computes it — see the challenges and streaks guide), and a completed-history list of past awards. A challenge whose bar is already full but not yet paid says so honestly: “Target reached — your bonus lands after the nightly award run.” |
| Rewards | The program’s active reward catalog. Tapping one quotes a redemption (POST /v1/me/redemptions/quote) and shows the resulting code as both text and a QR image for the till to scan or type — nothing is deducted yet at this point, and the quote expires. A member session structurally cannot commit a redemption (there is no consumer-reachable commit route anywhere on the API); only the till or a merchant key can finish it. |
| Wallet | Up to three “Add to Wallet” buttons — Apple, Google, Samsung — one per provider the tenant has actually configured. None configured shows an honest empty state instead of a dead or missing button. |
| Profile | The masked phone identifier the session resolves to, a marketing-consent recorder (below), and sign out. |
Branding
Branding is four optional, display-only fields on the tenant — a display name, a logo URL, a
primary color, and an accent color (tenants.branding_json, added in the same migration as the
gamification tables). They’re read through one shared, defensively-validated helper regardless of
who’s asking: a logo only renders if it’s an https:// URL, a color only applies if it’s a 6-digit
hex code, and the display name falls back to the tenant’s own registered name — so a malformed or
missing value degrades to the platform default look rather than breaking anything.
The portal applies this twice: before login, from the public, unauthenticated
GET /v1/portal/branding?t=<slug> (that’s what lets the login screen itself carry the merchant’s
logo and colors, before any session exists); and after login, from the display field already
included in GET /v1/me’s response — both calls resolve through the identical reader, so the two
moments can never disagree. Once fetched, branding is applied as CSS custom properties (the primary
color drives both the accent and the focus-ring color), the browser tab title, and — where the
browser supports a runtime-swapped manifest — the installed app’s own name and start URL, so an
installed icon reopens into the right tenant rather than the bare, tenant-less root.
This same stored configuration is designed to carry across every member-facing surface the platform serves, not just this app — the wallet pass landing page and the referral/feedback pages are meant to read the same branding. Check those pages’ current behavior directly if you need to confirm whether that’s live yet; this guide describes the portal side, which is.
Wallet pass colors are a separate setting. The colors on an actual Apple/Google/Samsung pass come from the wallet configuration (set up alongside connecting each wallet provider), not from this branding config — the two are deliberately kept apart rather than merged, so don’t expect changing one to move the other.
Wallet buttons: a lazily-issued token
The public wallet page (/w/<token>) has always worked off a per-member token embedded in the URL —
but a consumer session has no route that reveals its own token directly. The portal’s Wallet tab
closes that gap with GET /v1/me/wallet-links: on a member’s first visit to that tab, it mints their
one wallet token if they don’t already have one (the identical call the merchant-side flow uses to
mint it), then returns the exact same provider-link payload the public token-gated page returns.
There’s one token-issuing/link-building code path either way — the portal doesn’t duplicate it.
Consent is append-only, and the UI says so
Marketing consent (member_consents) is an append-only ledger platform-wide (CLAUDE.md §9) — every
decision is a new, dated row; nothing is ever updated or deleted, and the most recent row per channel
is what counts. The Profile page’s consent recorder takes that seriously: it deliberately does not
render a toggle pretending to know your current opted-in/opted-out status (there’s no read-back
endpoint for it), and instead just lets a member pick a channel (SMS, WhatsApp, or email) and record
an explicit opt-in or opt-out, confirming exactly what was written and when. POST /v1/me/consent
stamps the row’s source as "member-portal" server-side — a member can’t forge where a consent
decision was recorded — and always as category: "marketing".
Local development
The dev server runs on port 5176 (the next free slot after the dashboard, till-companion, and
developer-portal apps), proxying /api and /v1 to edge-api’s wrangler dev on 8787 by default
(override via VITE_DEV_API_PROXY_TARGET in a local .env — this is dev-tooling only, never read by
the built app). It’s an installable PWA with a deliberately minimal service worker: only the app
shell and static assets (icons, hashed build output) are ever cached — every /v1/* and /api/*
call always goes to the network, so balance, challenges, and rewards are always either live data or
an honest connection error, never a stale cached number presented as current. There is no
offline-data mode.
Next steps
- Wallet passes — how a provider ends up working for a tenant at all (platform default vs. bring-your-own-keys) before this app’s Wallet tab has anything to link to.
- Challenges and streaks — the progress math and reward timing behind the Home and Challenges tabs.
- Programs and rewards — the reward catalog and two-phase redemption model the Rewards tab quotes against.
- API reference — full request/response shapes for
/v1/me*and/v1/portal/branding.