// 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 ;
}
// ---- 星評価 ----
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 (
);
})}
);
}
// ---- 項目別スコア(★表示) ----
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
;
}
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) => (
))}
{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)}>
{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()}>
{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,
});