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:

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:

  1. A member types a phone number; the client normalizes it to strict E.164 (+<country><number>) before sending anything — the exact string that goes to send-otp is the same string verify sends and the same string the session ends up carrying, so it must byte-match the phone number the member is actually enrolled under.
  2. authClient.phoneNumber.sendOtp requests a 6-digit code, valid for 5 minutes.
  3. The member enters the code; authClient.phoneNumber.verify checks 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.

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