// Shared product card + catalog primitives
const { useState, useMemo } = React;
// 画像の優先順位:
// 1) p.images が配列で1枚以上 → そのまま使用(WP REST 経由はこちら)
// 2) p.imageUrl があれば1枚としてラップ
// 3) 静的フォルダ uploads/products/product_NN.jpg を1枚
// 重複URL(アイキャッチとサブ画像が同一など)はギャラリーの「進まない」原因になるため除外
function resolveProductImages(p) {
let list;
if (Array.isArray(p.images) && p.images.length > 0) list = p.images.filter(Boolean);
else if (p.imageUrl) list = [p.imageUrl];
else { const idStr = String(p.id).padStart(2, "0"); list = [`uploads/products/product_${idStr}.jpg`]; }
return [...new Set(list)];
}
function ProductImage({ p, height = 220 }) {
const images = React.useMemo(() => resolveProductImages(p), [p]);
const [idx, setIdx] = React.useState(0);
const [hovering, setHovering] = React.useState(false);
const [okImages, setOkImages] = React.useState(images);
// ホバー中は自動切替(1.2 秒ごと)
React.useEffect(() => {
if (!hovering || okImages.length < 2) return;
const t = setInterval(() => setIdx((i) => (i + 1) % okImages.length), 1200);
return () => clearInterval(t);
}, [hovering, okImages.length]);
// ホバーが外れたら 1 枚目に戻す
React.useEffect(() => { if (!hovering) setIdx(0); }, [hovering]);
const onImgError = (failedUrl) => {
setOkImages((arr) => {
const next = arr.filter((u) => u !== failedUrl);
return next.length > 0 ? next : arr;
});
setIdx(0);
};
const showCount = okImages.length;
const current = okImages[idx] || okImages[0];
return (
setHovering(true)}
onMouseLeave={() => setHovering(false)}
style={{
height, background: "#f4f0e8",
borderBottom: "1px solid #eae7e2", position: "relative", overflow: "hidden",
}}
>

onImgError(current)}
style={{
width: "100%", height: "100%", objectFit: "contain",
objectPosition: "center", display: "block",
transition: "opacity .25s ease",
}}
/>
{p.badge && (
{p.badge}
)}
{showCount > 1 && (
<>
{/* 画像枚数の小バッジ(右上) */}
{idx + 1}/{showCount}
{/* ドットインジケーター(下) */}
{okImages.map((_, i) => (
))}
>
)}
);
}
// 詳細モーダル内の写真ギャラリー(手送り: 矢印 + スワイプ + サムネイル)
function ProductGallery({ images, name }) {
const [imgs, setImgs] = React.useState(images);
const [idx, setIdx] = React.useState(0);
const touchX = React.useRef(null);
React.useEffect(() => { setImgs(images); setIdx(0); }, [images]);
const count = imgs.length;
const go = React.useCallback((dir) => {
setIdx((i) => (count ? (i + dir + count) % count : 0));
}, [count]);
// 左右キーでも送れる
React.useEffect(() => {
if (count < 2) return;
const onKey = (e) => {
if (e.key === "ArrowLeft") go(-1);
else if (e.key === "ArrowRight") go(1);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [go, count]);
const onImgError = (url) => {
setImgs((arr) => {
const next = arr.filter((u) => u !== url);
return next.length > 0 ? next : arr;
});
setIdx(0);
};
const onTouchStart = (e) => { touchX.current = e.touches[0].clientX; };
const onTouchEnd = (e) => {
if (touchX.current == null) return;
const dx = e.changedTouches[0].clientX - touchX.current;
if (Math.abs(dx) > 40) go(dx < 0 ? 1 : -1);
touchX.current = null;
};
const arrow = (side) => ({
position: "absolute", top: "50%", transform: "translateY(-50%)", [side]: 12,
width: 42, height: 42, borderRadius: "50%",
border: "1px solid rgba(45,37,32,0.15)", background: "rgba(255,255,255,0.92)",
color: "#2d2520", fontSize: 26, lineHeight: 1, cursor: "pointer", zIndex: 2,
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "'Cormorant Garamond', serif", boxShadow: "0 2px 12px -4px rgba(45,37,32,0.35)",
});
const current = imgs[idx] || imgs[0];
return (

onImgError(current)}
style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }}
/>
{count > 1 && (
<>
{idx + 1} / {count}
>
)}
{count > 1 && (
{imgs.map((src, i) => (
))}
)}
);
}
// 商品詳細モーダル(A.E.Köchert 風: 白基調・余白・ゴールドアクセント)
function ProductDetailModal({ p, onClose }) {
const { isMobile } = (window.useViewport || (() => ({ isMobile: false })))();
const QUOTE_URL = "https://pace.co.jp/#contact";
const images = React.useMemo(() => resolveProductImages(p), [p]);
React.useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [onClose]);
const specRows = [
["参考価格", p.price ? p.price + "(税抜)" : ""],
["カートン入数", p.cartonQty],
["サイズ", p.size],
["本体色・型", p.colorType],
["包装", p.packaging],
["印刷", p.print],
["名入れ範囲", p.printArea],
["カートン入数", p.cartonQty],
["納期", p.delivery],
["送料", p.shippingFee],
["仕様", p.spec],
["掲載", p.page ? "MARCHE73 " + p.page : ""],
].filter(([, v]) => v != null && String(v).trim() !== "");
return (
e.stopPropagation()}
style={{
position: "relative", background: "#fff", width: "100%", maxWidth: 1040,
borderRadius: isMobile ? 0 : 4, maxHeight: isMobile ? "100%" : "92vh", overflowY: "auto",
boxShadow: "0 30px 80px -20px rgba(0,0,0,0.5)",
}}
>
{p.cat && (
{p.cat}
)}
{p.badge && (
{p.badge}
)}
{p.name}
{p.desc && (
{p.desc}
)}
{Array.isArray(p.serviceTags) && p.serviceTags.length > 0 && (
{p.serviceTags.map((t) => (
{t}
))}
)}
{specRows.length > 0 && (
{specRows.map(([k, v], i) => (
- {k}
- {v}
))}
)}
この商品について無料お見積り
);
}
function ProductCard({ p, onOpen }) {
return (
onOpen && onOpen(p)}
onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && onOpen) { e.preventDefault(); onOpen(p); } }}
style={{
background: "#fff", border: "1px solid #eae7e2", borderRadius: 4, overflow: "hidden",
display: "flex", flexDirection: "column", transition: "transform .35s cubic-bezier(.16,1,.3,1), box-shadow .35s",
cursor: "pointer",
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = "translateY(-4px)"; e.currentTarget.style.boxShadow = "0 12px 36px -16px rgba(45,37,32,0.18)"; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = "translateY(0)"; e.currentTarget.style.boxShadow = "none"; }}
>
{p.cat}
{p.page && (
MARCHE73 {p.page}
)}
{Array.isArray(p.serviceTags) && p.serviceTags.length > 0 && (
{p.serviceTags.map((t) => (
{t}
))}
)}
{p.name}
{p.desc}
参考価格
{p.price || "—"}
(税抜)
カートン入数
{p.cartonQty || "—"}
詳しく見る →
);
}
// 商品が一切登録されていないとき(WP も静的データも空)に表示
function PreparingState() {
return (
—
商品ラインナップは準備中です
ご要望に応じて、戦略型ノベルティの最適な商品をご提案いたします。
お見積り・ご相談はお気軽にどうぞ。
);
}
function EmptyState({ onClear }) {
return (
—
該当する商品が見つかりませんでした
条件を緩めるか、お問い合わせください。
ご要望に合わせたOEM製作のご提案も可能です。
);
}
function SelectedTags({ filters, setFilters }) {
const all = [
...[...filters.scenes].map((v) => ({ axis: "scenes", v, label: v })),
...[...filters.budgets].map((v) => ({ axis: "budgets", v, label: BUDGETS.find((b) => b.id === v).label })),
];
if (all.length === 0) return null;
const remove = (axis, v) => {
setFilters((f) => {
const next = { ...f, [axis]: new Set(f[axis]) };
next[axis].delete(v);
return next;
});
};
const clear = () => setFilters({ scenes: new Set(), budgets: new Set(), lots: new Set() });
return (
選択中
{all.map((t) => (
))}
);
}
// =========================================================
// DIRECTION A — Sidebar filter (B2B-style, sticky left rail)
// =========================================================
function CatalogA() {
const [filters, setFilters] = useState(() => {
const init = window.buildFiltersFromSearch ? window.buildFiltersFromSearch(window.__noveltySearch || {}) : { scenes: new Set(), budgets: new Set(), lots: new Set(), keyword: "" };
return init;
});
const [productsTick, setProductsTick] = useState(0);
const filtered = useMemo(() => (window.PRODUCTS || []).filter((p) => matchesFilters(p, filters)), [filters, productsTick]);
const { isMobile, isTablet } = (window.useViewport || (() => ({ isMobile: false, isTablet: false })))();
const compact = isMobile || isTablet;
const [filterOpen, setFilterOpen] = useState(false);
const [selected, setSelected] = useState(null);
React.useEffect(() => {
const onSearch = (e) => setFilters(window.buildFiltersFromSearch(e.detail));
const onProductsLoaded = () => setProductsTick((t) => t + 1);
window.addEventListener("novelty:search", onSearch);
window.addEventListener("pace:products-loaded", onProductsLoaded);
return () => {
window.removeEventListener("novelty:search", onSearch);
window.removeEventListener("pace:products-loaded", onProductsLoaded);
};
}, []);
const toggle = (axis, v) => {
setFilters((f) => {
const next = { ...f, [axis]: new Set(f[axis]) };
if (next[axis].has(v)) next[axis].delete(v); else next[axis].add(v);
return next;
});
};
return (
{/* モバイル: 絞り込み開閉トグル */}
{compact && (
)}
{/* Sidebar(モバイルは閉じる) */}
{/* Grid */}
{filtered.length}
件 / 全 {(window.PRODUCTS || []).length} 件
{filtered.length === 0
? ((window.PRODUCTS || []).length === 0
?
:
setFilters({ scenes: new Set(), budgets: new Set(), lots: new Set(), keyword: "" })} />)
: filtered.map((p) => )}
{selected &&
setSelected(null)} />}
);
}
function FilterGroup({ title, en, sub, children }) {
return (
);
}
function CheckRow({ label, checked, onClick }) {
return (
);
}
function RadioRow({ label, sub, checked, onClick }) {
return (
);
}
Object.assign(window, { CatalogA, ProductCard, ProductImage, ProductGallery, ProductDetailModal, EmptyState, SelectedTags });