diff --git a/.prettierrc.json b/.prettierrc.json index ebd8881..46f15e9 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -2,5 +2,6 @@ "singleQuote": true, "semi": true, "trailingComma": "all", - "printWidth": 80 + "printWidth": 80, + "endOfLine": "auto" } diff --git a/backend/src/index.ts b/backend/src/index.ts index b4956dd..1a7054c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -10,18 +10,66 @@ function readPaging(args: { limit?: number; offset?: number }) { const offset = args.offset ?? 0; if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) { - throw new GraphQLError(`limit må være mellom 1 og ${MAX_LIMIT}`, { + throw new GraphQLError(`limit must be between 1 and ${MAX_LIMIT}`, { extensions: { code: 'BAD_USER_INPUT' }, }); } if (!Number.isInteger(offset) || offset < 0) { - throw new GraphQLError('offset kan ikke være negativ', { + throw new GraphQLError('offset cannot be negative', { extensions: { code: 'BAD_USER_INPUT' }, }); } return { limit, offset }; } +// Smakskartet viser prikker, ikke sider, så det får en egen grense. +// Flere enn ca. 300 prikker blir uleselig og tungt å tegne. +const TASTE_MAP_MAX = 300; + +type TasteFilter = { + type?: string | null; + grape?: string | null; + country?: string | null; + year?: number | null; + minRating?: number | null; + minRatingCount?: number | null; +}; + +/** + * Lager WHERE-delen for smakskartet ut fra filtrene. + * Verdiene sendes alltid som parametere ($1, $2, ...), aldri rett inn i SQL, + * så brukerinput kan ikke endre spørringen (SQL-injeksjon). + */ +function tasteWhere(filter: TasteFilter = {}) { + const where = ['w.body IS NOT NULL', 'w.acidity IS NOT NULL']; + const params: unknown[] = []; + const add = (sql: string, value: unknown) => { + params.push(value); + where.push(sql.replace('?', `$${params.length}`)); + }; + + if (filter.type) add('w.type = ?', filter.type); + if (filter.country) add('c.name = ?', filter.country); + if (filter.grape) { + add( + `EXISTS ( + SELECT 1 FROM wine_grapes wg + JOIN grapes g ON g.id = wg.grape_id + WHERE wg.wine_id = w.id AND g.name = ? + )`, + filter.grape, + ); + } + // @> bruker GIN-indeksen wines_vintages_idx + if (filter.year != null) add('w.vintages @> ARRAY[?]::int[]', filter.year); + if (filter.minRating != null) add('w.avg_rating >= ?', filter.minRating); + if (filter.minRatingCount != null) { + add('w.rating_count >= ?', filter.minRatingCount); + } + + return { sql: where.join(' AND '), params }; +} + const typeDefs = `#graphql type Wine { @@ -67,6 +115,24 @@ const typeDefs = `#graphql wineCount: Int! } + # Filtre for Explore by taste. Alle er valgfrie; det som mangler, filtreres ikke på. + input TasteFilter { + type: String + grape: String + country: String + year: Int + minRating: Float + minRatingCount: Int + } + + # Verdiene til nedtrekksmenyene i filterpanelet på Explore by taste + type TasteOptions { + types: [String!]! + countries: [String!]! + grapes: [String!]! + years: [Int!]! + } + type Query { health: String! @@ -85,6 +151,11 @@ const typeDefs = `#graphql limit: Int = 20 offset: Int = 0 ): WinePage! + + # Viner til smakskartet. Bare viner med både fylde og syre kommer med. + tasteMap(filter: TasteFilter, limit: Int = 150): WinePage! + + tasteOptions: TasteOptions! } `; @@ -273,6 +344,93 @@ const resolvers = { totalCount: countResult.rows[0].count, }; }, + + tasteMap: async ( + _: unknown, + args: { filter?: TasteFilter | null; limit?: number }, + ) => { + const limit = args.limit ?? 150; + if (!Number.isInteger(limit) || limit < 1 || limit > TASTE_MAP_MAX) { + throw new GraphQLError(`limit must be between 1 and ${TASTE_MAP_MAX}`, { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + const { sql, params } = tasteWhere(args.filter ?? {}); + + // Vi viser de vinene flest har vurdert, så kartet fylles med kjente + // viner i stedet for viner med én enkelt vurdering på 5.0. + const winesResult = await pool.query( + ` + SELECT + w.id, + w.name, + w.type, + w.abv::float AS abv, + w.body, + w.acidity, + wi.name AS winery, + r.name AS region, + c.name AS country, + w.avg_rating::float AS "avgRating", + w.rating_count AS "ratingCount" + FROM wines w + 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 ${sql} + ORDER BY w.rating_count DESC, w.id + LIMIT $${params.length + 1} + `, + [...params, limit], + ); + + // Totalen telles med de samme filtrene, så "X wines found" viser alle + // vinene som passer, selv om kartet bare tegner de første `limit`. + const countResult = await pool.query( + ` + SELECT count(*)::int AS count + FROM wines w + JOIN countries c ON w.country_code = c.code + WHERE ${sql} + `, + params, + ); + + return { + items: winesResult.rows, + totalCount: countResult.rows[0].count, + }; + }, + + tasteOptions: async () => { + // Fire små spørringer i parallell. Druer begrenses til de 60 vanligste, + // ellers blir nedtrekksmenyen for lang til å være nyttig. + const [types, countries, grapes, years] = await Promise.all([ + pool.query(`SELECT DISTINCT type FROM wines ORDER BY type`), + pool.query(`SELECT name FROM countries ORDER BY name`), + pool.query(` + SELECT g.name + FROM grapes g + JOIN wine_grapes wg ON wg.grape_id = g.id + GROUP BY g.name + ORDER BY count(*) DESC + LIMIT 60 + `), + pool.query(` + SELECT DISTINCT unnest(vintages) AS year + FROM wines + ORDER BY year DESC + `), + ]); + + return { + types: types.rows.map((r) => r.type), + countries: countries.rows.map((r) => r.name), + grapes: grapes.rows.map((r) => r.name).sort(), + years: years.rows.map((r) => r.year), + }; + }, }, }; diff --git a/client/index.html b/client/index.html index cb93dc2..f7693cd 100644 --- a/client/index.html +++ b/client/index.html @@ -1,5 +1,5 @@ - + diff --git a/client/src/App.tsx b/client/src/App.tsx index ec50821..1249a83 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,17 +1,52 @@ +import { Link, Route, Routes, useParams } from 'react-router'; +import SiteHeader from './components/SiteHeader'; +import ExploreByTaste from './components/ExploreByTaste'; import WineCatalog from './components/WineCatalog'; +/** + * 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 +
+ ); +} + /** * Root component for Wine not? * - *
marks the page's main content, so screen reader and keyboard users - * can jump straight to it. The page has one

, and WineList uses

, - * so the heading hierarchy is correct. + * SiteHeader is shown on every page. Routes picks the page from the URL: + * - / Explore by taste (front page) + * - /wines the wine catalog + * - /wines/:id detail page (placeholder for now) + * + * Each page has its own
and one

, so the heading hierarchy is + * correct on every page. */ export default function App() { return ( -
-

Wine not?

- -
+ <> + + + } /> + +

All wines

+ +

+ } + /> + } /> + + ); } diff --git a/client/src/assets/vineyard.jpg b/client/src/assets/vineyard.jpg new file mode 100644 index 0000000..a11b3aa Binary files /dev/null and b/client/src/assets/vineyard.jpg differ diff --git a/client/src/components/ExploreByTaste.css b/client/src/components/ExploreByTaste.css new file mode 100644 index 0000000..07d0935 --- /dev/null +++ b/client/src/components/ExploreByTaste.css @@ -0,0 +1,410 @@ +/* + * Explore by taste (front page). + * Colours come from the variables in index.css. + */ + +/* ---------- Hero with vineyard image ---------- */ + +.taste-hero { + position: relative; + overflow: hidden; + background: var(--color-panel); + border-bottom: 1px solid var(--color-border); +} + +/* The image. center 92% shows the lower part, with the rows of vines. */ +.taste-hero::before { + content: ''; + position: absolute; + inset: 0; + background: url('../assets/vineyard.jpg') center 92% / cover no-repeat; +} + +/* A light veil in the middle, so the text stays readable on the image */ +.taste-hero::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + ellipse 60% 85% at 50% 50%, + rgb(250 246 244 / 0.72), + rgb(250 246 244 / 0.15) + ); +} + +.taste-hero-inner { + position: relative; + z-index: 1; + max-width: 78rem; + margin: 0 auto; + padding: 3.75rem 1rem; + display: grid; + gap: 0.5rem; + justify-items: center; + text-align: center; +} + +.taste-hero h1 { + margin: 0; + font-family: var(--font-display); + font-weight: 400; + font-size: clamp(2rem, 4vw, 2.75rem); + color: var(--color-text); +} + +.taste-hero p { + margin: 0; + max-width: 40rem; + color: var(--color-text); +} + +/* ---------- Three columns: filters | map | selected wine ---------- */ + +main.taste-layout { + max-width: 78rem; + display: grid; + grid-template-columns: 15rem minmax(0, 1fr) 18rem; + gap: 1.25rem; + align-items: start; + padding: 1.5rem 1rem 3rem; +} + +.taste-panel { + min-width: 0; + padding: 1.1rem; + background: var(--color-panel); + border: 1px solid var(--color-border); + border-radius: 0.75rem; +} + +.taste-panel h2 { + margin: 0; +} + +.taste-note { + margin: 0; + font-size: 0.85rem; + color: var(--color-muted); +} + +/* ---------- Filters ---------- */ + +.taste-filters { + display: grid; + gap: 0.9rem; +} + +.taste-filters-head { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.taste-filters-head h2 { + font-family: var(--font-display); + font-weight: 400; + font-size: 1.35rem; +} + +.taste-link-button { + padding: 0; + border: 0; + background: none; + color: var(--color-accent); + font: inherit; + font-size: 0.85rem; + text-decoration: underline; + cursor: pointer; +} + +.taste-field { + display: grid; + gap: 0.3rem; +} + +.taste-field label { + font-size: 0.85rem; + font-weight: 600; +} + +.taste-field select { + padding: 0.45rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: 0.5rem; + background: var(--color-field); + color: var(--color-text); + font: inherit; +} + +.taste-field input[type='range'] { + width: 100%; + accent-color: var(--color-accent); +} + +.taste-check { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; +} + +.taste-check input { + width: 1rem; + height: 1rem; + accent-color: var(--color-accent); +} + +/* ---------- Map ---------- */ + +.taste-map-panel { + display: grid; + gap: 0.25rem; +} + +.taste-count { + margin: 0; + font-weight: 600; + font-size: 0.95rem; + font-variant-numeric: tabular-nums; +} + +.taste-map { + position: relative; +} + +.taste-map svg { + display: block; + width: 100%; + height: auto; +} + +.taste-map-axis { + stroke: var(--color-muted); + stroke-width: 1.5; +} + +.taste-map-arrow-head { + fill: var(--color-muted); +} + +.taste-map-label text { + fill: var(--color-text); + font-size: 13px; +} + +.taste-dot { + cursor: pointer; +} + +/* The focus ring is drawn by .taste-dot-ring, not the browser outline */ +.taste-dot:focus { + outline: none; +} + +.taste-dot-fill { + stroke: var(--color-panel); + stroke-width: 2; + transition: r 0.12s; +} + +.taste-dot:hover .taste-dot-fill { + r: 9.5; +} + +.taste-dot-ring { + fill: none; + stroke: transparent; + stroke-width: 2; +} + +.taste-dot.is-selected .taste-dot-ring, +.taste-dot:focus-visible .taste-dot-ring { + stroke: var(--color-text); +} + +.taste-dot-hit { + fill: transparent; +} + +.taste-map-tooltip { + position: absolute; + transform: translate(-50%, calc(-100% - 14px)); + display: grid; + padding: 0.45rem 0.65rem; + background: var(--color-field); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + box-shadow: 0 4px 14px rgb(0 0 0 / 0.12); + font-size: 0.82rem; + white-space: nowrap; + pointer-events: none; +} + +.taste-map-tooltip span { + color: var(--color-muted); +} + +.taste-map-legend { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem 1.1rem; + margin: 0.5rem 0 0; + padding: 0; + list-style: none; + font-size: 0.85rem; +} + +.taste-map-legend li { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.taste-swatch { + width: 0.7rem; + height: 0.7rem; + border-radius: 50%; +} + +/* ---------- Selected wine ---------- */ + +.taste-selected { + display: grid; + gap: 0.9rem; +} + +.taste-selected-top { + display: flex; + align-items: center; + gap: 0.9rem; +} + +.taste-selected h2 { + font-family: var(--font-display); + font-weight: 400; + font-size: 1.6rem; + line-height: 1.15; +} + +.taste-glass-box { + flex: none; + display: grid; + place-items: center; + width: 3.25rem; + height: 4.25rem; + border-radius: 0.5rem; + background: var(--color-sunk); + color: var(--color-muted); +} + +.taste-glass { + width: 28px; + height: 44px; +} + +.taste-winery { + margin: 0.15rem 0 0; + color: var(--color-muted); +} + +.taste-facts { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.3rem 0.75rem; + margin: 0; + font-size: 0.9rem; +} + +.taste-facts dt { + color: var(--color-muted); +} + +.taste-facts dd { + margin: 0; +} + +.taste-rating { + margin: 0; + font-weight: 600; +} + +.taste-rating span { + color: var(--color-star); +} + +.taste-rating small { + font-weight: 400; + color: var(--color-muted); +} + +.taste-bars { + display: grid; + gap: 0.5rem; +} + +.taste-bar-row { + display: grid; + grid-template-columns: 4.5rem 1fr auto; + align-items: center; + gap: 0.6rem; + font-size: 0.88rem; +} + +.taste-bar { + height: 0.5rem; + overflow: hidden; + border-radius: 999px; + background: var(--color-sunk); +} + +.taste-bar i { + display: block; + height: 100%; + border-radius: 999px; + background: var(--color-accent); +} + +.taste-bar-row b { + min-width: 7.5rem; + text-align: right; + font-size: 0.85rem; + font-weight: 600; +} + +.taste-button { + display: block; + padding: 0.7rem; + border-radius: 0.5rem; + background: var(--color-accent); + color: var(--color-accent-ink); + font-weight: 600; + text-align: center; + text-decoration: none; +} + +/* ---------- Smaller screens ---------- */ + +/* Tablet: selected wine moves below filters and map */ +@media (max-width: 1100px) { + main.taste-layout { + grid-template-columns: 14rem minmax(0, 1fr); + } + + .taste-selected { + grid-column: 1 / -1; + } +} + +/* Phone: one column */ +@media (max-width: 760px) { + main.taste-layout { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + .taste-dot-fill { + transition: none; + } +} diff --git a/client/src/components/ExploreByTaste.tsx b/client/src/components/ExploreByTaste.tsx new file mode 100644 index 0000000..a8a30ad --- /dev/null +++ b/client/src/components/ExploreByTaste.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { useQuery } from '@apollo/client/react'; +import TasteFilters from './TasteFilters'; +import TasteMap from './TasteMap'; +import SelectedWine from './SelectedWine'; +import { + TASTE_MAP_QUERY, + TASTE_OPTIONS_QUERY, + type TasteMapData, + type TasteOptionsData, +} from '../graphql/queries'; +import type { TasteFilter } from '../types/wine'; +import { + EMPTY_TASTE_FILTER, + POPULAR_MIN_RATINGS, + TASTE_MAP_LIMIT, +} from '../data/taste'; +import './ExploreByTaste.css'; + +/** + * Turns the filter state into GraphQL variables. + * Empty values become null, which the backend reads as "no filter". + */ +function toVariables(filter: TasteFilter) { + return { + limit: TASTE_MAP_LIMIT, + filter: { + type: filter.type || null, + grape: filter.grape || null, + country: filter.country || null, + year: filter.year ? Number(filter.year) : null, + minRating: filter.minRating || null, + minRatingCount: filter.popularOnly ? POPULAR_MIN_RATINGS : null, + }, + }; +} + +/** + * Explore by taste: the front page. + * + * Wines are shown on a map by body and acidity. The user filters in the left + * panel, picks a wine on the map, sees it in the right panel and can go on to + * the detail page. + * + * Data fetching and state live here; TasteFilters, TasteMap and SelectedWine + * only display what they get as props (same split as WineCatalog/WineList). + * + * Scalability: the map never loads the whole dataset. The backend returns at + * most TASTE_MAP_LIMIT wines (the most-rated ones that match the filters), + * and totalCount tells how many match in total. + */ +export default function ExploreByTaste() { + const [filter, setFilter] = useState(EMPTY_TASTE_FILTER); + const [selectedId, setSelectedId] = useState(null); + + const options = useQuery(TASTE_OPTIONS_QUERY); + const { data, previousData, loading, error } = useQuery( + TASTE_MAP_QUERY, + { variables: toVariables(filter) }, + ); + + // Keep showing the previous wines while new ones load, so the map + // doesn't flash empty every time a filter changes + const shown = (data ?? previousData)?.tasteMap; + const wines = shown?.items ?? []; + + // The selected wine. If it disappears because of a filter, or nothing is + // selected yet, we show the first (most-rated) wine instead. + const selected = wines.find((w) => w.id === selectedId) ?? wines[0] ?? null; + + return ( + <> +
+
+

Explore by taste

+

+ Find wines that match your taste. See how wines compare on body and + acidity, and filter to find exactly what you like. +

+
+
+ +
+ + +
+ {/* role="status" announces the new count when filters change */} +

+ {shown + ? `${shown.totalCount.toLocaleString('en')} wines found` + : 'Loading wines …'} +

+ {shown && shown.totalCount > wines.length && ( +

+ The map shows the {wines.length} most-rated of them. +

+ )} + + {error && ( +

Could not load wines. Please try again later.

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

+ No wines match these filters. Try removing some of them. +

+ )} + + +
+ + +
+ + ); +} diff --git a/client/src/components/Pagination.tsx b/client/src/components/Pagination.tsx index cfe367e..d24d7ad 100644 --- a/client/src/components/Pagination.tsx +++ b/client/src/components/Pagination.tsx @@ -32,17 +32,17 @@ export default function Pagination({ if (totalPages <= 1) return null; return ( -