Mobile-first Web-App fuer persoenliches Health-, Ernaehrungs-, Aktivitaets- und Einkaufs-Tracking im Home-Lab-Betrieb. Stack: pnpm Monorepo - Next.js 16 + React 19 + Tailwind (apps/admin) - Hono + tRPC + Better Auth mit 2FA und Passkeys (packages/api) - PostgreSQL + Drizzle ORM (packages/db) - Docker fuer Postgres und pgAdmin. Module: - Auth mit Login, 2FA (TOTP + Backup-Codes), Passkeys und Admin-Recovery - Tagesansicht mit Essen, Trinken, Gewicht, Aktivitaeten und Coach-Hinweisen - Lebensmittelkatalog mit Naehrwerten, Portionen, OCR/Barcode/Open Food Facts - Rezepte mit Live-Naehrwertberechnung und Community-Vorschlaegen - Essensplanung und Wochenbudget mit Sportbonus - Einkaufslisten mit Maerkten und Laufweg-Sortierung - Hellfireclub mit Aktivitaetspunkten und gemeinsamer Aktivitaetsplanung - Wearables-Vorbereitung (Fitbit / Google Fit / Health Connect) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
475 lines
23 KiB
TypeScript
475 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { PlusIcon, StarIcon } from "@heroicons/react/24/outline";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { FormField, inputClassName } from "@/app/_components/form-field";
|
|
import {
|
|
CatalogFood,
|
|
PortionUnit,
|
|
Recipe,
|
|
amountToGrams,
|
|
calcRecipe,
|
|
classifyRecipe,
|
|
describeIngredientAmount,
|
|
unitLabel,
|
|
} from "@/lib/hellth-data";
|
|
import { trpc } from "@/lib/trpc";
|
|
|
|
const recipeUnits: PortionUnit[] = ["g", "ml", "tsp", "tbsp", "piece", "slice", "cup", "pack", "bottle"];
|
|
|
|
function toNumber(value: unknown) {
|
|
return Number(value) || 0;
|
|
}
|
|
|
|
function toCatalogFood(food: {
|
|
id: string;
|
|
name: string;
|
|
brand: string | null;
|
|
category: string;
|
|
kcalPer100g: unknown;
|
|
proteinPer100g: unknown;
|
|
carbsPer100g: unknown;
|
|
fatPer100g: unknown;
|
|
servingG: unknown;
|
|
preferredUnit: PortionUnit | null;
|
|
densityGPerMl: unknown | null;
|
|
source: "seed" | "manual" | "openfoodfacts" | "ocr" | "community";
|
|
barcode: string | null;
|
|
availableEverywhere: boolean;
|
|
portions?: Array<{
|
|
id: string;
|
|
label: string;
|
|
unit: PortionUnit;
|
|
amount: unknown;
|
|
gramsEquivalent: unknown;
|
|
}>;
|
|
}): CatalogFood {
|
|
return {
|
|
id: food.id,
|
|
name: food.name,
|
|
brand: food.brand ?? undefined,
|
|
category: food.category,
|
|
kcalPer100g: toNumber(food.kcalPer100g),
|
|
proteinPer100g: toNumber(food.proteinPer100g),
|
|
carbsPer100g: toNumber(food.carbsPer100g),
|
|
fatPer100g: toNumber(food.fatPer100g),
|
|
servingG: toNumber(food.servingG) || 100,
|
|
preferredUnit: food.preferredUnit ?? "g",
|
|
densityGPerMl: food.densityGPerMl == null ? undefined : toNumber(food.densityGPerMl),
|
|
portions: food.portions?.map((portion) => ({
|
|
id: portion.id,
|
|
label: portion.label,
|
|
unit: portion.unit,
|
|
amount: toNumber(portion.amount),
|
|
gramsEquivalent: toNumber(portion.gramsEquivalent),
|
|
})),
|
|
source: food.source,
|
|
barcode: food.barcode ?? undefined,
|
|
availableEverywhere: food.availableEverywhere,
|
|
};
|
|
}
|
|
|
|
function recipeFromApi(recipe: {
|
|
id: string;
|
|
name: string;
|
|
portions: number;
|
|
minutes: number;
|
|
notes: string | null;
|
|
ingredients: Array<{
|
|
foodId: string;
|
|
amountG: unknown;
|
|
displayAmount: unknown | null;
|
|
displayUnit: PortionUnit | null;
|
|
portionId: string | null;
|
|
}>;
|
|
}): Recipe {
|
|
return {
|
|
id: recipe.id,
|
|
name: recipe.name,
|
|
portions: recipe.portions,
|
|
minutes: recipe.minutes,
|
|
notes: recipe.notes ?? undefined,
|
|
ingredients: recipe.ingredients.map((ingredient) => ({
|
|
foodId: ingredient.foodId,
|
|
amountG: toNumber(ingredient.amountG),
|
|
amount: ingredient.displayAmount == null ? undefined : toNumber(ingredient.displayAmount),
|
|
unit: ingredient.displayUnit ?? undefined,
|
|
portionId: ingredient.portionId ?? undefined,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function MacroBar({ distribution }: { distribution: { carbs: number; fat: number; protein: number } }) {
|
|
return (
|
|
<div className="flex h-3 overflow-hidden rounded-full bg-gray-100">
|
|
<div className="bg-amber-400" style={{ width: `${distribution.carbs}%` }} />
|
|
<div className="bg-blue-500" style={{ width: `${distribution.fat}%` }} />
|
|
<div className="bg-emerald-500" style={{ width: `${distribution.protein}%` }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RecipeNutritionInsight({ recipe, foods }: { recipe: Recipe; foods: CatalogFood[] }) {
|
|
const totals = calcRecipe(recipe, foods);
|
|
const classification = classifyRecipe(recipe, foods);
|
|
|
|
return (
|
|
<div className="rounded-3xl bg-gray-50 p-4">
|
|
<div className="flex flex-wrap gap-2">
|
|
{classification.tags.map((tag) => (
|
|
<span key={tag} className="rounded-full bg-white px-3 py-1 text-xs font-black text-gray-600">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="mt-4">
|
|
<MacroBar distribution={classification.distribution} />
|
|
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
|
|
<div><p className="text-lg font-black text-gray-950">{totals.kcal}</p><p className="text-xs font-bold text-gray-400">kcal</p></div>
|
|
<div><p className="text-lg font-black text-gray-950">{totals.carbs} g</p><p className="text-xs font-bold text-gray-400">KH {classification.distribution.carbs}%</p></div>
|
|
<div><p className="text-lg font-black text-gray-950">{totals.fat} g</p><p className="text-xs font-bold text-gray-400">Fett {classification.distribution.fat}%</p></div>
|
|
<div><p className="text-lg font-black text-gray-950">{totals.protein} g</p><p className="text-xs font-bold text-gray-400">Eiweiß {classification.distribution.protein}%</p></div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 grid gap-2">
|
|
{classification.criteria.map((criterion) => (
|
|
<div key={criterion.label} className="flex items-center justify-between gap-3 text-sm font-bold">
|
|
<span className={criterion.ok ? "text-emerald-700" : "text-amber-700"}>{criterion.ok ? "OK" : "!"} {criterion.label}</span>
|
|
<span className="text-gray-400">{criterion.detail}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function RezeptePage() {
|
|
const utils = trpc.useUtils();
|
|
const [name, setName] = useState("");
|
|
const [notes, setNotes] = useState("");
|
|
const [minutes, setMinutes] = useState("20");
|
|
const [portions, setPortions] = useState("2");
|
|
const [mealPrepDays, setMealPrepDays] = useState("1");
|
|
const [foodId, setFoodId] = useState("");
|
|
const [amount, setAmount] = useState("100");
|
|
const [unit, setUnit] = useState<PortionUnit>("g");
|
|
const [portionId, setPortionId] = useState("");
|
|
const [draftIngredients, setDraftIngredients] = useState<Recipe["ingredients"]>([]);
|
|
const [status, setStatus] = useState<string | null>(null);
|
|
const [suggestMealPrepDays, setSuggestMealPrepDays] = useState("0");
|
|
const [suggestCooksPerDay, setSuggestCooksPerDay] = useState("1");
|
|
|
|
const foodsQuery = trpc.health.listFoods.useQuery({ query: "" });
|
|
const recipesQuery = trpc.health.listRecipes.useQuery({ query: "" });
|
|
const recommendationsQuery = trpc.health.recipeRecommendations.useQuery({
|
|
mealPrepDays: Number(suggestMealPrepDays) || 0,
|
|
cooksPerDay: Number(suggestCooksPerDay) || 1,
|
|
limit: 8,
|
|
});
|
|
const submissionsQuery = trpc.health.myRecipeSubmissions.useQuery();
|
|
const saveRecipeMutation = trpc.health.saveRecipe.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.health.listRecipes.invalidate();
|
|
await utils.health.recipeRecommendations.invalidate();
|
|
setName("");
|
|
setNotes("");
|
|
setDraftIngredients([]);
|
|
setStatus("Rezept gespeichert.");
|
|
},
|
|
});
|
|
const submitRecipeMutation = trpc.health.submitRecipeSuggestion.useMutation({
|
|
onSuccess: async (result) => {
|
|
await utils.health.myRecipeSubmissions.invalidate();
|
|
await utils.health.recipeRecommendations.invalidate();
|
|
await utils.health.clubLeaderboard.invalidate();
|
|
await utils.health.activityWeek.invalidate();
|
|
setName("");
|
|
setNotes("");
|
|
setDraftIngredients([]);
|
|
setStatus(`Rezeptvorschlag wurde zur Prüfung eingereicht. +${result.points} Hellfireclub-Punkte.`);
|
|
},
|
|
});
|
|
|
|
const foods = useMemo(() => (foodsQuery.data ?? []).map(toCatalogFood), [foodsQuery.data]);
|
|
const recipes = useMemo(() => (recipesQuery.data ?? []).map(recipeFromApi), [recipesQuery.data]);
|
|
const foodById = useMemo(() => new Map(foods.map((food) => [food.id, food])), [foods]);
|
|
const selectedFood = foodId ? foodById.get(foodId) : foods[0];
|
|
const draftRecipe = useMemo<Recipe>(() => ({
|
|
id: "draft",
|
|
name: name.trim() || "Entwurf",
|
|
portions: Number(portions) || 1,
|
|
minutes: Number(minutes) || 0,
|
|
notes: notes.trim() || undefined,
|
|
ingredients: draftIngredients,
|
|
}), [draftIngredients, minutes, name, notes, portions]);
|
|
|
|
useEffect(() => {
|
|
if (foodId || !foods[0]) return;
|
|
setFoodId(foods[0].id);
|
|
setUnit(foods[0].preferredUnit ?? "g");
|
|
}, [foodId, foods]);
|
|
|
|
function recipePayload() {
|
|
return {
|
|
name: name.trim(),
|
|
portions: Number(portions) || 1,
|
|
minutes: Number(minutes) || 0,
|
|
notes: notes.trim() || null,
|
|
mealPrepDays: Number(mealPrepDays) || 0,
|
|
ingredients: draftIngredients.map((ingredient, index) => ({
|
|
foodId: ingredient.foodId,
|
|
amountG: ingredient.amountG,
|
|
displayAmount: ingredient.amount ?? null,
|
|
displayUnit: ingredient.unit ?? null,
|
|
portionId: ingredient.portionId ?? null,
|
|
sortOrder: index,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function addIngredient() {
|
|
if (!selectedFood) return;
|
|
const amountNumber = Number(amount.replace(",", ".")) || 1;
|
|
const amountG = amountToGrams(selectedFood, amountNumber, unit, portionId || undefined);
|
|
setDraftIngredients((current) => [
|
|
...current,
|
|
{
|
|
foodId: selectedFood.id,
|
|
amount: amountNumber,
|
|
unit,
|
|
portionId: portionId || undefined,
|
|
amountG: Math.round(amountG * 10) / 10,
|
|
},
|
|
]);
|
|
}
|
|
|
|
function saveRecipe() {
|
|
if (!name.trim() || draftIngredients.length === 0) return;
|
|
saveRecipeMutation.mutate({ ...recipePayload(), isPublic: false });
|
|
}
|
|
|
|
function submitRecipeSuggestion() {
|
|
if (!name.trim() || draftIngredients.length === 0) return;
|
|
submitRecipeMutation.mutate(recipePayload());
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
|
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
|
<p className="text-sm font-black uppercase text-[#0F766E]">Health-API</p>
|
|
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Gerichte & Rezepte</h1>
|
|
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
|
|
Zutaten aus dem Lebensmittelkatalog, Alltagseinheiten, Portionen, Nährwerte und Meal-Prep-Notizen.
|
|
</p>
|
|
</header>
|
|
|
|
<section className="grid gap-4 xl:grid-cols-[1fr_0.85fr]">
|
|
<div className="space-y-4">
|
|
{recipes.map((recipe) => {
|
|
const totals = calcRecipe(recipe, foods);
|
|
return (
|
|
<article key={recipe.id} className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-2xl font-black text-gray-950">{recipe.name}</p>
|
|
<p className="mt-1 text-sm font-bold text-gray-400">
|
|
{recipe.minutes} min · {recipe.portions} Portionen
|
|
{recipe.notes ? ` · ${recipe.notes}` : ""}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2">
|
|
<div className="rounded-full bg-green-50 px-4 py-2 text-sm font-black text-green-700">{totals.kcal} kcal</div>
|
|
<div className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-4 py-2 text-sm font-black text-amber-700">
|
|
<StarIcon className="h-4 w-4" />
|
|
Bewertung folgt
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-5 grid grid-cols-3 gap-3 text-center">
|
|
<div className="rounded-2xl bg-gray-50 p-3"><p className="text-xl font-black">{totals.carbs} g</p><p className="text-xs font-bold text-gray-400">KH</p></div>
|
|
<div className="rounded-2xl bg-gray-50 p-3"><p className="text-xl font-black">{totals.fat} g</p><p className="text-xs font-bold text-gray-400">Fett</p></div>
|
|
<div className="rounded-2xl bg-gray-50 p-3"><p className="text-xl font-black">{totals.protein} g</p><p className="text-xs font-bold text-gray-400">Protein</p></div>
|
|
</div>
|
|
<div className="mt-5">
|
|
<RecipeNutritionInsight recipe={recipe} foods={foods} />
|
|
</div>
|
|
<div className="mt-5 space-y-2">
|
|
{recipe.ingredients.map((ingredient, index) => (
|
|
<div key={`${ingredient.foodId}-${index}`} className="flex justify-between gap-3 text-sm font-bold text-gray-500">
|
|
<span className="min-w-0 truncate">{foodById.get(ingredient.foodId)?.name ?? "Unbekannt"}</span>
|
|
<span>{describeIngredientAmount(ingredient)} (~{Math.round(ingredient.amountG)} g)</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
{!recipes.length && !recipesQuery.isLoading ? (
|
|
<p className="rounded-[2rem] bg-white p-6 text-sm font-bold text-gray-400 shadow-sm ring-1 ring-gray-100">
|
|
Noch keine Rezepte gespeichert.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<aside className="space-y-4">
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<h2 className="text-xl font-black text-gray-950">Vorschläge für dich</h2>
|
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-black uppercase text-gray-400">Mealprep Tage</span>
|
|
<select value={suggestMealPrepDays} onChange={(event) => setSuggestMealPrepDays(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold">
|
|
<option value="0">kein Fokus</option>
|
|
<option value="2">2 Tage</option>
|
|
<option value="3">3 Tage</option>
|
|
</select>
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs font-black uppercase text-gray-400">Kochen/Tag</span>
|
|
<select value={suggestCooksPerDay} onChange={(event) => setSuggestCooksPerDay(event.target.value)} className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-sm font-bold">
|
|
<option value="1">1x</option>
|
|
<option value="2">2x</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div className="mt-4 space-y-3">
|
|
{(recommendationsQuery.data ?? []).map((recommendation) => (
|
|
<article key={recommendation.id} className="rounded-3xl bg-gray-50 p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="font-black text-gray-950">{recommendation.name}</p>
|
|
<p className="mt-1 text-xs font-bold text-gray-400">
|
|
{recommendation.minutes} min · Mealprep {recommendation.mealPrepDays || 1} Tag(e)
|
|
</p>
|
|
</div>
|
|
<span className="rounded-full bg-white px-3 py-1 text-xs font-black text-[#0F766E]">{recommendation.score}</span>
|
|
</div>
|
|
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
|
|
<div><p className="text-sm font-black">{recommendation.nutrition.kcal}</p><p className="text-[10px] font-bold text-gray-400">kcal</p></div>
|
|
<div><p className="text-sm font-black">{recommendation.nutrition.carbs} g</p><p className="text-[10px] font-bold text-gray-400">KH</p></div>
|
|
<div><p className="text-sm font-black">{recommendation.nutrition.fat} g</p><p className="text-[10px] font-bold text-gray-400">Fett</p></div>
|
|
<div><p className="text-sm font-black">{recommendation.nutrition.protein} g</p><p className="text-[10px] font-bold text-gray-400">Eiweiß</p></div>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{[...recommendation.tags, ...recommendation.reasons].slice(0, 5).map((tag) => (
|
|
<span key={tag} className="rounded-full bg-white px-3 py-1 text-xs font-bold text-gray-500">{tag}</span>
|
|
))}
|
|
</div>
|
|
</article>
|
|
))}
|
|
{!recommendationsQuery.isFetching && !(recommendationsQuery.data ?? []).length ? (
|
|
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">
|
|
Noch keine passenden Vorschläge. Prüfe deine Profil-Präferenzen oder lege mehr Rezepte an.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<h2 className="text-xl font-black text-gray-950">Neues Gericht</h2>
|
|
<div className="mt-4 grid gap-3">
|
|
<FormField label="Name des Gerichts">
|
|
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="z. B. Protein Bowl" className={inputClassName} />
|
|
</FormField>
|
|
<FormField label="Notiz">
|
|
<textarea value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Reste, Meal-Prep-Hinweis oder Zubereitungsidee" className={`${inputClassName} min-h-20`} />
|
|
</FormField>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<FormField label="Minuten">
|
|
<input value={minutes} onChange={(event) => setMinutes(event.target.value)} placeholder="20" className={inputClassName} />
|
|
</FormField>
|
|
<FormField label="Portionen">
|
|
<input value={portions} onChange={(event) => setPortions(event.target.value)} placeholder="2" className={inputClassName} />
|
|
</FormField>
|
|
<FormField label="Mealprep">
|
|
<input value={mealPrepDays} onChange={(event) => setMealPrepDays(event.target.value)} placeholder="Tage" className={inputClassName} />
|
|
</FormField>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<FormField label="Lebensmittel">
|
|
<select
|
|
value={foodId}
|
|
onChange={(event) => {
|
|
const nextFood = foodById.get(event.target.value);
|
|
setFoodId(event.target.value);
|
|
setUnit(nextFood?.preferredUnit ?? "g");
|
|
setPortionId("");
|
|
}}
|
|
className={inputClassName}
|
|
>
|
|
{foods.map((food) => <option key={food.id} value={food.id}>{food.name}</option>)}
|
|
</select>
|
|
</FormField>
|
|
<div className="grid grid-cols-[1fr_112px_48px] gap-2">
|
|
<FormField label="Menge">
|
|
<input value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="100" className={inputClassName} />
|
|
</FormField>
|
|
<FormField label="Einheit">
|
|
<select value={unit} onChange={(event) => setUnit(event.target.value as PortionUnit)} className={inputClassName}>
|
|
{recipeUnits.map((recipeUnit) => <option key={recipeUnit} value={recipeUnit}>{unitLabel(recipeUnit)}</option>)}
|
|
</select>
|
|
</FormField>
|
|
<button type="button" onClick={addIngredient} className="grid place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Zutat hinzufügen" title="Zutat hinzufügen">
|
|
<PlusIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
{selectedFood?.portions?.length ? (
|
|
<FormField label="Portionsgröße">
|
|
<select value={portionId} onChange={(event) => setPortionId(event.target.value)} className={inputClassName}>
|
|
<option value="">Freie Menge verwenden</option>
|
|
{selectedFood.portions.map((portion) => (
|
|
<option key={portion.id} value={portion.id}>{portion.label} ({portion.amount} {unitLabel(portion.unit)} = {portion.gramsEquivalent} g)</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
) : null}
|
|
</div>
|
|
<div className="rounded-3xl bg-gray-50 p-4">
|
|
{draftIngredients.length ? draftIngredients.map((ingredient, index) => (
|
|
<div key={`${ingredient.foodId}-${index}`} className="flex justify-between gap-3 py-1 text-sm font-bold text-gray-500">
|
|
<span className="min-w-0 truncate">{foodById.get(ingredient.foodId)?.name}</span>
|
|
<span>{describeIngredientAmount(ingredient)} (~{Math.round(ingredient.amountG)} g)</span>
|
|
</div>
|
|
)) : <p className="text-sm font-bold text-gray-400">Noch keine Zutaten.</p>}
|
|
</div>
|
|
{draftIngredients.length ? <RecipeNutritionInsight recipe={draftRecipe} foods={foods} /> : null}
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
<button type="button" onClick={saveRecipe} disabled={saveRecipeMutation.isPending || !foods.length} className="rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50">
|
|
Privat speichern
|
|
</button>
|
|
<button type="button" onClick={submitRecipeSuggestion} disabled={submitRecipeMutation.isPending || !foods.length} className="rounded-full bg-[#0F766E] px-4 py-4 text-sm font-black text-white disabled:opacity-50">
|
|
Zur Prüfung einreichen
|
|
</button>
|
|
</div>
|
|
{status ? <p className="rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">{status}</p> : null}
|
|
{saveRecipeMutation.error ? <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{saveRecipeMutation.error.message}</p> : null}
|
|
{submitRecipeMutation.error ? <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{submitRecipeMutation.error.message}</p> : null}
|
|
{submissionsQuery.data?.length ? (
|
|
<div className="rounded-3xl bg-gray-50 p-4">
|
|
<h3 className="text-sm font-black text-gray-950">Meine Vorschläge</h3>
|
|
<div className="mt-3 space-y-2">
|
|
{submissionsQuery.data.slice(0, 5).map((submission) => (
|
|
<div key={submission.id} className="flex items-center justify-between gap-3 text-sm font-bold text-gray-500">
|
|
<span className="min-w-0 truncate">{submission.name}</span>
|
|
<span className={`rounded-full px-3 py-1 text-xs ${
|
|
submission.status === "accepted"
|
|
? "bg-emerald-100 text-emerald-700"
|
|
: submission.status === "rejected"
|
|
? "bg-red-100 text-red-700"
|
|
: "bg-amber-100 text-amber-700"
|
|
}`}>
|
|
{submission.status === "accepted" ? "akzeptiert" : submission.status === "rejected" ? "abgelehnt" : "in Prüfung"}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|