Files
goods/public-frontend/src/components/ProductView.tsx
T
busenalbantoglu51424 5fe5774251
CI / Go (api) (pull_request) Successful in 1m59s
CI / Python (ingestion) (pull_request) Successful in 1m13s
CI / Migrations (postgres) (pull_request) Successful in 27s
feat(public): 商品详情页展示厂商建议零售价(MSRP)
后端 ProductByID/ProductByGTIN 在商品详情 JSON 中内嵌 msrp 字段(按 effective_date 倒序,过滤 amount=0 的无效快照);公开前台详情页新增「建议零售价」展示。

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-25 00:42:18 +00:00

162 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { ArrowLeft } from "lucide-react";
import { api } from "../api";
import type { Product } from "../types";
import { NUTRIMENT_LABELS } from "../types";
function Row({ label, value }: { label: string; value: React.ReactNode }) {
if (value === null || value === undefined || value === "") return null;
return (
<div className="flex py-2 border-b last:border-0 text-sm">
<div className="w-32 shrink-0 text-gray-400">{label}</div>
<div className="text-gray-800">{value}</div>
</div>
);
}
export default function ProductView({ id, onBack }: { id: string; onBack: () => void }) {
const [p, setP] = useState<Product | null>(null);
const [error, setError] = useState("");
useEffect(() => {
api.product(id).then(setP).catch((e) => setError(e.message));
}, [id]);
if (error) {
return (
<div>
<button onClick={onBack} className="text-sm text-gray-500 flex items-center gap-1 mb-4">
<ArrowLeft className="w-4 h-4" />
</button>
<div className="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
</div>
);
}
if (!p) return <div className="text-gray-400 animate-pulse"></div>;
const basisLabel: Record<string, string> = {
per_100g: "每 100g",
per_100ml: "每 100ml",
per_serving: "每份",
};
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 (
<div>
<button onClick={onBack} className="mb-4 inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
<ArrowLeft className="w-4 h-4" />
</button>
<div className="card p-6 animate-fade-up">
<div className="flex items-start justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight text-gray-900">{p.name}</h1>
<span className="shrink-0 rounded-full border border-brand-100 bg-brand-50 px-2.5 py-1 text-xs font-medium text-brand-700">
{Math.round(p.quality_score * 100)}
</span>
</div>
<div className="mt-4">
<Row label="品牌" value={p.brand} />
<Row label="条码 (GTIN)" value={p.gtin} />
{(() => {
const others = (p.barcodes || []).filter(
(b) => !b.is_primary && b.gtin !== p.gtin,
);
return others.length ? (
<Row
label="其他条码"
value={
<div className="flex flex-wrap gap-1.5">
{others.map((b) => (
<span
key={b.gtin}
className="font-mono text-xs bg-gray-100 text-gray-600 rounded px-1.5 py-0.5"
title={`${b.gtin_type} · ${b.pack_level}`}
>
{b.gtin}
</span>
))}
</div>
}
/>
) : null;
})()}
<Row label="品类" value={p.category_path} />
<Row
label="净含量"
value={
p.net_content_value != null
? `${p.net_content_value} ${p.net_content_unit || ""}`
: null
}
/>
<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="配料" value={p.ingredients_text} />
<Row
label="过敏原"
value={p.allergens && p.allergens.length ? p.allergens.join("、") : null}
/>
<Row
label="添加剂"
value={p.additives && p.additives.length ? p.additives.join("、") : null}
/>
</div>
</div>
{p.specs && p.specs.length > 0 && (
<div className="card p-6 mt-4">
<h2 className="font-medium text-gray-700 mb-2"></h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
{p.specs.map((s) => (
<div key={s.key} className="bg-gray-50 rounded px-3 py-2">
<div className="text-gray-400 text-xs">{s.label}</div>
<div className="text-gray-800">
{s.value}
{s.unit ? ` ${s.unit}` : ""}
</div>
</div>
))}
</div>
</div>
)}
{nutriEntries.length > 0 && (
<div className="card p-6 mt-4">
<h2 className="font-medium text-gray-700 mb-2">
{p.nutrition_basis ? `${basisLabel[p.nutrition_basis] || p.nutrition_basis}` : ""}
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 text-sm">
{nutriEntries.map(([k, v]) => (
<div key={k} className="bg-gray-50 rounded px-3 py-2">
<div className="text-gray-400 text-xs">{NUTRIMENT_LABELS[k] || k}</div>
<div className="text-gray-800">{String(v)}</div>
</div>
))}
</div>
</div>
)}
</div>
);
}