Phases 6-7
This commit is contained in:
parent
76a516a417
commit
029940b079
111 changed files with 17247 additions and 447 deletions
420
packages/web/src/app/(dashboard)/recipes/[id]/page.tsx
Normal file
420
packages/web/src/app/(dashboard)/recipes/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { getRecipe, scaleRecipe } from '@/services/recipes';
|
||||
import { NutritionWarning } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
import type { RecipeResponseSchema } from '@meshitrack/shared';
|
||||
|
||||
type Recipe = z.infer<typeof RecipeResponseSchema>;
|
||||
|
||||
const WARNING_LABELS: Partial<Record<string, string>> = {
|
||||
[NutritionWarning.HIGH_CALORIES]: 'High calories (>800 kcal/serving)',
|
||||
[NutritionWarning.HIGH_SODIUM]: 'High sodium (>1500mg/serving)',
|
||||
[NutritionWarning.HIGH_SUGAR]: 'High sugar (>25g/serving)',
|
||||
[NutritionWarning.HIGH_SATURATED_FAT]: 'High saturated fat (>13g/serving)',
|
||||
[NutritionWarning.LOW_PROTEIN]: 'Low protein (<10g/serving)',
|
||||
[NutritionWarning.LOW_FIBER]: 'Low fiber (<3g/serving)',
|
||||
[NutritionWarning.HIGH_CHOLESTEROL]: 'High cholesterol (>200mg/serving)',
|
||||
};
|
||||
|
||||
function formatTime(minutes?: number): string {
|
||||
if (!minutes) return '';
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
function NutritionPanel({
|
||||
nutrition,
|
||||
label,
|
||||
}: {
|
||||
nutrition: Recipe['perServingNutrition'];
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ink-muted)',
|
||||
marginBottom: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 16px' }}>
|
||||
<NutrRow label="Calories" value={`${Math.round(nutrition.calories)} kcal`} bold />
|
||||
<NutrRow label="Protein" value={`${nutrition.protein.toFixed(1)}g`} />
|
||||
<NutrRow label="Carbs" value={`${nutrition.carbs.toFixed(1)}g`} />
|
||||
<NutrRow label="Fat" value={`${nutrition.fat.toFixed(1)}g`} />
|
||||
{nutrition.fiber !== undefined && (
|
||||
<NutrRow label="Fiber" value={`${nutrition.fiber.toFixed(1)}g`} />
|
||||
)}
|
||||
{nutrition.sugar !== undefined && (
|
||||
<NutrRow label="Sugar" value={`${nutrition.sugar.toFixed(1)}g`} />
|
||||
)}
|
||||
{nutrition.sodium !== undefined && (
|
||||
<NutrRow label="Sodium" value={`${Math.round(nutrition.sodium)}mg`} />
|
||||
)}
|
||||
{nutrition.saturatedFat !== undefined && (
|
||||
<NutrRow label="Sat. fat" value={`${nutrition.saturatedFat.toFixed(1)}g`} />
|
||||
)}
|
||||
{nutrition.cholesterol !== undefined && (
|
||||
<NutrRow label="Cholesterol" value={`${Math.round(nutrition.cholesterol)}mg`} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NutrRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<span style={{ fontSize: 13, color: 'var(--ink-muted)' }}>{label}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--ink)', fontWeight: bold ? 600 : 400 }}>
|
||||
{value}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecipeDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [scaledServings, setScaledServings] = useState<number | null>(null);
|
||||
const [scaledRecipe, setScaledRecipe] = useState<Recipe | null>(null);
|
||||
const [scaling, setScaling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!householdId || !params.id) return;
|
||||
setLoading(true);
|
||||
getRecipe(householdId, params.id)
|
||||
.then((r) => {
|
||||
setRecipe(r);
|
||||
setScaledServings(r.servings);
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load recipe'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [householdId, params.id]);
|
||||
|
||||
async function handleScale() {
|
||||
if (!householdId || !recipe || !scaledServings) return;
|
||||
if (scaledServings === recipe.servings) {
|
||||
setScaledRecipe(null);
|
||||
return;
|
||||
}
|
||||
setScaling(true);
|
||||
try {
|
||||
const result = await scaleRecipe(householdId, recipe._id, { targetServings: scaledServings });
|
||||
setScaledRecipe(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to scale recipe');
|
||||
} finally {
|
||||
setScaling(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionLoading || loading) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Recipe" crumbs={['Recipes']} />
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
<div
|
||||
style={{
|
||||
height: 40,
|
||||
width: 200,
|
||||
background: 'var(--bg-elev)',
|
||||
borderRadius: 8,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !recipe) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Recipe" crumbs={['Recipes']} />
|
||||
<div style={{ padding: '28px 32px', color: 'var(--danger)', fontSize: 14 }}>
|
||||
{error || 'Recipe not found'}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const displayRecipe = scaledRecipe ?? recipe;
|
||||
const time = recipe.totalTime ?? (recipe.prepTime ?? 0) + (recipe.cookTime ?? 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader
|
||||
title={recipe.name}
|
||||
subtitle={recipe.cuisine ?? undefined}
|
||||
crumbs={['Recipes', recipe.name]}
|
||||
/>
|
||||
<div style={{ padding: '28px 32px 56px', maxWidth: 1100 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 320px', gap: 32 }}>
|
||||
{/* Main content */}
|
||||
<div>
|
||||
{/* Meta */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14, color: 'var(--ink-muted)' }}>
|
||||
{recipe.servings} servings
|
||||
</span>
|
||||
{time > 0 && (
|
||||
<span style={{ fontSize: 14, color: 'var(--ink-muted)' }}>{formatTime(time)}</span>
|
||||
)}
|
||||
{recipe.isFavorite && (
|
||||
<span style={{ fontSize: 14, color: 'var(--brand)' }}>Starred</span>
|
||||
)}
|
||||
<Link
|
||||
href={`/recipes/${recipe._id}/edit`}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 13,
|
||||
color: 'var(--brand)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recipe.description && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: 'var(--ink-muted)',
|
||||
marginBottom: 24,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{recipe.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{recipe.warnings.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--danger-soft, #fee)',
|
||||
border: '1px solid var(--danger)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: '12px 16px',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, color: 'var(--danger)' }}
|
||||
>
|
||||
Nutritional alerts
|
||||
</p>
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{recipe.warnings.map((w) => (
|
||||
<li key={w} style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 2 }}>
|
||||
{WARNING_LABELS[w] ?? w}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ingredients */}
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Ingredients</h2>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{displayRecipe.ingredients.map((ing, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{ing.isOptional && (
|
||||
<span style={{ color: 'var(--ink-muted)', fontSize: 12 }}>(optional) </span>
|
||||
)}
|
||||
{ing.productName}
|
||||
{ing.preparation && (
|
||||
<span style={{ color: 'var(--ink-muted)' }}>, {ing.preparation}</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ color: 'var(--ink-muted)', marginLeft: 16 }}>
|
||||
{ing.originalQuantity != null && ing.originalUnit
|
||||
? `${ing.originalQuantity} ${ing.originalUnit}`
|
||||
: `${ing.quantity} ${ing.unit}`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Steps */}
|
||||
{recipe.steps.length > 0 && (
|
||||
<section>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Instructions</h2>
|
||||
<ol style={{ paddingLeft: 20, margin: 0 }}>
|
||||
{recipe.steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((step) => (
|
||||
<li key={step.order} style={{ marginBottom: 16 }}>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, margin: 0 }}>
|
||||
{step.instruction}
|
||||
</p>
|
||||
{step.duration && (
|
||||
<p style={{ fontSize: 12, color: 'var(--ink-muted)', marginTop: 4 }}>
|
||||
{formatTime(step.duration)}
|
||||
</p>
|
||||
)}
|
||||
{step.tip && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--brand)',
|
||||
marginTop: 4,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
Tip: {step.tip}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div>
|
||||
{/* Scale */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>Scale Recipe</h3>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={scaledServings ?? recipe.servings}
|
||||
onChange={(e) => setScaledServings(Number(e.target.value))}
|
||||
style={{
|
||||
width: 70,
|
||||
padding: '6px 8px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--ink-muted)' }}>servings</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScale}
|
||||
disabled={scaling}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: 'var(--brand)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
opacity: scaling ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{scaling ? '...' : 'Scale'}
|
||||
</button>
|
||||
</div>
|
||||
{scaledRecipe && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScaledRecipe(null);
|
||||
setScaledServings(recipe.servings);
|
||||
}}
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 12,
|
||||
color: 'var(--ink-muted)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Reset to original
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nutrition */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<NutritionPanel
|
||||
nutrition={displayRecipe.perServingNutrition}
|
||||
label={`Per serving (${displayRecipe.servings} total)`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<NutritionPanel
|
||||
nutrition={displayRecipe.totalNutrition}
|
||||
label="Total (all servings)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue