// 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", }} > {p.name} 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 (
{name} 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 (
{title}
{sub &&
{sub}
}
{en}
{children}
); } function CheckRow({ label, checked, onClick }) { return ( ); } function RadioRow({ label, sub, checked, onClick }) { return ( ); } Object.assign(window, { CatalogA, ProductCard, ProductImage, ProductGallery, ProductDetailModal, EmptyState, SelectedTags });