// components.jsx — 共通UIコンポーネント const { useState, useMemo, useEffect } = React; // ---- 小さなアイコン(インラインSVG。装飾用の最小限のみ) ---- function Ico({ name, size = 18 }) { const p = { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" }; const paths = { search: <>, arrow: , chevron:, check: , cart: <>, filter: , grid: <>, list: <>, close: , leaf: <>, fiber: <>, potass: <>, natural:<>, energy: , star: , }; return {paths[name] || null}; } // ---- 星評価 ---- function Stars({ value, size }) { const cls = size === "lg" ? "stars lg" : "stars"; return ( {[0, 1, 2, 3, 4].map((i) => { const full = value >= i + 0.75; const half = !full && value >= i + 0.25; return ( {half && ( )} ); })} ); } // ---- 項目別スコア(★表示) ---- const SCORE_LABELS = { sweet: "甘さ", aroma: "香り", flavor: "味わい", cost: "コスパ" }; const SCORE_ORDER = ["sweet", "aroma", "flavor", "cost"]; function ScoreBar({ k, val }) { return (
{SCORE_LABELS[k]} {val.toFixed(1)}
); } // ---- 画像プレースホルダ ---- function Ph({ label, className, style }) { return (
{label}
); } // ---- 商品画像(imageUrl があれば写真、なければプレースホルダ) ---- function ProductImg({ p, label }) { if (p && p.imageUrl) { return {p.name}; } return ; } // ---- 商品画像スライダー(詳細モーダル用・複数枚をスワイプ/矢印で切替) ---- function ProductGallery({ p, label }) { const imgs = (p && p.images && p.images.length) ? p.images : (p && p.imageUrl ? [p.imageUrl] : []); const [i, setI] = React.useState(0); const startX = React.useRef(null); if (imgs.length === 0) return ; const go = (n) => setI((cur) => (n + imgs.length) % imgs.length); const onTouchStart = (e) => { startX.current = e.touches[0].clientX; }; const onTouchEnd = (e) => { if (startX.current == null) return; const dx = e.changedTouches[0].clientX - startX.current; if (dx > 40) go(i - 1); else if (dx < -40) go(i + 1); startX.current = null; }; return (
{imgs.map((src, j) => (
{`${p.name}
))}
{imgs.length > 1 && ( <>
{imgs.map((_, j) => (
{i + 1} / {imgs.length}
)}
); } // ---- 名前解決ヘルパ ---- const makerName = (id) => (MAKERS.find((m) => m.id === id) || {}).name || ""; const makerObj = (id) => MAKERS.find((m) => m.id === id) || {}; const varietyName = (id) => (VARIETIES.find((v) => v.id === id) || {}).name || ""; const shapeName = (id) => (SHAPES.find((s) => s.id === id) || {}).name || ""; const hardnessName = (id) => ((window.HARDNESS || []).find((h) => h.id === id) || {}).name || ""; const originName = (id) => ((window.ORIGINS || []).find((o) => o.id === id) || {}).name || ""; // ---- 購入ボタン(3チャンネル・未登録は無効化) ---- const BUY_CHANNELS = [ { key: "rakuten", label: "楽天市場", cls: "buy-rakuten" }, { key: "amazon", label: "Amazon", cls: "buy-amazon" }, { key: "official", label: "自社サイト", cls: "buy-official" }, ]; function BuyButtons({ p, compact }) { const links = p.links || {}; return (
{BUY_CHANNELS.map((c) => { const url = links[c.key]; if (!url) { return {c.label}取扱いなし; } return ( {c.label}で見る ); })}
); } // ---- 商品カード(縦型・トップやグリッド用) ---- function ProductCard({ p, onOpen }) { return (
onOpen(p)}>
{p.badge && {p.badge}}
{makerName(p.maker)} {p.name} {p.rating.toFixed(1)} ({p.reviewCount})
¥{p.price.toLocaleString()} / {p.weight}g
); } // ---- 商品行(一覧リスト表示用) ---- function ProductRow({ p, onOpen }) { return (
onOpen(p)} style={{ cursor: "pointer" }}> {p.badge && {p.badge}}
{p.rank <= 3 && {p.rank}} {makerName(p.maker)} {varietyName(p.variety)} {shapeName(p.shape)} {hardnessName(p.hardness)}

onOpen(p)} style={{ cursor: "pointer" }}>{p.name}

{p.rating.toFixed(1)} {p.reviewCount}件のレビュー
{SCORE_ORDER.map((k) => )}
{p.tags.map((t) => {t})}
¥{p.price.toLocaleString()}
税込 / {p.weight}g(100gあたり ¥{p.pricePer100})
); } // ---- 商品詳細モーダル ---- function ProductModal({ p, onClose }) { useEffect(() => { const h = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", h); document.body.style.overflow = "hidden"; return () => { window.removeEventListener("keydown", h); document.body.style.overflow = ""; }; }, []); if (!p) return null; const reviews = p.reviews || []; const mk = makerObj(p.maker); return (
e.stopPropagation()}>
{p.badge && {p.badge}}
{varietyName(p.variety)} {originName(p.origin) && 産地:{originName(p.origin)}} {shapeName(p.shape)} 硬さ:{hardnessName(p.hardness)} {p.tags.map((t) => {t})}

{p.name}

{mk.name}({mk.region}・創業{mk.since}年){originName(p.origin) ? ` 原料産地:${originName(p.origin)}` : ""}
{p.rating.toFixed(1)} {p.reviewCount}件のレビュー ¥{p.price.toLocaleString()} / {p.weight}g

{p.desc}

項目別スコア

{SCORE_ORDER.map((k) => )}
購入する(販売店を選ぶ)

レビュー ({reviews.length})

{reviews.length === 0 &&

まだレビューがありません。

} {reviews.map((r, i) => (
{r.user} {r.date}

{r.body}

{(r.images || []).filter((u) => u).length > 0 && ( )}
))}
); } Object.assign(window, { Ico, Stars, ScoreBar, Ph, ProductImg, ProductGallery, ProductCard, ProductRow, ProductModal, BuyButtons, makerName, makerObj, varietyName, shapeName, hardnessName, originName, SCORE_LABELS, SCORE_ORDER, });