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 (
-
+
onPageChange(page - 1)}
disabled={disabled || page <= 1}
>
- ← Forrige
+ ← Previous
- Side {page} av {totalPages}
+ Page {page} of {totalPages}
onPageChange(page + 1)}
disabled={disabled || page >= totalPages}
>
- Neste →
+ Next →
);
diff --git a/client/src/components/SelectedWine.tsx b/client/src/components/SelectedWine.tsx
new file mode 100644
index 0000000..895966e
--- /dev/null
+++ b/client/src/components/SelectedWine.tsx
@@ -0,0 +1,140 @@
+import { Link } from 'react-router';
+import { useQuery } from '@apollo/client/react';
+import type { TasteWine } from '../types/wine';
+import { WINE_GRAPES_QUERY, type WineGrapesData } from '../graphql/queries';
+import { ACIDITY_LEVELS, BODY_LEVELS, wineTypeColor } from '../data/taste';
+
+type Props = {
+ wine: TasteWine | null;
+};
+
+/** A wine glass filled with the colour of the wine type. Decorative only. */
+function WineGlass({ type }: { type: string }) {
+ return (
+
+
+
+
+
+ );
+}
+
+/** One row with label, bar and value, e.g. "Body ████░ Full-bodied". */
+function TasteBar(props: { label: string; share: number; value: string }) {
+ return (
+
+
{props.label}
+
+
+
+
{props.value}
+
+ );
+}
+
+/**
+ * The panel to the right of the map, showing the selected wine.
+ *
+ * The map query only has the fields every dot needs. Grapes and vintages
+ * are fetched here, for one wine at a time, with the existing wine(id) query.
+ */
+export default function SelectedWine({ wine }: Props) {
+ const { data } = useQuery(WINE_GRAPES_QUERY, {
+ variables: { id: wine?.id },
+ skip: !wine,
+ });
+
+ if (!wine) {
+ return (
+
+ );
+ }
+
+ // Only use the extra details if they belong to the wine shown now
+ const details = data?.wine?.id === wine.id ? data.wine : null;
+ const vintages = details?.vintages ?? [];
+ const bodyStep = BODY_LEVELS.indexOf(wine.body) + 1;
+ const acidityStep = ACIDITY_LEVELS.indexOf(wine.acidity) + 1;
+
+ return (
+ // aria-live: screen readers announce the new wine when the user picks one
+
+
+
+
+
+
+
{wine.name}
+
{wine.winery}
+
+
+
+
+ Grape
+ {details ? details.grapes.join(', ') || 'Unknown' : '…'}
+ Origin
+
+ {wine.region}, {wine.country}
+
+ Type
+ {wine.type}
+ {vintages.length > 0 && (
+ <>
+ Vintages
+
+ {Math.min(...vintages)}–{Math.max(...vintages)}
+
+ >
+ )}
+
+
+
+ ★ {' '}
+ {wine.avgRating != null ? wine.avgRating.toFixed(1) : 'No rating'}{' '}
+ ({wine.ratingCount.toLocaleString('en')} ratings)
+
+
+
+
+
+ {wine.abv != null && (
+
+ )}
+
+
+ {/*
+ 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/SiteHeader.css b/client/src/components/SiteHeader.css
new file mode 100644
index 0000000..2a2e279
--- /dev/null
+++ b/client/src/components/SiteHeader.css
@@ -0,0 +1,57 @@
+/* Top bar. A bit darker than the page, so it stands out. */
+.site-header {
+ background: var(--color-header);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.site-header-inner {
+ max-width: 78rem;
+ margin: 0 auto;
+ padding: 0.75rem 1rem;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem 2rem;
+}
+
+.site-logo {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ color: var(--color-text);
+ font-family: var(--font-display);
+ font-size: 1.6rem;
+ text-decoration: none;
+}
+
+/* The glass in the logo is wine red (currentColor inside the svg) */
+.site-logo svg {
+ width: 22px;
+ height: 28px;
+ color: var(--color-accent);
+}
+
+.site-header nav ul {
+ display: flex;
+ gap: 1.5rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.site-header nav a {
+ padding-block: 0.4rem;
+ border-bottom: 2px solid transparent;
+ color: var(--color-text);
+ text-decoration: none;
+}
+
+.site-header nav a:hover {
+ border-color: var(--color-muted);
+}
+
+.site-header nav a[aria-current='page'] {
+ border-color: var(--color-accent);
+ color: var(--color-accent);
+ font-weight: 600;
+}
diff --git a/client/src/components/SiteHeader.tsx b/client/src/components/SiteHeader.tsx
new file mode 100644
index 0000000..a348fda
--- /dev/null
+++ b/client/src/components/SiteHeader.tsx
@@ -0,0 +1,47 @@
+import { Link, NavLink } from 'react-router';
+import './SiteHeader.css';
+
+/**
+ * The top bar with logo and main menu, shown on every page.
+ *
+ * NavLink sets aria-current="page" on the link to the page we are on,
+ * so screen readers say which page is active, and CSS can underline it.
+ *
+ * NB: Not built yet, add here when the pages exist:
+ * - "Favourites" (needs login and the favourites page)
+ * - search field and "Log in"
+ */
+export default function SiteHeader() {
+ return (
+
+
+
+
+
+
+
+ Wine not?
+
+
+
+
+
+ {/* end: only active on exactly "/", not on every page */}
+
+ Explore
+
+
+
+ Wines
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/TasteFilters.tsx b/client/src/components/TasteFilters.tsx
new file mode 100644
index 0000000..38bdc94
--- /dev/null
+++ b/client/src/components/TasteFilters.tsx
@@ -0,0 +1,124 @@
+import type { TasteFilter } from '../types/wine';
+import type { TasteOptionsData } from '../graphql/queries';
+import { EMPTY_TASTE_FILTER, POPULAR_MIN_RATINGS } from '../data/taste';
+
+type Props = {
+ filter: TasteFilter;
+ onChange: (filter: TasteFilter) => void;
+ /** Choices for the dropdowns. Undefined while they are loading. */
+ options: TasteOptionsData['tasteOptions'] | undefined;
+};
+
+/**
+ * The filter panel on Explore by taste.
+ *
+ * Controlled component: the filter values live in ExploreByTaste, and every
+ * change is sent up with onChange. That way the parent decides when to fetch
+ * new wines, and this component is easy to test.
+ *
+ * Every control has a connected with htmlFor/id, so screen readers
+ * read the label when the control gets focus.
+ */
+export default function TasteFilters({ filter, onChange, options }: Props) {
+ function set(key: K, value: TasteFilter[K]) {
+ onChange({ ...filter, [key]: value });
+ }
+
+ return (
+
+
+
Filters
+ onChange(EMPTY_TASTE_FILTER)}
+ >
+ Reset all
+
+
+
+
+ Wine type
+ set('type', e.target.value)}
+ >
+ All types
+ {options?.types.map((t) => (
+ {t}
+ ))}
+
+
+
+
+ Grape
+ set('grape', e.target.value)}
+ >
+ All grapes
+ {options?.grapes.map((g) => (
+ {g}
+ ))}
+
+
+
+
+ Country
+ set('country', e.target.value)}
+ >
+ All countries
+ {options?.countries.map((c) => (
+ {c}
+ ))}
+
+
+
+
+ Year
+ set('year', e.target.value)}
+ >
+ All years
+ {options?.years.map((y) => (
+ {y}
+ ))}
+
+
+
+
+
+ Minimum rating: {filter.minRating ? `${filter.minRating}+` : 'any'}
+
+ set('minRating', Number(e.target.value))}
+ />
+
+
+
+ set('popularOnly', e.target.checked)}
+ />
+ Only wines with {POPULAR_MIN_RATINGS}+ ratings
+
+
+ );
+}
diff --git a/client/src/components/TasteMap.tsx b/client/src/components/TasteMap.tsx
new file mode 100644
index 0000000..9ecc1f8
--- /dev/null
+++ b/client/src/components/TasteMap.tsx
@@ -0,0 +1,205 @@
+import { useState } from 'react';
+import type { TasteWine } from '../types/wine';
+import {
+ ACIDITY_LEVELS,
+ BODY_LEVELS,
+ WINE_TYPES,
+ wineTypeColor,
+} from '../data/taste';
+
+type Props = {
+ wines: TasteWine[];
+ selectedId: string | null;
+ onSelect: (id: string) => void;
+};
+
+// Drawing area inside the SVG's viewBox (0 0 620 430).
+// The space outside it is used for the labels at the arrow ends.
+const VIEW_W = 620;
+const VIEW_H = 430;
+const PLOT = { left: 78, right: 542, top: 36, bottom: 392 };
+const CELL_W = (PLOT.right - PLOT.left) / ACIDITY_LEVELS.length;
+const CELL_H = (PLOT.bottom - PLOT.top) / BODY_LEVELS.length;
+const MID_X = (PLOT.left + PLOT.right) / 2;
+const MID_Y = (PLOT.top + PLOT.bottom) / 2;
+
+/**
+ * A number between 0 and 1 that is always the same for the same wine id.
+ * Used to spread the dots inside their area, so they don't move around
+ * every time the map is drawn again.
+ */
+function stableRandom(id: string, salt: number): number {
+ let hash = salt;
+ for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;
+ const x = Math.sin(hash) * 10000;
+ return x - Math.floor(x);
+}
+
+/**
+ * Where a wine's dot is drawn.
+ *
+ * Body and acidity are categories in the database (5 and 3 levels), not
+ * numbers. Each combination gets its own area of the map, and the dots are
+ * spread out inside it. Without the spreading, all wines with the same
+ * levels would lie on top of each other in one point.
+ */
+function dotPosition(wine: TasteWine) {
+ const a = ACIDITY_LEVELS.indexOf(wine.acidity);
+ const b = BODY_LEVELS.indexOf(wine.body);
+ const centerX = PLOT.left + CELL_W * (a + 0.5);
+ const centerY = PLOT.bottom - CELL_H * (b + 0.5);
+ return {
+ x: centerX + (stableRandom(wine.id, 1) - 0.5) * (CELL_W - 20),
+ y: centerY + (stableRandom(wine.id, 2) - 0.5) * (CELL_H - 14),
+ };
+}
+
+/**
+ * The taste map: wines plotted by acidity (left to right) and body
+ * (bottom to top), coloured by wine type.
+ *
+ * Display only: the wines come in as props, and a click is sent up with
+ * onSelect. Hovering or focusing a dot shows a small box with the wine's
+ * name, region and country.
+ *
+ * Accessibility: every dot is a focusable button with a full description
+ * (name, type, body, acidity), so the map works with keyboard and screen
+ * reader. The legend shows wine type as text, not only as colour.
+ */
+export default function TasteMap({ wines, selectedId, onSelect }: Props) {
+ const [hovered, setHovered] = useState(null);
+ const hoveredPos = hovered ? dotPosition(hovered) : null;
+
+ return (
+
+
+
+
+
+
+
+
+ {/* Two double-headed arrows that cross in the middle */}
+
+
+
+ {/* Labels at the arrow ends. aria-hidden: the aria-label above says the same. */}
+
+
+ Full-bodied
+
+
+ Light-bodied
+
+
+ Low
+
+ acidity
+
+
+
+ High
+
+ acidity
+
+
+
+
+ {wines.map((wine) => {
+ const { x, y } = dotPosition(wine);
+ const selected = wine.id === selectedId;
+ return (
+ onSelect(wine.id)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onSelect(wine.id);
+ }
+ }}
+ onMouseEnter={() => setHovered(wine)}
+ onMouseLeave={() => setHovered(null)}
+ onFocus={() => setHovered(wine)}
+ onBlur={() => setHovered(null)}
+ >
+
+
+ {/* Invisible, larger circle so the dot is easier to hit */}
+
+
+ );
+ })}
+
+
+ {hovered && hoveredPos && (
+
+ {hovered.name}
+
+ {hovered.region}, {hovered.country}
+
+
+ )}
+
+
+ {WINE_TYPES.map((t) => (
+
+
+ {t.label}
+
+ ))}
+
+
+ );
+}
diff --git a/client/src/components/WineCatalog.tsx b/client/src/components/WineCatalog.tsx
index 82906d2..9d60543 100644
--- a/client/src/components/WineCatalog.tsx
+++ b/client/src/components/WineCatalog.tsx
@@ -64,9 +64,9 @@ export default function WineCatalog() {
// role="status" and role="alert" make screen readers announce the messages
if (error) {
- return Kunne ikke hente viner. Prøv igjen senere.
;
+ return Could not load wines. Please try again later.
;
}
- if (!shown) return Laster viner …
;
+ if (!shown) return Loading wines …
;
const totalCount = shown.wines.totalCount;
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
@@ -75,14 +75,14 @@ export default function WineCatalog() {
// aria-busy tells screen readers the content is being updated
- Søk etter vin eller produsent
+ Search for a wine or winery
setSearchInput(event.target.value)}
- placeholder="For eksempel Barolo"
+ placeholder="For example Barolo"
/>
diff --git a/client/src/components/WineList.tsx b/client/src/components/WineList.tsx
index 076bb52..afb6728 100644
--- a/client/src/components/WineList.tsx
+++ b/client/src/components/WineList.tsx
@@ -25,14 +25,14 @@ type Props = {
*/
export default function WineList({ wines, totalCount, offset = 0 }: Props) {
if (wines.length === 0) {
- return
Ingen viner funnet.
;
+ return
No wines found.
;
}
return (
- Vinkatalog
+ Wine catalog
- Viser {offset + 1}-{offset + wines.length} av {totalCount} viner
+ Showing {offset + 1}-{offset + wines.length} of {totalCount} wines
{/*
@@ -45,7 +45,7 @@ export default function WineList({ wines, totalCount, offset = 0 }: Props) {
{wine.name}
- Produsent
+ Winery
{wine.winery}
Type
@@ -61,7 +61,7 @@ export default function WineList({ wines, totalCount, offset = 0 }: Props) {
{/* avgRating can be null, so check before .toFixed */}
{wine.avgRating != null
? wine.avgRating.toFixed(1)
- : 'Ingen vurderinger'}
+ : 'No ratings'}
diff --git a/client/src/data/taste.ts b/client/src/data/taste.ts
new file mode 100644
index 0000000..98f4da2
--- /dev/null
+++ b/client/src/data/taste.ts
@@ -0,0 +1,54 @@
+import type { AcidityLevel, BodyLevel, TasteFilter } from '../types/wine';
+
+/**
+ * Constants for Explore by taste.
+ * They live in their own file (not in a component file) so that
+ * React Fast Refresh keeps working, the same reason as graphql/queries.ts.
+ */
+
+/** Same order as the enum in the database: lightest first. */
+export const BODY_LEVELS: BodyLevel[] = [
+ 'Very light-bodied',
+ 'Light-bodied',
+ 'Medium-bodied',
+ 'Full-bodied',
+ 'Very full-bodied',
+];
+
+/** Same order as the enum in the database: lowest first. */
+export const ACIDITY_LEVELS: AcidityLevel[] = ['Low', 'Medium', 'High'];
+
+/** Wine types in the legend, with their colour from index.css. */
+export const WINE_TYPES = [
+ { label: 'Red', color: 'var(--wine-red)' },
+ { label: 'White', color: 'var(--wine-white)' },
+ { label: 'Rosé', color: 'var(--wine-rose)' },
+ { label: 'Sparkling', color: 'var(--wine-sparkling)' },
+ { label: 'Dessert', color: 'var(--wine-dessert)' },
+];
+
+/**
+ * The colour for a wine type from the database.
+ * X-Wines also has the type 'Dessert/Port', which gets the dessert colour.
+ */
+export function wineTypeColor(type: string): string {
+ if (type.startsWith('Dessert')) return 'var(--wine-dessert)';
+ return (
+ WINE_TYPES.find((t) => t.label === type)?.color ?? 'var(--color-muted)'
+ );
+}
+
+/** How many wines the map shows at most. The backend allows up to 300. */
+export const TASTE_MAP_LIMIT = 150;
+
+/** "Only popular wines" means at least this many ratings. */
+export const POPULAR_MIN_RATINGS = 100;
+
+export const EMPTY_TASTE_FILTER: TasteFilter = {
+ type: '',
+ grape: '',
+ country: '',
+ year: '',
+ minRating: 0,
+ popularOnly: false,
+};
diff --git a/client/src/graphql/queries.ts b/client/src/graphql/queries.ts
index afd9b66..aa33aef 100644
--- a/client/src/graphql/queries.ts
+++ b/client/src/graphql/queries.ts
@@ -1,5 +1,5 @@
import { gql } from '@apollo/client';
-import type { WineListItem } from '../types/wine';
+import type { TasteWine, WineListItem } from '../types/wine';
/**
* GraphQL queries used by the client.
@@ -41,3 +41,73 @@ export type WinesVariables = {
offset?: number;
limit?: number;
};
+
+/**
+ * Wines for the Explore by taste map.
+ *
+ * The backend returns the most-rated wines that match the filters, at most
+ * `limit` of them. totalCount is the number of all matching wines.
+ */
+export const TASTE_MAP_QUERY = gql`
+ query TasteMap($filter: TasteFilter, $limit: Int) {
+ tasteMap(filter: $filter, limit: $limit) {
+ totalCount
+ items {
+ id
+ name
+ type
+ winery
+ region
+ country
+ avgRating
+ ratingCount
+ abv
+ body
+ acidity
+ }
+ }
+ }
+`;
+
+export type TasteMapData = {
+ tasteMap: { totalCount: number; items: TasteWine[] };
+};
+
+/** The choices in the filter dropdowns on Explore by taste. */
+export const TASTE_OPTIONS_QUERY = gql`
+ query TasteOptions {
+ tasteOptions {
+ types
+ countries
+ grapes
+ years
+ }
+ }
+`;
+
+export type TasteOptionsData = {
+ tasteOptions: {
+ types: string[];
+ countries: string[];
+ grapes: string[];
+ years: number[];
+ };
+};
+
+/**
+ * Grapes and vintages for the selected wine on Explore by taste.
+ * Fetched for one wine at a time, so the map query stays small.
+ */
+export const WINE_GRAPES_QUERY = gql`
+ query WineGrapes($id: ID!) {
+ wine(id: $id) {
+ id
+ grapes
+ vintages
+ }
+ }
+`;
+
+export type WineGrapesData = {
+ wine: { id: string; grapes: string[]; vintages: number[] } | null;
+};
diff --git a/client/src/index.css b/client/src/index.css
index 19b30f9..27e139c 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -2,15 +2,41 @@
* Global base styling for Wine not?
* Replaces the Vite template. Colours are chosen for sufficient contrast
* (WCAG AA, at least 4.5:1 for normal text).
+ *
+ * Use these variables in all components instead of writing colours directly,
+ * so the whole app keeps the same look.
*/
:root {
- --color-bg: #faf7f5;
- --color-surface: #ffffff;
+ /* Page and surfaces */
+ --color-bg: #faf6f4; /* soft white page background */
+ --color-surface: #ffffff; /* cards, e.g. in the wine catalog */
+ --color-panel: #f1e8e4; /* slightly darker panels (filters, map, details) */
+ --color-field: #fdfbfa; /* inputs and dropdowns inside panels */
+ --color-sunk: #e6dad6; /* tracks for bars, icon backgrounds */
+ --color-header: #e6d6d0; /* top bar with logo and menu */
+
+ /* Text and lines */
--color-text: #2b1d1f;
--color-muted: #5f4b4e;
- --color-accent: #7b1e2b; /* wine red */
- --color-border: #e4dcd9;
+ --color-accent: #7b1e2b; /* wine red: buttons, links, active menu item */
+ --color-accent-ink: #ffffff; /* text on top of --color-accent */
+ --color-border: #e2d5d1;
+ --color-star: #c48a12; /* rating stars */
+
+ /*
+ * One colour per wine type (dots on the taste map, legends, icons).
+ * Always show the type as text as well, never with colour alone:
+ * --wine-white and --wine-sparkling are light and have low contrast
+ * against the background.
+ */
+ --wine-red: #6e1f2f;
+ --wine-white: #e3c97a;
+ --wine-rose: #d98a9a;
+ --wine-sparkling: #c4b5e0;
+ --wine-dessert: #d9864a;
+ /* Fonts. Local fonts only, so no extra download is needed. */
+ --font-display: Georgia, 'Times New Roman', serif; /* large headings */
font-family:
system-ui,
-apple-system,
diff --git a/client/src/types/wine.ts b/client/src/types/wine.ts
index d6804b0..88feb4b 100644
--- a/client/src/types/wine.ts
+++ b/client/src/types/wine.ts
@@ -13,3 +13,38 @@ export type WineListItem = {
/** null when the wine has no ratings */
avgRating: number | null;
};
+
+/** The body levels in the database (enum body_level), lightest first. */
+export type BodyLevel =
+ | 'Very light-bodied'
+ | 'Light-bodied'
+ | 'Medium-bodied'
+ | 'Full-bodied'
+ | 'Very full-bodied';
+
+/** The acidity levels in the database (enum acidity_level), lowest first. */
+export type AcidityLevel = 'Low' | 'Medium' | 'High';
+
+/**
+ * A wine on the Explore by taste map.
+ * The backend only returns wines that have both body and acidity,
+ * so they are never null here.
+ */
+export type TasteWine = WineListItem & {
+ body: BodyLevel;
+ acidity: AcidityLevel;
+ /** Alcohol in percent, null when unknown */
+ abv: number | null;
+ ratingCount: number;
+};
+
+/** Filters on Explore by taste. Empty values mean "no filter". */
+export type TasteFilter = {
+ type: string;
+ grape: string;
+ country: string;
+ year: string;
+ minRating: number;
+ /** Only wines with at least 100 ratings */
+ popularOnly: boolean;
+};