From 9eee2535436cebac97716ab0d7e190968966e64d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sofie=20Sirev=C3=A5g=20Tysdal?= Date: Tue, 20 Oct 2026 14:29:21 +0000 Subject: [PATCH] Lag detaljside for vin (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ny side på /wines/:id som viser navn, produsent, druer, region, land, årganger, alkoholprosent, fylde, syre, matmatching og rating. Henter data med wine(id)-queryen og erstatter plassholderen i App.tsx. - Semantisk HTML uten div-er, seksjoner navngitt med aria-labelledby - Fokus flyttes til h1 når vinen er lastet, sidetittel følger vinen - Egne visninger for lasting, feil og ukjent id - Fjernet NB-kommentarene om at detaljsiden ikke fantes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qv5B5TSh1KH58vA4MFhyPm --- client/src/App.tsx | 23 +- client/src/components/FoodWineCard.tsx | 4 - client/src/components/SelectedWine.tsx | 4 - client/src/components/WineDetailsPage.css | 242 +++++++++++++++++ client/src/components/WineDetailsPage.tsx | 301 ++++++++++++++++++++++ client/src/graphql/queries.ts | 34 +++ client/src/types/wine.ts | 28 ++ 7 files changed, 609 insertions(+), 27 deletions(-) create mode 100644 client/src/components/WineDetailsPage.css create mode 100644 client/src/components/WineDetailsPage.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index be3fa02..4962279 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,24 +1,9 @@ -import { Link, Route, Routes, useParams } from 'react-router'; +import { Route, Routes } 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. - * "View wine details" on Explore by taste links here, so the link - * already works. Replace this with the real detail page component. - */ -function WineDetailsPlaceholder() { - const { id } = useParams(); - return ( -
-

Wine details

-

The detail page for wine {id} is not built yet.

- ← Back to Explore by taste -
- ); -} +import WineDetailsPage from './components/WineDetailsPage'; /** * Root component for Wine not? @@ -27,7 +12,7 @@ function WineDetailsPlaceholder() { * - / Explore by taste (front page) * - /food Find wine by food * - /wines the wine catalog - * - /wines/:id detail page (placeholder for now) + * - /wines/:id detail page for one wine * * Each page has its own
and one

, so the heading hierarchy is * correct on every page. @@ -48,7 +33,7 @@ export default function App() { } /> } /> - } /> + } /> ); diff --git a/client/src/components/FoodWineCard.tsx b/client/src/components/FoodWineCard.tsx index d599cf9..7b62d4e 100644 --- a/client/src/components/FoodWineCard.tsx +++ b/client/src/components/FoodWineCard.tsx @@ -35,10 +35,6 @@ export default function FoodWineCard({ wine }: { wine: FoodWine }) {

- {/* - 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}

diff --git a/client/src/components/SelectedWine.tsx b/client/src/components/SelectedWine.tsx index 2d12cf7..3667298 100644 --- a/client/src/components/SelectedWine.tsx +++ b/client/src/components/SelectedWine.tsx @@ -110,10 +110,6 @@ export default function SelectedWine({ wine }: Props) { )}
- {/* - NB: The detail page (/wines/:id) is not built yet. Until then this - link opens a placeholder page, see App.tsx. - */} View wine details → diff --git a/client/src/components/WineDetailsPage.css b/client/src/components/WineDetailsPage.css new file mode 100644 index 0000000..56892cf --- /dev/null +++ b/client/src/components/WineDetailsPage.css @@ -0,0 +1,242 @@ +/* + * Detail page for one wine (/wines/:id). + * Uses the colour variables from index.css, so the page matches the rest + * of the app and keeps the contrast checked there (WCAG AA). + */ + +.wine-details-page { + max-width: 60rem; +} + +.wine-details-back { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-height: 44px; /* large enough to hit on a touch screen */ + padding: 0 1rem; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-surface); + color: var(--color-text); + font: inherit; + cursor: pointer; +} + +.wine-details-back:hover { + background: var(--color-panel); +} + +/* One column on phones, two from tablet width */ +.wine-details { + display: grid; + grid-template-columns: 1fr; + gap: 1.25rem; + margin-top: 1.25rem; +} + +@media (min-width: 48rem) { + .wine-details { + grid-template-columns: 1fr 1fr; + } + + .wine-details-header { + grid-column: 1 / -1; + } +} + +/* Top: bottle to the left, name and rating to the right */ +.wine-details-header { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto; + column-gap: 1.5rem; + align-items: center; + padding: 1.5rem; + border-radius: 1rem; + background: var(--color-panel); + border-left: 0.5rem solid var(--type-color, var(--color-accent)); +} + +.wine-details-header > :not(img) { + grid-column: 2; +} + +.wine-details-image { + grid-column: 1; + grid-row: 1 / span 4; + height: 11rem; + width: auto; + object-fit: contain; +} + +/* Colour stripe per wine type. Decorative only: the type is also text. */ +.wine-details[data-wine-type='red'] { + --type-color: var(--wine-red); +} +.wine-details[data-wine-type='white'] { + --type-color: var(--wine-white); +} +.wine-details[data-wine-type='rosé'] { + --type-color: var(--wine-rose); +} +.wine-details[data-wine-type='sparkling'] { + --type-color: var(--wine-sparkling); +} +.wine-details[data-wine-type^='dessert'] { + --type-color: var(--wine-dessert); +} + +.wine-details-type { + margin: 0; + color: var(--color-accent); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.wine-details-header h1 { + margin: 0.25rem 0 0.4rem; + font-family: var(--font-display); + font-size: clamp(1.75rem, 4vw, 2.5rem); + line-height: 1.15; +} + +/* The h1 gets focus from code on load; no outline needed there */ +.wine-details-header h1:focus { + outline: none; +} + +.wine-details-winery { + margin: 0; + color: var(--color-muted); + font-size: 1.1rem; +} + +.wine-details-rating { + margin: 0.75rem 0 0; + font-weight: 700; +} + +.wine-details-rating small { + color: var(--color-muted); + font-weight: 400; +} + +.wine-details-star { + color: var(--color-star); +} + +/* The four boxes: about, taste, food, vintages */ +.wine-details-card { + padding: 1.25rem 1.5rem; + border: 1px solid var(--color-border); + border-radius: 1rem; + background: var(--color-surface); +} + +.wine-details-card h2 { + margin: 0 0 1rem; + font-size: 1.15rem; +} + +/* Label to the left, value to the right */ +.wine-details-facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.6rem 1.25rem; + margin: 0; +} + +.wine-details-facts dt { + color: var(--color-muted); +} + +.wine-details-facts dd { + margin: 0; + font-weight: 600; + overflow-wrap: anywhere; +} + +.wine-details-facts a { + color: var(--color-accent); +} + +.wine-details-scale { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 0.75rem; +} + +.wine-details-scale meter { + width: 7rem; + height: 0.6rem; +} + +.wine-details-scale meter::-webkit-meter-bar { + border: none; + border-radius: 999px; + background: var(--color-sunk); +} + +.wine-details-scale meter::-webkit-meter-optimum-value { + border-radius: 999px; + background: var(--color-accent); +} + +.wine-details-scale meter::-moz-meter-bar { + border-radius: 999px; + background: var(--color-accent); +} + +/* Food pairings and vintages as small pills */ +.wine-details-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.wine-details-tags li { + padding: 0.3rem 0.75rem; + border-radius: 999px; + background: var(--color-panel); + font-size: 0.9rem; +} + +.wine-details-more { + margin-top: 0.75rem; +} + +.wine-details-more summary { + margin-bottom: 0.75rem; + color: var(--color-accent); + font-weight: 600; + cursor: pointer; +} + +.wine-details-muted { + margin: 0; + color: var(--color-muted); +} + +/* Phones: bottle above the text instead of beside it */ +@media (max-width: 36rem) { + .wine-details-header { + grid-template-columns: 1fr; + } + + .wine-details-header > :not(img), + .wine-details-image { + grid-column: 1; + grid-row: auto; + } + + .wine-details-image { + height: 8rem; + justify-self: center; + } +} diff --git a/client/src/components/WineDetailsPage.tsx b/client/src/components/WineDetailsPage.tsx new file mode 100644 index 0000000..4187b3a --- /dev/null +++ b/client/src/components/WineDetailsPage.tsx @@ -0,0 +1,301 @@ +import { useEffect, useRef } from 'react'; +import { Link, useNavigate, useParams } from 'react-router'; +import { useQuery } from '@apollo/client/react'; +import { WINE_DETAILS_QUERY, type WineDetailsData } from '../graphql/queries'; +import type { WineDetails } from '../types/wine'; +import { ACIDITY_LEVELS, BODY_LEVELS } from '../data/taste'; +import WineTypeImage from './WineTypeImage'; +import './WineDetailsPage.css'; + +/** + * The detail page for one wine, at /wines/:id (issue 16). + * + * Shows name, producer, grapes, region, vintages, alcohol, body, acidity, + * food pairings and rating. The data comes from the wine(id) query, which + * fetches one wine at a time, so the page stays small and fast. + * + * Accessibility: + * - Semantic elements only, no layout divs:
for the wine, + *
, one
per topic,
for label/value pairs,
    for + * lists and
    for the long vintage list. + * - Each
    gets its name from its heading through aria-labelledby, + * so screen reader users can jump between "About the wine", "Taste" etc. + * - The bars for body, acidity and alcohol are elements with an + * aria-label, and the value is always written out as text next to them, + * so the information never depends on the bar alone. + * - The

    gets focus when the wine has loaded, so screen reader and + * keyboard users start at the top of the new page. + * - Loading is announced with role="status", errors with role="alert". + */ +export default function WineDetailsPage() { + const { id = '' } = useParams(); + const { data, loading, error } = useQuery( + WINE_DETAILS_QUERY, + { variables: { id }, skip: !id }, + ); + + if (loading) { + return ( +
    +

    Loading wine …

    +
    + ); + } + + if (error) { + return ( +
    +

    Something went wrong

    +

    Could not load this wine. Please try again later.

    + Go to all wines +
    + ); + } + + if (!data?.wine) { + return ( +
    +

    Wine not found

    +

    There is no wine with this address. The link may be wrong.

    + Go to all wines +
    + ); + } + + return ( +
    + + +
    + ); +} + +/** + * "Back" goes to the previous page, so the user gets their search, filters + * and page in the catalog back. Someone who opened a shared link has no + * previous page in the app, so they are sent to the catalog instead. + * React Router stores the position in the history as window.history.state.idx. + */ +function BackButton() { + const navigate = useNavigate(); + const hasPreviousPage = (window.history.state?.idx ?? 0) > 0; + + return ( + + ); +} + +/** + * The wine itself. Display only (gets the wine as a prop), like WineList, + * so it can be tested without Apollo. + */ +export function WineDetailsContent({ wine }: { wine: WineDetails }) { + const headingRef = useRef(null); + + // Move focus to the heading and show the wine's name in the browser tab + useEffect(() => { + headingRef.current?.focus(); + const previousTitle = document.title; + document.title = `${wine.name} | Wine not?`; + return () => { + document.title = previousTitle; + }; + }, [wine.id, wine.name]); + + const bodyStep = wine.body ? BODY_LEVELS.indexOf(wine.body) + 1 : 0; + const acidityStep = wine.acidity + ? ACIDITY_LEVELS.indexOf(wine.acidity) + 1 + : 0; + + return ( +
    +
    + {/* Decorative: the type is written out as text next to it */} + + +

    {wine.type} wine

    + {/* tabIndex={-1}: can get focus from code, but is not a Tab stop */} +

    + {wine.name} +

    +

    + {wine.winery} · {wine.region}, {wine.country} +

    +

    + {' '} + {wine.avgRating != null ? ( + <> + {wine.avgRating.toFixed(1)} out of 5{' '} + ({wine.ratingCount.toLocaleString('en')} ratings) + + ) : ( + 'No ratings yet' + )} +

    +
    + +
    +

    About the wine

    +
    +
    Producer
    +
    + {wine.wineryWebsite ? ( + + {wine.winery} + (website, opens in a new tab) + + ) : ( + wine.winery + )} +
    + +
    {wine.grapes.length === 1 ? 'Grape' : 'Grapes'}
    +
    {wine.grapes.length > 0 ? wine.grapes.join(', ') : 'Unknown'}
    + +
    Region
    +
    {wine.region}
    + +
    Country
    +
    {wine.country}
    + +
    Style
    +
    {wine.elaborate ?? 'Unknown'}
    +
    +
    + +
    +

    Taste

    +
    +
    Body
    +
    + {wine.body ?? 'Unknown'} + {bodyStep > 0 && ( + + )} +
    + +
    Acidity
    +
    + {wine.acidity ?? 'Unknown'} + {acidityStep > 0 && ( + + )} +
    + +
    Alcohol
    +
    + {wine.abv != null ? `${wine.abv.toFixed(1)}%` : 'Unknown'} + {wine.abv != null && ( + // 20% is the top of the bar, the same scale as Explore by taste + + )} +
    +
    +
    + +
    +

    Goes well with

    + {wine.foodPairings.length > 0 ? ( + // role="list": list-style: none makes Safari stop announcing lists +
      + {wine.foodPairings.map((food) => ( +
    • {food}
    • + ))} +
    + ) : ( +

    No food pairings registered.

    + )} +
    + +
    +

    Vintages

    + +
    + + {/* + Comes later: + - Favourite button (issue 18) + - Link to similar wines with Wine DNA (issue 22) + */} +
    + ); +} + +/** How many vintages are shown before the rest are folded away. */ +const VISIBLE_VINTAGES = 12; + +/** + * Some wines in X-Wines have more than 50 vintages. The newest are shown, + * and the rest are behind a native
    element, which works with + * keyboard and screen readers without any extra code. + */ +function VintageList({ vintages }: { vintages: number[] }) { + if (vintages.length === 0) { + return

    No vintages registered.

    ; + } + + const newestFirst = [...vintages].sort((a, b) => b - a); + const shown = newestFirst.slice(0, VISIBLE_VINTAGES); + const older = newestFirst.slice(VISIBLE_VINTAGES); + + return ( + <> +
      + {shown.map((year) => ( +
    • {year}
    • + ))} +
    + {older.length > 0 && ( +
    + Show {older.length} older vintages +
      + {older.map((year) => ( +
    • {year}
    • + ))} +
    +
    + )} + + ); +} diff --git a/client/src/graphql/queries.ts b/client/src/graphql/queries.ts index 6f0ffff..911b3f3 100644 --- a/client/src/graphql/queries.ts +++ b/client/src/graphql/queries.ts @@ -4,6 +4,7 @@ import type { FoodCategory, FoodWine, TasteWine, + WineDetails, WineListItem, WineSort, } from '../types/wine'; @@ -235,3 +236,36 @@ export const WINES_BY_FOOD_QUERY = gql` export type WinesByFoodData = { winesByFoodCategory: { totalCount: number; items: FoodWine[] }; }; + +/** + * Everything the detail page shows about one wine. + * Apollo caches the result under the wine's id, so going back to the list + * and opening the same wine again does not send a new request. + */ +export const WINE_DETAILS_QUERY = gql` + query WineDetails($id: ID!) { + wine(id: $id) { + id + name + type + elaborate + abv + body + acidity + winery + wineryWebsite + region + country + vintages + grapes + foodPairings + avgRating + ratingCount + } + } +`; + +export type WineDetailsData = { + /** null when no wine has the id */ + wine: WineDetails | null; +}; diff --git a/client/src/types/wine.ts b/client/src/types/wine.ts index 277a49c..01615d7 100644 --- a/client/src/types/wine.ts +++ b/client/src/types/wine.ts @@ -91,3 +91,31 @@ export type FoodWine = WineListItem & { /** The dishes in the chosen category that the wine pairs with */ matchedFoods: string[]; }; + +/** + * Everything about one wine, for the detail page (/wines/:id). + * Matches the WineDetails type in the GraphQL schema (backend/src/index.ts). + */ +export type WineDetails = { + id: string; + name: string; + type: string; + /** How the wine is made, e.g. 'Varietal/100%' or 'Assemblage/Blend' */ + elaborate: string | null; + /** Alcohol in percent, null when unknown */ + abv: number | null; + body: BodyLevel | null; + acidity: AcidityLevel | null; + winery: string; + wineryWebsite: string | null; + region: string; + country: string; + /** Years the wine has been made, e.g. [2016, 2017, 2018] */ + vintages: number[]; + grapes: string[]; + /** Dishes from Harmonize in X-Wines, e.g. 'Beef' */ + foodPairings: string[]; + /** null when the wine has no ratings */ + avgRating: number | null; + ratingCount: number; +};