diff --git a/src/api/types.ts b/src/api/types.ts index bb711d5..ec10885 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -253,6 +253,43 @@ export interface CatalogueProduct { fssai_license?: string; highlights?: string[]; nutrients?: string[]; + /** + * The structured nutrition panel, where the catalogue carries one. + * + * `nutrients` above is the older shape — display lines like "Energy 350kcal", + * which are enough to print bullets and not enough for an app to lay out a + * table or say what the figures are measured against. This is what the agent + * team fills and what the customer app renders. + * + * Optional because most rows do not have it yet: the backend derives a panel + * from the lines when this is absent, so a reader should prefer this and fall + * back rather than treating its absence as "no nutrition". + */ + nutrition?: NutritionPanel; +} + +/** + * A nutrition panel as Fiesta sends it. + * + * Field names are the backend's and the customer app's — `servingsize`, not + * `servingSize`. An API is a promise to clients already written against it, and + * renaming on the way in would mean this file disagreeing with the wire. + */ +export interface NutritionPanel { + /** What the figures are measured against: "100g", "100ml", "1 serving". */ + per?: string; + /** What the pack calls one serving: "30g". A label routinely states both. */ + servingsize?: string; + /** Never empty when the panel is present — the backend omits an empty one. */ + items: NutritionItem[]; +} + +export interface NutritionItem { + name: string; + /** A float: saturated fat is 11.5g as often as it is 11g. */ + value: number; + /** "kcal", "g", "mg". Free text — the label is the authority. */ + unit?: string; } export interface CatalogueBrand { diff --git a/src/features/catalogue/CatalogueDetailDrawer.tsx b/src/features/catalogue/CatalogueDetailDrawer.tsx index 59d214b..883a57f 100644 --- a/src/features/catalogue/CatalogueDetailDrawer.tsx +++ b/src/features/catalogue/CatalogueDetailDrawer.tsx @@ -18,6 +18,7 @@ import { import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory'; import { aisleForCategory } from '@/features/store-admin/appAisle'; import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel'; +import { NutritionTable } from '@/features/catalogue/NutritionPanel'; /** * One global-catalogue product, in full. @@ -202,7 +203,7 @@ export function CatalogueDetailDrawer({ category={product.category} /> - {product.highlights?.length || product.nutrients?.length ? ( + {product.highlights?.length || product.nutrients?.length || product.nutrition?.items?.length ? (
{product.highlights?.length ? (
@@ -210,7 +211,13 @@ export function CatalogueDetailDrawer({
) : null} - {product.nutrients?.length ? ( + {/* The structured panel wins where the catalogue has one: it lines the + figures up so two products can be compared down a column, which is + most of what a nutrition table is for. The lines are the fallback + for every row the agent team has not reached yet. */} + {product.nutrition?.items?.length ? ( + + ) : product.nutrients?.length ? (

Nutrition

diff --git a/src/features/catalogue/NutritionPanel.tsx b/src/features/catalogue/NutritionPanel.tsx new file mode 100644 index 0000000..012f8c5 --- /dev/null +++ b/src/features/catalogue/NutritionPanel.tsx @@ -0,0 +1,80 @@ +import type { NutritionPanel as Panel } from '@/api/types'; + +/** + * A nutrition panel, laid out the way a label is. + * + * ── Why this is not bullets ───────────────────────────────────────────────── + * + * Nutrition used to render through `Bullets`, because the catalogue only ever + * held display strings — "Energy 350kcal" — and a list was the honest shape for + * a list. The catalogue now carries a structured panel, and a structured panel + * read as bullets is worse than either: the figures no longer line up, so the + * eye cannot run down a column to compare two products, which is most of what a + * nutrition table is for. + * + * So: names left, figures right, aligned. The same information, in the shape it + * was printed in. + * + * ── Why `per` sits at the top and not beside each row ─────────────────────── + * + * Because it applies to all of them. Repeating "per 100g" on seven rows is six + * repetitions of one fact, and a label does not do it either. Absent when the + * catalogue did not state it, rather than defaulted to "100g" — a wrong basis + * makes every figure beneath it a misstatement rather than an unknown. + */ +export function NutritionTable({ panel }: { panel: Panel }) { + if (!panel.items?.length) return null; + + const basis = [ + panel.per ? `per ${panel.per}` : null, + panel.servingsize ? `serving ${panel.servingsize}` : null, + ] + .filter(Boolean) + .join(' · '); + + return ( +
+

Nutrition

+ {basis ? ( +

{basis}

+ ) : null} + + + {panel.items.map((item, index) => ( + + + + + ))} + +
{item.name} + {/* A row with no figure is real label text — "Contains permitted + natural colour" — carried through rather than shown as 0, + which would state something false about the food. */} + {item.value || item.unit ? ( + <> + {item.value} + {item.unit ? ( + + {' '} + {item.unit} + + ) : null} + + ) : null} +
+
+ ); +}