Phases 6-7

This commit is contained in:
Aerilyn Weber 2026-05-14 14:47:23 +09:00
parent 76a516a417
commit 029940b079
111 changed files with 17247 additions and 447 deletions

View 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>
</>
);
}