Files
Cibello-app/apps/mobile/src/app/recipes.tsx
T
Claude fab8fa5ba9 fix(inkop+recept): delvis-kop, radera-vara, bort med plattformen-taggen
- 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.
2026-08-19 00:52:10 +00:00

187 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: 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>
);
}