420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
'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>
|
|
</>
|
|
);
|
|
}
|