18388bb31c
- API: översatt title/displayName/toName läggs nu bredvid Sv-fälten i favoriter,
receptvarianter, /scaled, skapar-profiler, topplistor, memory-impact,
substitutions — samt de denormaliserade titlarna (min dag, historik, matlådor,
matlåde-förslag, veckoplan) via nullable recipeId med svensk fallback.
10 endpoints, batchade resolvers (ett anrop per endpoint).
- Mobil: konsumerar de nya fälten med ?? Sv-fallback (swap-meal, sparade recept,
varianter, min dag, logg, matlådor, veckoplan, inköpslista). Hårdkodade
strängar -> t() (kitchen, scan-review, register, recept protein/Skapat av);
decimalkomma -> Intl.NumberFormat. Allergen-etiketter -> t() (+5 nya nycklar).
11 nya nycklar i alla 12 språk.
- i18n-vakt härdad: fångar nu hårdkodad svenska i prop={`...`}-mallliteraler
(blind fläck förr). Verifierat att den fäller men inte ger falska positiv.
- whySv var redan lokaliserad (buildWhy med språktagg) - orörd.
- typecheck grönt (alla paket), vakt grön (603 nycklar).
Co-Authored-By: Claude <noreply@anthropic.com>
133 lines
4.0 KiB
TypeScript
133 lines
4.0 KiB
TypeScript
import { useState } from "react";
|
|
import { router, useLocalSearchParams } from "expo-router";
|
|
import { Alert } from "react-native";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { api } from "@/lib/api";
|
|
import { t } from "@/lib/i18n";
|
|
import {
|
|
Body,
|
|
Card,
|
|
EmptyState,
|
|
ErrorView,
|
|
Input,
|
|
LoadingView,
|
|
Row,
|
|
Screen,
|
|
Small,
|
|
Spacer,
|
|
Tag,
|
|
Title,
|
|
} from "@/components/ui";
|
|
import { spacing } from "@/lib/theme";
|
|
|
|
interface Rec {
|
|
recipeId: string;
|
|
titleSv: string;
|
|
title?: string;
|
|
coveragePercent: number;
|
|
}
|
|
interface WhatToEatResponse {
|
|
recommendations: Rec[];
|
|
}
|
|
|
|
export default function SwapMealScreen() {
|
|
const params = useLocalSearchParams<{
|
|
entryId: string;
|
|
planId: string;
|
|
mealType: string;
|
|
date?: string;
|
|
}>();
|
|
const entryId = String(params.entryId);
|
|
const planId = String(params.planId);
|
|
const mealType = String(params.mealType || "dinner");
|
|
const date = typeof params.date === "string" ? params.date : "";
|
|
const addMode = date.length > 0; // "lägg till" i stället för "byt ut"
|
|
const queryClient = useQueryClient();
|
|
|
|
const [searchText, setSearchText] = useState("");
|
|
const [submitted, setSubmitted] = useState("");
|
|
|
|
const query = useQuery({
|
|
queryKey: ["swap-options", mealType, submitted],
|
|
// Med sökning: byt till VILKEN rätt som helst (mealType filtreras då bort på
|
|
// servern). Utan sökning: breda förslag för slotten (view=swap, låg täckningsvikt).
|
|
queryFn: () => {
|
|
const base = "/v1/recommendations/what-to-eat?limit=40&view=swap";
|
|
const path = submitted
|
|
? `${base}&search=${encodeURIComponent(submitted)}`
|
|
: `${base}&mealType=${mealType}`;
|
|
return api<WhatToEatResponse>(path);
|
|
},
|
|
});
|
|
|
|
const swap = useMutation({
|
|
mutationFn: (recipeId: string) =>
|
|
addMode
|
|
? api(`/v1/week-plans/${planId}/entries`, {
|
|
method: "POST",
|
|
body: { date, mealType, recipeId },
|
|
})
|
|
: api(`/v1/week-plans/${planId}/entries/${entryId}`, {
|
|
method: "PATCH",
|
|
body: { recipeId },
|
|
}),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ["week-plan"] });
|
|
router.back();
|
|
},
|
|
onError: (err) =>
|
|
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
|
});
|
|
|
|
const recs = query.data?.recommendations ?? [];
|
|
const mealLabel =
|
|
mealType === "lunch"
|
|
? t("swap.mealLunch")
|
|
: mealType === "breakfast"
|
|
? t("swap.mealBreakfast")
|
|
: t("swap.mealDinner");
|
|
|
|
return (
|
|
<Screen>
|
|
<Title>{addMode ? t("swap.addTitle") : t("swap.swapTitle")}</Title>
|
|
<Small>
|
|
{addMode
|
|
? t("swap.chooseFor", { meal: mealLabel })
|
|
: t("swap.chooseOther", { meal: mealLabel })}
|
|
</Small>
|
|
<Spacer size={spacing.sm} />
|
|
<Input
|
|
placeholder={t("swap.searchPlaceholder")}
|
|
value={searchText}
|
|
onChangeText={setSearchText}
|
|
onSubmitEditing={() => setSubmitted(searchText.trim())}
|
|
returnKeyType="search"
|
|
autoCorrect={false}
|
|
/>
|
|
{submitted ? (
|
|
<Small>
|
|
{t("swap.showingResults", { query: submitted })} <Small>{t("swap.clearHint")}</Small>
|
|
</Small>
|
|
) : null}
|
|
<Spacer size={spacing.xs} />
|
|
|
|
{query.isLoading && <LoadingView />}
|
|
{query.isError && <ErrorView onRetry={() => void query.refetch()} />}
|
|
{query.data && recs.length === 0 && (
|
|
<EmptyState text={submitted ? t("wte.emptySearch") : t("wte.empty")} />
|
|
)}
|
|
{recs.map((rec) => (
|
|
<Card key={rec.recipeId} onPress={() => swap.mutate(rec.recipeId)}>
|
|
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
|
<Body>{rec.title ?? rec.titleSv}</Body>
|
|
<Tag
|
|
label={t("wte.coverage", { pct: rec.coveragePercent })}
|
|
tone={rec.coveragePercent >= 80 ? "success" : "neutral"}
|
|
/>
|
|
</Row>
|
|
</Card>
|
|
))}
|
|
</Screen>
|
|
);
|
|
}
|