diff --git a/backend/src/index.ts b/backend/src/index.ts index b59e5f8..a06f4b6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,6 +26,11 @@ function readPaging(args: { limit?: number; offset?: number }) { // Flere enn ca. 300 prikker blir uleselig og tungt å tegne. const TASTE_MAP_MAX = 300; +// Find wine by food: viner med minst så mange vurderinger kommer først. +// Ellers havner ukjente viner med én vurdering øverst, fordi de tilfeldigvis +// passer til flest retter i kategorien. +const FOOD_POPULAR_MIN = 50; + /** * ORDER BY for hver sortering i vinkatalogen. * Klienten sender bare navnet (enum WineSort), og SQL-en hentes herfra, @@ -223,6 +228,30 @@ const typeDefs = `#graphql type FoodCategory { name: String! wineCount: Int! + # Rettene i kategorien, verdier fra Harmonize, f.eks. ["Beef", "Lamb"] + foods: [String!]! + } + + # En vin på Find wine by food, med rettene i den valgte kategorien som + # vinen passer til ifølge Harmonize + type FoodWine { + id: ID! + name: String! + type: String! + abv: Float + body: String + acidity: String + winery: String! + region: String! + country: String! + avgRating: Float + ratingCount: Int! + matchedFoods: [String!]! + } + + type FoodWinePage { + items: [FoodWine!]! + totalCount: Int! } # Sortering i vinkatalogen @@ -304,11 +333,15 @@ const typeDefs = `#graphql foodCategories: [FoodCategory!]! + # Viner som passer til en matkategori. food snevrer inn til én rett i + # kategorien. De som passer til flest retter i kategorien kommer først, + # deretter de flest har vurdert. winesByFoodCategory( category: String! + food: String limit: Int = 20 offset: Int = 0 - ): WinePage! + ): FoodWinePage! # Viner til smakskartet. Bare viner med både fylde og syre kommer med. tasteMap(filter: TasteFilter, limit: Int = 100): WinePage! @@ -468,7 +501,10 @@ const resolvers = { foodCategories: async () => { const result = await pool.query( ` - SELECT f.category AS name, count(DISTINCT wf.wine_id)::int AS "wineCount" + SELECT + f.category AS name, + count(DISTINCT wf.wine_id)::int AS "wineCount", + array_agg(DISTINCT f.name ORDER BY f.name) AS foods FROM foods f JOIN wine_foods wf ON wf.food_id = f.id WHERE f.category IS NOT NULL @@ -481,46 +517,62 @@ const resolvers = { winesByFoodCategory: async ( _: unknown, - args: { category: string; limit?: number; offset?: number }, + args: { + category: string; + food?: string | null; + limit?: number; + offset?: number; + }, ) => { const { limit, offset } = readPaging(args); - const inCategory = ` - EXISTS ( - SELECT 1 - FROM wine_foods wf - JOIN foods f ON f.id = wf.food_id - WHERE wf.wine_id = w.id AND f.category = $1 - )`; + + // Én rad per vin, med rettene i kategorien vinen passer til. + // Med food må vinen passe til akkurat den retten, men listen viser + // fortsatt alle rettene i kategorien den passer til. + const matches = ` + SELECT wf.wine_id, array_agg(f.name ORDER BY f.name) AS foods + FROM wine_foods wf + JOIN foods f ON f.id = wf.food_id + WHERE f.category = $1 + GROUP BY wf.wine_id + HAVING $2::text IS NULL OR bool_or(f.name = $2::text)`; + const params = [args.category, args.food || null]; const winesResult = await pool.query( ` + WITH matches AS (${matches}) SELECT w.id, w.name, w.type, - w.abv, + w.abv::float AS abv, w.body, w.acidity, wi.name AS winery, r.name AS region, c.name AS country, - w.avg_rating AS "avgRating", - w.rating_count AS "ratingCount" - FROM wines w + w.avg_rating::float AS "avgRating", + w.rating_count AS "ratingCount", + m.foods AS "matchedFoods" + FROM matches m + JOIN wines w ON w.id = m.wine_id JOIN wineries wi ON w.winery_id = wi.id JOIN regions r ON w.region_id = r.id JOIN countries c ON w.country_code = c.code - WHERE ${inCategory} - ORDER BY w.name, w.id - LIMIT $2 - OFFSET $3 + ORDER BY + w.rating_count >= $3 DESC, + cardinality(m.foods) DESC, + w.rating_count DESC, + w.id + LIMIT $4 + OFFSET $5 `, - [args.category, limit, offset], + [...params, FOOD_POPULAR_MIN, limit, offset], ); const countResult = await pool.query( - `SELECT count(*)::int AS count FROM wines w WHERE ${inCategory}`, - [args.category], + `WITH matches AS (${matches}) SELECT count(*)::int AS count FROM matches`, + params, ); return { diff --git a/client/src/App.tsx b/client/src/App.tsx index 1249a83..be3fa02 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,6 +2,7 @@ import { Link, Route, Routes, useParams } from 'react-router'; import SiteHeader from './components/SiteHeader'; import ExploreByTaste from './components/ExploreByTaste'; import WineCatalog from './components/WineCatalog'; +import FindWineByFood from './components/FindWineByFood'; /** * NB: Temporary placeholder. The detail page is not built yet. @@ -24,6 +25,7 @@ function WineDetailsPlaceholder() { * * SiteHeader is shown on every page. Routes picks the page from the URL: * - / Explore by taste (front page) + * - /food Find wine by food * - /wines the wine catalog * - /wines/:id detail page (placeholder for now) * @@ -45,6 +47,7 @@ export default function App() { } /> + } /> } /> diff --git a/client/src/assets/food/cheese.jpg b/client/src/assets/food/cheese.jpg new file mode 100644 index 0000000..b681eb1 Binary files /dev/null and b/client/src/assets/food/cheese.jpg differ diff --git a/client/src/assets/food/dessert.jpg b/client/src/assets/food/dessert.jpg new file mode 100644 index 0000000..a42c4fe Binary files /dev/null and b/client/src/assets/food/dessert.jpg differ diff --git a/client/src/assets/food/fish.jpg b/client/src/assets/food/fish.jpg new file mode 100644 index 0000000..7ac2ec0 Binary files /dev/null and b/client/src/assets/food/fish.jpg differ diff --git a/client/src/assets/food/meat.jpg b/client/src/assets/food/meat.jpg new file mode 100644 index 0000000..752e0a8 Binary files /dev/null and b/client/src/assets/food/meat.jpg differ diff --git a/client/src/assets/food/pasta.jpg b/client/src/assets/food/pasta.jpg new file mode 100644 index 0000000..4d6e110 Binary files /dev/null and b/client/src/assets/food/pasta.jpg differ diff --git a/client/src/assets/food/poultry.jpg b/client/src/assets/food/poultry.jpg new file mode 100644 index 0000000..2618c04 Binary files /dev/null and b/client/src/assets/food/poultry.jpg differ diff --git a/client/src/assets/food/snacks.jpg b/client/src/assets/food/snacks.jpg new file mode 100644 index 0000000..7d4d89d Binary files /dev/null and b/client/src/assets/food/snacks.jpg differ diff --git a/client/src/assets/food/spicy.jpg b/client/src/assets/food/spicy.jpg new file mode 100644 index 0000000..16551bb Binary files /dev/null and b/client/src/assets/food/spicy.jpg differ diff --git a/client/src/assets/food/vegetarian.jpg b/client/src/assets/food/vegetarian.jpg new file mode 100644 index 0000000..927658f Binary files /dev/null and b/client/src/assets/food/vegetarian.jpg differ diff --git a/client/src/components/FindWineByFood.css b/client/src/components/FindWineByFood.css new file mode 100644 index 0000000..31409d5 --- /dev/null +++ b/client/src/components/FindWineByFood.css @@ -0,0 +1,342 @@ +/* + * Find wine by food. + * Colours come from the variables in index.css. + */ + +/* ---------- Hero ---------- */ + +.food-hero { + background: + radial-gradient(circle at 82% 50%, rgb(123 30 43 / 0.12), transparent 45%), + linear-gradient(90deg, var(--color-bg) 35%, var(--color-panel)); + border-bottom: 1px solid var(--color-border); +} + +.food-hero-inner { + max-width: 78rem; + margin: 0 auto; + padding: 3rem 1rem; +} + +.food-eyebrow { + margin: 0 0 0.4rem; + font-size: 0.75rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--color-muted); +} + +.food-hero h1 { + margin: 0 0 0.5rem; + font-family: var(--font-display); + font-weight: 400; + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1.1; + color: var(--color-text); +} + +.food-hero p:last-child { + margin: 0; + max-width: 28rem; + font-size: 1.1rem; + color: var(--color-muted); +} + +/* ---------- Page ---------- */ + +main.food-page { + max-width: 78rem; + display: grid; + gap: 2rem; + padding: 1.75rem 1rem 3rem; +} + +.food-page h2 { + margin: 0 0 1rem; + font-family: var(--font-display); + font-weight: 400; + font-size: 1.6rem; +} + +.food-note { + margin: 0; + font-size: 0.9rem; + color: var(--color-muted); +} + +/* ---------- Category cards ---------- */ + +.food-categories { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; +} + +.food-category { + width: 100%; + height: 100%; + display: grid; + justify-items: center; + gap: 0.15rem; + padding: 0.5rem 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: 0.75rem; + background: var(--color-surface); + color: var(--color-text); + font: inherit; + cursor: pointer; + transition: + border-color 0.15s, + background 0.15s; +} + +.food-category:hover { + border-color: var(--color-muted); +} + +.food-category[aria-pressed='true'] { + border-color: var(--color-accent); + background: var(--color-panel); + box-shadow: inset 0 0 0 1px var(--color-accent); +} + +/* Photo tile at the top of the card */ +.food-category-image { + width: 100%; + height: 5.25rem; + margin-bottom: 0.35rem; + border-radius: 0.5rem; + object-fit: cover; +} + +/* Same height as the photos, so the names line up */ +.food-category-emoji { + display: grid; + place-items: center; + height: 5.25rem; + margin-bottom: 0.35rem; + font-size: 2.8rem; +} + +.food-category-name { + font-weight: 600; +} + +/* ---------- Dish chips ---------- */ + +.food-dishes { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-top: 1rem; +} + +.food-dishes-label { + margin-right: 0.25rem; + font-size: 0.85rem; + font-weight: 600; +} + +.food-dishes button { + padding: 0.35rem 0.85rem; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: 0.88rem; + cursor: pointer; +} + +.food-dishes button:hover { + border-color: var(--color-muted); +} + +.food-dishes button[aria-pressed='true'] { + border-color: var(--color-accent); + background: var(--color-accent); + color: var(--color-accent-ink); +} + +/* ---------- Results ---------- */ + +.food-results { + display: grid; + gap: 1rem; +} + +.food-results-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + gap: 0.25rem 1rem; +} + +.food-results-head h2 { + margin: 0; + scroll-margin-top: 1rem; +} + +.food-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr)); + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; +} + +.food-card { + height: 100%; + display: grid; + grid-template-columns: 4.5rem minmax(0, 1fr); + gap: 1rem; + padding: 1.1rem; + border: 1px solid var(--color-border); + border-radius: 0.75rem; + background: var(--color-surface); +} + +.food-bottle { + width: 100%; + height: auto; + max-height: 10rem; + align-self: center; +} + +.food-card-body { + display: grid; + align-content: start; + gap: 0.5rem; +} + +.food-card h3 { + margin: 0; + font-family: var(--font-display); + font-weight: 400; + font-size: 1.25rem; + line-height: 1.2; +} + +.food-card h3 a { + color: var(--color-text); + text-decoration: none; +} + +.food-card h3 a:hover { + color: var(--color-accent); + text-decoration: underline; +} + +.food-winery { + margin: -0.35rem 0 0; + font-size: 0.9rem; + color: var(--color-muted); +} + +/* "Pairs with" chip, in place of the "% match" chip in the sketch */ +.food-match { + justify-self: start; + margin: 0; + padding: 0.2rem 0.65rem; + border-radius: 999px; + background: var(--color-panel); + color: var(--color-accent); + font-size: 0.8rem; + font-weight: 600; +} + +.food-meta { + display: flex; + flex-wrap: wrap; + gap: 0.2rem 0.9rem; + margin: 0; + padding: 0; + list-style: none; + font-size: 0.82rem; + color: var(--color-muted); +} + +.food-meta li { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.food-meta svg { + width: 0.9rem; + height: 0.9rem; + color: var(--color-accent); +} + +.food-star { + color: var(--color-star); +} + +.food-bars { + display: grid; + gap: 0.35rem; + margin-top: 0.25rem; +} + +.food-bar-row { + display: grid; + grid-template-columns: 4.25rem 1fr; + align-items: center; + gap: 0.6rem; + font-size: 0.82rem; +} + +.food-bar { + height: 0.45rem; + overflow: hidden; + border-radius: 999px; + background: var(--color-sunk); +} + +.food-bar i { + display: block; + height: 100%; + border-radius: 999px; + background: var(--color-accent); +} + +/* ---------- Why this pairing works ---------- */ + +.food-why { + display: flex; + gap: 1.25rem; + align-items: flex-start; + padding: 1.25rem 1.5rem; + border-radius: 0.75rem; + background: var(--color-panel); +} + +.food-why svg { + flex: none; + width: 2.25rem; + height: 2.25rem; + color: var(--color-accent); +} + +.food-why h2 { + margin: 0 0 0.25rem; + font-size: 1.25rem; +} + +.food-why p { + margin: 0; + max-width: 50rem; + color: var(--color-muted); +} + +@media (prefers-reduced-motion: reduce) { + .food-category { + transition: none; + } +} diff --git a/client/src/components/FindWineByFood.tsx b/client/src/components/FindWineByFood.tsx new file mode 100644 index 0000000..53b0d11 --- /dev/null +++ b/client/src/components/FindWineByFood.tsx @@ -0,0 +1,227 @@ +import { useSearchParams } from 'react-router'; +import { useQuery } from '@apollo/client/react'; +import FoodWineCard from './FoodWineCard'; +import Pagination from './Pagination'; +import { + FOOD_CATEGORIES_QUERY, + WINES_BY_FOOD_QUERY, + type FoodCategoriesData, + type WinesByFoodData, +} from '../graphql/queries'; +import { + DEFAULT_FOOD_CATEGORY, + FOOD_CATEGORY_INFO, + FOOD_PAGE_SIZE, + foodCategoryOrder, +} from '../data/food'; +import './FindWineByFood.css'; + +/** + * Find wine by food. + * + * The user picks a food category (e.g. Meat), and can narrow it down to one + * dish (e.g. Beef). We show the wines that pair with it according to the + * Harmonize column in X-Wines, and explain why the pairing works. + * + * On purpose there are no wine filters or sorting here: the user chooses by + * food only. The backend decides the order: well-known wines (at least 50 + * ratings) that pair with the most dishes in the category come first. + * + * The choices live in the URL (?category=Meat&food=Beef&page=2), so the back + * button works and a search can be shared as a link. + */ +export default function FindWineByFood() { + const [params, setParams] = useSearchParams(); + const category = params.get('category') ?? DEFAULT_FOOD_CATEGORY; + const food = params.get('food'); + const page = Math.max(1, Number(params.get('page')) || 1); + + const categoriesQuery = useQuery(FOOD_CATEGORIES_QUERY); + const { data, previousData, loading, error } = useQuery( + WINES_BY_FOOD_QUERY, + { + variables: { + category, + food, + limit: FOOD_PAGE_SIZE, + offset: (page - 1) * FOOD_PAGE_SIZE, + }, + }, + ); + + const categories = [...(categoriesQuery.data?.foodCategories ?? [])].sort( + (a, b) => + foodCategoryOrder(a.name) - foodCategoryOrder(b.name) || + a.name.localeCompare(b.name), + ); + const current = categories.find((c) => c.name === category); + const info = FOOD_CATEGORY_INFO[category]; + const label = info?.label ?? category; + + // Keep showing the previous wines while new ones load, so the list + // doesn't flash empty on every click + const shown = (data ?? previousData)?.winesByFoodCategory; + const wines = shown?.items ?? []; + const totalPages = shown ? Math.ceil(shown.totalCount / FOOD_PAGE_SIZE) : 0; + + /** Changes the URL. A new category or dish starts at page 1. */ + function choose(next: { category?: string; food?: string | null }) { + const p = new URLSearchParams(); + p.set('category', next.category ?? category); + const nextFood = next.category ? null : (next.food ?? null); + if (nextFood) p.set('food', nextFood); + setParams(p); + } + + function goToPage(n: number) { + const p = new URLSearchParams(params); + p.set('page', String(n)); + setParams(p); + document + .getElementById('food-results-heading') + ?.scrollIntoView({ block: 'start' }); + } + + return ( + <> +
+
+

Explore

+

Find wine by food

+

+ Choose what you’re eating and discover wines that make it even + better. +

+
+
+ +
+
+

Choose a food category

+ {categoriesQuery.error && ( +

Could not load the food categories.

+ )} +
    + {categories.map((c) => { + const cInfo = FOOD_CATEGORY_INFO[c.name]; + return ( +
  • + +
  • + ); + })} +
+ + {current && current.foods.length > 1 && ( +
+ + + {current.foods.map((f) => ( + + ))} +
+ )} +
+ +
+
+

Best matches for {food ?? label}

+ {/* role="status" announces the new count when the choice changes */} +

+ {!shown + ? 'Loading wines …' + : `${shown.totalCount.toLocaleString('en')} ${shown.totalCount === 1 ? 'wine' : 'wines'} pair with ${food ?? label.toLowerCase()}`} +

+
+ + {error && ( +

Could not load wines. Please try again later.

+ )} + {shown && wines.length === 0 && ( +

No wines pair with this food yet.

+ )} + +
    + {wines.map((w) => ( +
  • + +
  • + ))} +
+ + +
+ + {info && ( + + )} +
+ + ); +} diff --git a/client/src/components/FoodWineCard.tsx b/client/src/components/FoodWineCard.tsx new file mode 100644 index 0000000..225e1a9 --- /dev/null +++ b/client/src/components/FoodWineCard.tsx @@ -0,0 +1,151 @@ +import { Link } from 'react-router'; +import type { FoodWine } from '../types/wine'; +import { ACIDITY_LEVELS, BODY_LEVELS, wineTypeColor } from '../data/taste'; + +/** + * A wine bottle in the colour of the wine type. Decorative only. + * X-Wines has no pictures of the wines, so we draw one. + */ +function Bottle({ type }: { type: string }) { + return ( + + ); +} + +/** One row with label and bar, e.g. "Body ████░". The exact value is read by screen readers and shown on hover. */ +function Bar(props: { label: string; share: number; value: string }) { + return ( +
+ {props.label} +
+ +
+
+ ); +} + +/** + * A card on Find wine by food: the wine, where it is from, which dishes in + * the chosen category it pairs with, and its taste. + * + * Display only, like WineList: it gets the wine as a prop. + */ +export default function FoodWineCard({ wine }: { wine: FoodWine }) { + return ( +
+ + +
+

+ {/* + NB: The detail page (/wines/:id) is not built yet. Until then this + link opens a placeholder page, see App.tsx. + */} + {wine.name} +

+

{wine.winery}

+ +

+ Pairs with + {wine.matchedFoods.join(', ')} +

+ +
    +
  • + + {wine.region}, {wine.country} +
  • +
  • + + {wine.type} wine +
  • +
  • + + {wine.avgRating != null + ? `${wine.avgRating.toFixed(1)} (${wine.ratingCount.toLocaleString('en')} ratings)` + : 'No ratings yet'} +
  • +
+ +
+ {wine.body && ( + + )} + {wine.acidity && ( + + )} + {wine.abv != null && ( + + )} +
+
+
+ ); +} diff --git a/client/src/components/SiteHeader.tsx b/client/src/components/SiteHeader.tsx index a348fda..18bb0eb 100644 --- a/client/src/components/SiteHeader.tsx +++ b/client/src/components/SiteHeader.tsx @@ -39,6 +39,9 @@ export default function SiteHeader() {
  • Wines
  • +
  • + Food pairing +
  • diff --git a/client/src/data/food.ts b/client/src/data/food.ts new file mode 100644 index 0000000..e79e3d7 --- /dev/null +++ b/client/src/data/food.ts @@ -0,0 +1,91 @@ +import pastaImage from '../assets/food/pasta.jpg'; +import meatImage from '../assets/food/meat.jpg'; +import fishImage from '../assets/food/fish.jpg'; +import poultryImage from '../assets/food/poultry.jpg'; +import cheeseImage from '../assets/food/cheese.jpg'; +import vegetarianImage from '../assets/food/vegetarian.jpg'; +import spicyImage from '../assets/food/spicy.jpg'; +import snacksImage from '../assets/food/snacks.jpg'; +import dessertImage from '../assets/food/dessert.jpg'; + +/** + * Constants for Find wine by food. + * In their own file (not in a component file) so that React Fast Refresh + * keeps working, the same reason as graphql/queries.ts. + */ + +/** How many wines are shown per page. Three columns, three rows. */ +export const FOOD_PAGE_SIZE = 9; + +/** The category that is selected when the page opens. */ +export const DEFAULT_FOOD_CATEGORY = 'Pasta, pizza & stews'; + +type FoodCategoryInfo = { + /** Short name on the category card */ + label: string; + /** Photo on the card (AI-generated in Canva, on a light background) */ + image: string; + /** Text in the "Why this pairing works" box */ + why: string; +}; + +/** + * Display text for each category. The names (keys) must match + * foods.category in the database, see backend/db/seeds/food_categories.sql. + * The order here is the order of the cards. Categories from the backend + * that are not listed here come last, with their database name as label + * and a plate emoji instead of a photo. + */ +export const FOOD_CATEGORY_INFO: Record = { + 'Pasta, pizza & stews': { + label: 'Pasta & pizza', + image: pastaImage, + why: 'Tomato sauces, cheese and slow-cooked stews are rich and savoury. Wines with bright acidity and moderate tannins cut through the richness and match the acidity of the tomatoes, so every bite feels balanced.', + }, + Meat: { + label: 'Meat', + image: meatImage, + why: 'Red meat is full of fat and protein, which soften the tannins in red wine. A full-bodied wine stands up to grilled and roasted flavours, while the tannins clean the palate between bites.', + }, + 'Fish & seafood': { + label: 'Fish & seafood', + image: fishImage, + why: 'Fish and shellfish are delicate and often served with lemon. Light, fresh wines with good acidity lift the flavours without overpowering them, while heavy tannins can make fish taste metallic.', + }, + Poultry: { + label: 'Poultry', + image: poultryImage, + why: 'Chicken and other poultry are mild and work with many styles. Medium-bodied whites and lighter reds follow the sauce and seasoning without hiding the meat.', + }, + Cheese: { + label: 'Cheese', + image: cheeseImage, + why: 'Cheese is salty and creamy. Acidity cuts through the fat, and sweet or fortified wines balance the salt in strong and blue cheeses. Fresh goat cheese loves crisp whites.', + }, + Vegetarian: { + label: 'Vegetarian', + image: vegetarianImage, + why: 'Vegetables, salads and mushrooms are lighter and often earthy or green. Fresh, aromatic wines match green flavours, and soft reds go well with mushrooms.', + }, + 'Spicy food': { + label: 'Spicy food', + image: spicyImage, + why: 'Heat from chili is made stronger by alcohol and tannins. Wines with a little sweetness, lower alcohol and plenty of fruit cool the burn and balance the spices.', + }, + 'Snacks & starters': { + label: 'Snacks & starters', + image: snacksImage, + why: 'Starters, cured meat and salty snacks need a wine that refreshes. Sparkling and light, crisp wines clean the palate and wake up the appetite before the meal.', + }, + Dessert: { + label: 'Dessert', + image: dessertImage, + why: 'The wine should be at least as sweet as the dessert, or it will taste sour and thin. Sweet and fortified wines match cake, fruit and chocolate.', + }, +}; + +/** Sort key for a category: its place in FOOD_CATEGORY_INFO, unknown last. */ +export function foodCategoryOrder(name: string): number { + const index = Object.keys(FOOD_CATEGORY_INFO).indexOf(name); + return index === -1 ? Infinity : index; +} diff --git a/client/src/graphql/queries.ts b/client/src/graphql/queries.ts index ea42253..6f0ffff 100644 --- a/client/src/graphql/queries.ts +++ b/client/src/graphql/queries.ts @@ -1,6 +1,8 @@ import { gql } from '@apollo/client'; import type { CatalogFilter, + FoodCategory, + FoodWine, TasteWine, WineListItem, WineSort, @@ -179,3 +181,57 @@ export const WINE_GRAPES_QUERY = gql` export type WineGrapesData = { wine: { id: string; grapes: string[]; vintages: number[] } | null; }; + +/** The food categories on Find wine by food, with their dishes. */ +export const FOOD_CATEGORIES_QUERY = gql` + query FoodCategories { + foodCategories { + name + foods + } + } +`; + +export type FoodCategoriesData = { + foodCategories: FoodCategory[]; +}; + +/** + * One page of wines that pair with a food category. + * food narrows it down to one dish in the category (null = all dishes). + */ +export const WINES_BY_FOOD_QUERY = gql` + query WinesByFood( + $category: String! + $food: String + $offset: Int + $limit: Int + ) { + winesByFoodCategory( + category: $category + food: $food + offset: $offset + limit: $limit + ) { + totalCount + items { + id + name + type + winery + region + country + avgRating + ratingCount + abv + body + acidity + matchedFoods + } + } + } +`; + +export type WinesByFoodData = { + winesByFoodCategory: { totalCount: number; items: FoodWine[] }; +}; diff --git a/client/src/index.css b/client/src/index.css index 27e139c..7e3afda 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -72,3 +72,13 @@ h1 { outline: 3px solid var(--color-accent); outline-offset: 2px; } + +/* Text only for screen readers, hidden on screen */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/client/src/types/wine.ts b/client/src/types/wine.ts index 95c8dbb..277a49c 100644 --- a/client/src/types/wine.ts +++ b/client/src/types/wine.ts @@ -77,3 +77,17 @@ export type TasteFilter = { /** Only wines with at least 100 ratings */ popularOnly: boolean; }; + +/** A food category on Find wine by food, e.g. 'Meat' with its dishes. */ +export type FoodCategory = { + name: string; + /** The dishes in the category, from Harmonize in X-Wines */ + foods: string[]; +}; + +/** A wine on Find wine by food. */ +export type FoodWine = WineListItem & { + ratingCount: number; + /** The dishes in the chosen category that the wine pairs with */ + matchedFoods: string[]; +};