2587ff539b
- DIET_PATTERNS: +nordic, +carnivore, +paleo (migration 0029). Ingen separat "lchf" - LCHF tacks av befintliga "low_carb" for att undvika dubblett med keto/low_carb. UI visar low_carb med etiketten "LCHF". - Mobil onboarding + profil: 9 rena val (allatare..carnivore), 12 sprak. - recommendation-engine: ny mjuk dietAffinity-poangterm (0-1, neutral 0.5). Rankar recept mot matvanan - ALDRIG hart filter (uteslutande matvanor, religion, allergener hanteras redan deterministiskt i sakerhetsfiltret). low_carb/keto: lag kolhydrat. carnivore: hog protein + lag kolhydrat. paleo: glutenfri/laktosfri-proxyer. nordic: svenskt/nordiskt kok + sasong. - 6 nya tester (35 grona). Full typecheck 20/20 + mobil gron.
471 lines
14 KiB
TypeScript
471 lines
14 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { Pressable, Text, View } from "react-native";
|
||
import { router } from "expo-router";
|
||
import { api } from "@/lib/api";
|
||
import { useAuth } from "@/lib/auth";
|
||
import { useAnalytics } from "@/lib/analytics";
|
||
import { t } from "@/lib/i18n";
|
||
import { onboardingStarted, onboardingStepCompleted, onboardingSkipped } from "@app/analytics";
|
||
import {
|
||
Body,
|
||
Button,
|
||
Card,
|
||
Heading,
|
||
Input,
|
||
Row,
|
||
Screen,
|
||
Small,
|
||
Spacer,
|
||
Title,
|
||
} from "@/components/ui";
|
||
import { colors, spacing, typography } from "@/lib/theme";
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
|
||
const GOALS = [
|
||
"lose_weight",
|
||
"build_muscle",
|
||
"maintain_weight",
|
||
"more_protein",
|
||
"less_waste",
|
||
"lower_cost",
|
||
"cook_more",
|
||
] as const;
|
||
|
||
const DIETS = [
|
||
"omnivore",
|
||
"flexitarian",
|
||
"pescatarian",
|
||
"vegetarian",
|
||
"vegan",
|
||
"nordic",
|
||
"paleo",
|
||
"low_carb",
|
||
"carnivore",
|
||
] as const;
|
||
|
||
const RELIGIOUS = ["none", "halal", "kosher", "hindu_no_beef", "buddhist_vegetarian"] as const;
|
||
|
||
const ALLERGENS = [
|
||
"gluten",
|
||
"milk",
|
||
"eggs",
|
||
"tree_nuts",
|
||
"peanuts",
|
||
"fish",
|
||
"crustaceans",
|
||
"soy",
|
||
"sesame",
|
||
] as const;
|
||
|
||
/** Which layer are we showing? */
|
||
type OnboardingLayer = "a" | "b" | "c";
|
||
|
||
export default function OnboardingScreen() {
|
||
const setOnboardingCompleted = useAuth((s) => s.setOnboardingCompleted);
|
||
const setOnboardingStep = useAuth((s) => s.setOnboardingStep);
|
||
const savedStep = useAuth((s) => s.onboardingStep);
|
||
const { track } = useAnalytics();
|
||
const [layer, setLayer] = useState<OnboardingLayer>(savedStep ?? "a");
|
||
|
||
useEffect(() => {
|
||
track(onboardingStarted({ properties: { step: layer } }));
|
||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// Step A state
|
||
const [goals, setGoals] = useState<string[]>([]);
|
||
const [mode, setMode] = useState<"simple" | "exact">("simple");
|
||
const primaryGoal = goals[0] ?? null;
|
||
|
||
// Step B state
|
||
const [diet, setDiet] = useState<string>("omnivore");
|
||
const [religiousRule, setReligiousRule] = useState<string>("none");
|
||
const [allergens, setAllergens] = useState<string[]>([]);
|
||
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 toggleGoal = (id: string) =>
|
||
setGoals((prev) => {
|
||
if (prev.includes(id)) return prev.filter((g) => g !== id);
|
||
// Keep the first-selected goal as primary by appending at the end.
|
||
return [...prev, id];
|
||
});
|
||
|
||
const finishStepA = async () => {
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const res = await api<{ step: "a" | "b" | "c" }>("/v1/onboarding/quick-start", {
|
||
method: "POST",
|
||
body: {
|
||
...(goals.length ? { goals } : {}),
|
||
precisionMode: mode,
|
||
},
|
||
});
|
||
track(
|
||
onboardingStepCompleted({
|
||
properties: { step: "a", goals, primaryGoal, 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,
|
||
preferences: {
|
||
dietPattern: diet,
|
||
allergens,
|
||
religiousRule,
|
||
},
|
||
householdChoice:
|
||
householdKind === "create"
|
||
? { kind: "create", name: householdName || t("onboarding.householdDefaultName") }
|
||
: householdKind === "join"
|
||
? { kind: "join", inviteCode }
|
||
: { kind: "skip" },
|
||
},
|
||
});
|
||
track(onboardingStepCompleted({ properties: { step: "b", diet, allergens, householdKind } }));
|
||
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 {
|
||
track(onboardingStepCompleted({ properties: { step: "c" } }));
|
||
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"));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const skip = async (targetLayer?: OnboardingLayer) => {
|
||
setBusy(true);
|
||
try {
|
||
track(onboardingSkipped({ properties: { step: targetLayer ?? layer } }));
|
||
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) => {
|
||
const index = goals.indexOf(id);
|
||
const selected = index >= 0;
|
||
return (
|
||
<SelectRow
|
||
key={id}
|
||
label={t(`onboarding.goal.${id}`)}
|
||
selected={selected}
|
||
onPress={() => toggleGoal(id)}
|
||
>
|
||
{selected && index === 0 && (
|
||
<View style={{ marginTop: 4 }}>
|
||
<Text style={[typography.small, { color: colors.primary, fontWeight: "600" }]}>
|
||
{t("onboarding.primaryGoal")}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</SelectRow>
|
||
);
|
||
})}
|
||
</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>
|
||
<Small>{t("onboarding.memoryTransparency")}</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} />
|
||
<Spacer size={spacing.sm} />
|
||
<Heading>{t("onboarding.religious")}</Heading>
|
||
<Row>
|
||
{RELIGIOUS.map((id) => (
|
||
<Chip
|
||
key={id}
|
||
label={t(`onboarding.religious.${id}` as never)}
|
||
selected={religiousRule === id}
|
||
onPress={() => setReligiousRule(id)}
|
||
/>
|
||
))}
|
||
</Row>
|
||
|
||
<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>
|
||
<Small>{t("onboarding.memoryTransparency")}</Small>
|
||
|
||
<Spacer size={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")}
|
||
/>
|
||
|
||
{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")}
|
||
keyboardType="numeric"
|
||
value={weightKg}
|
||
onChangeText={setWeightKg}
|
||
/>
|
||
<Input
|
||
placeholder={t("onboarding.heightPlaceholder")}
|
||
keyboardType="numeric"
|
||
value={heightCm}
|
||
onChangeText={setHeightCm}
|
||
/>
|
||
<Input
|
||
placeholder={t("onboarding.birthYearPlaceholder")}
|
||
keyboardType="numeric"
|
||
value={birthYear}
|
||
onChangeText={setBirthYear}
|
||
/>
|
||
<Small>{t("onboarding.notMedical")}</Small>
|
||
<Small>{t("onboarding.memoryTransparency")}</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("c")} />
|
||
</View>
|
||
<View style={{ flex: 2 }}>
|
||
<Button label={t("common.done")} onPress={() => void finishStepC()} loading={busy} />
|
||
</View>
|
||
</Row>
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
const sel = { borderColor: colors.primary, borderWidth: 2, backgroundColor: colors.primarySoft };
|
||
|
||
function SelectRow({
|
||
label,
|
||
selected,
|
||
onPress,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
selected: boolean;
|
||
onPress: () => void;
|
||
children?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<Pressable
|
||
onPress={onPress}
|
||
style={{
|
||
padding: spacing.md,
|
||
borderRadius: 12,
|
||
borderWidth: selected ? 2 : 1,
|
||
borderColor: selected ? colors.primary : colors.border,
|
||
backgroundColor: selected ? colors.primarySoft : colors.surface,
|
||
}}
|
||
>
|
||
<Text style={{ color: colors.text, fontWeight: selected ? "700" : "400" }}>{label}</Text>
|
||
{children}
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
function Chip({
|
||
label,
|
||
selected,
|
||
onPress,
|
||
}: {
|
||
label: string;
|
||
selected: boolean;
|
||
onPress: () => void;
|
||
}) {
|
||
return (
|
||
<Pressable
|
||
onPress={onPress}
|
||
style={{
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 8,
|
||
borderRadius: 999,
|
||
borderWidth: 1,
|
||
borderColor: selected ? colors.primary : colors.border,
|
||
backgroundColor: selected ? colors.primarySoft : colors.surface,
|
||
}}
|
||
>
|
||
<Text style={{ color: selected ? colors.primaryDark : colors.textMuted, fontSize: 13 }}>
|
||
{label}
|
||
</Text>
|
||
</Pressable>
|
||
);
|
||
}
|