Appearance
Consumer onboarding: reuse, modify, or design new
⚠ A LOG. MEASURED 2026-08-31 AGAINST THE REPO, NOT AGAINST EARLIER DOCUMENTS ABOUT IT.
The question this answers: the finalised consumer flow is WhatsApp OTP → PIN / Face ID → consumer landing. What already exists, what has to change, and what has never been designed.
The one-line answer
The wizard is reusable and well built for exactly this. The auth surface is the new work.
stepsFor(type, authed) already models a per-account-type step list, so adding or removing a consumer step is a one-line array change. What does not exist is any phone-shaped authentication at all.
Per component
| Piece | Verdict | Measured state, and what changes |
|---|---|---|
WelcomeStory + the business/individual fork | ♻️ REUSE, new copy | The fork is the right shape and resolveEntryRoute depends on it. The five scenes are business-flavoured (BuildCard, PhoneRunArt), so a consumer arriving to make a biodata is being sold a shop. Needs a consumer scene set or a neutral one — a design ask, not a rebuild. |
OnboardingSetup wizard shell | ♻️ REUSE | stepsFor() + BUSINESS_STEPS / INDIVIDUAL_STEPS, index-based navigation, a feature-local Zustand draft, and authed already drops the auth step for a resuming visitor. This is genuinely the right abstraction for the new flow. |
AuthStep (the wizard's routing host) | ♻️ REUSE — fixed today | Routed every returning consumer to the merchant console; now branches on the server's accountType (QRS-918). |
AuthScreen | 🔨 MODIFY, and this is the real work | Today: email OTP + Google, mode-switched. Needs a phone/WhatsApp mode. It is the shared surface /sign-in also renders, so the merchant path must keep working while the consumer path changes — this is a mode, not a replacement. |
useEmailAuth | 🆕 NEW sibling, usePhoneAuth | And it must not repeat the omission its neighbour has: sendEmailOtp(email) drops the primaryContext the seam accepts, so the fork's choice never reaches handle_new_user (QRS-917). The category rides on the OTP send or it is wrong until the celebrate step. |
AuthService | 🔨 EXTEND | Add sendPhoneOtp(phone, primaryContext) / verifyPhoneOtp. ⚠ Behind a provider-agnostic interface (QRS-919) so WhatsApp-versus-SMS is a swap rather than a rewrite. |
| Google sign-in | ⛔ REMOVE for consumer, KEEP for merchant | ⚠ Not yet, and not globally. All seven existing Dev users are Google-only with 0 phones and 0 passwords (QRS-910), and email OTP is independently broken at the SMTP layer. Removing the provider today leaves no working route for any principal. Exit condition is measurable, not a date. |
NameStep | ♻️ REUSE | A biodata needs a name to address the account by, and handle_new_user fills nothing from a phone signup. |
| PIN / Face ID | 🆕 NEW — and NOT an onboarding step | See below. This is the one structural correction in the assessment. |
CelebrateStep | ♻️ REUSE | Already writes setPrimaryContext('individual') and already routes to /consumer. It should stop being the first place the category is written (see usePhoneAuth) and remain the place it is confirmed. |
The structural correction: the PIN is a device gate, not an onboarding step
It is tempting to add 'pin' to INDIVIDUAL_STEPS and be done. That would be wrong, and the reason is visible in the completion predicate:
ts
export function hasCompletedOnboarding(ctx: MyContext): boolean {
if (primaryContextOf(ctx) === 'individual') return true; // <- no consumer-side state exists
return ctx.workspaces.length > 0;
}Completion for a consumer is true the instant their category is recorded, and that is correct — a consumer has nothing to provision. So the model has no server-side state for "signed in, PIN not set", and inventing one would be the wrong fix twice over:
- A PIN is per-DEVICE, not per-ACCOUNT. The same person on a second phone must set a new one, and a server flag saying "PIN set" would be false there. Making it an account fact guarantees the two disagree, which is the duplicate-source-of-truth class (QRS-249) this repo keeps paying for.
- It is checked at every app open, not once at signup. That is an app-shell concern, and an onboarding step runs exactly once.
So: a consumer-tier gate. The PIN lives in expo-secure-store (already a dependency, zero added weight), is set on first entry to the consumer surface, and is checked by the consumer layout on resume. Biometric unlock is an additive convenience over the same stored secret via expo-local-authentication — which is not currently installed and owes a size callout before it is.
⚠ State the PIN's job honestly in the copy. It protects the biodata from someone holding the unlocked phone — a real and common threat in a shared family device. It is not encryption and it is not account security, and it must not be described as either.
What has never been designed
- Every WhatsApp OTP screen. Number entry with country code, the code screen, resend and its cooldown, wrong-code, expired-code, rate-limited, "no WhatsApp on this number", and the change-number path. The existing email OTP screens are the right shape and the wrong channel.
- The PIN screens — set, confirm, enter, forgot, and the biometric opt-in.
- A consumer-flavoured welcome story.
- The failure state nobody draws and everybody hits: what a person sees when the OTP does not arrive. On WhatsApp specifically this has a cause the design must handle — the number has no WhatsApp account — and it is not "try again".
These are round 11. The prompt is in Consumer context prompt.
Sequencing
Do not build the screens first. QRS-919 is an external approval queue, and QRS-910 carries an unverified assumption that decides whether every consumer is provisioned as a merchant: does signInWithOtp({ phone }) write raw_user_meta_data the way the email path does?
That is one probe against Dev, it takes minutes, and if the answer is no then the metadata route is dead and the category has to be written another way — which changes the seam, the hook and the celebrate step. Verify it before the design round lands, so the screens are drawn against the mechanism that actually exists.