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>
181 lines
6.6 KiB
TypeScript
181 lines
6.6 KiB
TypeScript
import { useState } from "react";
|
||
import { router, useLocalSearchParams } from "expo-router";
|
||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||
import { api } from "@/lib/api";
|
||
import { t } from "@/lib/i18n";
|
||
import {
|
||
Button,
|
||
Card,
|
||
EmptyState,
|
||
ErrorView,
|
||
Heading,
|
||
Input,
|
||
LoadingView,
|
||
Row,
|
||
Screen,
|
||
Small,
|
||
Spacer,
|
||
} from "@/components/ui";
|
||
import { spacing } from "@/lib/theme";
|
||
|
||
/**
|
||
* Bläddra bland recept (spec §14): måltidskategori + fritextsök.
|
||
* Backend: GET /v1/recipes (search, mealType, sort=popularitet som standard).
|
||
*
|
||
* Kategorierna är de RIKTIGA måltidstyperna (MEAL_TYPES) och mappar 1:1 mot
|
||
* receptdatan – inga påhittade filter. Etiketterna återanvänder befintliga
|
||
* myday.mealType.*-nycklar (redan översatta till 12 språk). Tomma kategorier
|
||
* visar ett ärligt tomt-läge i stället för att gömmas, så listan speglar den
|
||
* faktiska katalogen och växer i takt med att fler recept skapas.
|
||
*/
|
||
|
||
interface BrowseRecipe {
|
||
id: string;
|
||
titleSv: string;
|
||
title?: string;
|
||
totalTimeMinutes: number | null;
|
||
mealTypes: string[];
|
||
nutritionPerPortion: { kcal?: number; proteinG?: number } | null;
|
||
ratingAverage: number | null;
|
||
ratingCount: number;
|
||
verificationStatus: string;
|
||
}
|
||
|
||
// De flesta kategorier filtrerar på måltidstyp (MEAL_TYPES). "Baka" filtrerar i
|
||
// stället på taggen "baking" (bullar/bröd/pajer) – därför bär varje kategori
|
||
// antingen ett mealType eller ett tag. Etiketter återanvänder myday.mealType.*
|
||
// där de finns; annars en egen svensk etikett.
|
||
const CATEGORIES: ReadonlyArray<{
|
||
key: string;
|
||
labelKey: string | null;
|
||
label?: string;
|
||
mealType?: string;
|
||
tag?: string;
|
||
glyph: string;
|
||
}> = [
|
||
{ key: "all", labelKey: "common.all", glyph: "🍴" },
|
||
{ key: "breakfast", labelKey: "myday.mealType.breakfast", mealType: "breakfast", glyph: "🥣" },
|
||
{ key: "lunch", labelKey: "myday.mealType.lunch", mealType: "lunch", glyph: "🥗" },
|
||
{ key: "dinner", labelKey: "myday.mealType.dinner", mealType: "dinner", glyph: "🍽️" },
|
||
{ key: "snack", labelKey: "myday.mealType.snack", mealType: "snack", glyph: "🍎" },
|
||
{ key: "dessert", labelKey: "myday.mealType.dessert", mealType: "dessert", glyph: "🍰" },
|
||
{ key: "baking", labelKey: "cat.baking", tag: "baking", glyph: "🥐" },
|
||
];
|
||
|
||
// Under-kategorier som visas när "Baka" är vald. Varje bakverk bär både taggen
|
||
// "baking" och en under-typ, så vi kan filtrera på under-typen direkt.
|
||
const BAKING_SUBS: ReadonlyArray<{ key: string; labelKey: string; tag?: string }> = [
|
||
{ key: "all", labelKey: "common.all" },
|
||
{ key: "bread", labelKey: "recipes.sub.bread", tag: "bread" },
|
||
{ key: "bun", labelKey: "recipes.sub.bun", tag: "bun" },
|
||
{ key: "pie", labelKey: "recipes.sub.pie", tag: "pie" },
|
||
{ key: "cake", labelKey: "recipes.sub.cake", tag: "cake" },
|
||
{ key: "cookie", labelKey: "recipes.sub.cookie", tag: "cookie" },
|
||
];
|
||
|
||
export default function BrowseRecipesScreen() {
|
||
// Förvald kategori via ?cat= (från förstasidans genvägar); annars "Alla".
|
||
const { cat } = useLocalSearchParams<{ cat?: string }>();
|
||
const initialCategory = CATEGORIES.some((c) => c.key === cat) ? (cat as string) : "all";
|
||
const [category, setCategory] = useState<string>(initialCategory);
|
||
const [sub, setSub] = useState<string>("all"); // under-kategori, bara relevant för Baka
|
||
const [search, setSearch] = useState("");
|
||
const term = search.trim();
|
||
|
||
const selectCategory = (key: string) => {
|
||
setCategory(key);
|
||
if (key !== "baking") setSub("all"); // nollställ under-val när man lämnar Baka
|
||
};
|
||
|
||
const query = useQuery({
|
||
queryKey: ["browse-recipes", category, sub, term],
|
||
queryFn: () => {
|
||
// Bygg query-strängen som resten av appen (template + encodeURIComponent).
|
||
const cat = CATEGORIES.find((c) => c.key === category);
|
||
let path = "/v1/recipes?limit=50";
|
||
if (cat?.mealType) path += `&mealType=${cat.mealType}`;
|
||
if (cat?.tag) path += `&tags=${cat.tag}`;
|
||
// Under Baka: filtrera på vald under-typ (bullar/bröd/…) i stället för "baking".
|
||
if (category === "baking" && sub !== "all") {
|
||
const subTag = BAKING_SUBS.find((s) => s.key === sub)?.tag;
|
||
if (subTag) path = `/v1/recipes?limit=50&tags=${subTag}`;
|
||
}
|
||
if (term) path += `&search=${encodeURIComponent(term)}`;
|
||
return api<{ recipes: BrowseRecipe[] }>(path);
|
||
},
|
||
// Behåll föregående lista medan en ny kategori/sökning laddar – ingen blink.
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
|
||
const recipes = query.data?.recipes ?? [];
|
||
|
||
return (
|
||
<Screen style={{ gap: spacing.md }}>
|
||
<Input
|
||
placeholder={t("recipes.searchPlaceholder")}
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
value={search}
|
||
onChangeText={setSearch}
|
||
/>
|
||
|
||
<Row style={{ flexWrap: "wrap" }}>
|
||
{CATEGORIES.map((c) => (
|
||
<Button
|
||
key={c.key}
|
||
label={`${c.glyph} ${c.labelKey ? t(c.labelKey as never) : ""}`}
|
||
variant={category === c.key ? "secondary" : "ghost"}
|
||
onPress={() => selectCategory(c.key)}
|
||
/>
|
||
))}
|
||
</Row>
|
||
|
||
{/* Under-kategorier: visas bara när Baka är valt (bröd/bullar/paj/…). */}
|
||
{category === "baking" && (
|
||
<Row style={{ flexWrap: "wrap" }}>
|
||
{BAKING_SUBS.map((s) => (
|
||
<Button
|
||
key={s.key}
|
||
label={t(s.labelKey)}
|
||
variant={sub === s.key ? "secondary" : "ghost"}
|
||
onPress={() => setSub(s.key)}
|
||
/>
|
||
))}
|
||
</Row>
|
||
)}
|
||
|
||
{query.isLoading ? (
|
||
<LoadingView />
|
||
) : query.isError ? (
|
||
<ErrorView onRetry={() => void query.refetch()} />
|
||
) : recipes.length === 0 ? (
|
||
<EmptyState text={term ? t("wte.emptySearch") : t("recipes.emptyCategory")} />
|
||
) : (
|
||
recipes.map((r) => (
|
||
<Card
|
||
key={r.id}
|
||
onPress={() => router.push(`/recipe/${r.id}`)}
|
||
style={{ gap: spacing.xs }}
|
||
>
|
||
<Heading>{r.title ?? r.titleSv}</Heading>
|
||
<Small>
|
||
{[
|
||
r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null,
|
||
r.nutritionPerPortion?.kcal != null
|
||
? `${Math.round(r.nutritionPerPortion.kcal)} kcal`
|
||
: null,
|
||
r.ratingCount > 0 && r.ratingAverage != null
|
||
? `★ ${r.ratingAverage.toFixed(1)} (${r.ratingCount})`
|
||
: null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ")}
|
||
</Small>
|
||
</Card>
|
||
))
|
||
)}
|
||
<Spacer />
|
||
</Screen>
|
||
);
|
||
}
|