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:
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user