diff --git a/api/internal/handler/openapi.json b/api/internal/handler/openapi.json index dd4a2e9..d2ff098 100644 --- a/api/internal/handler/openapi.json +++ b/api/internal/handler/openapi.json @@ -208,7 +208,22 @@ "nutriments": { "type": "object", "additionalProperties": true, "nullable": true }, "nutrition_basis": { "type": "string", "nullable": true }, "nutri_score": { "type": "string", "nullable": true }, - "ingredients_text": { "type": "string", "nullable": true } + "ingredients_text": { "type": "string", "nullable": true }, + "msrp": { + "type": "array", + "description": "Manufacturer suggested retail price snapshots (reference only, newest first; zero-amount entries omitted).", + "items": { + "type": "object", + "properties": { + "amount": { "type": "number" }, + "currency": { "type": "string" }, + "region": { "type": "string" }, + "effective_date": { "type": "string", "nullable": true }, + "source_url": { "type": "string", "nullable": true }, + "note": { "type": "string", "nullable": true } + } + } + } } } } diff --git a/api/internal/store/store.go b/api/internal/store/store.go index 1f31e6e..27f4d36 100644 --- a/api/internal/store/store.go +++ b/api/internal/store/store.go @@ -126,6 +126,7 @@ type Product struct { Ingredients *string `json:"ingredients_text,omitempty"` Allergens []string `json:"allergens,omitempty"` Additives []string `json:"additives,omitempty"` + MSRP []MSRP `json:"msrp,omitempty"` } // ProductBarcodes returns every barcode attached to a product, primary first. @@ -280,6 +281,9 @@ func (s *Store) ProductByGTIN(ctx context.Context, gtin string) (*Product, error if p.Specs, err = s.buildSpecs(ctx, p.ArchiveKind, attrs); err != nil { return nil, err } + if p.MSRP, err = s.ListMSRP(ctx, p.ID); err != nil { + return nil, err + } s.cacheSet(ctx, suffix+gtin, p, productCacheTTL) return p, nil } @@ -301,6 +305,9 @@ func (s *Store) ProductByID(ctx context.Context, id string) (*Product, error) { if p.Specs, err = s.buildSpecs(ctx, p.ArchiveKind, attrs); err != nil { return nil, err } + if p.MSRP, err = s.ListMSRP(ctx, p.ID); err != nil { + return nil, err + } s.cacheSet(ctx, suffix+id, p, productCacheTTL) return p, nil } @@ -445,11 +452,12 @@ type MSRP struct { Note *string `json:"note"` } -// ListMSRP returns all MSRP snapshots for a product. +// ListMSRP returns a product's suggested-retail-price snapshots, newest first. +// Zero-amount entries are excluded as they carry no price information. func (s *Store) ListMSRP(ctx context.Context, id string) ([]MSRP, error) { rows, err := s.pool.Query(ctx, `SELECT amount, currency, region, effective_date::text, source_url, note - FROM product_msrp WHERE product_id = $1 ORDER BY effective_date DESC NULLS LAST`, id) + FROM product_msrp WHERE product_id = $1 AND amount > 0 ORDER BY effective_date DESC NULLS LAST`, id) if err != nil { return nil, err } diff --git a/public-frontend/src/components/ProductView.tsx b/public-frontend/src/components/ProductView.tsx index 154eb02..c217295 100644 --- a/public-frontend/src/components/ProductView.tsx +++ b/public-frontend/src/components/ProductView.tsx @@ -42,6 +42,12 @@ export default function ProductView({ id, onBack }: { id: string; onBack: () => const nutriEntries = Object.entries(p.nutriments || {}).filter( ([, v]) => v !== null && v !== undefined, ); + const latestMsrp = (p.msrp || [])[0]; + const msrpText = latestMsrp + ? latestMsrp.currency === "CNY" + ? `¥${latestMsrp.amount.toFixed(2)}` + : `${latestMsrp.amount.toFixed(2)} ${latestMsrp.currency}` + : null; return (