// 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);