Files
Cibello-app/apps/mobile/src/app/recipes.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

181 lines
6.6 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 { 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>
);
}