fab8fa5ba9
- Inkopslista /complete: kopte (markerade) varor laggs i lagret OCH tas bort ur listan; OMARKERADE varor blir kvar (handla i flera butiker). Listan arkiveras forst nar den ar tom. Tidigare markerades HELA listan completed sa allt forsvann. - Ny radera-knapp (x) per vara med bekraftelse - ta bort nagot man angrat utan att kopa det (DELETE-endpointen fanns redan). - Bort med '✓ plattformen'-taggen pa recept + bladdra (satt pa nastan alla editorial-recept och sag efterblivet ut). Namn visas fortfarande bara for anvandarskapade recept (creatorDisplayName). Typecheck: api + mobil gron.
187 lines
6.6 KiB
TypeScript
187 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: null, 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: null, label: "Baka", 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; label: string; tag?: string }> = [
|
||
{ key: "all", label: "Alla" },
|
||
{ key: "bread", label: "Bröd", tag: "bread" },
|
||
{ key: "bun", label: "Bullar", tag: "bun" },
|
||
{ key: "pie", label: "Paj", tag: "pie" },
|
||
{ key: "cake", label: "Tårtor & kakor", tag: "cake" },
|
||
{ key: "cookie", label: "Småkakor", 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="Sök recept…"
|
||
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) : (c.label ?? "Alla")}`}
|
||
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={s.label}
|
||
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
|
||
? "Inga recept matchar din sökning."
|
||
: "Inga recept i den här kategorin än – de dyker upp när katalogen växer."
|
||
}
|
||
/>
|
||
) : (
|
||
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>
|
||
);
|
||
}
|