feat(public): 商品详情页展示厂商建议零售价(MSRP) #34

Merged
lixu merged 1 commits from devin/1782348138-public-msrp into main 2026-06-25 08:59:22 +08:00
4 changed files with 53 additions and 3 deletions
+16 -1
View File
@@ -208,7 +208,22 @@
"nutriments": { "type": "object", "additionalProperties": true, "nullable": true }, "nutriments": { "type": "object", "additionalProperties": true, "nullable": true },
"nutrition_basis": { "type": "string", "nullable": true }, "nutrition_basis": { "type": "string", "nullable": true },
"nutri_score": { "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 }
}
}
}
} }
} }
} }
+10 -2
View File
@@ -126,6 +126,7 @@ type Product struct {
Ingredients *string `json:"ingredients_text,omitempty"` Ingredients *string `json:"ingredients_text,omitempty"`
Allergens []string `json:"allergens,omitempty"` Allergens []string `json:"allergens,omitempty"`
Additives []string `json:"additives,omitempty"` Additives []string `json:"additives,omitempty"`
MSRP []MSRP `json:"msrp,omitempty"`
} }
// ProductBarcodes returns every barcode attached to a product, primary first. // 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 { if p.Specs, err = s.buildSpecs(ctx, p.ArchiveKind, attrs); err != nil {
return nil, err return nil, err
} }
if p.MSRP, err = s.ListMSRP(ctx, p.ID); err != nil {
return nil, err
}
s.cacheSet(ctx, suffix+gtin, p, productCacheTTL) s.cacheSet(ctx, suffix+gtin, p, productCacheTTL)
return p, nil 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 { if p.Specs, err = s.buildSpecs(ctx, p.ArchiveKind, attrs); err != nil {
return nil, err return nil, err
} }
if p.MSRP, err = s.ListMSRP(ctx, p.ID); err != nil {
return nil, err
}
s.cacheSet(ctx, suffix+id, p, productCacheTTL) s.cacheSet(ctx, suffix+id, p, productCacheTTL)
return p, nil return p, nil
} }
@@ -445,11 +452,12 @@ type MSRP struct {
Note *string `json:"note"` 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) { func (s *Store) ListMSRP(ctx context.Context, id string) ([]MSRP, error) {
rows, err := s.pool.Query(ctx, rows, err := s.pool.Query(ctx,
`SELECT amount, currency, region, effective_date::text, source_url, note `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 { if err != nil {
return nil, err return nil, err
} }
@@ -42,6 +42,12 @@ export default function ProductView({ id, onBack }: { id: string; onBack: () =>
const nutriEntries = Object.entries(p.nutriments || {}).filter( const nutriEntries = Object.entries(p.nutriments || {}).filter(
([, v]) => v !== null && v !== undefined, ([, 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 ( return (
<div> <div>
@@ -93,6 +99,17 @@ export default function ProductView({ id, onBack }: { id: string; onBack: () =>
} }
/> />
<Row label="产地" value={p.country_of_origin} /> <Row label="产地" value={p.country_of_origin} />
<Row
label="建议零售价"
value={
msrpText ? (
<span className="inline-flex items-baseline gap-2">
<span className="font-medium text-gray-900">{msrpText}</span>
<span className="text-xs text-gray-400"></span>
</span>
) : null
}
/>
<Row label="Nutri-Score" value={p.nutri_score} /> <Row label="Nutri-Score" value={p.nutri_score} />
<Row label="配料" value={p.ingredients_text} /> <Row label="配料" value={p.ingredients_text} />
<Row <Row
+10
View File
@@ -24,6 +24,15 @@ export interface ProductSpec {
unit?: string; unit?: string;
} }
export interface Msrp {
amount: number;
currency: string;
region: string;
effective_date?: string | null;
source_url?: string | null;
note?: string | null;
}
export interface Product { export interface Product {
id: string; id: string;
gtin: string | null; gtin: string | null;
@@ -43,6 +52,7 @@ export interface Product {
ingredients_text?: string | null; ingredients_text?: string | null;
allergens?: string[] | null; allergens?: string[] | null;
additives?: string[] | null; additives?: string[] | null;
msrp?: Msrp[] | null;
} }
export interface Category { export interface Category {