src/
├── app/ # Next.js App Router pages + layout
│ ├── api/ # Server-side API route handlers
│ ├── components/ # App-level components (page-specific)
│ │ ├── atoms/ # Primitive reusable UI atoms
│ │ └── styles/ # CSS files for atoms (kept separate from modules)
│ ├── history/ # /history page
│ ├── page1/ # /page1 — category selection
│ ├── page2/ # /page2 — area selection
│ ├── recommendation/# /recommendation (random) + /recommendation/[id] (by ID)
│ └── search/ # /search — dynamic search by name
├── components/ # Shared UI components (generic, reusable)
│ ├── footer/
│ ├── form/
│ ├── header/
│ ├── recipeCtas/ # Like/Dislike + action buttons (back, retry, print, share)
│ ├── recipePrint/ # Print-only recipe view
│ ├── RecommendationView/ # Main recommendation display
│ ├── shareButton/
│ └── sidebar/
├── hooks/ # Custom React hooks for data fetching
├── service/ # Business logic + external API integration
├── store/ # Zustand global state (persisted to localStorage)
│ └── slices/ # State slices (form, history)
└── types/ # TypeScript type definitions
| Route | Purpose |
|---|---|
/ |
Homepage with three navigation links |
/page1 |
Form step 1 — select a category |
/page2 |
Form step 2 — select an area (filtered by category) |
/recommendation |
Random meal selector — fetches and redirects to [id] |
/recommendation/[id] |
Single meal view with CTAs |
/search |
Dynamic search by name (debounced, 3+ chars) |
/history |
Full-page history with filter and sort |
All pages are "use client" except the homepage and layout (server components).
TheMealDB requests go through a Next.js Route Handler at src/app/api/meals/[...path]/route.ts. This avoids CORS issues and keeps the API key server-side (from MEALDB_API_KEY env var, defaults to public key "1"). The proxy forwards path and query params to https://www.themealdb.com/api/json/v1/{key}/{path}.
src/service/meal-db-service.ts wraps all TheMealDB endpoints as typed async functions:
searchMealsByName(query)—search.php?s=getMealById(id)—lookup.php?i=getRandomMeal()—random.phpgetCategories()—categories.phpfilterByCategory(cat)/filterByArea(area)—filter.phpfilterByIngredient(ing)—filter.php?i=getRandomMealByFilter(cat, area, oldId?)— composes category filter → area filter → random pick → full meal lookup
The service checks NEXT_PUBLIC_MOCK_API=1 and switches to offline mock data from meal-db-mock.ts when set.
src/service/meal-db-mock.ts provides hardcoded responses matching the real API shape, with a simulated 250ms delay. Covers categories, random, lookup, search, and filter endpoints. Used in development and tests by setting the env var.
src/hooks/use-api.ts is the primitive. Takes a fetcher: () => Promise<T> and a dependency array, returns:
{ data: T | null, isLoading: boolean, error: string | null, refetch: () => void }Features:
- Uses a
refto avoid stale closures in the dependency-lessrefetch - Cancellation flag to prevent state updates after unmount
- Error handling extracts
err.messageor falls back to a generic string
All specific hooks delegate to useApi with a typed fetcher. They are thin wrappers — the real logic stays in the service layer:
| Hook | Fetcher | Re-fetch deps |
|---|---|---|
useCategories() |
getCategories().then(r => r.categories) |
[] (once) |
useAreasByCategory(category) |
filterByCategory(category) → deduplicate areas |
[category] |
useRandomMeal(category, area) |
getRandomMealByFilter(category, area) |
[category, area] |
useMealById(id) |
getMealById(id) → extract first meal |
[id] |
useSearch() |
searchMealsByName(debouncedText) via useDebounce(300ms) |
[debouncedText] |
src/hooks/use-debounce.ts is a generic debounce hook using setTimeout/clearTimeout. Used by useSearch to delay API calls until the user stops typing for 300ms. Minimum search length is 3 characters.
Zustand with persist middleware stores state in localStorage under key "global-store". A single global store with separated slices.
Tracks category and area values across the two-step form flow. Actions: setPage1, setPage2, resetForm.
Tracks calls: Call[] — an ordered list of viewed recipes. Each call stores recipeId, title, imageUrl, timestamp, like (boolean | null), and inputs (category + area).
Actions:
logRequest(data)— adds a new entry (deduplicates by recipeId)remove(recipeId)— deletes a single entrysetLike(recipeId, like)— toggles like/dislikeresetHistory()— clears all entries
useShallow from zustand/shallow is used in components to prevent unnecessary re-renders when picking multiple values.
Components follow Atomic Design:
- Atoms (
src/components/atoms/) — primitive UI elements (Button, CheckboxFilter, SortBy, RecipeImage, ShareButton) - Molecules (
src/components/molecules/) — composed atoms (FormSelect, RecipeCtas, LikeDislikeCtas, RecipePrint) - Organisms (
src/components/organisms/) — complex sections (Header, Footer, Sidebar, RecommendationView)
Plus page-specific components in src/app/components/ (HistoryList, SearchResults).
Button— generic pressed-state toggle button, usesaria-pressedCheckboxFilter— generic multi-select checkbox fieldset, generic over value type<T>SortBy— asc/desc toggle button with chevron icons andaria-labelRecipeImage— image wrapper with placeholder fallback on error/missing src
FormSelect— generic form select with label, error display,aria-invalidRecommendationView— full recipe display (image, details, like/dislike, CTAs)RecipeCtas— action bar with Back, View full recipe (external link), New idea, Print, ShareLikeDislikeCtas— like/dislike toggle buttons witharia-pressedShareButton— copies current URL to clipboard, shows "Copied!" feedback for 2sRecipePrint—print-onlyCSS class component, shows ingredients + instructions on print
Header— app title with chef hat icon, links to homeFooter— credits with link to authorSidebar— history panel with filter and sort, hides on homepage (/). Shows compact entries with date/category/area, remove button per entry.
Two components in src/app/components/ are not wired into any page:
History.tsx— standalone component with ownuseStateand customHistoryDatainterface. Superseded by the history page and sidebar.HistoryFilter.tsx— filter bar using theButtonatom. Superseded byCheckboxFilter+SortByatoms.
Both are candidates for removal.
meal-db.ts— Meal, Category, Area, Ingredient types matching TheMealDB response shape. Uses a mappedIndextype (1–20) for ingredient/measure fields.form.ts—UserRequest(category + area),Page1Request,Page2Requesthistory.ts—Call(recipe view entry with like/timestamp/inputs),History(array of calls)
routes.d.ts— typed route params for App Routercache-life.d.ts—cacheLifeprofile typesvalidator.ts— compile-time validation of page/layout exports
Separate test/ directory mirroring src/ structure:
test/
├── app/
│ ├── components/ # Component tests (atoms + shared)
│ ├── layout.test.tsx
│ └── page.test.tsx
├── e2e/ # Playwright E2E tests
├── fixtures/ # Hardcoded API response fixtures
│ ├── index.ts # Re-exports all fixtures
│ ├── meals.ts # fishPieFull, pizzaMargherita, seafoodFilterResponse, buildMeal()
│ ├── categories.ts # mockCategoriesResponse
│ └── areas.ts # mealsWithAreas
├── hooks/ # Hook tests (renderHook)
├── pages/ # Page tests (render + check heading + links)
├── service/ # Service function tests
├── store/ # Zustand store tests
└── utils/ # Test utilities (mock-fetch helper)
test/utils/mock-fetch.ts overrides global.fetch with a jest.fn() for service-level tests. For component/hook tests, mock data comes from test/fixtures/.
test/e2e/happy-path.spec.ts tests the main user flow end-to-end. Four scenarios: full form → recommendation, search → results, history → empty state + viewed recipe, and back-navigation preserves category.
/recommendationfetches a random meal then redirects to/recommendation/[id]?category=X&area=Yviarouter.replace()/recommendation/[id]resolves search criteria from three sources (priority order): URL params → store history → meal data- "New Idea" re-runs
getRandomMealByFilter(category, area)when criteria are available - "Back" uses
router.back()to return to the origin regardless of entry point
Prettier's @trivago/prettier-plugin-sort-imports enforces a fixed order:
node:modules- Third-party packages (
react,next,lucide-react, etc.) - Internal
@/aliases - Relative imports (
./,../)
Groups are separated by a blank line. Run npx prettier --write . to fix.
Component folders and files use PascalCase for React components (e.g. Button/Button.tsx) and kebab-case for non-component files (e.g. meal-db-service.ts). Enforced by unicorn/filename-case.
| File | Purpose |
|---|---|
tsconfig.json |
TypeScript config: strict, bundler moduleResolution, @/ path alias |
next.config.ts |
Next.js config: remotePatterns for TheMealDB image host |
jest.config.ts |
Jest via next/jest, jsdom environment, @/ path mapped to ./src/ |
playwright.config.ts |
Playwright: chromium only, dev server on port 3000 with MOCK_API=1 |
eslint.config.mjs |
ESLint flat config with eslint-config-next + eslint-plugin-unicorn |
.prettierrc |
Prettier config with @trivago/prettier-plugin-sort-imports + prettier-plugin-packagejson |
| Script | Command |
|---|---|
npm run dev |
next dev |
npm run build |
next build |
npm run start |
next start |
npm run lint |
eslint |
npx prettier --write . |
Prettier formatting (import sort + package.json sort) |
npm run typecheck |
tsc --noEmit |
npm test |
jest |
npm run test:e2e |
playwright test |
| Package | Version | Purpose |
|---|---|---|
next |
16.2.9 | Framework (App Router) |
react / react-dom |
19.2.4 | UI library |
zustand |
5.0.14 | State management + localStorage persist |
react-hook-form |
7.80.0 | Form handling and validation |
lucide-react |
1.21.0 | Icon set |
jest |
30.4.2 | Test runner |
ts-jest |
29.4.11 | TypeScript transformer for Jest |
@playwright/test |
1.61.1 | E2E testing |
@trivago/prettier-plugin-sort-imports |
6.x | Prettier import sorting |
prettier-plugin-packagejson |
latest | Prettier package.json sorting |
eslint-plugin-unicorn |
56.x | ESLint opinionated rules |
The API key lives in .env.local as MEALDB_API_KEY (defaults to TheMealDB public key "1"). Set NEXT_PUBLIC_MOCK_API=1 to use offline mock data.
- Skip-to-content link (first focusable element, visually hidden until focused)
- Semantic HTML (
<header>,<main>,<footer>,<aside>,<nav>,<article>,<section>,<fieldset>,<legend>) aria-labelon icon-only buttons and linksaria-pressedon toggle buttons (like/dislike, filter toggles)aria-invalidon form selects with validation errorsrole="alert"on error messagesrole="status"+aria-live="polite"on dynamic loading statestabindexmanagement- Lucide icons use
aria-hidden="true"(decorative)
A full WCAG compliance report is available in docs/wcag-report.md.
- All async operations wrapped in try-catch that rejects on failure
- Error messages displayed using proper ARIA roles (
role="alert") - Use
try-catch-finallypattern consistently
- Unmount cleanup with cancellation flag
- Avoid stale closures in refetch functions using refs
- Use proper dependency arrays for hooks
- Optimize re-renders with memoization where appropriate
Lucide React icons (lucide-react) replace emojis for consistency across devices. Icons are always decorative (aria-hidden="true"), never carry semantic meaning on their own.