feat(recept): bladdra-skarm med maltidskategorier + fritextsok

- Ny skarm /recipes: kategori-chips (Alla/Frukost/Lunch/Middag/Mellanmal/
  Efterratt) + sok. Backend: befintliga GET /v1/recipes (mealType, search).
- Kategorierna ar de RIKTIGA MEAL_TYPES (1:1 mot datan) - inga pahittade
  filter. Etiketter ateranvander myday.mealType.* (redan 12 sprak).
- Tomma kategorier visar arligt tomt-lage (katalogen ar idag middagstung:
  ~240 middag, 8 frukost, 3 efterratt, 0 mellanmal) i stallet for att gommas.
- Ingang fran hemskarmen bredvid Sparade recept. Query-strang byggs som
  resten av appen (template + encodeURIComponent).
- Ingen ny tabb (baren har redan 5). Mobil typecheck gron.
This commit is contained in:
Claude
2026-08-18 23:03:21 +00:00
parent 2587ff539b
commit 7d0fca0dad
3 changed files with 150 additions and 3 deletions
+12 -3
View File
@@ -125,9 +125,18 @@ export default function HomeScreen() {
<QuickLink label={t("profile.title")} glyph="⚙️" onPress={() => router.push("/profile")} />
</Row>
<Card onPress={() => router.push("/saved-recipes")}>
<Body> Dina sparade recept</Body>
</Card>
<Row>
<View style={{ flex: 1 }}>
<Card onPress={() => router.push("/recipes")}>
<Body>📖 Bläddra bland recept</Body>
</Card>
</View>
<View style={{ flex: 1 }}>
<Card onPress={() => router.push("/saved-recipes")}>
<Body> Sparade recept</Body>
</Card>
</View>
</Row>
{/* Matlager städat, öppnas per plats eller som helhet (spec §8). */}
<Card>
+1
View File
@@ -86,6 +86,7 @@ export default function RootLayout() {
<Stack.Screen name="household" options={{ title: t("home.household"), presentation: "modal" }} />
<Stack.Screen name="memory" options={{ title: t("memory.title") }} />
<Stack.Screen name="saved-recipes" options={{ title: "Sparade recept" }} />
<Stack.Screen name="recipes" options={{ title: "Bläddra recept" }} />
<Stack.Screen name="kitchen" options={{ title: "Matlager" }} />
<Stack.Screen name="profile" options={{ title: t("profile.title"), presentation: "modal" }} />
<Stack.Screen
+137
View File
@@ -0,0 +1,137 @@
import { useState } from "react";
import { router } 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,
Tag,
} 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;
}
const CATEGORIES = [
{ key: "all", labelKey: null, glyph: "🍴" },
{ key: "breakfast", labelKey: "myday.mealType.breakfast", glyph: "🥣" },
{ key: "lunch", labelKey: "myday.mealType.lunch", glyph: "🥗" },
{ key: "dinner", labelKey: "myday.mealType.dinner", glyph: "🍽️" },
{ key: "snack", labelKey: "myday.mealType.snack", glyph: "🍎" },
{ key: "dessert", labelKey: "myday.mealType.dessert", glyph: "🍰" },
] as const;
export default function BrowseRecipesScreen() {
const [category, setCategory] = useState<string>("all");
const [search, setSearch] = useState("");
const term = search.trim();
const query = useQuery({
queryKey: ["browse-recipes", category, term],
queryFn: () => {
// Bygg query-strängen som resten av appen (template + encodeURIComponent).
let path = "/v1/recipes?limit=50";
if (category !== "all") path += `&mealType=${category}`;
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) : "Alla"}`}
variant={category === c.key ? "secondary" : "ghost"}
onPress={() => setCategory(c.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 }}
>
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Heading>{r.title ?? r.titleSv}</Heading>
{r.verificationStatus === "editorial" && <Tag label="✓ plattformen" tone="success" />}
</Row>
<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>
);
}