Build the mobile recipe detail screen (mock 1k)
Replace the /dinner/:id placeholder with DinnerDetailPage, reading
GET /api/v1/dinners/{id} through useDinnerDetail: a 250 px full-bleed
washed photo (striped placeholder without one) with a round back button
that goes Home from a deep link; a sheet overlapping by 28 px with the
Regular or Side quest, difficulty and time tags, the 28 px Bagel title,
the teal "Why it was picked" box, ingredients in recipe order with
quantities right-aligned (raw_text when a line has no name), numbered
steps and "Recipe from {site}" when the server names a site. The API
sends no source link, so the credit is text, not a link.
A dinner whose recipe was not found links to Settings -> Regular
dinners instead of showing a recipe; a pending one says it is still
searching and is read again until it lands. A 404 is a "Dinner not
found" state; other failures retry. The sticky bar renders "Add to this
week" and the heart (showing is_favorite) unavailable until task
07423ba7 wires them. IconButton gains a 52 px size for the heart.
Maestro task 70a903ef-107c-444e-b3b0-fb3d37e8fb7d.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ const SCREENS = {
|
||||
'/plan/shopping': true,
|
||||
'/plan/shopping/archive': true,
|
||||
'/discover': true,
|
||||
'/dinner/:id': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
'/settings/household': true,
|
||||
@@ -299,7 +300,7 @@ describe('App offline bootstrap', () => {
|
||||
|
||||
it.each([
|
||||
['/quick', 'Quick dinners'],
|
||||
['/dinner/01J9ZK', 'Dinner'],
|
||||
['/cook-from-pantry', 'Cook from what I have'],
|
||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||
await visitOnline()
|
||||
goOffline()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||
@@ -45,6 +46,7 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
'/discover': <DiscoverPage />,
|
||||
'/dinner/:id': <DinnerDetailPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/* Mock 1k. On mobile the photo and the sheet run full-bleed past the
|
||||
shell's 20 px side padding; from tablet up they sit in one column. */
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tablet,
|
||||
.desktop {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.mobile .hero,
|
||||
.mobile .skeletonHero {
|
||||
margin: 0 -20px;
|
||||
}
|
||||
|
||||
.tablet .hero,
|
||||
.desktop .hero {
|
||||
border-radius: var(--radius-card) var(--radius-card) 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Without a dinner to show, the back button sits alone above the message. */
|
||||
.backRow {
|
||||
padding-top: calc(16px + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.backRow .back {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.back {
|
||||
position: absolute;
|
||||
top: calc(16px + env(safe-area-inset-top));
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
/* The sheet overlaps the photo by 28 px. */
|
||||
.sheet {
|
||||
position: relative;
|
||||
margin-top: -28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 22px 20px 0;
|
||||
border-radius: 32px 32px 0 0;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.mobile .sheet {
|
||||
margin-left: -20px;
|
||||
margin-right: -20px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.why {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-900);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0 0 4px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.ingredients,
|
||||
.steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ingredient {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.ingredient:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.note,
|
||||
.qty {
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.qty {
|
||||
flex: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.number {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent-100);
|
||||
color: var(--color-accent-800);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.source {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.missing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 6px 0 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-800);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.missing p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* A 44 px tall tap target for the link. */
|
||||
.fix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
color: var(--color-accent-700);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Held at the bottom of the screen. Sticky offsets count from inside the
|
||||
scroll area's padding, which on mobile already keeps clear of the nav bar. */
|
||||
.bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
padding: 10px 0;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.bar > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.filled {
|
||||
color: var(--color-accent);
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.skeletonHero {
|
||||
height: 250px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.skeletonTitle {
|
||||
height: 32px;
|
||||
width: 70%;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProviders } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { DinnerDetailPage, REGULAR_DINNERS } from './DinnerDetailPage.tsx'
|
||||
|
||||
const DINNER = '/api/v1/dinners/d1'
|
||||
|
||||
// detail is a dinner as GET /api/v1/dinners/{id} sends it: mock 1k's
|
||||
// Korean beef bowls, a Side quest grounded in a page that named its site.
|
||||
function detail(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'd1',
|
||||
name: 'Korean beef bowls',
|
||||
status: 'quest_log',
|
||||
origin: 'suggested',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
cuisine: 'korean',
|
||||
is_favorite: false,
|
||||
photo_urls: { '400': '/media/photos/p1-400.jpg', '1200': '/media/photos/p1-1200.jpg' },
|
||||
ingredients: [
|
||||
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
|
||||
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
|
||||
{ name: 'Soy sauce', qty: '¼', unit: 'cup', note: null, raw_text: '¼ cup soy sauce' },
|
||||
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
|
||||
],
|
||||
steps: ['Cook the rice.', 'Brown the beef with the sauce.', 'Serve over rice with the onions.'],
|
||||
source: { site_name: 'Serious Eats' },
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
why_picked: 'close cousin of your Beef tacos and Chicken stir-fry.',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// A recipe the household typed in: only raw_text, no source.
|
||||
const MANUAL = {
|
||||
status: 'regular',
|
||||
origin: 'regular',
|
||||
photo_urls: null,
|
||||
ingredients: [
|
||||
{ name: null, qty: null, unit: null, note: null, raw_text: '2 lb chuck roast' },
|
||||
{ name: null, qty: null, unit: null, note: null, raw_text: 'A splash of red wine' },
|
||||
],
|
||||
steps: ['Sear the roast.', 'Slow cook 8 hours.'],
|
||||
source: null,
|
||||
grounding_status: 'manual',
|
||||
why_picked: null,
|
||||
}
|
||||
|
||||
// A Regular whose recipe could not be found: no recipe at all.
|
||||
const FAILED = {
|
||||
status: 'regular',
|
||||
origin: 'regular',
|
||||
photo_urls: null,
|
||||
minutes_total: null,
|
||||
difficulty: null,
|
||||
ingredients: [],
|
||||
steps: [],
|
||||
source: null,
|
||||
grounding_status: 'failed',
|
||||
grounding_error: 'no_recipe_found',
|
||||
why_picked: null,
|
||||
}
|
||||
|
||||
// renderAt renders the detail screen at the last of entries, with probes
|
||||
// for where its links and back button go.
|
||||
function renderAt(entries: string[]) {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: '/dinner/:id', element: <DinnerDetailPage /> },
|
||||
{ path: '/', element: <h1>Home probe</h1> },
|
||||
{ path: '/plan', element: <h1>Plan probe</h1> },
|
||||
{ path: REGULAR_DINNERS, element: <h1>Regular dinners probe</h1> },
|
||||
],
|
||||
{ initialEntries: entries, initialIndex: entries.length - 1 },
|
||||
)
|
||||
render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
</ApiProviders>,
|
||||
)
|
||||
return router
|
||||
}
|
||||
|
||||
function serve(body: unknown) {
|
||||
return stubFetchByPath({ [DINNER]: () => jsonResponse(200, body) })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewport(390)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('DinnerDetailPage', () => {
|
||||
describe('a grounded Side quest', () => {
|
||||
it('shows its photo washed and full-bleed, tags, title and why it was picked', async () => {
|
||||
serve(detail())
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
|
||||
const photo = screen.getByRole('img', { name: 'Korean beef bowls' })
|
||||
expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg')
|
||||
expect(photo).toHaveAttribute('srcset', '/media/photos/p1-400.jpg 400w, /media/photos/p1-1200.jpg 1200w')
|
||||
expect(declaredStyle(photo.parentElement!).height).toBe('250px')
|
||||
expect(declaredStyle(photo.parentElement!).margin).toBe('0px -20px')
|
||||
|
||||
expect(screen.getByText('Side quest')).toBeInTheDocument()
|
||||
expect(screen.getByText('Easy')).toBeInTheDocument()
|
||||
expect(screen.getByText('30 min')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Regular')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent(
|
||||
'Why it was picked: close cousin of your Beef tacos and Chicken stir-fry.',
|
||||
)
|
||||
})
|
||||
|
||||
it('lists ingredients in recipe order with quantities on the right, then numbered steps', async () => {
|
||||
serve(detail())
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument()
|
||||
const rows = within(screen.getAllByRole('list')[0]!).getAllByRole('listitem')
|
||||
expect(rows.map((row) => Array.from(row.children, (cell) => cell.textContent))).toEqual([
|
||||
['Ground beef', '1 lb'],
|
||||
['Jasmine rice', '2 cups'],
|
||||
['Soy sauce', '¼ cup'],
|
||||
['Green onions, sliced', '1 bunch'],
|
||||
])
|
||||
const qty = rows[0]!.lastElementChild!
|
||||
expect(declaredStyle(qty).textAlign).toBe('right')
|
||||
expect(declaredStyle(rows[0]!).justifyContent).toBe('space-between')
|
||||
expect(declaredStyle(rows[0]!).borderBottom).toBe('1px solid var(--color-divider)')
|
||||
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Steps' })).toBeInTheDocument()
|
||||
const steps = screen.getAllByRole('list')[1]!
|
||||
expect(steps.tagName).toBe('OL')
|
||||
expect(within(steps).getAllByRole('listitem').map((step) => step.textContent)).toEqual([
|
||||
'1Cook the rice.',
|
||||
'2Brown the beef with the sauce.',
|
||||
'3Serve over rice with the onions.',
|
||||
])
|
||||
})
|
||||
|
||||
it('credits the site the recipe came from, with no link to it', async () => {
|
||||
serve(detail())
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const credit = await screen.findByText('Recipe from Serious Eats')
|
||||
expect(credit.closest('a')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves the credit out when the server names no site', async () => {
|
||||
serve(detail({ source: { site_name: null } }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('holds Add to this week and the heart at the bottom, at rest until the next update', async () => {
|
||||
serve(detail({ is_favorite: true }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const add = await screen.findByRole('button', { name: 'Add to this week' })
|
||||
expect(add).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(add).toHaveAccessibleDescription('Adding to this week and the favorite heart arrive in the next update.')
|
||||
const heart = screen.getByRole('button', { name: 'Favorite' })
|
||||
expect(heart).toBeDisabled()
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'true')
|
||||
const bar = heart.parentElement!
|
||||
expect(bar).toContainElement(add)
|
||||
expect(declaredStyle(bar).position).toBe('sticky')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the striped placeholder when the dinner has no photo', async () => {
|
||||
serve(detail({ photo_urls: null }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
const placeholder = await screen.findByRole('img', { name: 'Korean beef bowls' })
|
||||
expect(placeholder.tagName).toBe('DIV')
|
||||
expect(placeholder).toHaveTextContent('dish photo')
|
||||
})
|
||||
|
||||
it('shows a manual Regular line by line, as typed, with no credit', async () => {
|
||||
serve(detail(MANUAL))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByText('Regular')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Side quest')).not.toBeInTheDocument()
|
||||
const [ingredients, steps] = screen.getAllByRole('list')
|
||||
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.firstElementChild!.textContent)).toEqual([
|
||||
'2 lb chuck roast',
|
||||
'A splash of red wine',
|
||||
])
|
||||
expect(within(steps!).getAllByRole('listitem')).toHaveLength(2)
|
||||
expect(screen.queryByText(/Why it was picked/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveTextContent('dish photo')
|
||||
})
|
||||
|
||||
it('links a dinner whose recipe was not found to Settings → Regular dinners, with no recipe', async () => {
|
||||
serve(detail(FAILED))
|
||||
const router = renderAt(['/dinner/d1'])
|
||||
|
||||
const fix = await screen.findByRole('link', { name: 'Fix recipe' })
|
||||
expect(screen.getByText(/couldn't find a recipe/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Steps' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(fix)
|
||||
expect(await screen.findByRole('heading', { name: 'Regular dinners probe' })).toBeInTheDocument()
|
||||
expect(router.state.location.pathname).toBe(REGULAR_DINNERS)
|
||||
})
|
||||
|
||||
it('says it is still finding a recipe while grounding is pending', async () => {
|
||||
serve(detail({ ...FAILED, grounding_status: 'pending', grounding_error: null }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Fix recipe' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says an unknown or removed dinner is not found', async () => {
|
||||
stubFetchByPath({ [DINNER]: () => errorResponse(404, 'not_found') })
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Dinner not found' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Go Home' })).toHaveAttribute('href', '/')
|
||||
})
|
||||
|
||||
it('reports any other failure with a retry', async () => {
|
||||
stubFetchByPath({ [DINNER]: () => errorResponse(500, 'internal') })
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('the back button', () => {
|
||||
it('goes back in the app', async () => {
|
||||
serve(detail())
|
||||
const router = renderAt(['/plan', '/dinner/d1'])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument()
|
||||
expect(router.state.location.pathname).toBe('/plan')
|
||||
})
|
||||
|
||||
it('goes Home when the dinner was opened from a link', async () => {
|
||||
serve(detail())
|
||||
const router = renderAt(['/dinner/d1'])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Back' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Home probe' })).toBeInTheDocument()
|
||||
expect(router.state.location.pathname).toBe('/')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import { ChevronLeft, Heart } from 'lucide-react'
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router'
|
||||
import { ApiError } from '../api/errors.ts'
|
||||
import { duration, Unavailable } from '../plan/PlanPage.tsx'
|
||||
import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import type { Difficulty } from './api.ts'
|
||||
import { useDinnerDetail, type DinnerDetail, type Ingredient } from './detail.ts'
|
||||
import styles from './DinnerDetailPage.module.css'
|
||||
|
||||
// REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed.
|
||||
export const REGULAR_DINNERS = '/settings/regulars'
|
||||
|
||||
// ADD_HINT is why Add to this week and the heart are unavailable.
|
||||
// TODO(07423ba7): the add-to-week sheet and the favorite heart.
|
||||
const ADD_HINT = 'Adding to this week and the favorite heart arrive in the next update.'
|
||||
|
||||
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||
|
||||
// BackButton goes back in the app's history, or, opened from a link with
|
||||
// no history in the app, to Home.
|
||||
function BackButton() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// The first entry of a session has the key "default".
|
||||
const back = () => (location.key === 'default' ? void navigate('/', { replace: true }) : void navigate(-1))
|
||||
return (
|
||||
<IconButton label="Back" tone="neutral" className={styles.back} onClick={back}>
|
||||
<Icon icon={ChevronLeft} size="inline" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
// quantity is an ingredient's amount as the right-hand column shows it,
|
||||
// "1 lb", or empty when the recipe gives none.
|
||||
function quantity(ingredient: Ingredient): string {
|
||||
return [ingredient.qty, ingredient.unit].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
// IngredientRow is one ingredient: its name with any note, or the line as
|
||||
// the recipe gives it when there is no name, and its amount on the right.
|
||||
function IngredientRow({ ingredient }: { ingredient: Ingredient }) {
|
||||
return (
|
||||
<li className={styles.ingredient}>
|
||||
<span>
|
||||
{ingredient.name ?? ingredient.raw_text}
|
||||
{ingredient.name !== null && ingredient.note !== null && <span className={styles.note}>, {ingredient.note}</span>}
|
||||
</span>
|
||||
<span className={styles.qty}>{quantity(ingredient)}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Recipe is the dinner's ingredients and steps, or what stands in for them:
|
||||
// still searching, or the link to fix a recipe that could not be found.
|
||||
function Recipe({ dinner }: { dinner: DinnerDetail }) {
|
||||
if (dinner.grounding_status === 'failed') {
|
||||
return (
|
||||
<section className={styles.missing} aria-label="No recipe">
|
||||
<p>We couldn't find a recipe for this dinner, so there are no ingredients or steps yet.</p>
|
||||
<Link to={REGULAR_DINNERS} className={styles.fix}>
|
||||
Fix recipe
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
if (dinner.grounding_status === 'pending') {
|
||||
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{dinner.ingredients.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Ingredients</h2>
|
||||
<ul className={styles.ingredients}>
|
||||
{dinner.ingredients.map((ingredient, i) => (
|
||||
<IngredientRow key={i} ingredient={ingredient} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{dinner.steps.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Steps</h2>
|
||||
<ol className={styles.steps}>
|
||||
{dinner.steps.map((step, i) => (
|
||||
<li key={i} className={styles.step}>
|
||||
<span className={styles.number} aria-hidden="true">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
{dinner.siteName !== null && <p className={styles.source}>Recipe from {dinner.siteName}</p>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ActionBar is the bar held at the bottom of the screen: Add to this week
|
||||
// and the favorite heart, showing whether the dinner is a favorite. Both
|
||||
// rest unavailable until the next update wires them.
|
||||
function ActionBar({ dinner }: { dinner: DinnerDetail }) {
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<Unavailable hint={ADD_HINT} className={styles.add}>
|
||||
Add to this week
|
||||
</Unavailable>
|
||||
<IconButton label="Favorite" size={52} aria-pressed={dinner.is_favorite} disabled>
|
||||
<Icon icon={Heart} size="nav" className={dinner.is_favorite ? styles.filled : undefined} />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Detail is a dinner as mock 1k shows it: the photo full-bleed under a sheet
|
||||
// with its tags, title, why it was picked and its recipe, over the action bar.
|
||||
function Detail({ dinner }: { dinner: DinnerDetail }) {
|
||||
const tags = [
|
||||
dinner.difficulty !== null && difficultyLabels[dinner.difficulty],
|
||||
dinner.minutes_total !== null && duration(dinner.minutes_total),
|
||||
].filter((tag) => tag !== false)
|
||||
return (
|
||||
<>
|
||||
<div className={styles.hero}>
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="0" sizes="100vw" className={styles.photo} />
|
||||
<BackButton />
|
||||
</div>
|
||||
<article className={styles.sheet}>
|
||||
<div className={styles.tags}>
|
||||
<Tag tone="grape">{dinner.status === 'regular' ? 'Regular' : 'Side quest'}</Tag>
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} tone="neutral">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<h1 className={styles.title}>{dinner.name}</h1>
|
||||
{dinner.why_picked !== null && (
|
||||
<p className={styles.why}>
|
||||
<b>Why it was picked:</b> {dinner.why_picked}
|
||||
</p>
|
||||
)}
|
||||
<Recipe dinner={dinner} />
|
||||
</article>
|
||||
<ActionBar dinner={dinner} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// DinnerDetailPage is a dinner with its recipe (mock 1k): its photo, or the
|
||||
// striped placeholder, under a sheet with Regular or Side quest, difficulty
|
||||
// and time, why it was picked, the ingredients and the steps. A dinner whose
|
||||
// recipe could not be found links to Settings → Regular dinners to fix it;
|
||||
// an unknown or removed dinner says it is not found.
|
||||
export function DinnerDetailPage() {
|
||||
const { id = '' } = useParams()
|
||||
const layout = useBreakpoint()
|
||||
const dinner = useDinnerDetail(id)
|
||||
|
||||
let body
|
||||
if (dinner.isError) {
|
||||
body = (
|
||||
<>
|
||||
<div className={styles.backRow}>
|
||||
<BackButton />
|
||||
</div>
|
||||
{dinner.error instanceof ApiError && dinner.error.status === 404 ? (
|
||||
<>
|
||||
<EmptyState title="Dinner not found">This dinner isn't in our kitchen — it may have been removed.</EmptyState>
|
||||
<Link to="/" className={[buttonStyles.button, buttonStyles.outline].join(' ')}>
|
||||
Go Home
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<ErrorBanner onRetry={() => void dinner.refetch()}>Couldn't load this dinner. {dinner.error.message}</ErrorBanner>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
} else if (dinner.isPending) {
|
||||
body = (
|
||||
<section aria-busy="true" aria-label="Dinner loading">
|
||||
<Skeleton className={styles.skeletonHero} />
|
||||
<div className={styles.sheet}>
|
||||
<Skeleton className={styles.skeletonTitle} />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
} else {
|
||||
body = <Detail dinner={dinner.data} />
|
||||
}
|
||||
return <div className={`${styles.page} ${styles[layout]}`}>{body}</div>
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export const GROUNDING_POLL_MS = 3000
|
||||
// Methods and Difficulties are the values the dinners API allows.
|
||||
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
|
||||
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
|
||||
const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
|
||||
export const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
|
||||
|
||||
export type Method = (typeof METHODS)[number]
|
||||
export type Difficulty = (typeof DIFFICULTIES)[number]
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import {
|
||||
DIFFICULTIES,
|
||||
dinnerPath,
|
||||
DINNERS_PATH,
|
||||
GROUNDING_POLL_MS,
|
||||
GROUNDING_STATUSES,
|
||||
METHODS,
|
||||
oneOf,
|
||||
photoId,
|
||||
type Difficulty,
|
||||
type GroundingStatus,
|
||||
type Method,
|
||||
} from './api.ts'
|
||||
|
||||
// The statuses a dinner with detail can have; an archived dinner is 404.
|
||||
const STATUSES = ['suggested', 'quest_log', 'tried', 'regular'] as const
|
||||
|
||||
export type DinnerStatus = (typeof STATUSES)[number]
|
||||
|
||||
// Ingredient is one recipe line. raw_text is the line as the recipe gives
|
||||
// it; name, qty, unit and note are the server's reading of it, each null
|
||||
// when it has none — always name, qty and unit for a recipe typed in.
|
||||
export type Ingredient = {
|
||||
name: string | null
|
||||
qty: string | null
|
||||
unit: string | null
|
||||
note: string | null
|
||||
raw_text: string
|
||||
}
|
||||
|
||||
// DinnerDetail is a dinner with its recipe (GET /api/v1/dinners/{id}).
|
||||
// siteName is the site the recipe was fetched from, or null when there is
|
||||
// none to name: a recipe typed in, a page that named no site, or a name the
|
||||
// server withheld because it could have come from the link. The recipe's
|
||||
// link itself never reaches the client.
|
||||
export type DinnerDetail = {
|
||||
id: string
|
||||
name: string
|
||||
status: DinnerStatus
|
||||
method: Method | null
|
||||
minutes_total: number | null
|
||||
difficulty: Difficulty | null
|
||||
is_favorite: boolean
|
||||
photo_id: string | undefined
|
||||
ingredients: Ingredient[]
|
||||
steps: string[]
|
||||
siteName: string | null
|
||||
grounding_status: GroundingStatus
|
||||
why_picked: string | null
|
||||
}
|
||||
|
||||
function text(where: string, field: string, value: unknown): string {
|
||||
if (typeof value !== 'string') throw new Error(`${where}: ${field} is not a string`)
|
||||
return value
|
||||
}
|
||||
|
||||
function nullableText(where: string, field: string, value: unknown): string | null {
|
||||
return value === null ? null : text(where, field, value)
|
||||
}
|
||||
|
||||
function parseIngredient(where: string, body: unknown): Ingredient {
|
||||
if (!isRecord(body)) throw new Error(`${where}: ingredient is not an object`)
|
||||
return {
|
||||
name: nullableText(where, 'ingredient name', body.name),
|
||||
qty: nullableText(where, 'ingredient qty', body.qty),
|
||||
unit: nullableText(where, 'ingredient unit', body.unit),
|
||||
note: nullableText(where, 'ingredient note', body.note),
|
||||
raw_text: text(where, 'ingredient raw_text', body.raw_text),
|
||||
}
|
||||
}
|
||||
|
||||
// detailPhotoId is the photo id of photo_urls, which the server sends as
|
||||
// both cached sizes of one photo, or null.
|
||||
function detailPhotoId(where: string, value: unknown): string | undefined {
|
||||
if (value === null) return undefined
|
||||
if (!isRecord(value)) throw new Error(`${where}: photo_urls is not an object`)
|
||||
const id = photoId(where, value['400'])
|
||||
if (id === undefined || value['1200'] !== `/media/photos/${encodeURIComponent(id)}-1200.jpg`) {
|
||||
throw new Error(`${where}: photo_urls is not both sizes of one stored photo`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
function siteName(where: string, value: unknown): string | null {
|
||||
if (value === null) return null
|
||||
if (!isRecord(value)) throw new Error(`${where}: source is not an object`)
|
||||
return nullableText(where, 'source site_name', value.site_name)
|
||||
}
|
||||
|
||||
// parseDinnerDetail checks a dinner with its recipe as the API sends it.
|
||||
export function parseDinnerDetail(where: string, body: unknown): DinnerDetail {
|
||||
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
||||
throw new Error(`${where}: response is not a dinner`)
|
||||
}
|
||||
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
|
||||
if (!Array.isArray(body.ingredients) || !Array.isArray(body.steps)) throw new Error(`${where}: response has no recipe lists`)
|
||||
const minutes = body.minutes_total
|
||||
if (minutes !== null && (typeof minutes !== 'number' || !Number.isInteger(minutes))) {
|
||||
throw new Error(`${where}: minutes_total is not a whole number`)
|
||||
}
|
||||
return {
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
status: oneOf(where, 'status', STATUSES, body.status),
|
||||
method: body.method === null ? null : oneOf(where, 'method', METHODS, body.method),
|
||||
minutes_total: minutes,
|
||||
difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty),
|
||||
is_favorite: body.is_favorite,
|
||||
photo_id: detailPhotoId(where, body.photo_urls),
|
||||
ingredients: body.ingredients.map((ingredient) => parseIngredient(where, ingredient)),
|
||||
steps: body.steps.map((step) => text(where, 'step', step)),
|
||||
siteName: siteName(where, body.source),
|
||||
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
|
||||
why_picked: nullableText(where, 'why_picked', body.why_picked),
|
||||
}
|
||||
}
|
||||
|
||||
// useDinnerDetail is dinner id with its recipe (GET /api/v1/dinners/{id}).
|
||||
// While the dinner is still finding its recipe it is read again every
|
||||
// GROUNDING_POLL_MS. An unknown or archived dinner fails with the API's 404.
|
||||
// It runs even while the browser reports offline, so the copy the service
|
||||
// worker saved is shown, or the read fails, instead of waiting for the
|
||||
// network.
|
||||
export function useDinnerDetail(id: string) {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: ['dinners', 'detail', id],
|
||||
queryFn: async () => parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, await apiFetch<unknown>(dinnerPath(id))),
|
||||
networkMode: 'always',
|
||||
refetchInterval: (query) => (query.state.data?.grounding_status === 'pending' ? GROUNDING_POLL_MS : false),
|
||||
})
|
||||
}
|
||||
@@ -24,6 +24,11 @@
|
||||
margin: -2px;
|
||||
}
|
||||
|
||||
.size52 {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.size62 {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
@@ -56,6 +61,11 @@
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.size52 .face {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.size62 .face {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
|
||||
@@ -26,6 +26,13 @@ describe('IconButton', () => {
|
||||
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
|
||||
})
|
||||
|
||||
it('is 52 px at the medium size', () => {
|
||||
render(<IconButton label="Favorite" size={52} />)
|
||||
const button = screen.getByRole('button', { name: 'Favorite' })
|
||||
expectMinHitArea(button)
|
||||
expect(hitArea(button)).toEqual({ width: 52, height: 52 })
|
||||
})
|
||||
|
||||
it('is 62 px at the large size', () => {
|
||||
render(<IconButton label="Try it" size={62} tone="teal" />)
|
||||
const button = screen.getByRole('button', { name: 'Try it' })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './IconButton.module.css'
|
||||
|
||||
export type IconButtonSize = 40 | 62
|
||||
export type IconButtonSize = 40 | 52 | 62
|
||||
export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal'
|
||||
|
||||
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
|
||||
@@ -15,7 +15,8 @@ export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'
|
||||
}
|
||||
|
||||
// Round icon button, named by its label and showing it only with showLabel.
|
||||
// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the
|
||||
// The 40 px size keeps a 44 px hit box (see the CSS); the 52 px size sits
|
||||
// beside a 52 px pill button, as the recipe's heart; the 62 px size is the
|
||||
// round action button with --shadow-md.
|
||||
export function IconButton({
|
||||
label,
|
||||
|
||||
Reference in New Issue
Block a user