FAS 1b: Progressive onboarding (3-layer flow)

- Add PROGRESSIVE_ONBOARDING feature flag to KNOWN_FLAGS
- Add onboarding_step column to users table (a/b/c)
- New backend endpoints:
  - GET /v1/onboarding/status – check current step + feature flag
  - POST /v1/onboarding/quick-start – complete Step A (goal + precision)
  - POST /v1/onboarding/complete-b – complete Step B (diet, allergens, household)
  - POST /v1/onboarding/complete-c – complete Step C (health profile)
  - POST /v1/onboarding/skip – GDPR-friendly skip
- Refactor mobile onboarding screen into 3 progressive layers
- Update auth store with onboardingStep state
- Update tab layout to only block on Step A
- Update registration to set onboardingStep='a'
- Add i18n keys for Step B/C titles across all 12 locales
- Add Zod validation schemas (quickStartInputSchema, onboardingStatusSchema)
- Add tests for validation and feature flag
- Preserve existing /v1/me/onboarding for backward compatibility
- Migration: 0004_progressive_onboarding.sql
This commit is contained in:
Sven (AAMOS AI)
2026-08-06 00:39:44 +07:00
parent b2b6601f54
commit 2f3cdf8016
24 changed files with 4338 additions and 3837 deletions
+2
View File
@@ -17,6 +17,7 @@ interface RegisterResponse {
export default function RegisterScreen() {
const setSession = useAuth((s) => s.setSession);
const setOnboardingCompleted = useAuth((s) => s.setOnboardingCompleted);
const setOnboardingStep = useAuth((s) => s.setOnboardingStep);
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -65,6 +66,7 @@ export default function RegisterScreen() {
await persistLanguageTag(deviceTag);
}
setOnboardingCompleted(false);
setOnboardingStep("a");
router.replace("/onboarding");
} catch (err) {
setError(err instanceof Error ? err.message : t("common.error"));
+3 -1
View File
@@ -36,10 +36,12 @@ function useApplyLocalePreferences(enabled: boolean) {
export default function TabsLayout() {
const accessToken = useAuth((s) => s.accessToken);
const onboardingCompleted = useAuth((s) => s.onboardingCompleted);
const onboardingStep = useAuth((s) => s.onboardingStep);
useApplyLocalePreferences(Boolean(accessToken));
if (!accessToken) return <Redirect href="/(auth)/login" />;
if (!onboardingCompleted) return <Redirect href="/onboarding" />;
// Progressive onboarding (FAS 1b): only block if step A not done
if (!onboardingCompleted && onboardingStep === "a") return <Redirect href="/onboarding" />;
return (
<Tabs
+224 -129
View File
@@ -14,14 +14,20 @@ import {
Screen,
Small,
Spacer,
Tag,
Title,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
/** Onboarding (spec §6): mål, kost, allergier, hushåll, läge allt frivilligt. */
/**
* Progressive onboarding (FAS 1b): 3-layer flow.
* Step A = immediate value (goal + precision mode) shown right after registration.
* Step B = after first value (diet, allergens, household) shown after first scan/cook.
* Step C = contextual (health profile) shown when user visits nutrition features.
*
* All steps are skippable (GDPR-friendly). Existing /v1/me/onboarding preserved
* for backward compatibility.
*/
// Etiketter hämtas ur i18n-katalogen (12 språk) id:na är stabila API-värden.
const GOALS = [
"lose_weight",
"build_muscle",
@@ -46,40 +52,68 @@ const ALLERGENS = [
"sesame",
] as const;
/** Which layer are we showing? */
type OnboardingLayer = "a" | "b" | "c";
export default function OnboardingScreen() {
const setOnboardingCompleted = useAuth((s) => s.setOnboardingCompleted);
const [step, setStep] = useState(0);
const setOnboardingStep = useAuth((s) => s.setOnboardingStep);
const savedStep = useAuth((s) => s.onboardingStep);
const [layer, setLayer] = useState<OnboardingLayer>(savedStep ?? "a");
// Step A state
const [goal, setGoal] = useState<string | null>(null);
const [mode, setMode] = useState<"simple" | "exact">("simple");
// Step B state
const [diet, setDiet] = useState<string>("omnivore");
const [allergens, setAllergens] = useState<string[]>([]);
const [mode, setMode] = useState<"simple" | "exact">("simple");
const [householdKind, setHouseholdKind] = useState<"create" | "join" | "skip">("create");
const [householdName, setHouseholdName] = useState(t("onboarding.householdDefaultName"));
const [inviteCode, setInviteCode] = useState("");
// Step C state
const [weightKg, setWeightKg] = useState("");
const [heightCm, setHeightCm] = useState("");
const [birthYear, setBirthYear] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const toggleAllergen = (id: string) =>
setAllergens((prev) => (prev.includes(id) ? prev.filter((a) => a !== id) : [...prev, id]));
const finish = async () => {
const finishStepA = async () => {
setBusy(true);
setError(null);
try {
await api("/v1/me/onboarding", {
const res = await api<{ step: "a" | "b" | "c" }>("/v1/onboarding/quick-start", {
method: "POST",
body: {
...(goal ? { primaryGoal: goal } : {}),
precisionMode: mode,
},
});
setOnboardingStep(res.step);
setLayer("b");
// Let user into the app Step B will be shown contextually later
router.replace("/(tabs)");
} catch (err) {
setError(err instanceof Error ? err.message : t("common.error"));
} finally {
setBusy(false);
}
};
const finishStepB = async () => {
setBusy(true);
setError(null);
try {
const res = await api<{ step: "a" | "b" | "c" }>("/v1/onboarding/complete-b", {
method: "POST",
body: {
precisionMode: mode,
healthProfile: {
...(weightKg ? { weightKg: Number(weightKg) } : {}),
...(heightCm ? { heightCm: Number(heightCm) } : {}),
...(birthYear ? { birthYear: Number(birthYear) } : {}),
},
preferences: {
...(goal ? { primaryGoal: goal } : {}),
dietPattern: diet,
allergens,
},
@@ -91,7 +125,32 @@ export default function OnboardingScreen() {
: { kind: "skip" },
},
});
setOnboardingStep(res.step);
setLayer("c");
} catch (err) {
setError(err instanceof Error ? err.message : t("common.error"));
} finally {
setBusy(false);
}
};
const finishStepC = async () => {
setBusy(true);
setError(null);
try {
await api("/v1/onboarding/complete-c", {
method: "POST",
body: {
precisionMode: mode,
healthProfile: {
...(weightKg ? { weightKg: Number(weightKg) } : {}),
...(heightCm ? { heightCm: Number(heightCm) } : {}),
...(birthYear ? { birthYear: Number(birthYear) } : {}),
},
},
});
setOnboardingCompleted(true);
setOnboardingStep("c");
router.replace("/(tabs)");
} catch (err) {
setError(err instanceof Error ? err.message : t("common.error"));
@@ -100,48 +159,158 @@ export default function OnboardingScreen() {
}
};
const steps = [
// 0: Mål
<View key="goal" style={{ gap: spacing.sm }}>
<Heading>{t("onboarding.goal")}</Heading>
{GOALS.map((id) => (
const skip = async (targetLayer?: OnboardingLayer) => {
setBusy(true);
try {
await api("/v1/onboarding/skip", {
method: "POST",
body: { step: targetLayer },
});
setOnboardingCompleted(true);
setOnboardingStep("c");
router.replace("/(tabs)");
} catch (err) {
setError(err instanceof Error ? err.message : t("common.error"));
} finally {
setBusy(false);
}
};
// Render Step A: Immediate value
if (layer === "a") {
return (
<Screen>
<Title>{t("onboarding.title")}</Title>
<Small>{t("onboarding.subtitle")}</Small>
<Spacer size={spacing.sm} />
<Heading>{t("onboarding.goal")}</Heading>
<View style={{ gap: spacing.sm }}>
{GOALS.map((id) => (
<SelectRow
key={id}
label={t(`onboarding.goal.${id}`)}
selected={goal === id}
onPress={() => setGoal(id)}
/>
))}
</View>
<Spacer size={spacing.sm} />
<Heading>{t("onboarding.mode")}</Heading>
<Card onPress={() => setMode("simple")} style={mode === "simple" ? sel : undefined}>
<Body>{t("onboarding.modeSimple")}</Body>
<Small>{t("onboarding.modeSimpleDesc")}</Small>
</Card>
<Card onPress={() => setMode("exact")} style={mode === "exact" ? sel : undefined}>
<Body>{t("onboarding.modeExact")}</Body>
<Small>{t("onboarding.modeExactDesc")}</Small>
</Card>
<Small>{t("onboarding.modesCombine")}</Small>
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
<Spacer />
<Row>
<View style={{ flex: 1 }}>
<Button label={t("common.skip")} variant="ghost" onPress={() => void skip("b")} />
</View>
<View style={{ flex: 2 }}>
<Button label={t("common.next")} onPress={() => void finishStepA()} loading={busy} />
</View>
</Row>
<Small>{t("onboarding.notMedical")}</Small>
</Screen>
);
}
// Render Step B: After first value
if (layer === "b") {
return (
<Screen>
<Title>{t("onboarding.stepBTitle")}</Title>
<Small>{t("onboarding.stepBSubtitle")}</Small>
<Spacer size={spacing.sm} />
<Heading>{t("onboarding.diet")}</Heading>
<Row>
{DIETS.map((id) => (
<Chip
key={id}
label={t(`onboarding.diet.${id}`)}
selected={diet === id}
onPress={() => setDiet(id)}
/>
))}
</Row>
<Spacer size={spacing.sm} />
<Heading>{t("onboarding.allergies")}</Heading>
<Row>
{ALLERGENS.map((id) => (
<Chip
key={id}
label={t(`onboarding.allergen.${id}`)}
selected={allergens.includes(id)}
onPress={() => toggleAllergen(id)}
/>
))}
</Row>
<Small>{t("onboarding.allergyNote")}</Small>
<Spacer size={spacing.sm} />
<Heading>{t("onboarding.household")}</Heading>
<SelectRow
key={id}
label={t(`onboarding.goal.${id}`)}
selected={goal === id}
onPress={() => setGoal(id)}
label={t("onboarding.householdCreate")}
selected={householdKind === "create"}
onPress={() => setHouseholdKind("create")}
/>
))}
</View>,
// 1: Kost + allergier
<View key="diet" style={{ gap: spacing.sm }}>
<Heading>{t("onboarding.diet")}</Heading>
<Row>
{DIETS.map((id) => (
<Chip
key={id}
label={t(`onboarding.diet.${id}`)}
selected={diet === id}
onPress={() => setDiet(id)}
{householdKind === "create" && (
<Input
placeholder={t("onboarding.householdName")}
value={householdName}
onChangeText={setHouseholdName}
/>
))}
</Row>
<Spacer />
<Heading>{t("onboarding.allergies")}</Heading>
<Row>
{ALLERGENS.map((id) => (
<Chip
key={id}
label={t(`onboarding.allergen.${id}`)}
selected={allergens.includes(id)}
onPress={() => toggleAllergen(id)}
)}
<SelectRow
label={t("onboarding.householdJoin")}
selected={householdKind === "join"}
onPress={() => setHouseholdKind("join")}
/>
{householdKind === "join" && (
<Input
placeholder={t("onboarding.inviteCode")}
autoCapitalize="characters"
value={inviteCode}
onChangeText={setInviteCode}
/>
))}
</Row>
<Small>{t("onboarding.allergyNote")}</Small>
</View>,
// 2: Kroppsdata (frivilligt, för energiberäkning)
<View key="body" style={{ gap: spacing.sm }}>
)}
<SelectRow
label={t("common.skip")}
selected={householdKind === "skip"}
onPress={() => setHouseholdKind("skip")}
/>
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
<Spacer />
<Row>
<View style={{ flex: 1 }}>
<Button label={t("common.skip")} variant="ghost" onPress={() => void skip("c")} />
</View>
<View style={{ flex: 2 }}>
<Button label={t("common.next")} onPress={() => void finishStepB()} loading={busy} />
</View>
</Row>
</Screen>
);
}
// Render Step C: Contextual (health profile)
return (
<Screen>
<Title>{t("onboarding.stepCTitle")}</Title>
<Small>{t("onboarding.stepCSubtitle")}</Small>
<Spacer size={spacing.sm} />
<Heading>{t("onboarding.bodyTitle")}</Heading>
<Input
placeholder={t("onboarding.weightPlaceholder")}
@@ -162,89 +331,15 @@ export default function OnboardingScreen() {
onChangeText={setBirthYear}
/>
<Small>{t("onboarding.notMedical")}</Small>
</View>,
// 3: Hushåll
<View key="household" style={{ gap: spacing.sm }}>
<Heading>{t("onboarding.household")}</Heading>
<SelectRow
label={t("onboarding.householdCreate")}
selected={householdKind === "create"}
onPress={() => setHouseholdKind("create")}
/>
{householdKind === "create" && (
<Input
placeholder={t("onboarding.householdName")}
value={householdName}
onChangeText={setHouseholdName}
/>
)}
<SelectRow
label={t("onboarding.householdJoin")}
selected={householdKind === "join"}
onPress={() => setHouseholdKind("join")}
/>
{householdKind === "join" && (
<Input
placeholder={t("onboarding.inviteCode")}
autoCapitalize="characters"
value={inviteCode}
onChangeText={setInviteCode}
/>
)}
<SelectRow
label={t("common.skip")}
selected={householdKind === "skip"}
onPress={() => setHouseholdKind("skip")}
/>
</View>,
// 4: Läge (spec §5)
<View key="mode" style={{ gap: spacing.sm }}>
<Heading>{t("onboarding.mode")}</Heading>
<Card onPress={() => setMode("simple")} style={mode === "simple" ? sel : undefined}>
<Body>{t("onboarding.modeSimple")}</Body>
<Small>{t("onboarding.modeSimpleDesc")}</Small>
</Card>
<Card onPress={() => setMode("exact")} style={mode === "exact" ? sel : undefined}>
<Body>{t("onboarding.modeExact")}</Body>
<Small>{t("onboarding.modeExactDesc")}</Small>
</Card>
<Small>{t("onboarding.modesCombine")}</Small>
</View>,
];
const last = step === steps.length - 1;
return (
<Screen>
<Title>{t("onboarding.title")}</Title>
<Small>{t("onboarding.subtitle")}</Small>
<Row style={{ marginVertical: spacing.sm }}>
{steps.map((_, i) => (
<View
key={i}
style={{
flex: 1,
height: 4,
borderRadius: 2,
backgroundColor: i <= step ? colors.primary : colors.border,
}}
/>
))}
</Row>
{steps[step]}
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
<Spacer />
<Row>
{step > 0 && (
<View style={{ flex: 1 }}>
<Button label={t("common.back")} variant="ghost" onPress={() => setStep(step - 1)} />
</View>
)}
<View style={{ flex: 1 }}>
<Button label={t("common.skip")} variant="ghost" onPress={() => void skip("c")} />
</View>
<View style={{ flex: 2 }}>
<Button
label={last ? t("common.done") : t("common.next")}
onPress={() => (last ? void finish() : setStep(step + 1))}
loading={busy}
/>
<Button label={t("common.done")} onPress={() => void finishStepC()} loading={busy} />
</View>
</Row>
</Screen>