- for label/value pairs,
- 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'} +
- 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
+
+ )} +
+ - {food} + ))} +
- {year} + ))} +
- {year} + ))} +
- for
+ * lists and
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
+
+
+
+
+
+ Taste
+
+
+
+
+
+ Goes well with
+ {wine.foodPairings.length > 0 ? (
+ // role="list": list-style: none makes Safari stop announcing lists
+
+ {wine.foodPairings.map((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) => (
+
+ {older.length > 0 && (
+
+ Show {older.length} older vintages
+
+ {older.map((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;
+};
Loading wine …
+Something went wrong
+Could not load this wine. Please try again later.
+ Go to all wines +Wine not found
+There is no wine with this address. The link may be wrong.
+ Go to all wines +{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
+-
+
Taste
+-
+
Goes well with
+ {wine.foodPairings.length > 0 ? ( + // role="list": list-style: none makes Safari stop announcing lists +-
+ {wine.foodPairings.map((food) => (
+
No food pairings registered.
+ )} +Vintages
+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) => (
+
Show {older.length} older vintages
+-
+ {older.map((year) => (
+