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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user