Files
Cibello-app/apps/mobile/src/app/onboarding.tsx
T
Claude 2587ff539b feat(diet): lagg till nordisk/carnivore/paleo + LCHF som matvanor med mjuk rankning
- 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.
2026-08-18 23:03:21 +00:00

471 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}