2d59a3e636
- Svepte 51 hårdkodade strängar -> t() över konto, sök/bläddra, sparade recept, kök, planera, skanna, byt måltid, inköp, minne, cooking, api-fel (+ locLabel/ kategorier via nycklar). 47 nya nycklar, översatta till alla 12 språk. - NY: apps/mobile/scripts/i18n-check.mjs (i18n:check) – fäller bygget om ett språk saknar en nyckel, en använd nyckel saknas, eller det finns hårdkodad UI-text. Wire:ad i CI (.github/workflows/ci.yml) efter typecheck. => 'lägg till språk' blir: kör vakten, den listar exakt vad som fattas. Aldrig mer leta för hand. - Prettier-fixade 5 filer från tidigare leveranser så format:check blir grön. - submit-recipe fri-text-payload + firebase dev-stubs markerade // i18n-ignore. Co-Authored-By: Claude <noreply@anthropic.com>
320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
import { Alert } from "react-native";
|
||
import { router } from "expo-router";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { api } from "@/lib/api";
|
||
import { firebaseAuthProvider } from "@/lib/auth-provider/firebase";
|
||
import { persistLanguageTag, useAuth } from "@/lib/auth";
|
||
import { SUPPORTED_LANGUAGES, t } from "@/lib/i18n";
|
||
import {
|
||
Body,
|
||
Button,
|
||
Card,
|
||
ErrorView,
|
||
Heading,
|
||
LoadingView,
|
||
Row,
|
||
Screen,
|
||
Small,
|
||
Spacer,
|
||
Tag,
|
||
} from "@/components/ui";
|
||
|
||
/** Profil: konto, samtycken, minne, prenumeration, GDPR-åtgärder. */
|
||
|
||
interface Me {
|
||
displayName: string;
|
||
email: string;
|
||
emailVerified: boolean;
|
||
precisionMode: string;
|
||
}
|
||
interface Entitlements {
|
||
plan: string;
|
||
status: string;
|
||
aiScansPerMonth: number;
|
||
aiScansUsedThisMonth: number;
|
||
expiresAt?: string;
|
||
}
|
||
interface Consent {
|
||
kind: string;
|
||
status: string;
|
||
}
|
||
interface Prefs {
|
||
dietPattern?: string | null;
|
||
allergens?: string[] | null;
|
||
religiousRule?: string | null;
|
||
}
|
||
|
||
/** Samtyckesetiketter ur i18n-katalogen: profile.consent.<kind> (12 språk). */
|
||
const CONSENT_KINDS = [
|
||
"personalization",
|
||
"anonymized_improvement",
|
||
"image_training",
|
||
"health_integration",
|
||
"location_weather",
|
||
"push_notifications",
|
||
"product_analytics",
|
||
] as const;
|
||
const DIETS = [
|
||
"omnivore",
|
||
"flexitarian",
|
||
"pescatarian",
|
||
"vegetarian",
|
||
"vegan",
|
||
"nordic",
|
||
"paleo",
|
||
"low_carb",
|
||
"carnivore",
|
||
] as const;
|
||
const ALLERGENS = [
|
||
"gluten",
|
||
"milk",
|
||
"eggs",
|
||
"tree_nuts",
|
||
"peanuts",
|
||
"fish",
|
||
"crustaceans",
|
||
"soy",
|
||
"sesame",
|
||
] as const;
|
||
const RELIGIOUS = ["none", "halal", "kosher", "hindu_no_beef", "buddhist_vegetarian"] as const;
|
||
|
||
export default function ProfileScreen() {
|
||
const queryClient = useQueryClient();
|
||
const rawLogout = useAuth((s) => s.logout);
|
||
const logout = async () => {
|
||
await firebaseAuthProvider.signOut().catch(() => {});
|
||
await rawLogout();
|
||
};
|
||
|
||
const me = useQuery({ queryKey: ["me"], queryFn: () => api<Me>("/v1/me") });
|
||
const entitlements = useQuery({
|
||
queryKey: ["entitlements"],
|
||
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
|
||
});
|
||
const consents = useQuery({
|
||
queryKey: ["consents"],
|
||
queryFn: () => api<Consent[]>("/v1/me/consents"),
|
||
});
|
||
const prefs = useQuery({
|
||
queryKey: ["preferences"],
|
||
queryFn: () => api<Prefs | null>("/v1/me/preferences"),
|
||
});
|
||
const savePrefs = useMutation({
|
||
mutationFn: (body: Record<string, unknown>) =>
|
||
api("/v1/me/preferences", { method: "PATCH", body }),
|
||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["preferences"] }),
|
||
onError: (err) =>
|
||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||
});
|
||
|
||
const setConsent = useMutation({
|
||
mutationFn: ({ kind, granted }: { kind: string; granted: boolean }) =>
|
||
api("/v1/me/consents", { method: "PUT", body: { kind, granted } }),
|
||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["consents"] }),
|
||
onError: (err) =>
|
||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||
});
|
||
|
||
const togglePrecision = useMutation({
|
||
mutationFn: (mode: string) => api("/v1/me", { method: "PATCH", body: { precisionMode: mode } }),
|
||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["me"] }),
|
||
onError: (err) =>
|
||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||
});
|
||
|
||
const deleteAccount = useMutation({
|
||
mutationFn: () => api("/v1/me", { method: "DELETE" }),
|
||
onSuccess: () => void logout().then(() => router.replace("/(auth)/login")),
|
||
onError: (err) =>
|
||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||
});
|
||
|
||
const resend = useMutation({
|
||
mutationFn: () => api("/v1/auth/resend-verification", { method: "POST" }),
|
||
onError: (err) =>
|
||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||
});
|
||
|
||
// Språkval (i18n M4): sparas i backend-preferenserna + lokalt, byter direkt.
|
||
const localePrefs = useQuery({
|
||
queryKey: ["locale-preferences"],
|
||
queryFn: () => api<{ languageTag: string }>("/v1/me/locale-preferences"),
|
||
});
|
||
const setLanguage = useMutation({
|
||
mutationFn: (languageTag: string) =>
|
||
api("/v1/me/locale-preferences", { method: "PATCH", body: { languageTag } }),
|
||
onSuccess: async (_data, languageTag) => {
|
||
await persistLanguageTag(languageTag);
|
||
await queryClient.invalidateQueries();
|
||
},
|
||
onError: (err) =>
|
||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||
});
|
||
|
||
if (me.isLoading) return <LoadingView />;
|
||
if (me.isError) return <ErrorView onRetry={() => void me.refetch()} />;
|
||
|
||
const consentMap = new Map((consents.data ?? []).map((c) => [c.kind, c.status]));
|
||
const curDiet = prefs.data?.dietPattern ?? "omnivore";
|
||
const curAllergens = prefs.data?.allergens ?? [];
|
||
const curReligious = prefs.data?.religiousRule ?? "none";
|
||
|
||
return (
|
||
<Screen>
|
||
<Card>
|
||
<Heading>{me.data?.displayName}</Heading>
|
||
<Small>{me.data?.email}</Small>
|
||
{me.data && !me.data.emailVerified && (
|
||
<>
|
||
<Small>⚠︎ {t("profile.emailUnverified")}</Small>
|
||
<Button
|
||
label={
|
||
resend.isSuccess ? t("profile.verificationSent") : t("profile.resendVerification")
|
||
}
|
||
variant="ghost"
|
||
onPress={() => resend.mutate()}
|
||
/>
|
||
</>
|
||
)}
|
||
<Row>
|
||
<Body>{t("profile.modeLabel")}</Body>
|
||
<Button
|
||
label={t("onboarding.modeSimple")}
|
||
variant={me.data?.precisionMode === "simple" ? "secondary" : "ghost"}
|
||
onPress={() => togglePrecision.mutate("simple")}
|
||
/>
|
||
<Button
|
||
label={t("onboarding.modeExact")}
|
||
variant={me.data?.precisionMode === "exact" ? "secondary" : "ghost"}
|
||
onPress={() => togglePrecision.mutate("exact")}
|
||
/>
|
||
</Row>
|
||
</Card>
|
||
|
||
<Card>
|
||
<Heading>{t("profile.language")}</Heading>
|
||
<Row style={{ flexWrap: "wrap" }}>
|
||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||
<Button
|
||
key={lang.code}
|
||
label={t(lang.labelKey)}
|
||
variant={
|
||
(localePrefs.data?.languageTag ?? "sv-SE").startsWith(lang.code)
|
||
? "secondary"
|
||
: "ghost"
|
||
}
|
||
onPress={() => setLanguage.mutate(lang.tag)}
|
||
/>
|
||
))}
|
||
</Row>
|
||
</Card>
|
||
|
||
<Card>
|
||
<Heading>{t("onboarding.diet")}</Heading>
|
||
<Row style={{ flexWrap: "wrap" }}>
|
||
{DIETS.map((id) => (
|
||
<Button
|
||
key={id}
|
||
label={t(`onboarding.diet.${id}` as never)}
|
||
variant={curDiet === id ? "secondary" : "ghost"}
|
||
onPress={() => savePrefs.mutate({ dietPattern: id })}
|
||
/>
|
||
))}
|
||
</Row>
|
||
<Spacer size={8} />
|
||
<Heading>{t("onboarding.allergies")}</Heading>
|
||
<Row style={{ flexWrap: "wrap" }}>
|
||
{ALLERGENS.map((id) => (
|
||
<Button
|
||
key={id}
|
||
label={t(`onboarding.allergen.${id}` as never)}
|
||
variant={curAllergens.includes(id) ? "secondary" : "ghost"}
|
||
onPress={() =>
|
||
savePrefs.mutate({
|
||
allergens: curAllergens.includes(id)
|
||
? curAllergens.filter((a) => a !== id)
|
||
: [...curAllergens, id],
|
||
})
|
||
}
|
||
/>
|
||
))}
|
||
</Row>
|
||
<Spacer size={8} />
|
||
<Heading>{t("onboarding.religious")}</Heading>
|
||
<Row style={{ flexWrap: "wrap" }}>
|
||
{RELIGIOUS.map((id) => (
|
||
<Button
|
||
key={id}
|
||
label={t(`onboarding.religious.${id}` as never)}
|
||
variant={curReligious === id ? "secondary" : "ghost"}
|
||
onPress={() => savePrefs.mutate({ religiousRule: id })}
|
||
/>
|
||
))}
|
||
</Row>
|
||
</Card>
|
||
|
||
{entitlements.data && (
|
||
<Card onPress={() => router.push("/paywall")}>
|
||
<Row style={{ justifyContent: "space-between" }}>
|
||
<Heading>{t("profile.subscription")}</Heading>
|
||
<Tag
|
||
label={`${entitlements.data.plan} · ${entitlements.data.status}`}
|
||
tone={entitlements.data.status === "free" ? "neutral" : "success"}
|
||
/>
|
||
</Row>
|
||
<Small>
|
||
{t("profile.aiScansUsed", {
|
||
used: entitlements.data.aiScansUsedThisMonth,
|
||
total: entitlements.data.aiScansPerMonth,
|
||
})}
|
||
</Small>
|
||
</Card>
|
||
)}
|
||
|
||
<Card onPress={() => router.push("/memory")}>
|
||
<Heading>🧠 {t("profile.memory")}</Heading>
|
||
<Small>{t("profile.memorySubtitle")}</Small>
|
||
</Card>
|
||
|
||
<Card>
|
||
<Heading>{t("profile.consents")}</Heading>
|
||
{CONSENT_KINDS.map((kind) => {
|
||
const granted = consentMap.get(kind) === "granted";
|
||
return (
|
||
<Row key={kind} style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||
<Small style={{ flex: 1, marginRight: 12 }}>{t(`profile.consent.${kind}`)}</Small>
|
||
<Button
|
||
label={granted ? t("common.on") : t("common.off")}
|
||
variant={granted ? "secondary" : "ghost"}
|
||
onPress={() => setConsent.mutate({ kind, granted: !granted })}
|
||
/>
|
||
</Row>
|
||
);
|
||
})}
|
||
<Small>{t("profile.consentsNote")}</Small>
|
||
</Card>
|
||
|
||
<Spacer />
|
||
<Button
|
||
label={t("profile.logout")}
|
||
variant="ghost"
|
||
onPress={() => void logout().then(() => router.replace("/(auth)/login"))}
|
||
/>
|
||
<Button
|
||
label={t("profile.deleteAccount")}
|
||
variant="danger"
|
||
onPress={() =>
|
||
Alert.alert(t("profile.deleteTitle"), t("profile.deleteBody"), [
|
||
{ text: t("common.cancel"), style: "cancel" },
|
||
{
|
||
text: t("memory.delete"),
|
||
style: "destructive",
|
||
onPress: () => deleteAccount.mutate(),
|
||
},
|
||
])
|
||
}
|
||
/>
|
||
</Screen>
|
||
);
|
||
}
|