// rakuten-product.jsx // 楽天アフィリエイトJSONを受け取って成形・表示するためのユーティリティ // // 想定するJSON構造(バックエンド ↔ フロント): // { // "diagnosisId": "dx_abc123", // "products": [ // { // "itemCode": "shop:item-id", // 楽天の一意ID // "name": "商品名", // "shopName": "ショップ名", // "price": 4980, // 数値(整数円) // "imageUrl": "https://thumbnail.image.rakuten.co.jp/...", // null許容 // "imageUrls": ["url1", "url2"], // optional, ギャラリー用 // "affiliateUrl": "https://hb.afl.rakuten.co.jp/...", // 必須 // "genre": "gadget", // フォールバック分岐用 // "hue": 250, // フォールバック背景の色相 // "matchScore": 0.92, // 0-1 // "matchReason": "「自分のための時間」スコア最上位とマッチ", // "matchedDesires": ["stimulation", "competence"] // } // ] // } // === 価格フォーマッタ === function formatPrice(price) { if (price == null) return "—"; if (typeof price === "string") return price; // 既に整形済みなら通す const n = Number(price); if (Number.isNaN(n)) return "—"; return "¥" + n.toLocaleString("ja-JP"); } // === ジャンル → アイコン(SVG path)=== // 画像が無い時のフォールバック表示用 const GENRE_ICONS = { gadget: ( ), cosme: ( ), book: ( ), home: ( ), food: ( ), fashion: ( ), experience: ( ), wellness: ( ), fitness: ( ), default: ( ), }; function getGenreIcon(genre) { return GENRE_ICONS[genre] || GENRE_ICONS.default; } // === ノーマライザ:バックエンドJSON → 内部表現 === // 楽天APIの生レスポンスでも、独自JSONでも、共通形に揃える function normalizeProduct(raw) { // 楽天APIの itemDetails 形式と独自形式の両対応 const item = raw.Item || raw.item || raw; return { itemCode: item.itemCode || item.id || `item_${Math.random().toString(36).slice(2, 8)}`, name: item.itemName || item.name || "—", shopName: item.shopName || "", price: typeof item.itemPrice === "number" ? item.itemPrice : (item.price ?? null), imageUrl: item.imageUrl || (item.mediumImageUrls && item.mediumImageUrls[0]?.imageUrl) || (item.smallImageUrls && item.smallImageUrls[0]?.imageUrl) || null, imageUrls: item.imageUrls || (item.mediumImageUrls && item.mediumImageUrls.map(o => o.imageUrl)) || [], affiliateUrl: item.affiliateUrl || item.itemUrl || "#", genre: item.genre || "default", hue: typeof item.hue === "number" ? item.hue : 220, matchScore: typeof item.matchScore === "number" ? item.matchScore : null, matchReason: item.matchReason || item.why || "", matchedDesires: Array.isArray(item.matchedDesires) ? item.matchedDesires : [], }; } function normalizeProductList(list) { if (!Array.isArray(list)) return []; return list.map(normalizeProduct); } // === 商品カードコンポーネント === // 雑誌風:大きな画像 + 価格大 + ショップ名 + マッチ理由 + 楽天ロゴ + PR function RakutenProductCard({ product, rank, theme = "monochrome" }) { const p = product; const isTop = rank === 1; const fallbackHue = p.hue ?? 220; const matchPct = p.matchScore != null ? Math.round(p.matchScore * 100) : null; const hasLink = p.affiliateUrl && p.affiliateUrl !== "#"; return ( {/* 画像エリア */} {p.imageUrl ? ( { e.currentTarget.style.display = "none"; }} /> ) : ( {getGenreIcon(p.genre)} )} {/* PR / ランクバッジ */} {isTop && TOP MATCH} PR {/* マッチスコア */} {matchPct != null && ( {matchPct} % match )} {/* 情報エリア */} {String(rank).padStart(2, "0")} {p.name} {p.shopName && {p.shopName}} {formatPrice(p.price)} {p.matchReason && ( {p.matchReason} )} {/* 楽天ロゴ + 遷移ヒント */} R 楽天市場で見る → ); } // グローバル公開 window.formatPrice = formatPrice; window.getGenreIcon = getGenreIcon; window.normalizeProduct = normalizeProduct; window.normalizeProductList = normalizeProductList; window.RakutenProductCard = RakutenProductCard;
{p.matchReason}