Files
Cibello-app/apps/mobile/src/app/profile.tsx
T
Claude 2d59a3e636 i18n(app-svep) + i18n-vakt: hela mobil-UI:t översatt + CI-vakt mot regress
- 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>
2026-08-21 01:50:08 +00:00

320 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}