// cms.jsx — microCMS 連携レイヤー(本番用のデータ取得・変換) // localStorage(store.jsx) の代わりに、microCMS のAPIから取得して // アプリ内部の形に変換します。設定が無い/失敗した場合はサンプル表示にフォールバック。 // data.jsx が先に読み込まれている前提。そのサンプルを「予備データ」として退避。 const SAMPLE_PRODUCTS = (window.PRODUCTS || []).slice(); const SAMPLE_MASTERS = { makers: window.MAKERS || [], varieties: window.VARIETIES || [], origins: window.ORIGINS || [], shapes: window.SHAPES || [], hardness: window.HARDNESS || [], }; // 硬さは microCMS では「商品内のセレクト」。絞り込み用に固定リストを用意。 const HARDNESS_FIXED = [ { id: "硬い", name: "硬い" }, { id: "ちょっと硬い", name: "ちょっと硬い" }, { id: "ふつう", name: "ふつう" }, { id: "ちょっとやわらかい", name: "ちょっとやわらかい" }, { id: "やわらかい", name: "やわらかい" }, ]; let __cache = null; // 取得済み商品(内部形) // ---- 共通計算(store.jsx と同じロジック)---- function costScore(per100, min, max) { if (!per100 || max === min) return 4.0; return Math.round((5 - 3 * (per100 - min) / (max - min)) * 10) / 10; } function withRanks(list) { const enriched = list.map((p) => ({ ...p, pricePer100: p.weight ? Math.round((p.price / p.weight) * 100) : 0, reviewCount: (p.reviews || []).length, })); const pp = enriched.map((p) => p.pricePer100).filter((v) => v > 0); const min = pp.length ? Math.min(...pp) : 0; const max = pp.length ? Math.max(...pp) : 0; const scored = enriched.map((p) => { const scores = { ...p.scores, cost: costScore(p.pricePer100, min, max) }; const vals = ["sweet", "aroma", "flavor", "cost"].map((k) => Number(scores[k]) || 0); const rating = Math.round((vals.reduce((s, v) => s + v, 0) / vals.length) * 10) / 10; return { ...p, scores, rating }; }); const sorted = [...scored].sort((a, b) => (b.rating - a.rating) || (b.reviewCount - a.reviewCount) || (a.pricePer100 - b.pricePer100)); const rankMap = {}; sorted.forEach((p, i) => { rankMap[p.id] = i + 1; }); return scored.map((p) => ({ ...p, rank: rankMap[p.id] })); } // app.jsx の refresh から呼ばれる。取得済みキャッシュ(無ければサンプル)を返す。 function loadProducts() { return withRanks(__cache && __cache.length ? __cache : SAMPLE_PRODUCTS); } // ---- microCMS 取得 ---- function cmsHeaders(cfg) { return { "X-MICROCMS-API-KEY": cfg.apiKey }; } async function fetchList(cfg, endpoint, withDepth, orders) { const base = `https://${cfg.serviceDomain}.microcms.io/api/v1/${endpoint}`; const url = base + "?limit=100" + (withDepth ? "&depth=2" : "") + (orders ? "&orders=" + encodeURIComponent(orders) : ""); const res = await fetch(url, { headers: cmsHeaders(cfg) }); if (!res.ok) throw new Error(`${endpoint} の取得に失敗 (HTTP ${res.status})`); const json = await res.json(); return json.contents || []; } // ---- 変換(microCMS → 内部形)---- const num = (v) => (v === "" || v == null ? 0 : Number(v)); const refId = (v) => (v && typeof v === "object" ? v.id : (v || "")); const selVal = (v) => (Array.isArray(v) ? (v[0] || "") : (v || "")); const imgUrl = (v) => (v && typeof v === "object" ? (v.url || "") : (v || "")); // microCMS の画像は設定方法で形が変わる(単一画像 / 複数画像 / 繰り返しフィールド内の画像)。 // どの形でも画像URLを集められるよう、商品オブジェクト全体から画像URLを抽出する。 function isImageObj(v) { return v && typeof v === "object" && typeof v.url === "string" && (v.height != null || v.width != null || /\.(jpe?g|png|webp|gif|avif)(\?|$)/i.test(v.url)); } function collectImagesFrom(value, out) { if (!value) return; if (typeof value === "string") { if (/^https?:\/\//.test(value) && /\.(jpe?g|png|webp|gif|avif)(\?|$)/i.test(value)) out.push(value); return; } if (isImageObj(value)) { out.push(value.url); return; } if (Array.isArray(value)) { value.forEach((v) => collectImagesFrom(v, out)); return; } if (typeof value === "object") { // 繰り返しフィールドやカスタムフィールド内の画像を再帰的に探す Object.keys(value).forEach((k) => { if (["fieldId", "id"].includes(k)) return; collectImagesFrom(value[k], out); }); } } function productImages(c) { const out = []; // よく使うフィールド名を優先(順序を安定させる) ["images", "image", "gallery", "photos", "画像", "商品画像"].forEach((k) => { if (c[k] !== undefined) collectImagesFrom(c[k], out); }); // それでも見つからなければ全フィールドを走査 if (out.length === 0) { Object.keys(c).forEach((k) => { if (["id", "createdAt", "updatedAt", "publishedAt", "revisedAt"].includes(k)) return; collectImagesFrom(c[k], out); }); } return [...new Set(out)]; // 重複除去 } function fmtDate(d) { if (!d) return ""; const m = String(d).match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? `${m[1]}.${m[2]}.${m[3]}` : String(d); } function adaptMaster(contents, extra) { return (contents || []).map((c) => { const o = { id: c.id, name: c.name || "" }; (extra || []).forEach((k) => { if (c[k] !== undefined) o[k] = c[k]; }); return o; }); } function adaptProduct(c, idx) { return { id: c.id, _seq: idx, // microCMS の取得順(既定で新しい登録が先頭)。新着判定に使用 name: c.name || "", maker: refId(c.maker), variety: refId(c.variety), origin: refId(c.origin), shape: refId(c.shape), hardness: selVal(c.hardness), price: num(c.price), weight: num(c.weight), scores: { sweet: num(c.sweet), aroma: num(c.aroma), flavor: num(c.flavor) }, tags: Array.isArray(c.tags) ? c.tags : [], badge: c.badge || "", imageUrl: (function () { const a = productImages(c); return a[0] || ""; })(), images: productImages(c), desc: c.desc || "", links: { rakuten: c.rakuten || "", amazon: c.amazon || "", official: c.official || "" }, reviews: (c.reviews || []).map((r) => ({ user: r.user || "管理人", date: fmtDate(r.date), body: r.body || "", images: (r.images || []).map(imgUrl).filter((u) => u), })), }; } // ---- ステータス表示(画面下の小さな帯。正常時は出さない)---- function setBanner(msg, kind) { let el = document.getElementById("cms-banner"); if (!msg) { if (el) el.remove(); return; } if (!el) { el = document.createElement("div"); el.id = "cms-banner"; document.body.appendChild(el); } el.className = "cms-banner " + (kind || ""); el.innerHTML = `${msg}`; } // ---- 起動:CMS取得 → 成功でマウント/失敗でサンプル表示 ---- function useSample(reason) { window.MAKERS = SAMPLE_MASTERS.makers; window.VARIETIES = SAMPLE_MASTERS.varieties; window.ORIGINS = SAMPLE_MASTERS.origins; window.SHAPES = SAMPLE_MASTERS.shapes; window.HARDNESS = SAMPLE_MASTERS.hardness; __cache = SAMPLE_PRODUCTS.slice(); window.PRODUCTS = withRanks(__cache); if (reason) setBanner("⚠️ " + reason + "(いまはサンプルデータを表示しています)", "warn"); window.mountApp(); } async function bootstrapFromCMS() { const cfg = window.CMS_CONFIG || {}; const unset = !cfg.serviceDomain || !cfg.apiKey || cfg.serviceDomain.indexOf("ここに") >= 0 || cfg.apiKey.indexOf("ここに") >= 0; if (unset) { useSample("microCMS未設定:cms-config.js にサービスIDとAPIキーを入力してください"); return; } try { const [makers, varieties, origins, shapes, products] = await Promise.all([ fetchList(cfg, "makers"), fetchList(cfg, "varieties"), fetchList(cfg, "origins"), fetchList(cfg, "shapes"), fetchList(cfg, "products", true, "-publishedAt"), ]); window.MAKERS = adaptMaster(makers, ["region", "since"]); window.VARIETIES = adaptMaster(varieties, ["note"]); window.ORIGINS = adaptMaster(origins, []); window.SHAPES = adaptMaster(shapes, ["note"]); window.HARDNESS = HARDNESS_FIXED; __cache = products.map(adaptProduct); // 画像が取得できているか、開発者ツールのConsoleで確認できるよう出力 try { console.log("[干し芋ナビ] 取得した商品数:", products.length); if (products[0]) { console.log("[干し芋ナビ] 1件目の生データ:", products[0]); console.log("[干し芋ナビ] 1件目から抽出した画像URL:", productImages(products[0])); } } catch (e) { /* noop */ } window.PRODUCTS = withRanks(__cache); setBanner(""); window.mountApp(); } catch (e) { useSample("microCMS接続エラー:" + (e && e.message ? e.message : e)); } } Object.assign(window, { loadProducts, bootstrapFromCMS, __cmsWithRanks: withRanks }); // data.jsx 由来のグローバルを、取得完了まで暫定でサンプルにそろえておく window.PRODUCTS = withRanks(SAMPLE_PRODUCTS);