// ╔══════════════════════════════════════════════════════════╗ // ║ 六命解析 — LIFE CODE ENGINE v2 ║ // ╚══════════════════════════════════════════════════════════╝ // ═══ I18N(多言語化 v1: タイ語) ═══ // LANG は URL ?lang= → localStorage → 既定 ja の順で決まる。 // タイ語時のみ React.createElement をラップし、静的な日本語文字列 // (JSXテキスト・表示用props)を辞書 TH_DICT で置き換える。 // 辞書に無い文言は日本語のまま表示されるため、日本語版の挙動は不変。 const SUPPORTED_LANGS = ["ja", "th", "en"]; const LANG = (() => { try { const p = new URLSearchParams(window.location.search).get("lang"); if (p && SUPPORTED_LANGS.includes(p)) { window.localStorage.setItem("lc_lang", p); return p; } const saved = window.localStorage.getItem("lc_lang"); return saved && SUPPORTED_LANGS.includes(saved) ? saved : "ja"; } catch (e) { return "ja"; } })(); let TH_DICT = {}; const t = (s) => { if (LANG === "ja" || typeof s !== "string") return s; return TH_DICT[s] || TH_DICT[s.trim()] || s; }; const tf = (tpl, vars) => { let s = t(tpl); if (vars) for (const k of Object.keys(vars)) s = s.split("{" + k + "}").join(String(vars[k])); return s; }; // ヘッダーのログイン中ユーザー名(ja「{name} 様」/en 名前のみ/th「คุณ{name}」)。 // createElement ラッパーは文字列の子だけを t() に通すため、配列で返して // ユーザー名そのものが辞書で置き換わらないようにする。 const userHonorific = (name) => { const n = name || t("ユーザー"); if (LANG === "en") return [n]; if (LANG === "th") return ["คุณ" + n]; return [n + " 様"]; }; document.documentElement.lang = LANG; if (LANG !== "ja") { document.title = "Life Code — วิเคราะห์ลิขิตชีวิต"; if (LANG === "en") document.title = "Life Code — Six Destinies Reading"; const __origCreateElement = React.createElement; const __translatableProps = ["placeholder", "title", "aria-label", "alt", "label", "sub", "subtitle"]; React.createElement = function (type, props, ...children) { let nextProps = props; if (props) { let changed = false; const copy = { ...props }; for (const k of __translatableProps) { if (typeof copy[k] === "string" && /[ぁ-んァ-ヶ一-龠]/.test(copy[k])) { const tr = t(copy[k]); if (tr !== copy[k]) { copy[k] = tr; changed = true; } } } if (changed) nextProps = copy; } const nextChildren = children.map((c) => (typeof c === "string" ? t(c) : c)); return __origCreateElement(type, nextProps, ...nextChildren); }; } // タイ語表示時の仏暦(พ.ศ.)誤入力防止ヒント。内部値は常に西暦。 const BuddhistYearHint = ({ value }) => { if (LANG !== "th" || !value) return null; const m = /^(\d{4})/.exec(String(value)); if (!m) return null; const ce = parseInt(m[1], 10); if (!ce) return null; const looksBuddhist = ce > 2100; return (
{looksBuddhist ? "ปีเกิดดูเหมือนเป็น พ.ศ. กรุณากรอกเป็นปี ค.ศ. (พ.ศ. − 543) นะ" : `ค.ศ. ${ce} = พ.ศ. ${ce + 543}`}
); }; // ═══ SHICHUSUIMEI (四柱推命) ═══ const STEMS = ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"]; const STEM_EL = ["木", "木", "火", "火", "土", "土", "金", "金", "水", "水"]; const BRANCHES = [ "子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥", ]; const BRANCH_ANIMALS = [ "鼠", "牛", "虎", "兔", "龍", "蛇", "馬", "羊", "猿", "鶏", "犬", "猪", ]; const BRANCH_EL = [ "水", "土", "木", "木", "土", "火", "火", "土", "金", "金", "土", "水", ]; const ELEMENTS = ["木", "火", "土", "金", "水"]; const ELEMENT_DATA = { 木: { title: "木気", nature: "成長・発展・柔軟性", season: "春", dir: "東", desc: "生命の芽吹きを司る。常に上へ向かう成長のエネルギー。", color: "#5a9e8a", }, 火: { title: "火気", nature: "情熱・変革・輝き", season: "夏", dir: "南", desc: "炎のように周囲を照らし、変化をもたらす強烈なエネルギー。", color: "#c06060", }, 土: { title: "土気", nature: "安定・包容・中庸", season: "土用", dir: "中央", desc: "万物を育む大地の力。バランスを保ち全てを包み込む。", color: "#c8a84b", }, 金: { title: "金気", nature: "収穫・精錬・完璧", season: "秋", dir: "西", desc: "純粋さを追求し、不要なものを削ぎ落とす刃のエネルギー。", color: "#9090b0", }, 水: { title: "水気", nature: "智慧・流動・深淵", season: "冬", dir: "北", desc: "深い知性と適応力。あらゆる形に変化する無限の可能性。", color: "#5080a0", }, }; function mod(n, base) { return ((n % base) + base) % base; } function getJieDate(year, index) { const table = window.LC_JIE_JST || {}; const years = Object.keys(table).map(Number).sort((a, b) => a - b); if (!years.length) return null; // S3bで入力を1900〜2100年へ制限する。範囲外は最寄年の月日を対象年へ投影して安全に扱う。 const sourceYear = Math.max(years[0], Math.min(years[years.length - 1], year)); const source = table[String(sourceYear)]?.[index]; return source ? `${year}${source.slice(4)}` : null; } function dateKey(year, month, day) { return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } const MIN_SUPPORTED_BIRTH_DATE = "1900-01-01"; const MAX_SUPPORTED_BIRTH_DATE = "2100-12-31"; function isSupportedBirthDate(value) { return ( /^\d{4}-\d{2}-\d{2}$/.test(String(value || "")) && value >= MIN_SUPPORTED_BIRTH_DATE && value <= MAX_SUPPORTED_BIRTH_DATE ); } function getSolarYear(year, month, day) { const risshun = getJieDate(year, 1); return risshun && dateKey(year, month, day) < risshun ? year - 1 : year; } function getYearPillar(year, month = 12, day = 31) { const solarYear = getSolarYear(year, month, day); const si = mod(solarYear - 4, 10); const bi = mod(solarYear - 4, 12); return { stem: STEMS[si], branch: BRANCHES[bi], el: STEM_EL[si], animal: BRANCH_ANIMALS[bi], }; } function getMonthPillar(year, month, day = 1) { const current = dateKey(year, month, day); let jieIndex = -1; for (let i = 0; i < 12; i += 1) { if (current >= getJieDate(year, i)) jieIndex = i; } // 小寒前は前年の大雪後(子月)。小寒から丑月、立春から寅月。 const monthOrdinal = jieIndex < 0 ? 11 : jieIndex === 0 ? 12 : jieIndex; const solarYear = getSolarYear(year, month, day); const yearStemIdx = mod(solarYear - 4, 10); const tigerStemIdx = mod((yearStemIdx % 5) * 2 + 2, 10); const si = mod(tigerStemIdx + monthOrdinal - 1, 10); const bi = mod(monthOrdinal + 1, 12); return { stem: STEMS[si], branch: BRANCHES[bi], el: STEM_EL[si] }; } function getDayPillar(year, month, day) { const days = Math.floor(Date.UTC(year, month - 1, day) / 86400000); const si = mod(days + 7, 10); const bi = mod(days + 5, 12); return { stem: STEMS[si], branch: BRANCHES[bi], el: STEM_EL[si] }; } function getHourPillar(hour) { // 子時=23-1, 丑時=1-3, ... const branchIdx = Math.floor(((hour + 1) % 24) / 2) % 12; return { branch: BRANCHES[branchIdx], el: BRANCH_EL[branchIdx], animal: BRANCH_ANIMALS[branchIdx], }; } function getDominantEl(year, month, day) { const yp = getYearPillar(year, month, day), mp = getMonthPillar(year, month, day), dp = getDayPillar(year, month, day); const counts = {}; [yp.el, mp.el, dp.el].forEach((e) => { counts[e] = (counts[e] || 0) + 1; }); return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; } function formatHourInputValue(value) { if (!value) return ""; const match = String(value).match(/^(\d{1,2})/); if (!match) return ""; const hour = parseInt(match[1], 10); if (Number.isNaN(hour)) return ""; return String(hour); } function formatBirthTimeForStorage(hourValue) { const hour = parseInt(String(hourValue ?? "").trim(), 10); if (Number.isNaN(hour) || hour < 0 || hour > 23) { return null; } return `${String(hour).padStart(2, "0")}:00:00`; } function normalizeOptionalDecimalString(value, min, max) { if (value === null || typeof value === "undefined") { return null; } const trimmed = String(value).trim(); if (trimmed === "") { return null; } const num = parseFloat(trimmed); if (Number.isNaN(num) || num < min || num > max) { return null; } return trimmed; } function formatBirthLatLngValue(lat, lng) { const latStr = lat != null && String(lat).trim() !== "" ? String(lat).trim() : ""; const lngStr = lng != null && String(lng).trim() !== "" ? String(lng).trim() : ""; if (latStr && lngStr) { return `${latStr}, ${lngStr}`; } return latStr || lngStr || ""; } function parseBirthLatLngValue(rawValue) { const raw = String(rawValue ?? "").trim(); if (raw === "") { return { birthLatitude: null, birthLongitude: null }; } const matches = raw.replace(/[,]/g, ",").match(/-?\d+(?:\.\d+)?/g); if (!matches || matches.length < 2) { return { birthLatitude: null, birthLongitude: null }; } return { birthLatitude: normalizeOptionalDecimalString(matches[0], -90, 90), birthLongitude: normalizeOptionalDecimalString(matches[1], -180, 180), }; } // ═══ SUUHI (数秘術) ═══ function calcLifePath(year, month, day) { const digits = `${year}${String(month).padStart(2, "0")}${String(day).padStart(2, "0")}`; let s = digits.split("").reduce((a, b) => a + parseInt(b), 0); while (s > 9 && s !== 11 && s !== 22 && s !== 33) { s = s .toString() .split("") .reduce((a, b) => a + parseInt(b), 0); } return s; } const LIFEPATH_DATA = { 1: { title: "先駆者", keywords: ["独立", "創造", "主導"] }, 2: { title: "調和者", keywords: ["協力", "直感", "調和"] }, 3: { title: "表現者", keywords: ["創造", "表現", "喜び"] }, 4: { title: "建設者", keywords: ["安定", "規律", "実直"] }, 5: { title: "探求者", keywords: ["自由", "変化", "冒険"] }, 6: { title: "保護者", keywords: ["愛情", "責任", "奉仕"] }, 7: { title: "探偵者", keywords: ["探求", "直観", "神秘"] }, 8: { title: "支配者", keywords: ["成功", "野心", "豊かさ"] }, 9: { title: "完成者", keywords: ["完成", "奉仕", "智慧"] }, 11: { title: "照明者", keywords: ["啓示", "直観", "霊性"] }, 22: { title: "建築者", keywords: ["実現", "偉業", "建設"] }, 33: { title: "教師", keywords: ["愛", "癒し", "教育"] }, }; // ═══ KYUSEI (九星気学) ═══ const KYUSEI_NAMES = [ "一白水星", "二黒土星", "三碧木星", "四緑木星", "五黄土星", "六白金星", "七赤金星", "八白土星", "九紫火星", ]; const KYUSEI_DIRS = [ "北", "南西", "東", "東南", "中央", "北西", "西", "北東", "南", ]; const KYUSEI_COLORS = [ "#4a7fa0", "#604030", "#4a7a60", "#5a8060", "#b8963c", "#8090a0", "#c04850", "#8090a0", "#a040a0", ]; function calcHonmeiKyusei(year, month, day) { const solarYear = getSolarYear(year, month, day); let sum = String(Math.abs(solarYear)).split("").reduce((a, n) => a + Number(n), 0); while (sum > 9) sum = String(sum).split("").reduce((a, n) => a + Number(n), 0); return mod(11 - sum - 1, 9) + 1; } function calcMonthKyusei(year, month, day) { const solarYear = getSolarYear(year, month, day); const yearBranchIdx = mod(solarYear - 4, 12); const monthBranchIdx = BRANCHES.indexOf(getMonthPillar(year, month, day).branch); let n = 27 - (yearBranchIdx % 3) * 3; if (monthBranchIdx < 2) n -= 3; return mod(n - monthBranchIdx, 9) + 1; } function calcGregorianMonthKyusei(year, month) { const jie = getJieDate(year, month - 1); const day = jie ? Number(jie.slice(8, 10)) : 1; return calcMonthKyusei(year, month, day); } // ═══ ZODIAC (12星座) ═══ function getZodiac(month, day) { const tbl = [ [1, 20, "水瓶座", "♒"], [2, 19, "魚座", "♓"], [3, 21, "牡羊座", "♈"], [4, 20, "牡牛座", "♉"], [5, 21, "双子座", "♊"], [6, 21, "蟹座", "♋"], [7, 23, "獅子座", "♌"], [8, 23, "乙女座", "♍"], [9, 23, "天秤座", "♎"], [10, 23, "蠍座", "♏"], [11, 22, "射手座", "♐"], [12, 22, "山羊座", "♑"], ]; for (let i = 0; i < tbl.length; i++) { const [m, d, name, sym] = tbl[i]; if (month === m && day < d) return { name, symbol: sym }; if (month === m && day >= d) { const n = tbl[(i + 1) % 12]; return { name: n[2], symbol: n[3] }; } } return { name: "山羊座", symbol: "♑" }; } // ═══ BIORHYTHM ═══ function calcBio(by, bm, bd, ty, tm, td) { const days = Math.floor( (Date.UTC(ty, tm - 1, td) - Date.UTC(by, bm - 1, bd)) / 86400000, ); return { physical: Math.sin((2 * Math.PI * days) / 23), emotional: Math.sin((2 * Math.PI * days) / 28), intellectual: Math.sin((2 * Math.PI * days) / 33), days, }; } // ═══ MAYA ═══ const MAYA_KINS = [ "イミシュ", "イク", "アクバル", "カン", "チクチャン", "キミ", "マニク", "ラマット", "ムルク", "オク", "チュエン", "エブ", "ベン", "イシュ", "メン", "キブ", "カバン", "エツナブ", "カワク", "アハウ", ]; const MAYA_TONES = [ "磁気", "月", "電気", "自己存在", "倍音", "韻律", "共鳴", "銀河", "太陽", "惑星", "スペクトル", "水晶", "宇宙", ]; function calcMaya(y, m, d) { const leapDaysThrough = (year, month, day) => { const before = Math.floor((year - 1) / 4) - Math.floor((year - 1) / 100) + Math.floor((year - 1) / 400); const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); return before + (leap && (month > 2 || (month === 2 && day >= 29)) ? 1 : 0); }; const serial = (year, month, day) => Math.floor(Date.UTC(year, month - 1, day) / 86400000) - leapDaysThrough(year, month, day); const diff = serial(y, m, d) - serial(1987, 7, 26); const kinNumber = mod(diff + 33, 260) + 1; const kin = mod(kinNumber - 1, 20); const tone = mod(kinNumber - 1, 13); return { kin: MAYA_KINS[kin], tone: MAYA_TONES[tone], kinNum: kin + 1, toneNum: tone + 1, kinNumber, }; } // ═══════════════════════════════════════════════════════ // OS SYSTEM — Drive・Care・Vision・Logic・Quest・Balance // ═══════════════════════════════════════════════════════ // 各占術から 6つのOS点(0-20)を返すマッピング const ELEMENT_OS = { 木: { Drive: 8, Care: 15, Vision: 16, Logic: 8, Quest: 16, Balance: 10 }, 火: { Drive: 18, Care: 13, Vision: 14, Logic: 8, Quest: 14, Balance: 10 }, 土: { Drive: 14, Care: 18, Vision: 12, Logic: 14, Quest: 6, Balance: 18 }, 金: { Drive: 12, Care: 7, Vision: 8, Logic: 14, Quest: 12, Balance: 14 }, 水: { Drive: 10, Care: 14, Vision: 16, Logic: 16, Quest: 12, Balance: 8 }, }; const LIFEPATH_OS = { 1: { Drive: 20, Care: 6, Vision: 14, Logic: 12, Quest: 16, Balance: 8 }, 2: { Drive: 6, Care: 20, Vision: 10, Logic: 12, Quest: 6, Balance: 18 }, 3: { Drive: 14, Care: 12, Vision: 20, Logic: 8, Quest: 14, Balance: 12 }, 4: { Drive: 10, Care: 14, Vision: 8, Logic: 20, Quest: 8, Balance: 16 }, 5: { Drive: 10, Care: 8, Vision: 8, Logic: 10, Quest: 20, Balance: 10 }, 6: { Drive: 8, Care: 18, Vision: 14, Logic: 12, Quest: 8, Balance: 18 }, 7: { Drive: 6, Care: 10, Vision: 12, Logic: 20, Quest: 12, Balance: 12 }, 8: { Drive: 18, Care: 10, Vision: 10, Logic: 18, Quest: 12, Balance: 10 }, 9: { Drive: 12, Care: 20, Vision: 14, Logic: 10, Quest: 14, Balance: 14 }, 11: { Drive: 8, Care: 12, Vision: 20, Logic: 14, Quest: 12, Balance: 16 }, 22: { Drive: 16, Care: 12, Vision: 14, Logic: 18, Quest: 10, Balance: 14 }, 33: { Drive: 8, Care: 20, Vision: 16, Logic: 10, Quest: 8, Balance: 18 }, }; const KYUSEI_OS = { 1: { Drive: 8, Care: 12, Vision: 16, Logic: 18, Quest: 10, Balance: 12 }, 2: { Drive: 8, Care: 18, Vision: 10, Logic: 12, Quest: 6, Balance: 20 }, 3: { Drive: 16, Care: 10, Vision: 18, Logic: 8, Quest: 18, Balance: 12 }, 4: { Drive: 14, Care: 12, Vision: 20, Logic: 10, Quest: 16, Balance: 14 }, 5: { Drive: 20, Care: 12, Vision: 12, Logic: 16, Quest: 12, Balance: 14 }, 6: { Drive: 12, Care: 10, Vision: 10, Logic: 20, Quest: 12, Balance: 18 }, 7: { Drive: 10, Care: 12, Vision: 18, Logic: 14, Quest: 12, Balance: 14 }, 8: { Drive: 10, Care: 14, Vision: 8, Logic: 18, Quest: 10, Balance: 20 }, 9: { Drive: 18, Care: 12, Vision: 16, Logic: 10, Quest: 14, Balance: 12 }, }; const ZODIAC_OS = { 牡羊座: { Drive: 20, Care: 8, Vision: 14, Logic: 8, Quest: 16, Balance: 10 }, 牡牛座: { Drive: 8, Care: 16, Vision: 10, Logic: 14, Quest: 8, Balance: 20 }, 双子座: { Drive: 14, Care: 10, Vision: 18, Logic: 12, Quest: 18, Balance: 12, }, 蟹座: { Drive: 6, Care: 20, Vision: 12, Logic: 10, Quest: 6, Balance: 18 }, 獅子座: { Drive: 18, Care: 12, Vision: 18, Logic: 8, Quest: 14, Balance: 10 }, 乙女座: { Drive: 8, Care: 14, Vision: 10, Logic: 20, Quest: 10, Balance: 16 }, 天秤座: { Drive: 12, Care: 14, Vision: 14, Logic: 12, Quest: 12, Balance: 20, }, 蠍座: { Drive: 12, Care: 16, Vision: 16, Logic: 18, Quest: 14, Balance: 8 }, 射手座: { Drive: 16, Care: 10, Vision: 14, Logic: 10, Quest: 20, Balance: 12, }, 山羊座: { Drive: 14, Care: 12, Vision: 8, Logic: 18, Quest: 10, Balance: 18 }, 水瓶座: { Drive: 12, Care: 8, Vision: 20, Logic: 14, Quest: 18, Balance: 14 }, 魚座: { Drive: 6, Care: 18, Vision: 18, Logic: 8, Quest: 12, Balance: 16 }, }; function getMayaOS(kinNum) { const g = Math.ceil(kinNum / 5); return ( [ { Drive: 16, Care: 14, Vision: 6, Logic: 12, Quest: 14, Balance: 14 }, { Drive: 14, Care: 14, Vision: 16, Logic: 12, Quest: 14, Balance: 12 }, { Drive: 12, Care: 14, Vision: 8, Logic: 16, Quest: 16, Balance: 12 }, { Drive: 12, Care: 16, Vision: 14, Logic: 14, Quest: 12, Balance: 16 }, ][g - 1] || { Drive: 13, Care: 13, Vision: 13, Logic: 13, Quest: 13, Balance: 13, } ); } const OS_TYPES = ["Drive", "Care", "Vision", "Logic", "Quest", "Balance"]; const OS_META = { Drive: { label: "🔥 点火者", jpName: "点火者", emoji: "🔥", color: "#e07040", tagline: "行動・推進・決断", }, Care: { label: "🌙 包容者", jpName: "包容者", emoji: "🌙", color: "#5080a0", tagline: "共感・支援・調和", }, Vision: { label: "🎨 表現者", jpName: "表現者", emoji: "🎨", color: "#a050a0", tagline: "創造・直感・洞察", }, Logic: { label: "💎 洞察者", jpName: "洞察者", emoji: "💎", color: "#6090c0", tagline: "分析・計画・精緻", }, Quest: { label: "🪽 解放者", jpName: "解放者", emoji: "🪽", color: "#508060", tagline: "探求・冒険・自由", }, Balance: { label: "🌿 調律者", jpName: "調律者", emoji: "🌿", color: "#c8a84b", tagline: "統合・調和・柔軟", }, }; // OSタイプ別の「力が出る役割」 const OS_CAREER_MAP = { Drive: { primary: { career: "立ち上げ役" }, subModifiers: { Care: "周りを巻き込む", Vision: "構想を語る", Logic: "勝ち筋を読む", Quest: "新しい市場に打って出る", Balance: "続く形まで作る", }, }, Care: { primary: { career: "伴走役" }, subModifiers: { Drive: "決断を後押しする", Vision: "想いを言葉にする", Logic: "原因を突き止める", Quest: "外の知恵をつなぐ", Balance: "解決まで段取りする", }, }, Vision: { primary: { career: "企画の作り手" }, subModifiers: { Drive: "すぐ世に出す", Care: "受け手の声を映す", Logic: "根拠で裏づける", Quest: "ジャンルを越える", Balance: "最後まで仕上げる", }, }, Logic: { primary: { career: "読み解き役" }, subModifiers: { Drive: "結論を実行に移す", Care: "現場の声を拾う", Vision: "図解で伝える", Quest: "未知の領域を調べ上げる", Balance: "仕組みに落とし込む", }, }, Quest: { primary: { career: "開拓役" }, subModifiers: { Drive: "先陣を切る", Care: "人脈を育てる", Vision: "新しい見せ方を持ち込む", Logic: "機会を見極める", Balance: "新旧をつなぎ替える", }, }, Balance: { primary: { career: "まとめ役" }, subModifiers: { Drive: "決めて進める", Care: "一人ひとりを見る", Vision: "全体像を描く", Logic: "数字で管理する", Quest: "変化に合わせて組み替える", }, }, }; // OSタイプの組み合わせから役割名を取得 const getOSCareerInfo = (mainOS, subOS) => { const mainCareer = OS_CAREER_MAP[mainOS]; if (!mainCareer) return null; const modifier = mainCareer.subModifiers[subOS] || ""; const careerLabel = modifier ? `${modifier}${mainCareer.primary.career}` : mainCareer.primary.career; return { career: careerLabel, mainCareerType: mainCareer.primary.career, }; }; // 層1:OSスコア計算(仕様の重み付け) function calcOSScores(lifePath, el, kyusei, zodiac, maya) { const s = Object.fromEntries(OS_TYPES.map((k) => [k, 0])); const add = (map, w) => map && OS_TYPES.forEach((k) => { s[k] += (map[k] || 0) * w; }); add(ELEMENT_OS[el], 0.35); add(LIFEPATH_OS[lifePath], 0.25); add(KYUSEI_OS[kyusei], 0.15); add(ZODIAC_OS[zodiac.name], 0.1); add(getMayaOS(maya.kinNum), 0.15); const sorted = OS_TYPES.map((key) => [key, s[key]]).sort((a, b) => b[1] - a[1]); return { scores: s, mainOS: sorted[0][0], subOS: sorted[1][0], innerOS: sorted[2][0] }; } // ═══════════════════════════════════════════════════════ // 6 TYPE SYSTEM — 点火者・包容者・表現者・洞察者・解放者・調律者(互換性用、将来削除予定) // ═══════════════════════════════════════════════════════ const SIX_TYPES = { 点火者: { color: "#e07040", tagline: "誰よりも早く、誰よりも遠くへ", desc: "物事を一から動かす強烈な推進力を持ちます。停滞を嫌い、常に新しいフロンティアへ向かう。リスクを恐れず、失敗から学ぶ速度が異常に速い。", personality: "強い意志と独立心の持ち主。周囲のペースに合わせることが苦手だが、その突破力は他の追随を許さない。感情より理性を優先し、目標達成のためには孤独も厭わない。自分の信念に忠実で、曲げることを嫌う。", love: "恋愛においても積極的で、好きになったら一直線。しかし自立心が強く、束縛を嫌うため、対等なパートナーシップが理想。相手の自由も尊重できる人と深く結ばれる。", work: "起業家・経営者・プロジェクトリーダーに適性。新規事業の立ち上げや、前例のない挑戦に真価を発揮。金運は行動に比例して高まる。リスクを取った分だけ報われる星の下に生まれている。", advice: "「完璧を待つより、今すぐ動く」があなたのテーゼ。ただし、仲間を置き去りにしないよう意識的に振り返る習慣を。", compat: ["調律者", "解放者"], }, 包容者: { color: "#c06080", tagline: "すべてを受け入れ、すべてを育む", desc: "深い愛情と受容力で、周囲に安心の場を作ります。人の痛みを自分のこととして感じ取る共感力の持ち主。", personality: "繊細で感受性が豊か。表には出さないが、内側では常に深く考えている。人間関係を大切にし、誰かの役に立つことで自分の存在意義を感じる。ただし自己犠牲が過ぎると燃え尽きやすい。", love: "深く、真剣に愛する。一度結ばれると長く続く傾向。相手の感情を敏感に察知するため、言葉がなくても通じ合える関係を築く。嫉妬心は強いが、それは愛の深さの裏返し。", work: "カウンセラー・医療・教育・福祉の分野で才能を発揮。人を育てる仕事に天職を見出す。金運は「与えること」から循環する。惜しまず与える姿勢が、やがて豊かさとして返ってくる。", advice: "「No」と言う練習をしてください。あなたの優しさを守るために、境界線を引くことは愛の行為です。", compat: ["洞察者", "表現者"], }, 表現者: { color: "#8060c0", tagline: "言葉と色彩で、世界を塗り替える", desc: "豊かな創造性と独自の世界観を持ち、表現することで魂が輝きます。芸術・言語・音楽などあらゆる媒体を通じて、見えない何かを可視化する力。", personality: "個性的で独創的。マイペースで、流行より自分のスタイルを貫く。気分の波が激しいことがあるが、その振れ幅が創造力の源泉。孤独な時間が必要で、それが才能の充電になる。", love: "恋愛は情熱的かつ詩的。理想が高く、ありきたりな関係には満足しない。精神的な結びつきを最重視し、魂の共鳴を感じる人との縁が深まる。", work: "アーティスト・作家・デザイナー・ミュージシャンなど創造的職業に天賦の才。また、マーケティングやブランディングでも独自センスが光る。金運は「好きなことをする」ことで引き寄せられる。", advice: "完璧主義を手放すと、作品がさらに自由になります。未完成のままでも、世界に出す勇気を持ってください。", compat: ["包容者", "調律者"], }, 洞察者: { color: "#4070a0", tagline: "見えないものを見る、静かな哲人", desc: "深い分析力と直観を持ち、物事の本質を瞬時に見抜きます。表面的な情報に惑わされず、構造と真実を追求する。", personality: "内向的で思慮深い。会話より観察を好み、言葉を大切に使う。少数精鋭の深い関係を好み、広く浅い付き合いには消耗する。知的探求が生きがいで、学ぶことをやめない。", love: "恋愛は慎重で、相手を深く理解してから動く。表面的な魅力より、知性と誠実さに惹かれる。一度心を開いた相手には深い信頼を寄せるが、裏切りには敏感。", work: "研究者・分析家・戦略家・医師・哲学者など。データと洞察を組み合わせる仕事で真価を発揮。金運は「専門性を極める」ことで安定する。浅く広くより、深く狭く。", advice: "知識を行動に変える練習を。分析が終わったら、まず一歩だけ動いてみてください。思考と行動のバランスが運を開きます。", compat: ["包容者", "点火者"], }, 解放者: { color: "#50a070", tagline: "枠を超え、風のように自由に", desc: "変化と自由を愛し、あらゆる環境に適応する柔軟性を持ちます。固定観念を打ち破り、新しいパラダイムをもたらす変革者。", personality: "好奇心旺盛で飽きやすい面もあるが、その多様な経験が独自の視点を生む。ルールより自由、安定より変化。旅・異文化・新しい体験が魂の栄養となる。人脈が広く、縁の引力が強い。", love: "恋愛は自由で軽やか。重い関係より、互いに成長し合えるパートナーシップを好む。単調さを嫌うため、常に新鮮さを保つ工夫が必要。長期の関係では、意図的に変化を作ることが鍵。", work: "トラベラー・フリーランス・営業・起業家・外交官など多様な環境で活躍。変化の多い職場環境が合っている。金運は「動くほど」増える。じっとしていると停滞する。", advice: "「一つのことを続ける」練習が、あなたの可能性を10倍にします。自由と責任はセットです。", compat: ["点火者", "洞察者"], }, 調律者: { color: "#a09030", tagline: "すべての音を、美しい和音に変える", desc: "バランス感覚と審美眼を持ち、対立を調和へと導く力があります。場の空気を読み、最適なタイミングで最適な言葉を選ぶ。", personality: "穏やかで公平。どんな立場の人とも良好な関係を築き、コンフリクトを避けながら解決に向かう。完璧主義的な側面もあり、細部へのこだわりが強い。美しいものに敏感で、環境が心に影響しやすい。", love: "恋愛は穏やかで安定している。刺激より安心を重視し、信頼関係をゆっくり築く。パートナーへの思いやりが深く、長続きする関係を作る。ただし、自分の気持ちを後回しにしすぎる傾向がある。", work: "調停者・外交官・デザイナー・音楽家・マネージャーなど。チームの調和を保ちながら成果を出す役割で光る。金運は「環境の美しさ」に比例する。整えられた空間が豊かさを呼ぶ。", advice: "「曖昧な返事」をやめることが、あなたの運気を開きます。自分の意見をはっきり伝えることで、本来の力が解放されます。", compat: ["表現者", "点火者"], }, }; function determineType(lifePath, el, kyusei, zodiac) { // Multi-factor scoring const scores = { 点火者: 0, 包容者: 0, 表現者: 0, 洞察者: 0, 解放者: 0, 調律者: 0, }; // LifePath contribution const lpMap = { 1: "点火者", 2: "調律者", 3: "表現者", 4: "調律者", 5: "解放者", 6: "包容者", 7: "洞察者", 8: "点火者", 9: "包容者", 11: "洞察者", 22: "点火者", 33: "包容者", }; if (lpMap[lifePath]) scores[lpMap[lifePath]] += 3; // Element contribution const elMap = { 木: "解放者", 火: "点火者", 土: "包容者", 金: "洞察者", 水: "洞察者", }; if (elMap[el]) scores[elMap[el]] += 2; // Kyusei contribution const kyMap = { 1: "洞察者", 2: "包容者", 3: "解放者", 4: "表現者", 5: "点火者", 6: "調律者", 7: "表現者", 8: "調律者", 9: "点火者", }; if (kyMap[kyusei]) scores[kyMap[kyusei]] += 2; // Zodiac contribution const zodMap = { 牡羊座: "点火者", 牡牛座: "調律者", 双子座: "解放者", 蟹座: "包容者", 獅子座: "表現者", 乙女座: "洞察者", 天秤座: "調律者", 蠍座: "洞察者", 射手座: "解放者", 山羊座: "点火者", 水瓶座: "表現者", 魚座: "包容者", }; if (zodMap[zodiac.name]) scores[zodMap[zodiac.name]] += 2; return Object.entries(scores).sort((a, b) => b[1] - a[1])[0][0]; } function takeSentenceSnippet(text, sentenceCount = 1) { const raw = String(text ?? "").trim(); if (!raw) return ""; if (LANG !== "ja") return raw; const parts = raw .split("。") .map((part) => part.trim()) .filter(Boolean); if (!parts.length) return raw; return `${parts.slice(0, sentenceCount).join("。")}。`; } function ensureJapanesePeriod(text) { const raw = String(text ?? "").trim(); if (!raw) return ""; if (LANG !== "ja") return raw; return /[。!?!?]$/.test(raw) ? raw : `${raw}。`; } function splitJapaneseSentences(text) { const raw = String(text ?? "").trim(); if (!raw) return []; const parts = raw.match(/[^。!?!?]+[。!?!?]?/g) || [raw]; return parts.map((part) => ensureJapanesePeriod(part)).filter(Boolean); } const DIAGNOSIS_TRAITS = { 点火者: { basic: "止まっている物事に最初の一手を入れ、動かしながら決める", outward: "考えたことを小さく試し、反応を見て次を決める動き方が出やすい", latent: "追い込まれると、検討を打ち切ってでも自分で決めて動きやすい", fit: "立ち上げや停滞の打開など、最初の一手が求められる場面", friction: "確認を省いて走り、前提の見直しに何度も戻る状況", }, 包容者: { basic: "相手の本当の困りごとを聞き出し、自分ごととして受け止める", outward: "動く前に相手の状況を聞き、その人に合う形を探す動き方が出やすい", latent: "追い込まれると、自分の作業より目の前の人の困りごとを先に片づけやすい", fit: "ヒアリングや相談など、一対一で課題を掘り下げる場面", friction: "相手の事情を優先して、必要な判断を先送りし続ける状況", }, 表現者: { basic: "頭の中の構想や考えを、伝わる言葉や形に変える", outward: "考えたことを図や言葉、見せ方に落として届ける動き方が出やすい", latent: "追い込まれると、説明や見せ方を作り直して立て直そうとしやすい", fit: "企画や提案、発表など、考えを伝わる形にする場面", friction: "見せ方を磨き続けて、出すタイミングを逃す状況", }, 洞察者: { basic: "情報の奥にある構造や原因を読み取り、筋道を立てる", outward: "根拠と順序を確かめてから出す動き方が出やすい", latent: "追い込まれると、口数が減って一人で分析に入りやすい", fit: "原因を突き止めたり、判断の根拠をそろえたりする場面", friction: "確証を待ち続けて、決めるべき時期を過ぎる状況", }, 解放者: { basic: "外から新しい選択肢を持ち込み、前提を入れ替える", outward: "新しい場や人、方法につなげて広げる動き方が出やすい", latent: "追い込まれると、今のやり方を手放して別の道を探しやすい", fit: "新しい市場や環境に出る場面、やり方を転換する局面", friction: "やり方を変える余地がなく、同じ枠のまま続ける状況", }, 調律者: { basic: "全体を見て人と物事の配置を整え、前に進める", outward: "役割と手順を決めて、続く形に落とし込む動き方が出やすい", latent: "追い込まれると、まず手の届く範囲を整えて足場を固めやすい", fit: "プロジェクトの進行や、関係者の調整が必要な場面", friction: "急な変更が重なり、全体の段取りが組めない状況", }, }; const getDiagnosisTrait = (typeName) => { return DIAGNOSIS_TRAITS[typeName] || DIAGNOSIS_TRAITS["調律者"]; }; function getSixTypeNameFromOS(osName) { const jpName = OS_META[osName]?.jpName || ""; return jpName.split(" ").pop() || ""; } // 六命タイプ(日本語名) → SEO入口ページ slug(/types//・/compatibility/ で共通利用) const TYPE_SLUG_BY_NAME = { 点火者: "igniter", 包容者: "nurturer", 表現者: "creator", 洞察者: "seer", 解放者: "liberator", 調律者: "harmonizer", }; function getTypeSlugFromOS(osName) { return TYPE_SLUG_BY_NAME[getSixTypeNameFromOS(osName)] || null; } function getTypeSlugFromTendency(tendency) { return ( { igniter: "igniter", embracer: "nurturer", expresser: "creator", insighter: "seer", liberator: "liberator", harmonizer: "harmonizer", }[tendency] || null ); } function getPrimaryTypeSlugFromBirth(birthDate) { const parts = String(birthDate || "") .split("-") .map((value) => Number(value)); if (parts.length !== 3 || parts.some((value) => !Number.isFinite(value))) { return null; } const [year, month, day] = parts; const dominantElement = getDominantEl(year, month, day); const lifePath = calcLifePath(year, month, day); const kyusei = calcHonmeiKyusei(year, month, day); const zodiac = getZodiac(month, day); const maya = calcMaya(year, month, day); return getTypeSlugFromOS( calcOSScores(lifePath, dominantElement, kyusei, zodiac, maya).mainOS, ); } function getOSResultFromBirth(birthDate) { const parts = String(birthDate || "") .split("-") .map((value) => Number(value)); if (parts.length !== 3 || parts.some((value) => !Number.isFinite(value))) { return null; } const [year, month, day] = parts; return calcOSScores( calcLifePath(year, month, day), getDominantEl(year, month, day), calcHonmeiKyusei(year, month, day), getZodiac(month, day), calcMaya(year, month, day), ); } // タイプページの絶対URL(シェア着地先=bot可読OG) function typePageUrl(osName) { const slug = getTypeSlugFromOS(osName); const url = slug ? "https://lifecodereading.com/types/" + slug + "/" : "https://lifecodereading.com/"; return LANG === "ja" ? url : url + "?lang=" + encodeURIComponent(LANG); } // 主命×副命の紹介ページ(日本語のみ) function combinationPageUrl(mainOS, subOS) { const mainSlug = getTypeSlugFromOS(mainOS); const subSlug = getTypeSlugFromOS(subOS); return mainSlug && subSlug ? "https://lifecodereading.com/types/combination/" + mainSlug + "-" + subSlug + "/" : typePageUrl(mainOS); } function typeShareLandingUrl(mainOS, subOS) { return LANG === "ja" ? combinationPageUrl(mainOS, subOS) : typePageUrl(mainOS); } // ═══ G0 計測: lc_ref(どの仕組みのリンクから来たか・2026-10-03) ═══ // utm_source を付けると GA4 本来の参照元(x.com 等)が上書きされるため、独自パラメータ lc_ref だけを付ける。 // 着地時に share_landing を送り、localStorage.lc_ref に {ref, ts} を7日間保存して // diagnosis_start / diagnosis_complete に付ける。PHP 側は includes/entry_parts.php の lc_entry_ga_script()。 const LC_REF_VALUES = ["card", "invite", "daily", "compat", "result", "profile", "year2027", "oshi"]; const LC_REF_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 既に ? があれば & でつなぎ、# があればその前に入れる。lc_ref が付いていればそのまま返す。 function withLcRef(url, ref) { if (typeof url !== "string" || !url || !ref) return url; const hashIdx = url.indexOf("#"); const base = hashIdx === -1 ? url : url.slice(0, hashIdx); const hash = hashIdx === -1 ? "" : url.slice(hashIdx); if (/[?&]lc_ref=/.test(base)) return url; const sep = base.indexOf("?") === -1 ? "?" : /[?&]$/.test(base) ? "" : "&"; return base + sep + "lc_ref=" + encodeURIComponent(ref) + hash; } // 保存済みで期限内の lc_ref を返す(無い・期限切れ・不正値は null) function getStoredLcRef() { try { const raw = window.localStorage.getItem("lc_ref"); if (!raw) return null; const rec = JSON.parse(raw); if ( !rec || LC_REF_VALUES.indexOf(rec.ref) === -1 || typeof rec.ts !== "number" ) { return null; } if (Date.now() - rec.ts > LC_REF_TTL_MS) { window.localStorage.removeItem("lc_ref"); return null; } return rec.ref; } catch (e) { return null; } } // GA4 イベントに付けるパラメータ(lc_ref が無ければ空) function lcRefEventParams() { const ref = getStoredLcRef(); return ref ? { lc_ref: ref } : {}; } // URL の lc_ref を読み、share_landing を送って保存する。再読み込みで二重計測しないよう URL から外す。 function captureLcRefLanding() { let ref = null; try { ref = new URLSearchParams(window.location.search).get("lc_ref"); } catch (e) {} if (!ref || LC_REF_VALUES.indexOf(ref) === -1) return; if (typeof gtag === "function") { try { gtag("event", "share_landing", { lc_ref: ref, page_path: window.location.pathname, }); } catch (_) {} } try { window.localStorage.setItem( "lc_ref", JSON.stringify({ ref, ts: Date.now() }), ); } catch (e) {} try { const url = new URL(window.location.href); url.searchParams.delete("lc_ref"); window.history.replaceState(window.history.state, "", url.toString()); } catch (e) {} } // ═══ 主命×副命 30組の称号(P2・2026-10-01) ═══ // 正本は config/type_combination_titles.json(PHP の includes/type_combination_title_loader.php と共用)。 // 起動時に読み込み、"<主命slug>-<副命slug>" → レコード の対応表にする。 // 称号の値は各言語フィールドを直接使い、t() には通さない(二重翻訳防止)。 // 読み込み失敗・該当なしのときは null を返し、呼び出し側は従来の主命×副命表示に戻す。 const TYPE_COMBO_TITLES_VERSION = "20261004v2"; let TYPE_COMBO_TITLES = {}; function loadTypeComboTitles() { return fetch( "./config/type_combination_titles.json?v=" + TYPE_COMBO_TITLES_VERSION, ) .then((r) => (r.ok ? r.json() : [])) .then((list) => { const map = {}; if (Array.isArray(list)) { for (const rec of list) { if (rec && rec.code && rec.main && rec.sub) { map[rec.main + "-" + rec.sub] = rec; } } } TYPE_COMBO_TITLES = map; }) .catch(() => {}); } function getTypeComboTitle(mainOS, subOS) { const mainSlug = getTypeSlugFromOS(mainOS); const subSlug = getTypeSlugFromOS(subOS); if (!mainSlug || !subSlug) return null; const rec = TYPE_COMBO_TITLES[mainSlug + "-" + subSlug]; if (!rec) return null; // 表示言語の値が欠けている場合は日本語を混ぜず、従来表示へフォールバックする const loc = rec[LANG]; if (!loc || !loc.name || !loc.copy) return null; // ふりがな(ja.reading)は日本語表示のときだけ返す。シェア文・title 等には使わない const reading = LANG === "ja" && typeof loc.reading === "string" ? loc.reading : ""; return { code: rec.code, name: loc.name, copy: loc.copy, reading }; } // G3: 年運文章の正本。言語別フィールドは t() に通さず、無い言語だけ ja で補う。 const YEAR_FORTUNE_CONTENT_VERSION = "20261003e"; let YEAR_FORTUNE_CONTENT = {}; function loadYearFortuneContent() { return fetch( "./config/year_fortune_content.json?v=" + YEAR_FORTUNE_CONTENT_VERSION, ) .then((r) => (r.ok ? r.json() : {})) .then((data) => { YEAR_FORTUNE_CONTENT = data && typeof data === "object" ? data : {}; }) .catch(() => {}); } function yearFortuneLocalized(record) { if (!record || !record.ja) return null; const localized = record[LANG] && typeof record[LANG] === "object" ? record[LANG] : {}; const merged = { ...record.ja, ...localized }; if (!Array.isArray(merged.actions)) merged.actions = record.ja.actions || []; return merged; } // G4: 推し画面の文言。JSON が未配置・取得失敗でも同じキーの日本語既定文言で動かす。 const OSHI_UI_DEFAULTS = { mode_title: "推しとの相性", self_birth_label: "あなたの生年月日", oshi_name_label: "推しの呼び名(任意)", oshi_name_placeholder: "例:推し", oshi_birth_label: "推しの生年月日", oshi_birth_help: "生まれ年も必要だよ", submit: "相性を見る", result_heading_type: "推しのタイプ", result_heading_compat: "推しとの相性", result_heading_2027: "推しの2027年", share_button: "相性の画像を保存・シェア", share_text: "推しとの相性は{score}点、『{type}』だったよ #六命解析", another_oshi: "別の推しで見る", privacy_inline: "名前と生年月日は保存されません", entry_button_on_top: "💫 推しとの相性", oshi_fallback_name: "推し", }; let OSHI_UI = { ...OSHI_UI_DEFAULTS }; function loadOshiUi() { return fetch("./config/oshi_pages_content.json?v=20261004a", { cache: "no-store" }) .then((r) => (r.ok ? r.json() : {})) .then((data) => { const ui = data && data.ui && typeof data.ui === "object" ? data.ui : {}; const ja = ui.ja && typeof ui.ja === "object" ? ui.ja : {}; const localized = ui[LANG] && typeof ui[LANG] === "object" ? ui[LANG] : {}; OSHI_UI = { ...OSHI_UI_DEFAULTS, ...ja, ...localized }; }) .catch(() => { OSHI_UI = { ...OSHI_UI_DEFAULTS }; }); } // 相性ページの正規化パス(slugをアルファベット順に連結。A1のURL規則と一致) function compatPairPath(slugA, slugB) { if (!slugA || !slugB) return null; const s = [slugA, slugB].sort(); return "compatibility/" + s[0] + "-" + s[1] + "/"; } function buildDiagnosisNarrative( mainOS, subOS, innerOS, typePattern, subTypePattern, innerTypePattern, ) { const mainName = getSixTypeNameFromOS(mainOS); const subName = getSixTypeNameFromOS(subOS); const innerName = getSixTypeNameFromOS(innerOS); const main = getDiagnosisTrait(mainName); const sub = getDiagnosisTrait(subName); const inner = getDiagnosisTrait(innerName); const stripTypeIntro = (text) => String(text || "") .replace(/^あなたの(?:ライフコード|主命タイプ)は「[^」]+」です。?/, "") .trim(); const overview = ensureJapanesePeriod( stripTypeIntro(typePattern?.overview) || main.basic, ); const seenByOthers = ensureJapanesePeriod( typePattern?.seen_by_others || "", ); const hasSubAxis = !!subTypePattern?.axis_sub; const hasInnerAxis = !!innerTypePattern?.axis_inner; const outwardMove = ensureJapanesePeriod( subTypePattern?.axis_sub || subTypePattern?.human_relationship || subTypePattern?.seen_by_others || sub.outward, ); const trueInner = ensureJapanesePeriod( innerTypePattern?.axis_inner || innerTypePattern?.true_inner || inner.latent, ); const lifePattern = ensureJapanesePeriod(typePattern?.life_pattern || ""); const strength = ensureJapanesePeriod( typePattern?.strength || main.fit, ).replace(/^強みは、?/, ""); const pitfall = ensureJapanesePeriod( typePattern?.pitfall || main.friction, ).replace(/^(注意点|気をつけたい点)は、?/, ""); const body = [ overview, seenByOthers, `${hasSubAxis ? tf("副命の{type}は、あなたの力の使い方に出るよ。", { type: t(subName), }) : t("でもね、それがすべてじゃないんだよね。人と関わる場面になると、ちょっと違う顔が出てくる。")}${outwardMove}`, `${hasInnerAxis ? tf("内命の{type}は、一人のときや追い込まれたときに出やすい面。", { type: t(innerName), }) : t("で、ここからがもう少し奥の話。誰にも見せてない場所に、また別の顔があるんだよね。")}${trueInner}`, // life_pattern は主命・副命・内命を同じタイプで説明する文なので、新しい組み立てでは使わない hasSubAxis ? "" : lifePattern, `${hasSubAxis ? t("強みとして出やすいのは、こんなところ。") : t("あなたがいちばんあなたらしくいられるのは、")}${strength}`, `${hasSubAxis ? t("一方で、この力の使い方が偏ると、") : t("あと、こんな感じ方をすることがあっても、自分を責めなくて大丈夫。")}${pitfall}`, ].filter(Boolean); return { title: mainName, catchphrase: typePattern?.catchphrase || "", body, affirmation: "そのままでいいよ。今のあなたで、大丈夫。", closer: typePattern?.share_phrase || typePattern?.catchphrase || "", }; } // ═══ FORTUNE PHASES ═══ // スコアマッピングテーブル(2026-05-08 最適化) const ELEMENT_SCORE = { 木: 16, 火: 18, 土: 10, 金: 12, 水: 6 }; const KYUSEI_SCORE = { 1: 10, 2: 4, 3: 16, 4: 14, 5: 20, 6: 16, 7: 12, 8: 6, 9: 18, }; const PERSONAL_MONTH_SCORE = { 1: 18, 2: 6, 3: 16, 4: 8, 5: 14, 6: 12, 7: 4, 8: 18, 9: 14, }; const PERSONAL_YEAR_SCORE = { 1: 20, 2: 6, 3: 16, 4: 8, 5: 14, 6: 12, 7: 4, 8: 18, 9: 14, 11: 16, 22: 18, 33: 16, }; const MAYA_TONE_SCORE = { 1: 18, 2: 8, 3: 14, 4: 8, 5: 16, 6: 12, 7: 18, 8: 12, 9: 18, 10: 10, 11: 6, 12: 14, 13: 16, }; const ZODIAC_SCORE = { 牡羊座: 18, 獅子座: 16, 射手座: 14, 牡牛座: 12, 乙女座: 10, 山羊座: 10, 双子座: 16, 天秤座: 14, 水瓶座: 14, 蟹座: 8, 蠍座: 10, 魚座: 8, }; // 数秘術:個人年数を計算(年運スコア・個人月数用) // 1桁になるまで繰り返し足す。途中で 11・22・33(マスターナンバー)になったらそこで止める。 // 返り値は 1〜9・11・22・33(PERSONAL_YEAR_SCORE のキーと一致)。 // 2026-10-03 修正: 以前は while 内の return で1回しか足さず、10・12 等が残っていた。 // PHP の includes/client_fortune.php personalYear() と一致させること。 // ※ 2027年の運気(yearFortuneKeys)はマスターナンバーを使わない personalYear19 を使う。 function calcPersonalYear(m, d, y) { let s = `${m}${d}${y}`.split("").reduce((a, b) => a + parseInt(b), 0); while (s > 9 && s !== 11 && s !== 22 && s !== 33) { s = s .toString() .split("") .reduce((a, b) => a + parseInt(b), 0); } return s; } // スコアをフェーズに変換 function scoreToPhase(score) { if (score >= 16) return { phase: "春-始動期", emoji: "🌱", desc: "新しいことを始めるのに最適な時期。エネルギーが高く、チャンスが訪れやすい。積極的な行動が運を広げます。", }; if (score >= 11) return { phase: "夏-拡大期", emoji: "🌿", desc: "活動や影響力を広げるのに良い時期。周囲との協力も進みやすく、成長が加速します。", }; if (score >= 6) return { phase: "秋-収穫期", emoji: "🍂", desc: "これまでの努力が実を結ぶ時期。成果を受け取り、整理することが大切です。", }; return { phase: "冬-調整期", emoji: "🌙", desc: "内面を整える大切な時期。焦らず、自分のペースで準備を進めることが運を開きます。", }; } function getTurningPointSummary(score) { const val = score * 5; if (val >= 85) return "大きく動き始める節目。新しい挑戦や環境の切り替えが起こりやすい年だよ。"; if (val >= 70) return "人や仕事の幅が広がる節目。出会いや役割の増加が流れを変えるよ。"; if (val >= 50) return "基盤を固める節目。積み上げたものが形になりやすい年だよ。"; if (val >= 30) return "見直しと整理の節目。不要なものを手放すと次へ進みやすくなるよ。"; return "内側を整える節目。無理を減らし、次の変化に備える年だよ。"; } function buildTurningPointHighlights( birthYear, birthMonth, birthDay, currentAge, el, lifePath, kyusei, zodiac, maya, ) { const startAge = currentAge >= 18 ? 18 : Math.max(0, currentAge - 2); const endAge = currentAge >= 18 ? currentAge : Math.max(18, currentAge + 6); const candidates = []; for (let age = startAge; age <= endAge; age++) { const year = birthYear + age; const fortune = calcYearFortune( year, birthYear, birthMonth, birthDay, el, lifePath, kyusei, zodiac, maya, ); const prev = age > startAge ? calcYearFortune( year - 1, birthYear, birthMonth, birthDay, el, lifePath, kyusei, zodiac, maya, ) : fortune; const next = age < endAge ? calcYearFortune( year + 1, birthYear, birthMonth, birthDay, el, lifePath, kyusei, zodiac, maya, ) : fortune; const impact = Math.abs(fortune.score - 10) * 1.2 + Math.abs(fortune.score - prev.score) * 0.65 + Math.abs(fortune.score - next.score) * 0.65; candidates.push({ age, year, fortune, impact }); } candidates.sort((a, b) => b.impact - a.impact || a.age - b.age); const selected = []; const minGap = 7; for (const candidate of candidates) { if ( selected.every((item) => Math.abs(item.age - candidate.age) >= minGap) ) { selected.push(candidate); } if (selected.length >= 4) { break; } } if (selected.length < 4) { for (const candidate of candidates) { if (selected.some((item) => item.age === candidate.age)) { continue; } selected.push(candidate); if (selected.length >= 4) { break; } } } return selected .sort((a, b) => a.age - b.age) .map(({ age, year, fortune }) => ({ age, year, label: tf("{age}歳({year}年)頃", { age, year }), phase: fortune.phase, emoji: fortune.emoji, summary: getTurningPointSummary(fortune.score), detail: fortune.desc, })); } // ═══ その年の運気(G3「2027年のあなた」): 九星の年盤の宮 × 数秘の個人年数 ═══ // 計画: docs/growth_g3_2027/implementation_plan.md §2-1・§2-1b。 // PHP の includes/year_fortune.php と同じ決まり(scripts/year_fortune/tests で一致を確認)。 // // 年の区切り: // - 九星の「年」は立春で始まる節切りの年(例: 2027年 = 2027年2月4日ごろ〜2028年2月3日ごろ)。 // ここでの year はその節切りの年を指す。「2027年のあなた」カードは今日の日付に関係なく // year=2027 で評価する(立春前に見ても2027年の年盤を出す)。 // - 本命星は生年月日から立春区切りで求める(calcHonmeiKyusei)。 // - 数秘の個人年数は西暦(1月1日区切り)で、year をそのまま使う。 // 年盤の飛泊の順(中宮 → 乾 → 兌 → 艮 → 離 → 坎 → 坤 → 震 → 巽) const YEAR_PALACE_ORDER = ["center", "qian", "dui", "gen", "li", "kan", "kun", "zhen", "xun"]; // 外の流れ(九星): 動く宮 / 静かな宮 const YEAR_PALACE_STILL = ["center", "kan", "kun"]; // 内のテーマ(数秘): 動く数 const PERSONAL_YEAR19_MOVE = [1, 3, 5, 8]; // その年(立春区切りの年)の年盤の中宮の星(1=一白 … 9=九紫) // 年の各桁を1桁になるまで足した s から (11 - s) mod 9(0 は 9)。例: 2026→1(一白)、2027→9(九紫) function yearCenterStar(year) { let s = String(Math.abs(year)).split("").reduce((a, n) => a + Number(n), 0); while (s > 9) s = String(s).split("").reduce((a, n) => a + Number(n), 0); return mod(11 - s - 1, 9) + 1; } // 本命星 honmei が、その年の年盤で入る宮のキー // 飛泊の順で k 番目の宮には (中宮の星 + k) の星が入るので、k = (honmei - 中宮の星) mod 9 function yearPalace(honmei, year) { return YEAR_PALACE_ORDER[mod(honmei - yearCenterStar(year), 9)]; } // 数秘の個人年数(1〜9、マスターナンバーなし)。月・日・年の各桁を1桁になるまで足す。 // 例: 6月15日・2027年 → 6+1+5+2+0+2+7 = 23 → 5 // ※ 年運スコア用の calcPersonalYear(11・22・33 を残す)とは別物。 // 入力は検証済みの正の整数(月1〜12・日1〜31・年)を前提とする。負数や非数は未定義(NaN)。 function personalYear19(month, day, year) { let s = `${month}${day}${year}`.split("").reduce((a, n) => a + Number(n), 0); while (s > 9) s = String(s).split("").reduce((a, n) => a + Number(n), 0); return s; } // 「その年のあなた」のキー一式。文章は config/year_fortune_content.json をこのキーで引く。 function yearFortuneKeys(birthY, birthM, birthD, year) { const honmei = calcHonmeiKyusei(birthY, birthM, birthD); const palace = yearPalace(honmei, year); const outer = YEAR_PALACE_STILL.includes(palace) ? "still" : "move"; const personalYear = personalYear19(birthM, birthD, year); const inner = PERSONAL_YEAR19_MOVE.includes(personalYear) ? "move" : "still"; return { honmei, palace, outer, personalYear, inner, quadrant: `${outer}_${inner}` }; } const YEAR_PALACE_NAMES = { center: "中宮(まん中)", qian: "乾(北西)", dui: "兌(西)", gen: "艮(北東)", li: "離(南)", kan: "坎(北)", kun: "坤(南西)", zhen: "震(東)", xun: "巽(南東)", }; const YEAR2027_KYUSEI_SLUGS = [ "ippaku-suisei", "jikoku-dosei", "sanpeki-mokusei", "shiroku-mokusei", "goou-dosei", "roppaku-kinsei", "shichiseki-kinsei", "happaku-dosei", "kyushi-kasei", ]; function yearReadingData(birthY, birthM, birthD, year) { const keys = yearFortuneKeys(birthY, birthM, birthD, year); const palace = yearFortuneLocalized(YEAR_FORTUNE_CONTENT.palaces?.[keys.palace]); const personalYear = yearFortuneLocalized( YEAR_FORTUNE_CONTENT.personal_years?.[String(keys.personalYear)], ); const quadrant = yearFortuneLocalized( YEAR_FORTUNE_CONTENT.quadrants?.[keys.quadrant], ); if (!palace || !personalYear || !quadrant) return null; return { keys, palace, personalYear, quadrant }; } function yearKyuseiName(star) { const jaName = KYUSEI_NAMES[star - 1] || ""; return LANG === "ja" ? jaName : t("年運:" + jaName); } function yearPalaceName(key) { const jaName = YEAR_PALACE_NAMES[key] || key; return LANG === "ja" ? jaName : t("年運宮:" + jaName); } function yearReadingLead(reading, year) { const centerStar = yearKyuseiName(yearCenterStar(year)); const honmeiStar = yearKyuseiName(reading.keys.honmei); let lead = String(reading.palace.lead || "").replace(/2027/g, String(year)); if (LANG === "ja") { lead = lead.replace("あなたの本命星は", `あなたの本命星・${honmeiStar}は`); return `${year}年は${centerStar}が中宮の年。${lead.replace(new RegExp(`^${year}年、`), "")}`; } if (LANG === "en") { lead = lead.replace(/your birth star/i, `your birth star, ${honmeiStar},`); } else if (LANG === "th") { lead = lead.replace("ดาวประจำตัวของเธอ", `ดาวประจำตัวของเธอ ${honmeiStar}`); } lead = lead.replace( LANG === "en" ? new RegExp(`^In ${year},\\s*`, "i") : new RegExp(`^ปี ${year}\\s*`), "", ); if (LANG === "en" && lead) lead = lead.charAt(0).toUpperCase() + lead.slice(1); return tf("{year}年は{star}が中宮の年。{lead}", { year, star: centerStar, lead, }); } function year2027PageUrl(honmei) { const slug = YEAR2027_KYUSEI_SLUGS[honmei - 1]; const base = `https://lifecodereading.com/2027/${slug || ""}/`; return LANG === "ja" ? base : base + "?lang=" + encodeURIComponent(LANG); } // 層2-1:年運計算(最適化版:スコアマッピング使用) function calcYearFortune(ty, by, bm, bd, el, lifePath, kyusei, zodiac, maya) { const fourPillarsScore = ELEMENT_SCORE[el] || 12; const personalYear = calcPersonalYear(bm, bd, ty); const numerologyScore = PERSONAL_YEAR_SCORE[personalYear] || 12; const kyuseiYearScore = KYUSEI_SCORE[kyusei] || 12; const mayaScore = MAYA_TONE_SCORE[maya.toneNum] || 12; const zodiacScore = ZODIAC_SCORE[zodiac.name] || 12; const bioScore = 12; // より大きな分散を出すために、スコア範囲を拡大(0-20→0-24)してから正規化 const expandedScore = Math.round( fourPillarsScore * 0.35 + numerologyScore * 0.22 + kyuseiYearScore * 0.27 + mayaScore * 0.1 + zodiacScore * 0.04 + bioScore * 0.02, ); // スコアを非線形に変換して分散を拡大 const normalized = Math.max(0, Math.min(20, expandedScore)); const dispersed = Math.round((normalized - 12) * 1.8 + 12); const final = Math.max(0, Math.min(20, dispersed)); return { score: final, ...scoreToPhase(final) }; } // 月初日のバイオリズムスコア計算(0-20) function getCalcBioMonthScore(by, bm, bd, ty, tm) { // 対象月の初日のバイオリズム(身体23日、感情28日、知性33日周期)を計算 const bio = calcBio(by, bm, bd, ty, tm, 1); // 月初日(1日) const avg = (bio.physical + bio.emotional + bio.intellectual) / 3; // バイオリズム平均値 (-1.0〜+1.0) を スコア (0〜20) に正規化 return Math.max(0, Math.min(20, Math.round(avg * 10 + 10))); } // 層2-2:月運計算(最適化版:スコアマッピング使用) function calcMonthFortune( ty, tm, by, bm, bd, el, lifePath, kyusei, zodiac, maya, ) { const personalMonth = (tm + calcPersonalYear(bm, bd, ty)) % 9 || 9; const fourPillarsScore = ELEMENT_SCORE[el] || 12; const numerologyScore = PERSONAL_MONTH_SCORE[personalMonth] || 12; const kyuseiMonthScore = calcGregorianMonthKyusei(ty, tm); const kyuseiScore = KYUSEI_SCORE[kyuseiMonthScore] || 12; const mayaScore = MAYA_TONE_SCORE[maya.toneNum] || 12; const bioMonthScore = getCalcBioMonthScore(by, bm, bd, ty, tm); const zodiacScore = ZODIAC_SCORE[zodiac.name] || 12; // より大きな分散を出すために、スコア範囲を拡大(0-20→0-24)してから正規化 const expandedScore = Math.round( fourPillarsScore * 0.28 + kyuseiScore * 0.28 + numerologyScore * 0.18 + mayaScore * 0.12 + bioMonthScore * 0.1 + zodiacScore * 0.04, ); // スコアを非線形に変換して分散を拡大 const normalized = Math.max(0, Math.min(20, expandedScore)); const dispersed = Math.round((normalized - 12) * 1.8 + 12); const final = Math.max(0, Math.min(20, dispersed)); return { score: final, ...scoreToPhase(final) }; } // 層3:今日の占い - 仕様の重み付け function calcDayFortune( y, m, d, ty, tm, td, el, lifePath, kyusei, zodiac, maya, ) { const bio = calcBio(y, m, d, ty, tm, td); const bioScore = Math.round( ((bio.physical + bio.emotional + bio.intellectual) / 3) * 10 + 10, ); const normalizedBioScore = Math.max(0, Math.min(20, bioScore)); const kyuseiDayScore = 12; const fourPillarsDayScore = 12; const numerologyDayScore = 11; const mayaDayScore = 12; const zodiacDayScore = 10; const score = Math.round( normalizedBioScore * 0.3 + kyuseiDayScore * 0.2 + fourPillarsDayScore * 0.2 + numerologyDayScore * 0.15 + mayaDayScore * 0.1 + zodiacDayScore * 0.05, ); const normalizedScore = Math.max(0, Math.min(20, score)); const lucky = (((td + lifePath + kyusei) % 5) + 5) % 5; const luckyItems = [ { color: "白", item: "白いハンカチ", number: "3", direction: "北" }, { color: "金", item: "ゴールドのアクセサリー", number: "8", direction: "南", }, { color: "緑", item: "植物・観葉植物", number: "4", direction: "東" }, { color: "青", item: "水を入れたクリスタルグラス", number: "7", direction: "西", }, { color: "紫", item: "アメジスト", number: "9", direction: "南東" }, ][lucky]; const lifePathHour = (lifePath - 1) % 24; // lifePath を 0-23 の時刻に変換 const actions = [ tf("{dir}の方角を向いて深呼吸を三回。{el}の気があなたを満たす。", { dir: t(ELEMENT_DATA[el]?.dir || "東"), el }), tf("{n}という数字に縁のある時刻({h}時)に重要な決断を。", { n: lifePath, h: String(lifePathHour).padStart(2, "0") }), tf("{dir}方向にある場所で、大切な人と食事をすることで運気が上昇。", { dir: t(KYUSEI_DIRS[kyusei - 1]) }), tf("{zodiac}の特質を活かした行動が吉。今日は協調を心がけて。", { zodiac: t(zodiac.name) }), tf("今日の幸運色「{color}」を身につけることで、見えない加護を受け取る。", { color: t(luckyItems.color) }), ]; const action = actions[(td + lifePath) % actions.length]; return { score: normalizedScore, luckyItems, action, bio }; } // 旧6タイプシステム用の相性計算(互換性維持) function calcCompat(myType) { const typeData = SIX_TYPES[myType]; if (!typeData) return []; return typeData.compat.map((t) => ({ type: t, ...SIX_TYPES[t] })); } // ═══════════════════════════════════════════════════════ // UI PRIMITIVES // ═══════════════════════════════════════════════════════ const HexSymbol = ({ size = 60, color = "#c8a84b", animate = false }) => { const pts = Array.from({ length: 6 }, (_, i) => { const a = ((i * 60 - 30) * Math.PI) / 180; return `${size / 2 + (size / 2 - 4) * Math.cos(a)},${size / 2 + (size / 2 - 4) * Math.sin(a)}`; }).join(" "); return ( ); }; const OrbitalRing = ({ size = 120 }) => ( ); const GoldDivider = ({ delay = 0 }) => (
); const ScoreDots = ({ score, max = 10, color = "#c8a84b" }) => (
{Array.from({ length: max }, (_, i) => (
))}
); const BioChart = ({ data, delay = 0 }) => { const W = 280, H = 80, mid = H / 2, pts = 40; const makePath = (period, color) => { const d = Array.from({ length: pts }, (_, i) => { const x = (i / (pts - 1)) * W; const dy = data.days - 20 + (i / (pts - 1)) * 40; const yy = mid - Math.sin((2 * Math.PI * dy) / period) * (mid - 8); return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${yy.toFixed(1)}`; }).join(" "); return ( ); }; return (
{makePath(23, "#c8a84b")} {makePath(28, "#5a9e8a")} {makePath(33, "#8080c0")} TODAY
{[ { label: "肉体", v: data.physical, color: "#c8a84b" }, { label: "感情", v: data.emotional, color: "#5a9e8a" }, { label: "知性", v: data.intellectual, color: "#8080c0" }, ].map(({ label, v, color }) => (
{label}
0 ? 1 : 0.4, }} />
{v > 0.3 ? "↑高" : v < -0.3 ? "↓低" : "→中"}
))}
); }; // ═══ EXPANDABLE CARD ═══ const ReadingCard = ({ index, title, subtitle, system, icon, children, delay = 0, accentColor = "var(--gold)", defaultOpen = false, }) => { const [open, setOpen] = React.useState(defaultOpen); return (
setOpen(!open)} style={{ background: "linear-gradient(135deg,rgba(255,255,255,0.92),rgba(255,248,240,0.96))", border: `1px solid ${open ? accentColor + "44" : "var(--border)"}`, borderRadius: "14px", padding: "20px 24px", cursor: "pointer", transition: "all 0.4s ease", opacity: 0, animation: `reveal-card 0.7s ease ${delay}s forwards`, boxShadow: open ? `0 0 40px ${accentColor}18, 0 6px 24px rgba(61,52,40,0.1)` : "0 4px 24px rgba(61,52,40,0.12)", position: "relative", overflow: "hidden", }} >
{icon}
{tf("命 {n}", { n: t(index) })} {system}

{title}

{subtitle && ( {subtitle} )}
▾
{open && (
{children}
)}
); }; // ═══ SECTION HEADER ═══ const TEXT_WRAP_STYLE = { overflowWrap: "anywhere", wordBreak: "break-word" }; // localized: true のページは lifecodereading.com 配下の自前ページで、 // ?lang= による日本語/タイ語切替に対応している(tokushoho.html は未対応)。 const FOOTER_EXTERNAL_LINKS = [ { label: "六命タイプ一覧", href: "types/", localized: true }, { label: "プライバシーポリシー", href: "privacy.html", localized: true }, { label: "利用規約", href: "terms.html", localized: true }, { label: "運営会社", href: "company.html", localized: true }, { label: "特定商取引法に基づく表記", href: "tokushoho.html" }, { label: "お問合せ", href: "https://resource-sharing.co.jp/contact/" }, ]; // タイ語表示中は、切替対応ページへ ?lang= を引き継いで遷移する。 const withLang = (href, localized) => localized && LANG !== "ja" ? `${href}?lang=${LANG}` : href; const FooterLink = ({ href, children, fontSize = "0.94rem", letterSpacing = "0.08em", }) => ( (e.currentTarget.style.color = "var(--gold)")} onMouseLeave={(e) => (e.currentTarget.style.color = "var(--text-dim)")} > {children} ); const FooterSeparator = ({ fontSize = "0.76rem" }) => ( | ); const FooterLinks = ({ fontSize = "0.94rem", letterSpacing = "0.08em" }) => (
{FOOTER_EXTERNAL_LINKS.map((link, idx) => ( {idx > 0 && } {t(link.label)} ))} シェアードToDo
© Copyright — 株式会社リソース・シェアリング
); // タイプ別カラー(2026-07-19 カジュアルUI導入) const TYPE_COLORS = { 点火者: "#FF6B5A", 包容者: "#FFA94D", 表現者: "#F783AC", 洞察者: "#4DABF7", 解放者: "#38D9A9", 調律者: "#9775FA", }; const getTypeColor = (typeName) => TYPE_COLORS[typeName] || "var(--gold)"; // ═══ 六命タイプ シェアカード画像生成(2026-07-26) ═══ // html2canvas等のライブラリを使わず、Canvas 2D APIで直接PNGを描画する。 // Webフォント・絵文字のキャプチャ崩れを避けるため、document.fonts.ready を待ってから描画する。 function roundRectPath(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } // 表示言語に合わせた分割単位で文字列を区切る(英語・タイ語は単語、日本語は1文字)。 // タイ語は結合文字(母音記号・声調記号)を子音から切り離さないよう、書記素/単語単位で扱う。 function segmentCanvasText(text) { const s = String(text || ""); if (typeof Intl !== "undefined" && Intl.Segmenter) { try { const seg = new Intl.Segmenter(LANG, { granularity: LANG === "ja" ? "grapheme" : "word", }); return Array.from(seg.segment(s), (x) => x.segment); } catch (e) { // Segmenter 非対応の言語指定は下のフォールバックへ } } if (LANG === "en") return s.split(/(\s+)/).filter((x) => x !== ""); return Array.from(s); } function wrapCanvasText(ctx, text, maxWidth) { const pieces = segmentCanvasText(text); const lines = []; let line = ""; const pushLine = () => { const trimmed = line.trim(); if (trimmed) lines.push(trimmed); line = ""; }; for (const piece of pieces) { const test = line + piece; if (!line.trim() || ctx.measureText(test).width <= maxWidth) { line = test; continue; } pushLine(); if (/^\s+$/.test(piece)) continue; line = piece; } pushLine(); // 1単語だけで幅を超える行は文字単位で折り返す const out = []; for (const l of lines) { if (ctx.measureText(l).width <= maxWidth) { out.push(l); continue; } let buf = ""; for (const ch of Array.from(l)) { if (buf && ctx.measureText(buf + ch).width > maxWidth) { out.push(buf); buf = ch; } else { buf += ch; } } if (buf) out.push(buf); } return out; } // 指定行数に収まるまで文字サイズを下げて折り返す(長い英語・タイ語のはみ出し防止)。 // oneLineMin を指定すると、その文字サイズまでは1行に収めることを優先する(不自然な改行・取り残し防止)。 function fitCanvasText(ctx, text, { weight, maxSize, minSize, maxWidth, maxLines, oneLineMin }) { let size = maxSize; let lines = []; if (oneLineMin) { for (let s1 = maxSize; s1 >= oneLineMin; s1 -= 2) { ctx.font = `${weight} ${s1}px ${SHARE_CARD_FONT}`; if (ctx.measureText(String(text || "")).width <= maxWidth) { return { size: s1, lines: [String(text || "")], lineHeight: Math.round(s1 * 1.35) }; } } } while (size >= minSize) { ctx.font = `${weight} ${size}px ${SHARE_CARD_FONT}`; lines = wrapCanvasText(ctx, text, maxWidth); if (lines.length <= maxLines) break; size -= 2; } if (size < minSize) size = minSize; ctx.font = `${weight} ${size}px ${SHARE_CARD_FONT}`; lines = wrapCanvasText(ctx, text, maxWidth); return { size, lines, lineHeight: Math.round(size * 1.35) }; } const SHARE_CARD_FONT = "'Zen Maru Gothic','Hiragino Maru Gothic ProN',sans-serif"; // P2: 主役は 絵文字 → 漢字コード → 呼び名 → 一言コピー → 誘い文。サイト名・URL は下部に小さく。 // code が空(称号データなし)のときは name に主命×副命を入れて描く。 async function buildTypeShareCardCanvas({ emojiLine, code, name, reading, copy, inviteLine, siteName, footerUrl, mainColor, }) { const W = 1080; const H = 1350; const canvas = document.createElement("canvas"); canvas.width = W; canvas.height = H; const ctx = canvas.getContext("2d"); if (document.fonts && document.fonts.ready) { try { await document.fonts.ready; } catch (e) { // フォント読み込み待機の失敗は致命的ではないため、既定フォントのまま続行する } } ctx.fillStyle = "#FFF8F0"; ctx.fillRect(0, 0, W, H); roundRectPath(ctx, 48, 48, W - 96, H - 96, 56); ctx.lineWidth = 10; ctx.strokeStyle = mainColor; ctx.stroke(); ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; const maxWidth = W - 240; // 各ブロックの寸法を先に計算し、上下中央にまとめて配置する const blocks = []; if (emojiLine) { blocks.push({ kind: "text", text: emojiLine, font: `400 110px ${SHARE_CARD_FONT}`, lineHeight: 130, color: "#3D3428", gapAfter: 26 }); } if (code) { blocks.push({ kind: "text", text: code, font: `900 168px ${SHARE_CARD_FONT}`, lineHeight: 180, color: mainColor, gapAfter: reading ? 14 : 22 }); } // ふりがな(ja のみ): 呼び名の上に小さく中央寄せ。呼び名との間は詰める if (code && name && reading) { const fit = fitCanvasText(ctx, reading, { weight: 500, maxSize: 34, minSize: 24, maxWidth, maxLines: 1, oneLineMin: 24 }); blocks.push({ kind: "lines", lines: fit.lines, font: `500 ${fit.size}px ${SHARE_CARD_FONT}`, lineHeight: Math.round(fit.size * 1.3), color: "rgba(61,52,40,0.62)", gapAfter: 0 }); } if (name) { const fit = fitCanvasText(ctx, name, { weight: 900, maxSize: code ? 92 : 64, minSize: 44, maxWidth, maxLines: 2, oneLineMin: code ? 64 : 48 }); blocks.push({ kind: "lines", lines: fit.lines, font: `900 ${fit.size}px ${SHARE_CARD_FONT}`, lineHeight: fit.lineHeight, color: "#3D3428", gapAfter: 26 }); } if (copy) { const fit = fitCanvasText(ctx, copy, { weight: 500, maxSize: 46, minSize: 30, maxWidth, maxLines: 3, oneLineMin: 38 }); blocks.push({ kind: "lines", lines: fit.lines, font: `500 ${fit.size}px ${SHARE_CARD_FONT}`, lineHeight: fit.lineHeight, color: "rgba(61,52,40,0.85)", gapAfter: 0 }); } blocks.push({ kind: "divider", height: 2, gapBefore: 54, gapAfter: 54 }); if (inviteLine) { const fit = fitCanvasText(ctx, inviteLine, { weight: 700, maxSize: 44, minSize: 30, maxWidth, maxLines: 2 }); blocks.push({ kind: "lines", lines: fit.lines, font: `700 ${fit.size}px ${SHARE_CARD_FONT}`, lineHeight: fit.lineHeight, color: mainColor, gapAfter: 0 }); } const blockHeight = (b) => b.kind === "divider" ? b.gapBefore + b.height + b.gapAfter : (b.kind === "text" ? b.lineHeight : b.lines.length * b.lineHeight) + (b.gapAfter || 0); const total = blocks.reduce((sum, b) => sum + blockHeight(b), 0); const top = 110; const bottom = H - 200; let y = top + Math.max(0, (bottom - top - total) / 2); for (const b of blocks) { if (b.kind === "divider") { y += b.gapBefore; ctx.strokeStyle = "rgba(61,52,40,0.15)"; ctx.lineWidth = b.height; ctx.beginPath(); ctx.moveTo(W / 2 - 120, y); ctx.lineTo(W / 2 + 120, y); ctx.stroke(); y += b.height + b.gapAfter; continue; } ctx.font = b.font; ctx.fillStyle = b.color; const lines = b.kind === "text" ? [b.text] : b.lines; for (const line of lines) { // ベースラインは行の下寄り(行高の約78%)に置く ctx.fillText(line, W / 2, y + Math.round(b.lineHeight * 0.78)); y += b.lineHeight; } y += b.gapAfter || 0; } ctx.font = `600 28px ${SHARE_CARD_FONT}`; ctx.fillStyle = "rgba(61,52,40,0.5)"; ctx.fillText(siteName, W / 2, H - 132); ctx.font = `400 24px ${SHARE_CARD_FONT}`; ctx.fillStyle = "rgba(61,52,40,0.42)"; ctx.fillText(footerUrl, W / 2, H - 92); return canvas; } function canvasToPngFile(canvas, filename) { return new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (!blob) { reject(new Error("Canvas toBlob failed")); return; } resolve(new File([blob], filename, { type: "image/png" })); }, "image/png"); }); } // G4: 推し名を一切描かない専用カード(1080×1350)。既存カードの描画部品を共用する。 async function buildOshiShareCardCanvas({ score, compatibilityType, oshiTitle, selfTitle, ui }) { const W = 1080; const H = 1350; const canvas = document.createElement("canvas"); canvas.width = W; canvas.height = H; const ctx = canvas.getContext("2d"); if (document.fonts && document.fonts.ready) { try { await document.fonts.ready; } catch (_) {} } ctx.fillStyle = "#FFF8F0"; ctx.fillRect(0, 0, W, H); roundRectPath(ctx, 48, 48, W - 96, H - 96, 56); ctx.lineWidth = 10; ctx.strokeStyle = "#c8a84b"; ctx.stroke(); ctx.textAlign = "center"; ctx.fillStyle = "#3D3428"; ctx.font = `700 58px ${SHARE_CARD_FONT}`; ctx.fillText(t("私と推しの相性"), W / 2, 170); ctx.fillStyle = "#c07832"; ctx.font = `900 190px ${SHARE_CARD_FONT}`; ctx.fillText(String(score), W / 2, 410); ctx.font = `700 40px ${SHARE_CARD_FONT}`; ctx.fillText(t("相性スコア"), W / 2, 475); const typeFit = fitCanvasText(ctx, compatibilityType, { weight: 800, maxSize: 68, minSize: 40, maxWidth: W - 220, maxLines: 2, oneLineMin: 46, }); ctx.font = `800 ${typeFit.size}px ${SHARE_CARD_FONT}`; ctx.fillStyle = "#3D3428"; let y = 580; for (const line of typeFit.lines) { ctx.fillText(line, W / 2, y); y += typeFit.lineHeight; } ctx.strokeStyle = "rgba(61,52,40,0.14)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(180, 745); ctx.lineTo(W - 180, 745); ctx.stroke(); const drawTitle = (label, value, baseline) => { ctx.fillStyle = "rgba(61,52,40,0.58)"; ctx.font = `600 30px ${SHARE_CARD_FONT}`; ctx.fillText(label, W / 2, baseline); const fit = fitCanvasText(ctx, value, { weight: 800, maxSize: 48, minSize: 32, maxWidth: W - 220, maxLines: 2, oneLineMin: 36, }); ctx.fillStyle = "#3D3428"; ctx.font = `800 ${fit.size}px ${SHARE_CARD_FONT}`; let lineY = baseline + 72; for (const line of fit.lines) { ctx.fillText(line, W / 2, lineY); lineY += fit.lineHeight; } }; drawTitle(ui.result_heading_type, oshiTitle, 830); drawTitle(t("あなたのタイプ"), selfTitle, 1035); ctx.fillStyle = "rgba(61,52,40,0.55)"; ctx.font = `600 28px ${SHARE_CARD_FONT}`; ctx.fillText(LANG === "ja" ? "六命解析 - Life Code -" : "Life Code", W / 2, H - 132); ctx.font = `400 24px ${SHARE_CARD_FONT}`; ctx.fillText("lifecodereading.com/oshi/", W / 2, H - 90); return canvas; } const TypeShareCard = ({ mainOS, subOS, sharePhrase }) => { const [status, setStatus] = React.useState("idle"); const mainTypeName = getSixTypeNameFromOS(mainOS); const subTypeName = getSixTypeNameFromOS(subOS); const mainColor = getTypeColor(mainTypeName); const mainEmoji = OS_META[mainOS]?.emoji || ""; const subEmoji = OS_META[subOS]?.emoji || ""; // P2: 30組の称号(値は言語別フィールドを直接使用。t() に通さない) const comboTitle = getTypeComboTitle(mainOS, subOS); const hashtagText = LANG !== "ja" ? "#LifeCode" : "#六命解析 #LifeCode"; const cardTitle = LANG !== "ja" ? "Life Code" : "六命解析 - Life Code -"; const inviteLine = t("あなたは30タイプのどれ?"); const emojiLine = `${mainEmoji}${subEmoji}`; const combinationLine = tf("主命×副命: {main} × {sub}", { main: `${mainEmoji}${t(mainTypeName)}`, sub: `${subEmoji}${t(subTypeName)}`, }); // 称号データが無いときは従来どおり主命×副命を名前の位置に出す const fallbackName = `${t(mainTypeName)} × ${t(subTypeName)}`; const shareText = ( comboTitle ? [ `${comboTitle.code}|${comboTitle.name}`, comboTitle.copy, inviteLine, hashtagText, ] : [sharePhrase, combinationLine, hashtagText] ) .filter(Boolean) .join("\n"); const shareUrl = withLcRef(typeShareLandingUrl(mainOS, subOS), "card"); const handleShare = async () => { setStatus("building"); // GA4 ファネル: タイプシェアカード if (typeof gtag === "function") { try { gtag("event", "share_card_click", { type_slug: getTypeSlugFromOS(mainOS), }); } catch (_) {} } try { const canvas = await buildTypeShareCardCanvas({ emojiLine, code: comboTitle ? comboTitle.code : "", name: comboTitle ? comboTitle.name : fallbackName, reading: comboTitle ? comboTitle.reading : "", copy: comboTitle ? comboTitle.copy : "", inviteLine, siteName: cardTitle, footerUrl: "lifecodereading.com", mainColor, }); const file = await canvasToPngFile(canvas, "lifecode-type-card.png"); if (navigator.canShare && navigator.canShare({ files: [file] })) { try { await navigator.share({ files: [file], title: cardTitle, text: shareText, url: shareUrl, }); } catch (shareErr) { if (shareErr && shareErr.name !== "AbortError") throw shareErr; } setStatus("idle"); } else { const url = URL.createObjectURL(file); const a = document.createElement("a"); a.href = url; a.download = "lifecode-type-card.png"; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 4000); setStatus("downloaded"); } } catch (e) { setStatus("error"); } }; return (
{/* 称号の見た目は上部のタイプ宣言カードと重複するため、プレビューは出さずシェア操作だけ置く。 画像は buildTypeShareCardCanvas が DOM に依存せず Canvas で生成する。 */}
{status === "downloaded" && (
{t("保存した画像をX・Instagram・LINEに貼ってシェアしてね")}
)} {status === "error" && (
{t("画像の作成に失敗したよ。もう一度試してみてね")}
)}
); }; let YEAR2027_VIEW_TRACKED = false; async function buildYear2027ShareCanvas({ headlineLead, headlineTitle, palaceValue, personalYearValue, quadrantValue, comboLine, mainColor, }) { const W = 1080; const H = 1350; const canvas = document.createElement("canvas"); canvas.width = W; canvas.height = H; const ctx = canvas.getContext("2d"); if (document.fonts && document.fonts.ready) { try { await document.fonts.ready; } catch (_) {} } ctx.fillStyle = "#FFF8F0"; ctx.fillRect(0, 0, W, H); roundRectPath(ctx, 48, 48, W - 96, H - 96, 56); ctx.lineWidth = 10; ctx.strokeStyle = mainColor; ctx.stroke(); ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; ctx.font = `900 176px ${SHARE_CARD_FONT}`; ctx.fillStyle = mainColor; ctx.fillText("2027", W / 2, 238); const drawLines = (text, y, options) => { const fit = fitCanvasText(ctx, text, options); ctx.font = `${options.weight} ${fit.size}px ${SHARE_CARD_FONT}`; ctx.fillStyle = options.color; fit.lines.forEach((line, index) => { ctx.fillText(line, W / 2, y + index * fit.lineHeight); }); return y + fit.lines.length * fit.lineHeight; }; drawLines(headlineLead, 332, { weight: 900, maxSize: 58, minSize: 36, maxWidth: W - 190, maxLines: 1, oneLineMin: 36, color: "#3D3428", }); drawLines(headlineTitle, 408, { weight: 900, maxSize: 66, minSize: 28, maxWidth: W - 190, maxLines: 1, oneLineMin: 28, color: "#3D3428", }); const boxX = 130; const boxWidth = W - 260; const boxHeight = 130; const boxGap = 28; const boxStartY = 468; const blocks = [ { label: t("外の流れ"), value: palaceValue }, { label: t("内のテーマ"), value: personalYearValue }, { label: t("過ごし方"), value: quadrantValue }, ]; blocks.forEach((block, index) => { const boxY = boxStartY + index * (boxHeight + boxGap); roundRectPath(ctx, boxX, boxY, boxWidth, boxHeight, 28); ctx.fillStyle = "rgba(255,255,255,0.76)"; ctx.fill(); drawLines(block.label, boxY + 39, { weight: 600, maxSize: 25, minSize: 18, maxWidth: boxWidth - 110, maxLines: 1, oneLineMin: 18, color: "rgba(61,52,40,0.56)", }); drawLines(block.value, boxY + 96, { weight: 700, maxSize: 38, minSize: 20, maxWidth: boxWidth - 110, maxLines: 1, oneLineMin: 20, color: "#3D3428", }); }); drawLines(comboLine, 1052, { weight: 800, maxSize: 42, minSize: 22, maxWidth: W - 250, maxLines: 1, oneLineMin: 22, color: mainColor, }); ctx.font = `600 28px ${SHARE_CARD_FONT}`; ctx.fillStyle = "rgba(61,52,40,0.56)"; ctx.fillText(LANG === "ja" ? "六命解析 - Life Code -" : "Life Code", W / 2, H - 132); ctx.font = `400 24px ${SHARE_CARD_FONT}`; ctx.fillStyle = "rgba(61,52,40,0.44)"; ctx.fillText("lifecodereading.com", W / 2, H - 92); return canvas; } const YearReadingCard = ({ year, birthY, birthM, birthD, mainOS, subOS, featured = false, animationDelay = 1, }) => { const [shareStatus, setShareStatus] = React.useState("idle"); const reading = yearReadingData(birthY, birthM, birthD, year); const comboTitle = getTypeComboTitle(mainOS, subOS); const mainColor = getTypeColor(getSixTypeNameFromOS(mainOS)); React.useEffect(() => { if (year !== 2027 || !reading || YEAR2027_VIEW_TRACKED) return; YEAR2027_VIEW_TRACKED = true; if (typeof gtag === "function") { try { gtag("event", "year2027_view", { palace: reading.keys.palace, quadrant: reading.keys.quadrant, }); } catch (_) {} } }, [year, reading?.keys.palace, reading?.keys.quadrant]); if (!reading) return null; const { keys, palace, personalYear, quadrant } = reading; const palaceName = yearPalaceName(keys.palace); const headline = featured ? tf("私の{year}年は「{title}」", { year, title: palace.title }) : tf("{year}年のあなた", { year }); const shareHeadline = tf("私の{year}年は「{title}」", { year, title: palace.title, }); const methodsUrl = withLang("/methods/nine-star-ki/", true); const handleYearShare = async () => { setShareStatus("building"); if (typeof gtag === "function") { try { gtag("event", "year2027_share", { palace: keys.palace, quadrant: keys.quadrant, }); } catch (_) {} } const shareText = tf("私の2027年は「{title}」。{quadrant} #六命解析", { title: palace.title, quadrant: quadrant.name, }); const shareUrl = withLcRef(year2027PageUrl(keys.honmei), "year2027"); try { const canvas = await buildYear2027ShareCanvas({ headlineLead: t("私の2027年は"), headlineTitle: tf("「{title}」", { title: palace.title }), palaceValue: palaceName, personalYearValue: tf("個人年数 {number}・{title}", { number: keys.personalYear, title: personalYear.title, }), quadrantValue: quadrant.name, comboLine: comboTitle ? `${comboTitle.code}|${comboTitle.name}` : tf("主命×副命: {main} × {sub}", { main: t(getSixTypeNameFromOS(mainOS)), sub: t(getSixTypeNameFromOS(subOS)), }), mainColor, }); const file = await canvasToPngFile(canvas, "lifecode-2027.png"); if (navigator.canShare && navigator.canShare({ files: [file] })) { try { await navigator.share({ files: [file], title: shareHeadline, text: shareText, url: shareUrl, }); } catch (shareError) { if (shareError && shareError.name !== "AbortError") throw shareError; } setShareStatus("idle"); } else { const objectUrl = URL.createObjectURL(file); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = "lifecode-2027.png"; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); setTimeout(() => URL.revokeObjectURL(objectUrl), 4000); setShareStatus("downloaded"); } } catch (_) { setShareStatus("error"); } }; const panelStyle = { padding: "16px", background: "rgba(255,255,255,0.76)", border: "1px solid rgba(255,138,61,0.15)", borderRadius: "14px", }; const titleStyle = { color: "var(--gold)", fontSize: "1.08rem", fontWeight: 700, lineHeight: 1.6, marginBottom: "8px", }; return (
{headline}
{yearReadingLead(reading, year)}
{t("外の流れ")}{LANG === "ja" ? "|" : " | "}{palaceName}
{palace.title}
{palace.body}
    {palace.actions.map((action, index) =>
  • {action}
  • )}
{t("気をつけたいこと")}:{palace.caution}
{t("内のテーマ")}{LANG === "ja" ? "|" : " | "}{tf("個人年数 {number}", { number: keys.personalYear })}
{personalYear.title}
{personalYear.body}
    {personalYear.actions.map((action, index) =>
  • {action}
  • )}
{t("2つを合わせた過ごし方")}
{quadrant.name}
{quadrant.body}
{t("この年のコツ")}:{quadrant.tip}
{t("Life Code では、九星をその年の外の流れ、個人年数をあなた自身のテーマとして重ねて読みます")}
{t("計算の決まりを見る →")}
{year === 2027 && (
{shareStatus === "downloaded" && (
{t("保存した画像をX・Instagram・LINEに貼ってシェアしてね")}
)} {shareStatus === "error" && (
{t("画像の作成に失敗したよ。もう一度試してみてね")}
)}
)}
); }; // G1: SNS のプロフィール(自己紹介欄)に貼る一行。称号データが無いときは null。 // 称号の値(code・name)は言語別フィールドをそのまま使い t() に通さない。タイプ名だけ t() で訳す。 // 長さは scripts/type_titles/check_profile_line.py で30組×3言語を検証(60字以内)。 function buildProfileLine(mainOS, subOS) { const comboTitle = getTypeComboTitle(mainOS, subOS); if (!comboTitle) return null; const mainName = t(getSixTypeNameFromOS(mainOS)); const subName = t(getSixTypeNameFromOS(subOS)); return LANG === "ja" ? `${comboTitle.code}|${comboTitle.name}(${mainName}×${subName})#六命解析` : `${comboTitle.code} | ${comboTitle.name} (${mainName} × ${subName}) #LifeCode`; } const ProfileLineCard = ({ mainOS, subOS }) => { const [copied, setCopied] = React.useState(null); // "line" | "link" | null const [fallback, setFallback] = React.useState(null); // { kind, text } | null const timerRef = React.useRef(null); const fallbackInputRef = React.useRef(null); const mainColor = getTypeColor(getSixTypeNameFromOS(mainOS)); const line = buildProfileLine(mainOS, subOS); const link = withLcRef(typeShareLandingUrl(mainOS, subOS), "profile"); React.useEffect( () => () => { if (timerRef.current) clearTimeout(timerRef.current); }, [], ); // クリップボードが使えないとき: 読み取り専用の入力欄を選択状態にして長押しコピーを促す React.useEffect(() => { const el = fallbackInputRef.current; if (!fallback || !el) return; try { el.focus(); el.select(); el.setSelectionRange(0, el.value.length); } catch (e) {} }, [fallback]); if (!line) return null; const handleCopy = async (kind) => { const text = kind === "line" ? line : link; // GA4: プロフィールの一行コピー if (typeof gtag === "function") { try { gtag("event", "profile_copy", { kind, type_slug: getTypeSlugFromOS(mainOS), }); } catch (_) {} } let ok = false; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); ok = true; } } catch (_) {} if (ok) { setFallback(null); setCopied(kind); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(null), 2000); } else { setCopied(null); setFallback({ kind, text }); } }; const buttonStyle = { background: "transparent", color: mainColor, border: `1.5px solid ${mainColor}`, borderRadius: "999px", padding: "8px 18px", fontSize: "0.82rem", fontWeight: 700, fontFamily: "var(--serif-jp)", cursor: "pointer", }; return (
{t("プロフィールに書く")}
{t("SNSの自己紹介欄に貼れる一行です")}
{line}
{copied && (
{t("コピーしました")}
)} {fallback && (
{ try { e.target.select(); } catch (_) {} }} style={{ width: "100%", boxSizing: "border-box", padding: "8px 10px", border: "1px solid var(--border)", borderRadius: "10px", fontSize: "0.85rem", color: "var(--text)", background: "#fff", }} />
{t("長押しでコピーしてください")}
)}
); }; // ═══ G2a 招待(グループでタイプを見せ合う・2026-10-04) ═══ // 招待する人: invite_create → /?invite=&lc_ref=invite を共有。持ち主キーは localStorage にだけ置く。 // 招待された人: ?invite= → invite_get で帯を表示 → 診断後に invite_answer で2人の相性(未ログイン範囲)を表示。 // 招待した人: invite_list(持ち主キー or ログイン)で回答一覧。生年月日はどの応答にも含まれない(API 側で除去)。 // 旧形式 ?with= は API が失敗したときのフォールバックとして残す。 const INVITE_OWNER_KEYS_LS = "lc_invite_owner_keys"; const INVITE_SEEN_AT_LS = "lc_invite_seen_at"; const INVITE_TOKEN_SS = "lc_invite_token"; const INVITE_TOKEN_RE = /^[0-9a-f]{32}$/; const INVITE_NICKNAME_MAX = 20; const INVITE_OWNER_KEYS_MAX = 20; let csrfToken = ""; let csrfRefreshPromise = null; function applyCsrfTokenFromMe(data) { const nextToken = data && typeof data.csrf_token === "string" && data.csrf_token ? data.csrf_token : data && data.sales && typeof data.sales.csrf_token === "string" && data.sales.csrf_token ? data.sales.csrf_token : ""; csrfToken = nextToken; return csrfToken; } function refreshCsrfToken() { if (csrfRefreshPromise) return csrfRefreshPromise; csrfRefreshPromise = fetch( `./api/auth.php?action=me&lang=${encodeURIComponent(LANG)}&_ts=${Date.now()}`, { credentials: "include", cache: "no-store" }, ) .then((response) => response.json()) .then((data) => applyCsrfTokenFromMe(data)) .catch(() => "") .then((token) => { csrfRefreshPromise = null; return token; }); return csrfRefreshPromise; } async function postWithCsrf(url, options) { if (!csrfToken) { await refreshCsrfToken(); } const requestOptions = { ...(options || {}) }; const headers = new Headers(requestOptions.headers || {}); headers.set("X-CSRF-Token", csrfToken || ""); requestOptions.headers = headers; let response = await fetch(url, requestOptions); if (response.status !== 403) return response; let errorData = null; try { errorData = await response.clone().json(); } catch (_) {} if (!errorData || errorData.code !== "invalid_csrf") return response; await refreshCsrfToken(); const retryHeaders = new Headers(requestOptions.headers || {}); retryHeaders.set("X-CSRF-Token", csrfToken || ""); return fetch(url, { ...requestOptions, headers: retryHeaders }); } const INVITE_OS_BY_SLUG = { igniter: "Drive", nurturer: "Care", creator: "Vision", seer: "Logic", liberator: "Quest", harmonizer: "Balance", }; // api/diagnoses.php?action= へ JSON を POST する(get_paid_compatibility と同じ呼び方) function inviteApi(action, body) { return postWithCsrf( "./api/diagnoses.php?action=" + encodeURIComponent(action) + "&lang=" + encodeURIComponent(LANG), { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...(body || {}), lang: LANG }), }, ) .then((r) => r.text().then((raw) => ({ r, raw }))) .then(({ r, raw }) => { try { return JSON.parse(raw); } catch (e) { return { success: false, error: "http_" + r.status }; } }); } // "YYYY-MM-DD HH:MM:SS" → Date(解釈できなければ null) function parseInviteDate(s) { if (typeof s !== "string" || !s) return null; const d = new Date(s.replace(" ", "T")); return isNaN(d.getTime()) ? null : d; } // 持ち主キー一覧(期限切れ・不正な要素は除く) function loadInviteOwnerKeys() { try { const raw = window.localStorage.getItem(INVITE_OWNER_KEYS_LS); const list = raw ? JSON.parse(raw) : []; if (!Array.isArray(list)) return []; const now = Date.now(); return list .filter((x) => x && typeof x.k === "string" && x.k) .filter((x) => { const exp = parseInviteDate(x.exp); return !exp || exp.getTime() > now; }) .slice(0, INVITE_OWNER_KEYS_MAX); } catch (e) { return []; } } function saveInviteOwnerKey(k, exp) { if (typeof k !== "string" || !k) return; try { const list = loadInviteOwnerKeys().filter((x) => x.k !== k); list.unshift({ k, exp: typeof exp === "string" ? exp : "" }); window.localStorage.setItem( INVITE_OWNER_KEYS_LS, JSON.stringify(list.slice(0, INVITE_OWNER_KEYS_MAX)), ); } catch (e) {} } function getPendingInviteToken() { try { const s = window.sessionStorage.getItem(INVITE_TOKEN_SS); return s && INVITE_TOKEN_RE.test(s) ? s : null; } catch (e) { return null; } } function setPendingInviteToken(token) { try { if (token) window.sessionStorage.setItem(INVITE_TOKEN_SS, token); else window.sessionStorage.removeItem(INVITE_TOKEN_SS); } catch (e) {} } // 招待リンクの宛先。招待トークンはそのサイトの DB にしか無いため、ローカル開発環境(MAMP 等)では // 自分自身を宛先にする。それ以外(本番)は常に https://lifecodereading.com function inviteLinkOrigin() { try { const host = window.location.hostname; if ( host === "localhost" || host === "127.0.0.1" || /\.(localhost|test)$/.test(host) ) { return window.location.origin; } } catch (e) {} return "https://lifecodereading.com"; } // 招待リンク(?invite=、en/th は &lang=、lc_ref=invite) function buildInviteUrl(token) { return withLcRef( inviteLinkOrigin() + "/?invite=" + token + (LANG !== "ja" ? "&lang=" + LANG : ""), "invite", ); } // slug(主命・副命)→ 称号「コード|呼び名」。称号の値は t() に通さない。無ければタイプ名で代替 function inviteTypeLabel(mainSlug, subSlug) { const mainOS = INVITE_OS_BY_SLUG[mainSlug]; const subOS = INVITE_OS_BY_SLUG[subSlug]; if (!mainOS) return ""; const title = subOS ? getTypeComboTitle(mainOS, subOS) : null; if (title) { return LANG === "ja" ? `${title.code}|${title.name}` : `${title.code} | ${title.name}`; } const part = (os) => `${OS_META[os]?.emoji || ""}${t(getSixTypeNameFromOS(os))}`; return subOS ? `${part(mainOS)} × ${part(subOS)}` : part(mainOS); } // 呼び名の表示(空なら「招待した人」「回答した人」)。 // 呼び名そのものが辞書で置き換わらないよう、tf の差し込み値として渡す。 function inviteDisplayName(nickname, fallbackKey) { const n = typeof nickname === "string" ? nickname.trim() : ""; return n ? tf("{nickname}さん", { nickname: n }) : t(fallbackKey); } // 英語で呼び名の代替("your friend")が文頭に来たときの大文字化 function capitalizeFirstForEn(s) { if (LANG !== "en" || typeof s !== "string" || !s) return s; return s.charAt(0).toUpperCase() + s.slice(1); } function inviteScoreMood(score) { const s = Number(score || 0); if (s >= 88) return { emoji: "💖", label: t("強い共鳴") }; if (s >= 72) return { emoji: "💞", label: t("好相性") }; if (s >= 58) return { emoji: "✨", label: t("安定相性") }; if (s >= 44) return { emoji: "🧭", label: t("調整相性") }; return { emoji: "🌱", label: t("育成相性") }; } function sendInviteGa(name, params) { if (typeof gtag !== "function") return; try { gtag("event", name, { ...(params || {}), ...lcRefEventParams() }); } catch (_) {} } // 結果画面「みんなに送って、タイプを見せ合う」。押したときに招待を作り、共有シート → コピー → X の順で送る。 // 共有が許可されなかった(非同期で利用者操作の扱いが切れた等)ときは、リンクを表示して再送ボタンを出す。 const PartnerInviteButton = ({ selfSlug, inviteText, fallbackUrl, birthStr, canCreateInvite, defaultNickname, isLoggedIn, }) => { const [nickname, setNickname] = React.useState( (defaultNickname || "").slice(0, INVITE_NICKNAME_MAX), ); const [busy, setBusy] = React.useState(false); const [created, setCreated] = React.useState(null); // { nickname, url } const [manual, setManual] = React.useState(null); // { url } const manualInputRef = React.useRef(null); React.useEffect(() => { if (defaultNickname && !nickname) { setNickname(defaultNickname.slice(0, INVITE_NICKNAME_MAX)); } }, [defaultNickname]); React.useEffect(() => { const el = manualInputRef.current; if (!manual || !el) return; try { el.focus(); el.select(); el.setSelectionRange(0, el.value.length); } catch (e) {} }, [manual]); // 共有シート → クリップボード → X。"aborted"(利用者が閉じた)以外の失敗は reject const shareInvite = (url) => { if (navigator.share) { return navigator .share({ text: inviteText, url }) .then( () => "shared", (e) => e && e.name === "AbortError" ? "aborted" : Promise.reject(e), ); } if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard .writeText(inviteText + " " + url) .then(() => "copied"); } window.open( "https://twitter.com/intent/tweet?text=" + encodeURIComponent(inviteText) + "&url=" + encodeURIComponent(url), "_blank", ); return Promise.resolve("shared"); }; const runShare = (url) => shareInvite(url) .then((r) => { setManual(null); if (r === "copied") window.alert(t("招待リンクをコピーしたよ")); }) .catch(() => setManual({ url })); const handleClick = async () => { if (busy) return; // GA4 ファネル: 相性招待(相手に送る) if (typeof gtag === "function") { try { gtag("event", "share_invite_click", { type_slug: selfSlug }); } catch (_) {} } const nick = nickname.trim().slice(0, INVITE_NICKNAME_MAX); let url = created && created.nickname === nick ? created.url : null; if (!url && canCreateInvite && /^\d{4}-\d{2}-\d{2}$/.test(birthStr || "")) { setBusy(true); let data = null; try { data = await inviteApi("invite_create", { self_birthday: birthStr, nickname: nick, }); } catch (e) { data = null; } setBusy(false); if (data && data.success && INVITE_TOKEN_RE.test(data.token || "")) { url = buildInviteUrl(data.token); saveInviteOwnerKey(data.owner_key, data.expires_at); setCreated({ nickname: nick, url }); sendInviteGa("invite_create", { type_slug: selfSlug }); } } // 招待を作れなかったときは旧形式 ?with= で送る runShare(url || fallbackUrl); }; return (
setNickname(e.target.value)} placeholder={t("例: ゆう")} style={{ width: "100%", boxSizing: "border-box", padding: "8px 12px", marginBottom: "12px", border: "1px solid var(--border)", borderRadius: "999px", fontSize: "0.9rem", color: "var(--text)", background: "#fff", textAlign: "center", fontFamily: "var(--serif-jp)", }} />
{t("友達・職場・サークルのグループに送ると、診断した人のタイプとあなたとの相性が届くよ。生年月日はおたがいに見えないから、誕生年を聞かなくて大丈夫")} {!isLoggedIn && (
{t("ログインしてから送ると、別の端末でも回答を見られます(ログインせずに送った回答は、このブラウザでだけ見られます)")}
)}
{manual && (
{t("招待リンクができたよ。下のボタンで送るか、長押しでコピーしてください")}
{ try { e.target.select(); } catch (_) {} }} style={{ width: "100%", boxSizing: "border-box", padding: "8px 10px", border: "1px solid var(--border)", borderRadius: "10px", fontSize: "0.85rem", color: "var(--text)", background: "#fff", marginBottom: "8px", }} />
)}
); }; // 招待リンクから来た人向けの帯(トップ画面の上部) const InviteBanner = ({ invite, onStart }) => { if (!invite || invite.status === "loading" || invite.status === "unknown") { return null; } const boxStyle = { width: "100%", maxWidth: "460px", boxSizing: "border-box", margin: "0 auto 28px", padding: "16px 16px", background: "var(--surface)", border: "1px solid var(--gold-dim)", borderTop: "4px solid var(--gold)", borderRadius: "14px", textAlign: "center", }; if (invite.status !== "ok") { const msg = invite.status === "expired" ? t("この招待は期限切れです。あなた自身の診断はこちらから") : invite.status === "closed" ? t("この招待は回答の受付を終了しました。あなた自身の診断はこちらから") : t("この招待は見つかりませんでした。あなた自身の診断はこちらから"); return (
💌 {msg}
); } const info = invite.info || {}; const name = inviteDisplayName(info.nickname, "招待した人"); const label = inviteTypeLabel(info.main_slug, info.sub_slug); return (
{t("👥")}{" "} {capitalizeFirstForEn( label ? tf("{name}({title})から、六命タイプの診断が届いています。あなたは何タイプ?", { name, title: label, }) : tf("{name}から、六命タイプの診断が届いています。あなたは何タイプ?", { name }), )}
); }; // 診断フォーム内: 回答前の告知と、回答者の呼び名(任意) const InviteFormNote = ({ invite, nickname, onNicknameChange }) => { if (!invite || invite.status !== "ok") return null; const name = inviteDisplayName(invite.info?.nickname, "招待した人"); return (
{tf( "診断すると、あなたのタイプと{name}との相性が{name}にも届きます(生年月日は伝わりません)", { name }, )}
onNicknameChange(e.target.value)} placeholder={t("例: ゆう")} style={{ width: "100%", boxSizing: "border-box", padding: "8px 12px", border: "1px solid var(--border)", borderRadius: "999px", fontSize: "0.9rem", color: "var(--text)", background: "#fff", textAlign: "center", fontFamily: "var(--serif-jp)", }} />
); }; // 結果画面のタイプ宣言直後: 招待した人との相性(未ログイン範囲) const InviteAnswerCard = ({ state, onRetry }) => { if (!state) return null; const cardStyle = { margin: "0 0 32px", padding: "18px 16px", background: "var(--surface)", border: "1px solid rgba(255,138,61,0.25)", borderTop: "4px solid var(--gold)", borderRadius: "14px", boxSizing: "border-box", }; const name = inviteDisplayName( state.inviter?.nickname || state.nickname, "招待した人", ); if (state.status === "loading") { return (
💞 {tf("{name}との相性を計算中…", { name })}
); } if (state.status === "error") { const err = state.error; const msg = err === "full" ? t("この招待は回答の上限に達したため、相性を表示できませんでした") : err === "expired" ? t("この招待は期限切れのため、相性を表示できませんでした") : err === "not_found" ? t("この招待は見つかりませんでした") : err === "rate_limited" ? t("アクセスが集中しています。しばらくしてからもう一度お試しください") : t("相性を表示できませんでした。時間をおいてもう一度お試しください"); return (
{msg}
{state.retryable && onRetry && ( )}
); } const c = state.compatibility || {}; const mood = inviteScoreMood(c.score); const label = inviteTypeLabel(state.inviter?.main_slug, state.inviter?.sub_slug); const strength = Array.isArray(c.strengths) && c.strengths.length ? c.strengths[0] : ""; return (
💞 {tf("{name}との相性", { name })}
{label && (
{label}
)}
{c.compatibility_type && (
{c.compatibility_type}
)}
{mood.emoji} {t("相性スコア")}
{c.score} {t("点")}
{c.headline && (
{c.headline}
)}
{mood.label} {c.level && ( <> ・ {c.level} )}
{c.summary && (
{c.summary}
)} {strength && (
{t("相性の強み")} {LANG === "ja" ? ":" : ": "} {strength}
)}
); }; // 招待した人向け: 「みんなのタイプ」カード(トップ画面・マイページ)。招待が無ければ何も出さない。 const InviteResponsesCard = ({ isLoggedIn, place }) => { const [invites, setInvites] = React.useState(null); const [expandedInvites, setExpandedInvites] = React.useState({}); // 前回見たときの最新の回答日時(サーバーの created_at 文字列をそのまま比べる) const [seenAt] = React.useState(() => { try { return window.localStorage.getItem(INVITE_SEEN_AT_LS) || ""; } catch (e) { return ""; } }); React.useEffect(() => { const keys = loadInviteOwnerKeys().map((x) => x.k); if (!keys.length && !isLoggedIn) { setInvites([]); return; } let cancelled = false; inviteApi("invite_list", { owner_keys: keys }) .then((data) => { if (cancelled) return; setInvites( data && data.success && Array.isArray(data.invites) ? data.invites : [], ); }) .catch(() => { if (!cancelled) setInvites([]); }); return () => { cancelled = true; }; }, [isLoggedIn]); React.useEffect(() => { if (!invites || !invites.length) return; let latest = seenAt; let total = 0; let fresh = 0; for (const inv of invites) { for (const r of Array.isArray(inv.responses) ? inv.responses : []) { total += 1; const at = String(r.created_at || ""); if (at > seenAt) fresh += 1; if (at > latest) latest = at; } } sendInviteGa("invite_result_view", { place, invites: invites.length, responses: total, new_responses: fresh, }); if (latest && latest !== seenAt) { try { window.localStorage.setItem(INVITE_SEEN_AT_LS, latest); } catch (e) {} } }, [invites]); if (!invites || !invites.length) return null; const shown = invites.slice(0, 5); return (
{t("👥 みんなのタイプ")}
{t("この結果は、招待を送ったときのあなたの診断と、回答した人が招待リンクで入力した生年月日で計算しています")}
{!isLoggedIn && (
{t("ログインせずに送った招待の回答は、このブラウザでだけ見られます。ログインしてから送ると、別の端末でも見られます")}
)} {shown.map((inv, idx) => { const responses = Array.isArray(inv.responses) ? inv.responses : []; const inviteKey = inv.token || String(idx); const sortedResponses = responses.slice().sort((a, b) => { const scoreA = Number(a?.summary?.score); const scoreB = Number(b?.summary?.score); const safeScoreA = Number.isFinite(scoreA) ? scoreA : -Infinity; const safeScoreB = Number.isFinite(scoreB) ? scoreB : -Infinity; if (safeScoreA !== safeScoreB) return safeScoreB - safeScoreA; return String(b?.created_at || "").localeCompare( String(a?.created_at || ""), ); }); const isExpanded = !!expandedInvites[inviteKey]; const visibleResponses = isExpanded ? sortedResponses : sortedResponses.slice(0, 3); const remainingCount = Math.max(sortedResponses.length - 3, 0); const typeCounts = responses.reduce((counts, response) => { const slug = response?.main_slug; if (INVITE_OS_BY_SLUG[slug]) counts[slug] = (counts[slug] || 0) + 1; return counts; }, {}); const distribution = Object.keys(INVITE_OS_BY_SLUG) .filter((slug) => typeCounts[slug]) .map((slug) => tf("{type} {n}人", { type: t(getSixTypeNameFromOS(INVITE_OS_BY_SLUG[slug])), n: typeCounts[slug], }), ) .join(t("・")); const inviterLabel = inviteTypeLabel( inv.inviter_main_slug, inv.inviter_sub_slug, ); const inviterMainOS = INVITE_OS_BY_SLUG[inv.inviter_main_slug]; const inviterSubOS = INVITE_OS_BY_SLUG[inv.inviter_sub_slug]; const inviterMainName = inviterMainOS ? t(getSixTypeNameFromOS(inviterMainOS)) : ""; const inviterSubName = inviterSubOS ? t(getSixTypeNameFromOS(inviterSubOS)) : ""; return (
{tf("{date} に送った招待 · {n}人が診断", { date: String(inv.created_at || "").slice(0, 10), n: responses.length, })}
{inviterLabel && inviterMainName && inviterSubName && (
{tf("あなた: {title}({main} × {sub})", { title: inviterLabel, main: inviterMainName, sub: inviterSubName, })}
)} {responses.length === 0 && (
{t("まだ回答はありません")}
)} {visibleResponses.map((r, j) => { const s = r.summary || {}; const isNew = String(r.created_at || "") > seenAt; const label = inviteTypeLabel(r.main_slug, r.sub_slug); return (
{isNew && ( NEW )} {label ? tf("{name}: {title}", { name: inviteDisplayName(r.nickname, "回答した人"), title: label, }) : inviteDisplayName(r.nickname, "回答した人")}
{tf("あなたとの相性 {score}点・{type}", { score: s.score, type: s.type || "", })}
); })} {remainingCount > 0 && (
)} {distribution && (
{distribution}
)}
); })}
); }; const SectionHeader = ({ num, emoji, label, sub, delay = 0 }) => { const isSmall = typeof window !== "undefined" ? window.innerWidth <= 768 : false; return (
{emoji || num}
{label}
{sub && (
{sub}
)}
); }; // ═══ LOADING ═══ const LoadingOracle = ({ onComplete }) => { const [step, setStep] = React.useState(0); const steps = [ "命盤を展開中…", "星図を照合中…", "運気波動を計算中…", "六タイプを判定中…", "マヤ暦と同調中…", "六命を統合中…", ]; React.useEffect(() => { const timers = steps.map((_, i) => setTimeout(() => setStep(i + 1), i * 550 + 350), ); const done = setTimeout(onComplete, steps.length * 550 + 700); return () => { timers.forEach(clearTimeout); clearTimeout(done); }; }, []); return (
{steps.map((s, i) => (
i ? 1 : 0.2, transition: "opacity 0.4s ease", }} >
i ? "var(--gold)" : "var(--border)", flexShrink: 0, transition: "background 0.3s ease", boxShadow: step > i ? "0 0 8px var(--gold)" : "none", }} /> {s}
))}
); }; // ═══ HERO SCREEN ═══ const HeroScreen = ({ onSubmit, isLoggedIn, userBirth, userName, onLoginClick, onRegisterClick, onLogoutClick, onMyPageClick, onCompatibilityClick, onOshiClick, preselectedTargetDate, onBirthDraftChange, isPaidMember = false, topSlot = null, formSlot = null, }) => { const today = new Date(); const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; const [birth, setBirth] = React.useState( isLoggedIn && userBirth ? userBirth : "", ); const [targetDate, setTargetDate] = React.useState(todayStr); const [err, setErr] = React.useState(""); const [focusedField, setFocused] = React.useState(null); const formRef = React.useRef(null); const timeMachineLocked = !isLoggedIn; React.useEffect(() => { if (!isLoggedIn) { onBirthDraftChange && onBirthDraftChange(birth); } }, [birth, isLoggedIn]); React.useEffect(() => { if (isLoggedIn) { setBirth(userBirth || ""); } else { setBirth(""); setTargetDate(todayStr); } }, [isLoggedIn, userBirth, todayStr]); React.useEffect(() => { if (isLoggedIn && preselectedTargetDate) { setTargetDate(preselectedTargetDate); } }, [isLoggedIn, preselectedTargetDate]); const handleSubmit = (e) => { e.preventDefault(); if (!birth) { setErr("生年月日を入力してください"); return; } if (!isSupportedBirthDate(birth)) { setErr("生年月日は1900年1月1日から2100年12月31日の間で入力してください"); return; } setErr(""); // GA4 ファネル: 診断開始 if (typeof gtag === "function") { try { gtag("event", "diagnosis_start", { logged_in: !!isLoggedIn, time_machine: !!(isLoggedIn && isTimeMachine), ...lcRefEventParams(), }); } catch (_) {} } const selectedTargetDate = isLoggedIn && isTimeMachine ? targetDate : null; onSubmit(birth, selectedTargetDate); }; const isTimeMachine = targetDate && targetDate !== todayStr; const formatTargetDate = (str) => { if (!str) return ""; const d = new Date(str); return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`; }; const inputStyle = (id) => ({ width: "100%", maxWidth: "100%", padding: "12px 14px", background: "rgba(255,255,255,0.78)", border: `1px solid ${focusedField === id ? "rgba(255,138,61,0.5)" : "var(--border)"}`, color: "var(--text)", fontSize: "0.95rem", fontFamily: "var(--serif-en)", letterSpacing: "0.1em", outline: "none", textAlign: "center", transition: "border-color 0.3s ease", colorScheme: "dark", borderRadius: "14px", boxSizing: "border-box", minWidth: 0, }); const [isSmallScreen, setIsSmallScreen] = React.useState( window.innerWidth <= 768, ); React.useEffect(() => { const handleResize = () => setIsSmallScreen(window.innerWidth <= 768); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); return (
{/* Header */} {isSmallScreen && (
{/* Auth buttons - ログイン時のみ表示 */} {isLoggedIn && (
)} {/* ユーザー名 - 2番目に表示 */} {isLoggedIn && (
{userHonorific(userName)}
)} {/* Brand - スマホはセンター寄せ、一番下 */}
Life Code Reading
六命解析
)} {/* PC Header */} {!isSmallScreen && (
{/* Auth buttons - ログイン時のみ表示 */} {isLoggedIn && (
)} {/* ユーザー名 - 下に表示 */} {isLoggedIn && (
{userHonorific(userName)}
)}
)} {/* G2a: 招待の帯・招待への回答 */} {topSlot}
Life Code Reading

六命解析

生年月日に刻まれた六つの命の傾向を
古典占術と宇宙のリズムで解き明かす

六命解析は、複数の占術を組み合わせた占い。
自己理解や話題のきっかけとして、気軽に楽しんでね。

{[ "四柱推命", "数秘術", "九星気学", "12星座", "バイオリズム", "マヤ暦", ].map((s) => ( {s} ))}
👇 タップして各タイプの詳細を見る(六命タイプ図鑑)
{[ ["igniter", "🔥 点火者"], ["nurturer", "🌙 包容者"], ["creator", "🎨 表現者"], ["seer", "💎 洞察者"], ["liberator", "🪽 解放者"], ["harmonizer", "🌿 調律者"], ].map(([slug, label]) => ( {t(label)} › ))}
{(() => { // 相性ページは en/th に対応済みのため、言語を引き継いで表示する。 // それ以外(組み合わせ・テーマ・有名人・占術)は ja 専用の導線。 const links = [ ["2027/", "✨ 2027年の運気", true], ["types/combination/", "🧩 組み合わせ", false], ["compatibility/", "💞 相性", true], ["work/", "💼 仕事", false], ["love/", "💕 恋愛", false], ["celebrity/", "⭐ 有名人", false], ["methods/", "🔮 占術", false], ].filter(([, , localized]) => LANG === "ja" || localized); return (
{links.map(([href, label, localized]) => ( {t(label)} ))}
); })()} 6タイプの一覧・診断ロジックを見る →
{formSlot}
setBirth(e.target.value)} onFocus={() => setFocused("date")} onBlur={() => setFocused(null)} min="1900-01-01" max="2100-12-31" style={inputStyle("date")} />
{isLoggedIn ? tf("ログイン中の生年月日: {birth}", { birth: t(userBirth || "未登録") }) : "ログイン中でなくても入力できます"}
{/* ⏳ タイムマシン */}
{isLoggedIn && isTimeMachine && ( )}
!timeMachineLocked && setTargetDate(e.target.value) } disabled={timeMachineLocked} onFocus={() => setFocused("target")} onBlur={() => setFocused(null)} min="1900-01-01" max="2099-12-31" style={{ ...inputStyle("target"), border: `1px solid ${isLoggedIn && isTimeMachine ? "rgba(255,138,61,0.5)" : focusedField === "target" ? "rgba(255,138,61,0.4)" : "var(--border)"}`, background: isLoggedIn && isTimeMachine ? "rgba(255,138,61,0.06)" : "rgba(255,255,255,0.78)", color: isLoggedIn && isTimeMachine ? "var(--gold-light)" : "var(--text)", opacity: timeMachineLocked ? 0.7 : 1, cursor: timeMachineLocked ? "not-allowed" : "text", }} /> {isLoggedIn && isTimeMachine && (
{formatTargetDate(targetDate)} の運勢を解析します
)} {!isLoggedIn && (
タイムマシン機能は会員登録後に使えます。
会員登録すると、過去・未来の日付を指定して診断できます。
)} {isLoggedIn && !isTimeMachine && (
日付を変えると過去・未来の運勢を確認できます
)}
{err && (

{t(err)}

)} {/* ログイン・会員登録 */} {!isLoggedIn && (
)}
{["一", "二", "三", "四", "五", "六"].map((n, i) => ( {t(n + "命")} ))}
{/* Footer */}
); }; // ═══════════════════════════════════════════════════════ // PLAN COMPARISON MODAL(鑑定書の比較モーダル) // 商品・権利対応は docs/pricing_redesign/implementation_plan.md §2-1 が正。 // 買い切り3商品(reading / compat / calendar_next)。 // 価格はサーバー(me の purchase_products)を正とし、表示は Phase C で出す。 // ═══════════════════════════════════════════════════════ const PLAN_COMPARISON_FEATURES = [ { key: "love", label: "恋愛・結婚の流れ" }, { key: "career", label: "仕事・キャリアのこと" }, { key: "relations", label: "人間関係のこと" }, { key: "family", label: "家族のこと" }, { key: "money", label: "お金・住まい・トラブルのこと" }, { key: "calendar", label: "購入した年の運気カレンダー" }, { key: "calendar_next", label: "翌年の運気カレンダー" }, { key: "compatibility", label: "相性診断の全文(お相手1人)" }, ]; // 販売の可否はサーバーの販売スイッチ(me の sales.open)だけで決める(D8=b)。 // sales.open が false の間は、料金表と「鑑定書の内容を見る」導線を出さず、 // 「準備中」の文言をそのまま出す(販売停止中の見た目は従来と同じ)。 const isSalesOpen = (sales) => !!(sales && sales.open === true && typeof sales.public_key === "string" && sales.public_key); const PLAN_SUSPENDED_CTA = "この続きは「自分の鑑定書」で読める内容だよ。有料の鑑定書は、いま準備中だよ。"; const COMPAT_SUSPENDED_CTA = "この続きは「相性の鑑定書」で読める内容だよ。有料の鑑定書は、いま準備中だよ。"; // 買い切り商品の表示名(ja を正とし、en/th は辞書で訳す)。 const PURCHASE_PRODUCT_NAMES = { reading: "自分の鑑定書", compat: "相性の鑑定書", calendar_next: "翌年の運気カレンダー", }; const PLAN_COMPARISON_PLANS = [ { key: "reading", name: PURCHASE_PRODUCT_NAMES.reading, rights: ["love", "career", "relations", "family", "money", "calendar"], }, { key: "compat", name: PURCHASE_PRODUCT_NAMES.compat, rights: ["compatibility"], }, { key: "calendar_next", name: PURCHASE_PRODUCT_NAMES.calendar_next, rights: ["calendar_next"], }, ]; // me の応答(user.purchases / user.purchase_products)を読むための関数。 // 権利の正本はサーバー。ここは表示の出し分けにだけ使う。 const userPurchases = (user) => Array.isArray(user?.purchases) ? user.purchases.filter((p) => p && typeof p.product === "string") : []; const hasPurchasedProduct = (user, product) => userPurchases(user).some((p) => p.product === product); // 価格・表示名(Phase C の購入モーダル用。現時点では画面に価格を出さない)。 const purchaseProductInfo = (products, key) => { const p = products && typeof products === "object" ? products[key] : null; return { label: (p && p.label) || PURCHASE_PRODUCT_NAMES[key] || "", price: p && p.price != null ? p.price : null, }; }; // 相性の鑑定書を、お相手の呼び名で探す(呼び名が一致したものだけ)。 // 全文かどうかはサーバーの access_level が正。ここは表示文言の土台。 const findCompatPurchaseLabel = (user, partnerName) => { const name = String(partnerName || "").trim(); if (!name) return ""; const hit = userPurchases(user).find( (p) => p.product === "compat" && p.partner_label && String(p.partner_label).trim() === name, ); return hit ? String(hit.partner_label) : ""; }; const formatPurchaseDate = (s) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(s || "")); if (!m) return ""; return LANG === "ja" ? `${m[1]}年${parseInt(m[2], 10)}月${parseInt(m[3], 10)}日` : `${m[1]}-${m[2]}-${m[3]}`; }; // 価格の表示(タイ語・英語の利用者にも円で出す。§7)。 const formatPurchasePrice = (price) => { const n = Number(price); if (!isFinite(n)) return ""; return tf("{price}円(税込)", { price: n.toLocaleString("ja-JP") }); }; const PlanComparisonModal = ({ isOpen, onClose, products = null, sales = null, onPurchase = null, ownedProducts = null, }) => { if (!isOpen || !isSalesOpen(sales)) return null; const headCellStyle = { padding: "10px 6px", borderBottom: "1px solid rgba(255,138,61,0.25)", textAlign: "center", verticalAlign: "bottom", }; const featureCellStyle = { padding: "10px 8px", borderBottom: "1px solid rgba(255,138,61,0.12)", fontSize: "0.86rem", color: "var(--text)", lineHeight: 1.5, textAlign: "left", }; const markCellStyle = { padding: "10px 6px", borderBottom: "1px solid rgba(255,138,61,0.12)", fontSize: "0.92rem", textAlign: "center", }; return (
e.stopPropagation()} style={{ background: "linear-gradient(160deg,#FFFFFF,#FFF6EC)", border: "1px solid rgba(255,138,61,0.3)", borderRadius: "14px", padding: "30px 24px", width: "100%", maxWidth: "680px", maxHeight: "calc(100vh - 40px)", overflowY: "auto", boxShadow: "0 20px 60px rgba(61,52,40,0.25)", position: "relative", }} >
鑑定書をくらべる
読みたい内容に合わせて鑑定書を選べるよ。どれも買い切りで、月額の料金はかからないよ。
{PLAN_COMPARISON_PLANS.map((plan) => ( ))} {PLAN_COMPARISON_FEATURES.map((feature) => ( {PLAN_COMPARISON_PLANS.map((plan) => ( ))} ))} {PLAN_COMPARISON_PLANS.map((plan) => ( ))}
{t(plan.name)}
{plan.key !== "calendar_next" && purchaseProductInfo(products, plan.key).price != null ? (
{formatPurchasePrice(purchaseProductInfo(products, plan.key).price)}
) : null}
{t(feature.label)} {plan.rights.indexOf(feature.key) !== -1 ? ( ○ ) : ( × )}
{(() => { // calendar_next は Phase D まで「近日公開」(D10=b)。 const comingSoon = plan.key === "calendar_next"; const owned = !comingSoon && !!(ownedProducts && ownedProducts[plan.key]); const enabled = !comingSoon && !owned && typeof onPurchase === "function"; return ( ); })()}
価格は税込だよ。「相性の鑑定書」は、相性診断の画面でお相手を選んでから購入できるよ。
); }; // ═══════════════════════════════════════════════════════ // PURCHASE MODAL(買い切りの購入。PAY.JP v1 charge + 支払い時 3D セキュア) // 正: docs/pricing_redesign/phase_c_implementation_plan.md §5-2・§6。 // confirm → card → processing → tds(iframe)→ done / error(tds_retry を含む) // 金額・対象年はサーバーが決める。画面から送るのは商品・お相手(compat のみ)・ // カードのトークン・client_request_id だけ。カード名義と連絡先(メールまたは電話)は // 利用者がこのフォームに入力し、payjp.js がブラウザから PAY.JP へ直接送る(D1=b)。 // サーバーやアカウントのメールは使わず、自動入力もしない。 // ═══════════════════════════════════════════════════════ // payjp.js は js.pay.jp から直接読む必要がある(同梱・自前ホスト不可)。URL は固定。 const PAYJP_JS_URL = "https://js.pay.jp/v2/pay.js"; let __payjpLoadPromise = null; const loadPayjpScript = () => { if (typeof window.Payjp === "function") return Promise.resolve(window.Payjp); if (__payjpLoadPromise) return __payjpLoadPromise; __payjpLoadPromise = new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = PAYJP_JS_URL; s.async = true; s.onload = () => typeof window.Payjp === "function" ? resolve(window.Payjp) : reject(new Error("payjp_unavailable")); s.onerror = () => { if (s.parentNode) s.parentNode.removeChild(s); reject(new Error("payjp_load_failed")); }; document.head.appendChild(s); }).catch((e) => { // 失敗したら次にモーダルを開いたとき読み直せるようにする __payjpLoadPromise = null; throw e; }); return __payjpLoadPromise; }; const __payjpInstances = {}; const getPayjpInstance = (PayjpCtor, publicKey) => { const locale = LANG === "ja" ? "ja" : "en"; // payjp.js にタイ語ロケールは無い const cacheKey = publicKey + "|" + locale; if (!__payjpInstances[cacheKey]) { __payjpInstances[cacheKey] = PayjpCtor(publicKey, { locale, threeDSecureWorkflow: "iframe", }); } return __payjpInstances[cacheKey]; }; // サーバーの lc_purchase_normalize_request_id(16〜64 文字、英数字・_・-)に合わせる。 const newClientRequestId = () => { try { if (window.crypto && typeof window.crypto.randomUUID === "function") { return window.crypto.randomUUID(); } if (window.crypto && typeof window.crypto.getRandomValues === "function") { const bytes = new Uint8Array(16); window.crypto.getRandomValues(bytes); return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } } catch (e) {} let s = ""; while (s.length < 32) s += Math.random().toString(36).slice(2); return ("r" + Date.now().toString(36) + s).slice(0, 40); }; // 「メールまたは電話」の欄を payjp.js の card.email / card.phone(E.164)に振り分ける。 const normalizePurchaseContact = (raw) => { const v = String(raw || "").trim(); if (!v) return null; if (v.indexOf("@") !== -1) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? { email: v } : null; } const digits = v.replace(/[\s\-‐-ー()()]/g, ""); if (/^\+\d{8,15}$/.test(digits)) return { phone: digits }; if (/^0\d{9,10}$/.test(digits)) return { phone: "+81" + digits.slice(1) }; return null; }; // 購入した年(reading の対象年)。サーバーと同じく Asia/Tokyo で数える。 const currentTokyoYear = () => { try { const y = new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Tokyo", year: "numeric", }).format(new Date()); const n = parseInt(y, 10); if (n) return n; } catch (e) {} return new Date().getFullYear(); }; // サーバーのエラーコード(api/actions/purchase.php・includes/purchase_service.php・PAY.JP)→ 画面の文言(文体B)。 const PURCHASE_CARD_DECLINED_CODES = [ "card_declined", "unacceptable_brand", "card_flagged", "insufficient_funds", "processing_error", ]; const PURCHASE_CARD_INPUT_CODES = [ "invalid_number", "incorrect_number", "invalid_cvc", "incorrect_cvc", "invalid_expiry_month", "invalid_expiry_year", "invalid_expiration_date", "incorrect_expiry", "expired_card", "incorrect_card_data", "invalid_card", ]; const purchaseErrorMessage = (code) => { const c = String(code || ""); if (PURCHASE_CARD_DECLINED_CODES.indexOf(c) !== -1) return "このカードでは決済できなかったよ。別のカードで試すか、カード会社に確認してみてね。"; if (PURCHASE_CARD_INPUT_CODES.indexOf(c) !== -1) return "カードの情報を確認して、もう一度入力してね。"; if (c === "tds_failed" || c.indexOf("three_d_secure") === 0) return "カード会社の本人認証が完了しなかったよ。もう一度試してみてね。"; if (c === "tds_pending") return "本人認証が終わっていないよ。カード情報の入力からやり直すか、やめることもできるよ。"; if (c === "already_owned") return "この鑑定書はもうお持ちだよ。"; if (c === "sales_closed" || c === "product_unavailable") return "いまは購入を受け付けていないよ。少し時間をおいて、もう一度見てみてね。"; if (c === "rate_limited" || c === "too_many_pending") return "購入の操作が続いたから、少し時間をおいてからもう一度試してね。"; if (c === "purchase_in_progress" || c === "purchase_busy") return "購入の手続きが進んでいるところだよ。少し待ってから、もう一度確かめてね。"; if (c === "invalid_csrf" || c === "invalid_origin") return "購入画面を開き直してから、もう一度試してね。"; if (c === "email_not_verified") return "購入の前に、メールアドレスの確認を済ませてね。"; if (c === "unauthorized") return "ログインしてから、もう一度試してね。"; if (c === "invalid_partner_birthday") return "お相手の生年月日を確認してね。"; if (c === "request_failed") return "もう一度、カード情報の入力からやり直してね。"; if (c === "payjp_load_failed") return "決済画面を読み込めなかったよ。通信の状態を確かめて、もう一度開いてみてね。"; if ( c === "network_error" || c === "payment_provider_error" || c === "invalid_charge_response" || c === "rate_limit_unavailable" ) return "決済サービスにつながらなかったよ。少し時間をおいて、もう一度試してね。"; return "購入の手続きがうまくいかなかったよ。少し時間をおいて、もう一度試してね。"; }; // 購入 API(api/auth.php?action=purchase_*)。応答は {ok, code, error, retryable} 形式。 const postPurchaseAction = async (action, body, csrfToken) => { let res; try { res = await fetch("./api/auth.php?action=" + action, { method: "POST", credentials: "include", cache: "no-store", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken || "", }, body: JSON.stringify(body), }); } catch (e) { return { ok: false, code: "network_error", retryable: true, network: true }; } let data = null; try { data = await res.json(); } catch (e) { data = null; } if (!data || typeof data !== "object") { return { ok: false, code: "purchase_failed", retryable: res.status >= 500, status: res.status }; } if (res.ok && data.ok) return data; return { ok: false, code: typeof data.code === "string" ? data.code : "purchase_failed", retryable: !!data.retryable, status: res.status, }; }; const PurchaseModal = ({ isOpen, onClose, product, partner = null, sales = null, products = null, user = null, onPurchased = null, onSalesStale = null, }) => { const [step, setStep] = React.useState("confirm"); const [agreed, setAgreed] = React.useState(false); const [payjpState, setPayjpState] = React.useState("idle"); // idle | loading | ready | failed const [cardHolder, setCardHolder] = React.useState(""); const [contact, setContact] = React.useState(""); const [cardMessage, setCardMessage] = React.useState(""); const [formMessage, setFormMessage] = React.useState(""); const [errorCode, setErrorCode] = React.useState(""); // error のときの「もう一度」の行き先: card(新しいトークンで最初から)/finish(同じ購入の確定を再送)/start(同じ ID で再送) const [retryMode, setRetryMode] = React.useState("card"); const payjpRef = React.useRef(null); const cardElementRef = React.useRef(null); const requestIdRef = React.useRef(""); const pendingRef = React.useRef({ token: "", attemptId: 0, chargeId: "" }); const busyRef = React.useRef(false); const cardMountId = "lc-payjp-card"; const salesOpen = isSalesOpen(sales); const info = purchaseProductInfo(products, product); const plan = PLAN_COMPARISON_PLANS.find((p) => p.key === product) || null; const purchaseYear = currentTokyoYear(); const emailUnverified = !!user && user.is_email_verified !== undefined && Number(user.is_email_verified) === 0; // 開くたびに状態と client_request_id を作り直し、payjp.js を読み込む(全訪問者には読ませない)。 React.useEffect(() => { if (!isOpen) return; setStep("confirm"); setAgreed(false); setCardHolder(""); setContact(""); setCardMessage(""); setFormMessage(""); setErrorCode(""); setRetryMode("card"); requestIdRef.current = newClientRequestId(); pendingRef.current = { token: "", attemptId: 0, chargeId: "" }; busyRef.current = false; if (!salesOpen) return; let cancelled = false; setPayjpState("loading"); loadPayjpScript() .then((PayjpCtor) => { if (cancelled) return; payjpRef.current = getPayjpInstance(PayjpCtor, sales.public_key); setPayjpState("ready"); }) .catch(() => { if (!cancelled) setPayjpState("failed"); }); return () => { cancelled = true; }; }, [isOpen]); // カード欄(PAY.JP ドメインの iframe)。モーダルを開いている間は 1 つだけ作って使い回す。 React.useEffect(() => { if (!isOpen || payjpState !== "ready" || !payjpRef.current) return; if (cardElementRef.current) return; const mountEl = document.getElementById(cardMountId); if (!mountEl) return; try { const elements = payjpRef.current.elements(); const card = elements.create("card", { style: { base: { color: "#3d3428", fontSize: "16px" } }, }); card.mount("#" + cardMountId); card.on("change", (ev) => { setCardMessage(ev && ev.error && ev.error.message ? String(ev.error.message) : ""); }); cardElementRef.current = card; } catch (e) { setPayjpState("failed"); } }, [isOpen, payjpState, step]); // 閉じたらカード欄を外す(カード情報を画面に残さない)。 React.useEffect(() => { if (isOpen) return; const card = cardElementRef.current; cardElementRef.current = null; if (card) { try { if (typeof card.unmount === "function") card.unmount(); } catch (e) {} } }, [isOpen]); if (!isOpen || !product) return null; const locked = step === "processing" || step === "tds"; // 3DS を閉じたまま残った attempt を abandoned にして、処理中の上限を空ける(課金は起きない)。 const cancelPendingAttempt = () => { const attemptId = pendingRef.current.attemptId; if (!attemptId) return; pendingRef.current.attemptId = 0; postPurchaseAction("purchase_cancel", { attempt_id: attemptId }, sales && sales.csrf_token).catch(() => {}); }; const closeModal = () => { if (locked) return; if (step === "tds_retry") cancelPendingAttempt(); onClose && onClose(); }; const fail = (code, mode) => { busyRef.current = false; setErrorCode(code || "purchase_failed"); setRetryMode(mode || "card"); if (code === "tds_pending") { setStep("tds_retry"); return; } if (code === "invalid_csrf" || code === "sales_closed" || code === "unauthorized") { onSalesStale && onSalesStale(); } setStep("error"); }; const succeed = (data) => { busyRef.current = false; setStep("done"); onPurchased && onPurchased(data || null); }; const runFinish = async () => { setStep("processing"); const data = await postPurchaseAction( "purchase_finish", { attempt_id: pendingRef.current.attemptId }, sales && sales.csrf_token, ); if (data.ok) { succeed(data); return; } // 確定の再送で直る失敗(通信・ロック待ち)は、同じ購入のまま確定だけをやり直す。 fail(data.code, data.retryable ? "finish" : "card"); }; const runTds = async () => { setStep("tds"); try { await payjpRef.current.openThreeDSecureIframe(pendingRef.current.chargeId); } catch (e) { // 閉じた・認証済み・エラーでも reject される。結果は必ずサーバーで charge を取り直して判断する。 } await runFinish(); }; const runStart = async () => { setStep("processing"); const body = { product, token: pendingRef.current.token, client_request_id: requestIdRef.current, }; if (product === "compat") { body.context = { partner_name: (partner && partner.name) || "", partner_birthday: (partner && partner.birthday) || "", }; } const data = await postPurchaseAction("purchase_start", body, sales && sales.csrf_token); if (!data.ok) { // 通信が切れただけなら同じ ID・同じトークンで再送できる(サーバーが既存の購入を返す)。 fail(data.code, data.network ? "start" : "card"); return; } pendingRef.current.attemptId = Number(data.attempt_id) || 0; pendingRef.current.chargeId = String(data.charge_id || ""); if (!pendingRef.current.attemptId) { fail("purchase_failed", "card"); return; } if (data.state === "paid") { await runFinish(); return; } if (!pendingRef.current.chargeId) { fail("purchase_failed", "card"); return; } await runTds(); }; const submitCard = async () => { if (busyRef.current) return; setFormMessage(""); if (!payjpRef.current || !cardElementRef.current) { setFormMessage(purchaseErrorMessage("payjp_load_failed")); return; } const holder = cardHolder.trim(); if (!holder) { setFormMessage("カードの名義を入力してね。"); return; } const contactData = normalizePurchaseContact(contact); if (!contactData) { setFormMessage("メールアドレスか電話番号を、正しい形で入力してね。"); return; } // カード欄の入力の正しさは createToken の応答(response.error)で判断する。 busyRef.current = true; setStep("processing"); let response; try { response = await payjpRef.current.createToken(cardElementRef.current, { card: { name: holder, ...contactData }, }); } catch (e) { busyRef.current = false; setStep("card"); setFormMessage(purchaseErrorMessage("network_error")); return; } if (!response || response.error || !response.id) { busyRef.current = false; setStep("card"); const code = response && response.error && response.error.code; setFormMessage(purchaseErrorMessage(code || "invalid_card")); return; } pendingRef.current = { token: String(response.id), attemptId: 0, chargeId: "" }; await runStart(); }; const retry = () => { if (retryMode === "finish" && pendingRef.current.attemptId) { busyRef.current = true; runFinish(); return; } if (retryMode === "start" && pendingRef.current.token) { busyRef.current = true; runStart(); return; } // トークンは 1 回しか使えないので、新しい ID でカード入力からやり直す。 requestIdRef.current = newClientRequestId(); pendingRef.current = { token: "", attemptId: 0, chargeId: "" }; setFormMessage(""); setStep("card"); }; // 閉じた 3DS は PAY.JP 側で開き直せない(「不正な遷移」になる)ので、 // 前の attempt をやめて、新しい ID でカード入力からやり直す。 const retryTds = () => { if (busyRef.current) return; cancelPendingAttempt(); requestIdRef.current = newClientRequestId(); pendingRef.current = { token: "", attemptId: 0, chargeId: "" }; setFormMessage(""); setStep("card"); }; const primaryButtonStyle = (enabled) => ({ padding: "12px 18px", background: enabled ? "var(--gold)" : "rgba(61,52,40,0.12)", border: "none", color: enabled ? "#fff" : "var(--text-dim)", fontSize: "0.92rem", letterSpacing: "0.08em", fontFamily: "var(--serif-jp)", cursor: enabled ? "pointer" : "not-allowed", borderRadius: "14px", }); const secondaryButtonStyle = { padding: "12px 18px", background: "transparent", border: "1px solid var(--border)", color: "var(--text-dim)", fontSize: "0.9rem", letterSpacing: "0.08em", fontFamily: "var(--serif-jp)", cursor: "pointer", borderRadius: "14px", }; const noteStyle = { fontSize: "0.84rem", color: "var(--text-dim)", lineHeight: 1.7, ...TEXT_WRAP_STYLE, }; const inputStyle = { width: "100%", boxSizing: "border-box", padding: "10px 12px", border: "1px solid var(--border)", borderRadius: "10px", background: "#fff", color: "var(--text)", fontSize: "16px", }; const messageStyle = { fontSize: "0.86rem", color: "#c0392b", lineHeight: 1.7, marginTop: "10px", ...TEXT_WRAP_STYLE, }; const featureLabels = plan ? PLAN_COMPARISON_FEATURES.filter((f) => plan.rights.indexOf(f.key) !== -1).map((f) => f.label) : []; const canProceed = salesOpen && agreed && !emailUnverified && payjpState !== "failed"; return (
e.stopPropagation()} style={{ background: "linear-gradient(160deg,#FFFFFF,#FFF6EC)", border: "1px solid rgba(255,138,61,0.3)", borderRadius: "14px", padding: "28px 22px", width: "100%", maxWidth: "460px", maxHeight: "calc(100vh - 40px)", overflowY: "auto", boxShadow: "0 20px 60px rgba(61,52,40,0.25)", position: "relative", boxSizing: "border-box", }} > {!locked && ( )}
{t(info.label)}
{info.price != null && (
{formatPurchasePrice(info.price)}
)} {step === "confirm" && (
{product === "compat" && partner && (
{partner.name ?
{tf("お相手:{name}", { name: partner.name })}
: null}
{tf("お相手の生年月日:{date}", { date: formatPurchaseDate(partner.birthday) || partner.birthday || "", })}
{t("お相手の生年月日が変わると、別の鑑定書になります")}
)}
この鑑定書で読めること
{featureLabels.map((label) => (
・{t(label)}
))}
{product === "reading" && (
{tf("運気カレンダーは {year} 年分です", { year: purchaseYear })}
)}
買い切りで、月額の料金はかからないよ。
デジタルコンテンツのため、購入後の返金はできません。
{emailUnverified && (
{t(purchaseErrorMessage("email_not_verified"))}
)} {payjpState === "failed" && (
{t(purchaseErrorMessage("payjp_load_failed"))}
)}
)} {/* カード欄は PAY.JP の iframe。作り直さないよう、card 以外の段階では隠しておく。 */}
カード情報は PAY.JP に直接送られて、このサイトには保存されないよ。
カード情報
{payjpState === "loading" &&
決済画面を読み込んでいるよ…
} {cardMessage &&
{cardMessage}
}
カードの名義
setCardHolder(e.target.value)} autoComplete="cc-name" placeholder="TARO YAMADA" maxLength={100} style={inputStyle} />
メールアドレスまたは電話番号
setContact(e.target.value)} autoComplete="off" inputMode="email" maxLength={254} style={inputStyle} />
カード会社の本人認証(3Dセキュア)に使うよ。PAY.JP とカード会社にだけ送られるよ。
{formMessage &&
{t(formMessage)}
}
{step === "processing" && (
決済の手続きをしているよ。この画面を閉じずに、少しだけ待ってね。
)} {step === "tds" && (
カード会社の本人認証の画面で、手続きを進めてね。
)} {step === "tds_retry" && (
{t(purchaseErrorMessage("tds_pending"))}
)} {step === "error" && (
{t(purchaseErrorMessage(errorCode))}
{errorCode !== "already_owned" && errorCode !== "sales_closed" && errorCode !== "product_unavailable" && errorCode !== "email_not_verified" && errorCode !== "unauthorized" && errorCode !== "invalid_origin" && ( )}
)} {step === "done" && (
購入ありがとう。鑑定書が読めるようになったよ。
購入の内容はマイページの「お持ちの鑑定書」でも確かめられるよ。
)}
); }; // ═══════════════════════════════════════════════════════ // PAID SECTION TEASER CARD(有料セクションの「さわり」カード) // 無料会員向け。サーバー(get_paid_sections の teasers)が限定生成した // さわりだけを表示する(監査 H-6: 全文はクライアントに来ない)。 // 🔒見出しリストは docs/free_teaser_redesign/implementation_plan.md §2-2 が正。 // ═══════════════════════════════════════════════════════ const PAID_TEASER_META = { love: { emoji: "💗", title: "恋愛・結婚の流れ", locked: ["結婚につながる出会いのヒント", "結婚との向き合い方", "判定の根拠"], ctaText: PLAN_SUSPENDED_CTA, }, career: { emoji: "💼", title: "仕事・キャリアのこと", locked: ["転職を考えるヒント", "昇進・昇給", "独立への向き合い方", "今の仕事の続け方"], ctaText: PLAN_SUSPENDED_CTA, }, relations: { emoji: "🤝", title: "人間関係のこと", locked: [ "周囲からの見え方", "対人ストレス", "苦手な人", "トラブル回避", "マウンティング対策", ], ctaText: PLAN_SUSPENDED_CTA, }, family: { emoji: "🏠", title: "家族のこと", locked: ["関係改善", "子供の進路", "家族の本音", "意見の扱い", "支え方"], ctaText: PLAN_SUSPENDED_CTA, }, money: { emoji: "💰", title: "お金・住まい・トラブルのこと", locked: ["金銭トラブル回避・解決", "引っ越し判断", "あなたの恵方(方角)"], ctaText: PLAN_SUSPENDED_CTA, }, calendar: { emoji: "🗓️", title: "1年の運気カレンダー", locked: ["残り11か月", "動きやすい月", "整える月", "転機の月"], ctaText: PLAN_SUSPENDED_CTA, }, }; const PaidTeaserCard = ({ anchorId, emoji, label, sub, delay = 2.9, badge, fragment, lockedTitles, ctaText, onCtaClick, ctaHidden = true, ctaButtonLabel = "鑑定書の内容を見る", children, }) => (
{badge && (
{badge}
)} {fragment && (
{fragment}
)} {children}
続きで読めること
{(lockedTitles || []).map((title) => (
🔒 {t(title)}
))}
{ctaText ? (
{t(ctaText)}
) : null}
); // ═══════════════════════════════════════════════════════ // RESULTS SCREEN // ═══════════════════════════════════════════════════════ const ResultsScreen = ({ birthStr, targetDateStr, onReset, tweaks, isLoggedIn, userName, onLoginClick, onRegisterClick, onLogoutClick, onMyPageClick, onCompatibilityClick, onOshiClick, diagnosisId, typePatterns, dayFortuneMessages, userStatus = "無料", entitlements = null, purchaseProducts = null, sales = null, user = null, onRefreshMe = null, inviteAnswer = null, onInviteRetry = null, canCreateInvite = true, }) => { console.log("ResultsScreen userStatus:", userStatus); const dt = new Date(birthStr); const y = dt.getFullYear(), m = dt.getMonth() + 1, d = dt.getDate(); // タイムマシン対応: targetDateStrがあればそちらを使う const targetDt = targetDateStr ? new Date(targetDateStr) : new Date(); const ty = targetDt.getFullYear(), tm = targetDt.getMonth() + 1, td = targetDt.getDate(); const isTimeMachine = isLoggedIn && targetDateStr && targetDateStr !== (() => { const t = new Date(); return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, "0")}-${String(t.getDate()).padStart(2, "0")}`; })(); const actualToday = new Date(); const showYear2027Card = actualToday.getFullYear() < 2027; const showYear2027CardNearTop = [0, 10, 11].includes(actualToday.getMonth()); // All calculations const yp = getYearPillar(y, m, d); const mp = getMonthPillar(y, m, d); const dp = getDayPillar(y, m, d); const domEl = getDominantEl(y, m, d); const lifePath = calcLifePath(y, m, d); const lpData = LIFEPATH_DATA[lifePath] || LIFEPATH_DATA[1]; const kyusei = calcHonmeiKyusei(y, m, d); const zodiac = getZodiac(m, d); const bio = calcBio(y, m, d, ty, tm, td); const maya = calcMaya(y, m, d); // 層1:OS診断(新仕様) const osResult = calcOSScores(lifePath, domEl, kyusei, zodiac, maya); const myType = getSixTypeNameFromOS(osResult.mainOS); const typeInfo = SIX_TYPES[myType]; const typePattern = typePatterns && typePatterns.types ? typePatterns.types.find((t) => t.type_name === myType) : null; const mainOSMeta = OS_META[osResult.mainOS]; const subOSMeta = OS_META[osResult.subOS]; const careerInfo = getOSCareerInfo(osResult.mainOS, osResult.subOS); // 層2・層3:運気診断 const yearF = calcYearFortune( ty, y, m, d, domEl, lifePath, kyusei, zodiac, maya, ); const monthF = calcMonthFortune( ty, tm, y, m, d, domEl, lifePath, kyusei, zodiac, maya, ); const dayF = calcDayFortune( y, m, d, ty, tm, td, domEl, lifePath, kyusei, zodiac, maya, ); const age = ty - y - (tm < m || (tm === m && td < d) ? 1 : 0); const [paidSections, setPaidSections] = React.useState(null); // 無料会員向け「さわり」(サーバー限定生成)とプラン比較モーダルの状態 const [paidTeasers, setPaidTeasers] = React.useState(null); const [showPlanCompare, setShowPlanCompare] = React.useState(false); // 購入モーダル(sales.open のときだけ開ける)と、購入後に有料本文を取り直すための番号 const salesOpen = isSalesOpen(sales); const [purchaseProduct, setPurchaseProduct] = React.useState(null); const [paidReloadNonce, setPaidReloadNonce] = React.useState(0); const purchaseReturnYRef = React.useRef(null); const purchaseDoneRef = React.useRef(false); const openReadingPurchase = (anchorId) => { if (!salesOpen) return; const el = anchorId ? document.getElementById(anchorId) : null; purchaseReturnYRef.current = el ? el.getBoundingClientRect().top + (window.scrollY || window.pageYOffset || 0) : null; purchaseDoneRef.current = false; setShowPlanCompare(false); setPurchaseProduct("reading"); }; const paidResultPanelStyle = { padding: "14px", border: "1px solid rgba(255,138,61,0.14)", background: "rgba(255,255,255,0.6)", borderRadius: "14px", boxSizing: "border-box", }; const paidBasisPanelStyle = { padding: "14px", border: "1px solid rgba(255,138,61,0.22)", borderLeft: "4px solid var(--gold)", background: "rgba(255,138,61,0.06)", borderRadius: "14px", boxSizing: "border-box", }; const paidSectionDividerStyle = { marginTop: "14px", paddingTop: "14px", borderTop: "1px dashed rgba(255,138,61,0.22)", }; // OSスコアバーグラフコンポーネント const OSScoreBar = ({ osName, score, max = 20 }) => (
{osName} {Math.round(score)}/20
); const accentColors = [ "#c8a84b", "#5a9e8a", "#8080c0", "#c06080", "#5080a0", "#a06030", ]; const fsScale = tweaks && tweaks.fontSize === "large" ? 1.15 : tweaks && tweaks.fontSize === "small" ? 0.88 : 1; // Responsive detection const [isSmallScreen, setIsSmallScreen] = React.useState( window.innerWidth < 768, ); React.useEffect(() => { const handleResize = () => setIsSmallScreen(window.innerWidth < 768); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); // Tabs for result sections const [activeTab, setActiveTab] = React.useState("综合"); const tabs = ["综合", "運勢", "今日", "相性"]; const [recommendation, setRecommendation] = React.useState(""); const [shareMessage, setShareMessage] = React.useState(""); // 年運フェーズ(0-20スコア → 0-100に換算) const getYearPhase = (score) => { const val = score * 5; if (val >= 85) return { label: "飛躍期", desc: "大きく動く・挑戦する年。新しいプロジェクトや目標達成にぴったりの時期。果敢に動くほど大きな成果につながりやすいよ。", }; if (val >= 70) return { label: "拡大期", desc: "活動や影響力を広げるのにいい時期。周囲との協力も進みやすく、成長が加速するよ。新しい出会いやチャンスに恵まれやすい。", }; if (val >= 50) return { label: "安定期", desc: "基盤を固める時期。急激な変化よりも、現在の資産や関係を大切にしながら、着実に前に進むことが重要。質を高める工夫や改善に注力すると効果的。", }; if (val >= 30) return { label: "調整期", desc: "見直し・整理・休息が必要な年。無理に進めるより、これまでの成果を整理し、次のステップへの準備を進めるべき時期。", }; return { label: "準備期", desc: "無理に広げず、内側を整える年。基礎を強化し、自分自身と向き合う時間を大切に。次のステップへのエネルギー蓄積期。", }; }; // 月運フェーズ(0-20スコア → 0-100に換算) const getMonthPhase = (score) => { const val = score * 5; if (val >= 85) return { label: "勢いの月", desc: "パワー全開。積極的に動くほど大きな成果につながりやすい月。新しいことに挑戦するのにぴったりだよ。", }; if (val >= 70) return { label: "広がる月", desc: "活動や人間関係が広がる月。新しい出会いやチャンスが増えて、視野も広がるよ。", }; if (val >= 50) return { label: "整える月", desc: "今の状況を整理して、基盤を強くする月。細部に目を配って、質を高める工夫が効果的だよ。", }; if (val >= 30) return { label: "見直す月", desc: "立ち止まって考える月。これまでのやり方を見直して、必要な調整をするのに向いてるよ。", }; return { label: "休める月", desc: "心身を休める月。疲れを癒して、自分と向き合う時間を大切に。無理は避けて。", }; }; // 今日のフェーズ(0-20スコア → 0-100に換算) const getDayPhase = (score) => { const val = score * 5; if (val >= 85) return { label: "攻める日", desc: "エネルギー満タンの日。重要な決断や大きなチャレンジに挑戦するのに最適。積極的に行動を。", }; if (val >= 70) return { label: "動く日", desc: "行動力が高い日。新しいことを始めたり、前に進めたい案件に取り組むのに向いています。", }; if (val >= 50) return { label: "整える日", desc: "細かな調整や整理整頓に向いた日。細部に目を配り、質を高める作業が効果的。", }; if (val >= 30) return { label: "慎重な日", desc: "慎重さが必要な日。大きな決断は避け、冷静に判断することが重要。見直しや準備作業に最適。", }; return { label: "休む日", desc: "休息が必要な日。無理をせず、ゆっくりと過ごしましょう。心身のリセットが大切。", }; }; // キャリア指針(月運と年運から人生決断への適性を判定) const getCareerAdvice = (monthScore, yearScore) => { const combined = Math.round(monthScore * 0.6 + yearScore * 0.4); const signal = combined >= 15 ? { label: "追い風", tone: "high", note: "強く出た結果だよ。積極的に進めやすい時期。", } : combined >= 11 ? { label: "やや出ています", tone: "middle", note: "弱いシグナルはあるよ。強くは出ていないから参考扱いでOK。", } : combined >= 6 ? { label: "見直し", tone: "warn", note: "今は大きく動くより、見直して整える時期。", } : { label: "保留", tone: "none", note: "この指標では強い判断は出てないよ。", }; if (combined >= 15) return { level: "最高機", indicator: "🚀", headline: "人生の決断に最適なタイミング", signal, advice: "転職・昇進交渉・起業・重要プロジェクト開始などを前向きに考えやすい月。気持ちと条件を整理しながら、大きな決断を進めるときの参考にしてね。", actions: [ "転職・求人応募", "昇進交渉・キャリア相談", "起業・副業スタート", "重要なプロジェクト立案", "独立・新事業開始", ], caution: null, }; if (combined >= 11) return { level: "好機", indicator: "⬆️", headline: "キャリアを展開させるのに向いた時期", signal, advice: "新スキル習得・ネットワーク拡大・新プロジェクト参加など、キャリアを段階的に広げやすい月。小さく動き始めると、その後の広がりにつながりやすいよ。", actions: [ "新スキル習得・資格取得", "キャリア面談・相談", "ネットワーキング活動", "新プロジェクト参加", "業務範囲の拡大提案", ], caution: null, }; if (combined >= 6) return { level: "調整", indicator: "→", headline: "現状を深め、次に備える時期", signal, advice: "大きく動くより、今の職務を深めながら準備を整えたい時期。見直しや改善を進めると、次の動きにつながりやすいよ。", actions: [ "現在のスキルをより深める", "業務の質を高める工夫", "長期キャリアプランの再確認", "人脈の整理・関係構築", "自分の市場価値の分析", ], caution: "大きな決断は次の好機まで待つのが賢明", }; return { level: "休養", indicator: "⏸", headline: "充電と内省の時期", signal, advice: "無理に動くより、休息や整理を優先したい時期。今は強い判断を置かず、次の動きに向けて整えることを大事にしてね。", actions: [ "自己分析・強み再発見", "学習・資格取得の準備", "体調管理・ストレス軽減", "人間関係の整理・修復", "将来のキャリアについて考える時間", ], caution: "転職・起業・大きなキャリア決断は、今は急がなくてOK。", }; }; const careerAdvice = getCareerAdvice(monthF.score, yearF.score); const oneLineDiagnosticItems = (() => { const buttonAction = onRegisterClick; const buttonLabel = "無料会員登録して詳しい診断を見る"; // 行き止まり4テーマの再マップ(self→タイプ解説 / health→calendar / // study→career / move→money)。対応先がページ内に描画されていれば // スクロールし、なければ従来どおり登録導線へフォールバックする。 const scrollToSectionAnchor = (ids) => { for (let i = 0; i < ids.length; i++) { const el = document.getElementById(ids[i]); if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); return true; } } return false; }; const remappedAction = (ids) => () => { if (!scrollToSectionAnchor(ids)) onRegisterClick(); }; return [ { key: "love", title: "恋愛のお悩み", summary: "いまは自分の気持ちが最優先。会話の温度は、自然体でいこ。", action: "相手に送りたい言葉を、ひとつだけ書き出してみて。", buttonLabel, buttonAction, cta: "相手との相性・進展しやすい時期・距離の縮め方を詳しく見る", }, { key: "marriage", title: "結婚・パートナーのお悩み", summary: "条件より、日常の安心感。そこを確かめるのが縁を育てるよ。", action: "譲れない安心感と、ほしい生活の温度を分けて書いてみて。", buttonLabel, buttonAction, cta: "結婚に向く時期・相手との価値観の違い・長く続く関係の作り方を見る", }, { key: "work", title: "仕事・キャリアのお悩み", summary: "強みが自然に出る役割を見直すと、次の流れが開くよ。", action: "疲れる作業と気持ちよく進む作業を、1つずつ書き出してみて。", buttonLabel, buttonAction, cta: "適職・転職時期・独立運・今後3か月の仕事運を詳しく見る", }, { key: "career_change", title: "転職・独立のお悩み", summary: "いまは勢いより準備の質。動く理由を一行にしてみよ。", action: "変えたい理由を、1行だけ書いてみて。", buttonLabel, buttonAction, cta: "転職・独立に向く時期と、今動くべきか待つべきかを見る", }, { key: "money", title: "お金のお悩み", summary: "いまは増やすより整える時期。支出の見直しが金運の土台だよ。", action: "固定費をひとつ見直して、毎月の流れを整えてみて。", buttonLabel, buttonAction, cta: "金運が上がりやすい時期・支出が増えやすい月・お金の使い方の癖を見る", }, { key: "relationships", title: "人間関係のお悩み", summary: "無理に合わせる前に、疲れにくい距離感を選んでいいんだよ。", action: "連絡をひとつだけ、丁寧に返してみて。", buttonLabel, buttonAction, cta: "相性の良いタイプ・苦手な人との付き合い方・対人ストレスの減らし方を見る", }, { key: "family", title: "家族のお悩み", summary: "近い関係ほど、背負いすぎてないか立ち止まってみて。", action: "家族に伝えたいことを、一度メモに書いてみて。", buttonLabel, buttonAction, cta: "家族内での役割・親子/夫婦/兄弟姉妹の相性・距離感の作り方を見る", }, { key: "self", title: "自己理解のお悩み", summary: "短所より、自分らしさが戻る場面を見つけるのが開運の入口だよ。", action: "気分がちょっと軽くなる行動を、3つ書いてみて。", buttonLabel: "あなたのタイプ解説を見る", buttonAction: remappedAction(["lc-section-type-explain"]), cta: "あなたの才能・弱点・人生で繰り返しやすいパターンを詳しく見る", }, { key: "future", title: "将来・人生の方向性のお悩み", summary: "大きな答えより、違和感をひとつ減らす。それが次の流れを開くよ。", action: "いまの予定から、迷いをひとつだけ減らしてみて。", buttonLabel, buttonAction, cta: "人生の転機・今年の流れ・今後12か月の運気カレンダーを見る", }, { key: "health", title: "健康・コンディションのお悩み", summary: "いまは頑張る力より、回復する力。睡眠と食事が土台だよ。", action: "寝る時間を、15分だけ早めてみて。", buttonLabel, buttonAction: remappedAction(["lc-teaser-calendar"]), cta: "疲れが出やすい時期・休むべきタイミング・整えるアクションを見る", }, { key: "study", title: "学業・試験のお悩み", summary: "詰め込みより継続。毎日続く形を先に作ると結果につながるよ。", action: "勉強を、10分だけ始めてみて。", buttonLabel, buttonAction: remappedAction(["lc-teaser-career"]), cta: "集中しやすい時期・試験前の運気・勉強スタイルを見る", }, { key: "move", title: "引っ越し・環境変化のお悩み", summary: "動く前に、何を変えたいのかを整理すると選びやすくなるよ。", action: "いまの場所で変えたい点を、3つ書き出してみて。", buttonLabel, buttonAction: remappedAction(["lc-teaser-money"]), cta: "引っ越し・転勤・環境変化に向く時期を見る", }, ]; })(); // 恋愛・仕事の有料本文はサーバー側(get_paid_sections)で生成する(監査 H-6)。 // loveTimingScore/loveTimingBand は本文と一体でサーバーへ移設した。 const turningPointHighlights = buildTurningPointHighlights( y, m, d, age, domEl, lifePath, kyusei, zodiac, maya, ); // ── 有料本文(恋愛・仕事): サーバーで権利検証のうえ生成(監査 H-6) ── // 診断エンジンはクライアント側のまま。無料の中間値だけを送り、本文はサーバー専用。 const hasLoveEntitlement = !!(entitlements && entitlements.love); const hasCareerEntitlement = !!(entitlements && entitlements.career); const hasRelationsEntitlement = !!(entitlements && entitlements.relations); const hasFamilyEntitlement = !!(entitlements && entitlements.family); const hasMoneyEntitlement = !!(entitlements && entitlements.money); const hasCalendarEntitlement = !!(entitlements && entitlements.calendar); // さわりカードの出し分け用(キーは PAID_TEASER_META / サーバーの teasers と同じ) const sectionEntitled = { love: hasLoveEntitlement, career: hasCareerEntitlement, relations: hasRelationsEntitlement, family: hasFamilyEntitlement, money: hasMoneyEntitlement, calendar: hasCalendarEntitlement, }; const __pad2 = (n) => String(n).padStart(2, "0"); const paidRequestBody = { lang: LANG, birth_date: `${y}-${__pad2(m)}-${__pad2(d)}`, target_date: `${ty}-${__pad2(tm)}-${__pad2(td)}`, pitfall: t((typePattern && typePattern.pitfall) || (typeInfo && typeInfo.pitfall) || ""), type_love: t((typePattern && typePattern.love) || (typeInfo && typeInfo.love) || ""), work_pattern: t((typePattern && typePattern.work) || (typeInfo && typeInfo.work) || ""), human_relationship: t( (typePattern && typePattern.human_relationship) || (typeInfo && typeInfo.human_relationship) || "", ), career_level: careerAdvice.level, career_advice: t(careerAdvice.advice), career_action0: careerAdvice.actions && careerAdvice.actions[0] ? t(careerAdvice.actions[0]) : null, career_signal_label: careerAdvice.signal ? t(careerAdvice.signal.label) : null, career_info: careerInfo ? { career: t(careerInfo.career), main_career_type: t(careerInfo.mainCareerType) } : null, }; // ログイン済みなら常に取得。権利のないセクションはサーバーが「さわり」 // (teasers)だけを限定生成して返す(監査 H-6: 全文は返さない)。 // キー化して入力が変わったときのみ再取得する。 // 権利が変わったとき(管理画面での付与・ログイン直後など)も再取得するよう、権利もキーに含める。 // サーバーへ送る body は paidRequestBody のまま(権利はサーバー側で再解決する)。 const paidRequestKey = isLoggedIn ? JSON.stringify({ body: paidRequestBody, entitled: sectionEntitled }) : ""; React.useEffect(() => { if (!paidRequestKey) { setPaidSections(null); setPaidTeasers(null); return; } let cancelled = false; fetch("./api/diagnoses.php?action=get_paid_sections", { method: "POST", credentials: "include", cache: "no-store", headers: { "Content-Type": "application/json" }, body: JSON.stringify(JSON.parse(paidRequestKey).body), }) .then((r) => r.json()) .then((data) => { if (cancelled) return; setPaidSections(data && data.sections ? data.sections : null); // PHP 側は空だと {} ではなく [] を返すことがあるため、 // 配列はオブジェクトとして扱わず null に正規化する。 setPaidTeasers( data && data.teasers && typeof data.teasers === "object" && !Array.isArray(data.teasers) ? data.teasers : null, ); }) .catch(() => { if (!cancelled) { setPaidSections(null); setPaidTeasers(null); } }); return () => { cancelled = true; }; }, [paidRequestKey, paidReloadNonce]); const loveSection = paidSections && paidSections.love ? paidSections.love : null; const careerSection = paidSections && paidSections.career ? paidSections.career : null; const relationsSection = paidSections && paidSections.relations ? paidSections.relations : null; const familySection = paidSections && paidSections.family ? paidSections.family : null; const moneySection = paidSections && paidSections.money ? paidSections.money : null; const calendarSection = paidSections && paidSections.calendar ? paidSections.calendar : null; // ── 無料会員向け「さわり」カード(有料全文がない場合のフォールバック表示) ── const paidTeaserFor = (key) => paidTeasers && paidTeasers[key] ? paidTeasers[key] : null; const renderSectionTeaser = (key, delay) => { const teaser = paidTeaserFor(key); const meta = PAID_TEASER_META[key]; // 権利を持っているセクションは、古い応答が残っていてもさわり(購入導線)を出さない if (!teaser || !meta || sectionEntitled[key]) return null; const cardSub = tf("あなたの{m}月{d}日の診断結果", { m: tm, d: td }); // 販売中のさわりカードは reading の購入モーダルを直接開く(§6-3)。 // 停止中(sales.open=false)は従来どおり「準備中」の文言だけを出し、ボタンは隠す。 const readingPrice = purchaseProductInfo(purchaseProducts, "reading").price; const readingCtaText = readingPrice != null ? tf("この続きは「自分の鑑定書」({price})で読めるよ。買い切りで、月額の料金はかからないよ。", { price: formatPurchasePrice(readingPrice), }) : t("この続きは「自分の鑑定書」で読めるよ。買い切りで、月額の料金はかからないよ。"); let teaserCtaText = meta.ctaText; let teaserCtaHidden = true; if (salesOpen) { // calendar は対象年が購入年(当年)のときだけ reading へ。翌年は Phase D まで近日公開、過去年は CTA なし。 const purchaseYear = currentTokyoYear(); if (key !== "calendar" || ty === purchaseYear) { teaserCtaText = readingCtaText; teaserCtaHidden = false; } else if (ty > purchaseYear) { teaserCtaText = t("翌年の運気カレンダーは、近日公開だよ。"); } else { teaserCtaText = ""; } } if (key === "calendar") { const month = teaser.month || null; return ( openReadingPurchase("lc-teaser-calendar")} > {teaser.year_label ? (
{teaser.year_label}
) : null} {teaser.intro ? (
{teaser.intro}
) : null} {month ? (
= 70 ? "rgba(255,138,61,0.28)" : "rgba(255,138,61,0.14)", background: month.score100 >= 70 ? "rgba(255,138,61,0.055)" : "rgba(255,255,255,0.6)", }} >
{month.label}
{month.emoji}
{month.phase_label}
{month.action} / {month.score_label}
) : null}
); } return ( openReadingPurchase("lc-teaser-" + key)} /> ); }; const shareResult = async (channel) => { if (!diagnosisId) { setShareMessage("共有URLを作るために、先に診断を保存してください"); return; } if (!recommendation) { setShareMessage("先にしっくり度を選択してください"); return; } // GA4 ファネル: しっくり度シェア if (typeof gtag === "function") { try { gtag("event", "share_result_click", { channel }); } catch (_) {} } try { const res = await postWithCsrf("./api/diagnoses.php?action=create_share_link", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diagnosis_id: diagnosisId, recommendation, channel, }), }); const data = await res.json(); if (!data.success || !data.share_url) { setShareMessage(data.error || t("共有URLの作成に失敗しました")); return; } // G0: サーバーが返した共有URL(?share=)に lc_ref=result を付け足す const url = withLcRef(data.share_url, "result"); if (channel === "sns") { const text = encodeURIComponent( t("この診断、かなり面白かった!あなたもやってみて"), ); window.open( `https://twitter.com/intent/tweet?text=${text}&url=${encodeURIComponent(url)}`, "_blank", ); } else if (channel === "email") { location.href = `mailto:?subject=${encodeURIComponent(t("この診断をシェアします"))}&body=${encodeURIComponent(url)}`; } else { await navigator.clipboard.writeText(url); } setShareMessage("共有URLを作成しました"); } catch { setShareMessage("通信エラーで共有に失敗しました"); } }; return (
{/* ── Top nav: ブランド + ログイン/会員登録 ── */}
{/* ユーザー名 - ログイン時のみ表示 */} {isLoggedIn && (
{/* マイページ・ログアウト */}
{/* ユーザー名 */} {userHonorific(userName)}
)} {!isLoggedIn && (
)}
{/* Brand - センター寄せ */}
Life Code Reading
六命解析
{/* ── Header ── */}
LIFE CODE READING
{tf("{y}年 {m}月 {d}日生まれ", { y, m, d })}
{tf("診断時の年齢 {age} 歳 · {y}年{m}月{d}日 解析", { age, y: ty, m: tm, d: td })} {isTimeMachine && ( ⏳ タイムマシン )}
{/* 主命×副命のタイプ宣言 */} {(() => { const mainName = getSixTypeNameFromOS(osResult.mainOS); const subName = getSixTypeNameFromOS(osResult.subOS); const mainColor = getTypeColor(mainName); const mainPattern = typePatterns && typePatterns.types ? typePatterns.types.find((item) => item.type_name === mainName) : null; const mainTrait = getDiagnosisTrait(mainName); const subTrait = getDiagnosisTrait(subName); const mainSlug = getTypeSlugFromOS(osResult.mainOS); const combinationUrl = combinationPageUrl(osResult.mainOS, osResult.subOS); // P2: 30組の称号(読み込み失敗・該当なしなら null → 従来の主命×副命表示) const comboTitle = getTypeComboTitle(osResult.mainOS, osResult.subOS); return (
{t("あなたは")}
{comboTitle ? (
{mainOSMeta?.emoji} {subOSMeta?.emoji}
{comboTitle.code}
{LANG === "ja" && comboTitle.reading ? ( // は読みが長いと漢字の間を広げるため、読みを上に重ねる {comboTitle.reading} {comboTitle.name} ) : ( comboTitle.name )}
{comboTitle.copy}
{tf("主命×副命: {main} × {sub}", { main: `${mainOSMeta?.emoji || ""}${t(mainName)}`, sub: `${subOSMeta?.emoji || ""}${t(subName)}`, })}
) : (
{mainOSMeta?.emoji} {t(mainName)} × {subOSMeta?.emoji} {t(subName)}
)} {!comboTitle && mainPattern?.catchphrase && (
{mainPattern.catchphrase}
)}
{t("力の出方")}
{t(mainTrait.basic)}
{t("力の使い方")}
{t(subTrait.outward)}
); })()} {/* G2a: タイプ宣言に添える、招待した人との相性 */} {showYear2027Card && showYear2027CardNearTop && (
)} {/* ══════════════════════════════════════════ 4. 今日の占い ══════════════════════════════════════════ */} {/* きょうの一言ラベル・シェアカード */} {(() => { const phase = getDayPhase(dayF.score); const typeName = getSixTypeNameFromOS(osResult.mainOS); const labelVariants = dayFortuneMessages?.labels?.[typeName]?.[phase.label]; if (!Array.isArray(labelVariants) || !labelVariants.length) { return null; } const dayLabel = labelVariants[(tm + td) % labelVariants.length]; const typeColor = getTypeColor(typeName); const typeEmoji = OS_META[osResult.mainOS]?.emoji || ""; const shareText = LANG === "th" ? `วันนี้ฉันคือ "${dayLabel}" 🔮 #LifeCode` : LANG === "en" ? `Today I'm a "${dayLabel}" 🔮 #LifeCode` : `きょうの私、「${dayLabel}」らしい🔮 #六命解析`; const shareUrl = withLcRef(typePageUrl(osResult.mainOS), "daily"); const handleShare = () => { // GA4 ファネル: きょうの私カード if (typeof gtag === "function") { try { gtag("event", "share_daily_click", { type_slug: getTypeSlugFromOS(osResult.mainOS), }); } catch (_) {} } if (navigator.share) { navigator.share({ text: shareText, url: shareUrl }).catch(() => {}); } else { window.open( `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}`, "_blank", ); } }; return (
{ty}.{String(tm).padStart(2, "0")}. {String(td).padStart(2, "0")}
{typeEmoji} {tf("{type}のあなたは、きょう…", { type: t(typeName) })}
{dayLabel}
六命解析 | lifecodereading.com
); })()}
{/* Day score - 2行レイアウト */}
{/* 1行目:フェーズ説明 */}
{getDayPhase(dayF.score).label}
{(() => { const phase = getDayPhase(dayF.score); const typeName = getSixTypeNameFromOS(osResult.mainOS); const variants = dayFortuneMessages?.messages?.[typeName]?.[phase.label]; return Array.isArray(variants) && variants.length ? variants[(tm + td) % variants.length] : phase.desc; })()}
{/* 2行目:ラッキーアイテム */}
ラッキーカラー
{dayF.luckyItems.color}
ラッキーナンバー
{dayF.luckyItems.number}
吉方位
{dayF.luckyItems.direction}
開運アイテム
{dayF.luckyItems.item}
{/* Action banner */}
開運アクション

✦ {dayF.action}

{/* Biorhythm mini */}
今日のバイオリズム
📊 グラフの見方:
💪 身体リズム {" "} — エネルギー、体力、活動性
❤️ 感情リズム {" "} — 気分、感性、対人関係
🧠 知性リズム {" "} — 集中力、判断力、学習力
↑ 上向き = 上昇期(好調)
↓ 下向き = 下降期(調整が必要)
{/* ══════════════════════════════════════════ 1. 総合診断 - 6つの六命タイプ + 性格詳細 ══════════════════════════════════════════ */} {/* 六命タイプ表示 */}
{/* 上位3つのタイプ:主命・副命・内命タイプ(横並び) */} {(() => { const sorted = Object.entries(osResult.scores) .sort((a, b) => b[1] - a[1]) .slice(0, 3); const main = { osName: sorted[0][0], label: "主命タイプ", score: sorted[0][1], }; const sub = { osName: sorted[1][0], label: "副命タイプ", score: sorted[1][1], }; const inner = { osName: sorted[2][0], label: "内命タイプ", score: sorted[2][1], }; const findTypePattern = (osName) => { const name = getSixTypeNameFromOS(osName); return typePatterns && typePatterns.types ? typePatterns.types.find((t) => t.type_name === name) : null; }; const narrative = buildDiagnosisNarrative( main.osName, sub.osName, inner.osName, typePattern, findTypePattern(sub.osName), findTypePattern(inner.osName), ); return ( <> {/* 3つのタイプ(PCは横並び、スマホは縦) */}
{[main, sub, inner].map((item, idx) => { const typeName = getSixTypeNameFromOS(item.osName); const typeColor = getTypeColor(typeName); const typeTrait = getDiagnosisTrait(typeName); const roleLine = idx === 0 ? typeTrait.basic : idx === 1 ? typeTrait.outward : typeTrait.latent; return (
{item.label}
{OS_META[item.osName]?.emoji}{" "} {OS_META[item.osName]?.jpName}
{roleLine && (
{idx === 0 ? t("力の出方: ") : idx === 1 ? t("力の使い方: ") : t("一人のときの出方: ")} {t(roleLine)}
)}
); })}
{/* 六命タイプ シェアカード */} {/* G1: プロフィールに書く一行 */} {/* 相手に送る+過去の ?with=slug 招待経由の相性導線 */} {(() => { const selfSlug = getTypeSlugFromOS(main.osName); if (!selfSlug) return null; // G2a: 招待は ?invite=(PartnerInviteButton が invite_create で作る)。 // 作れないとき(API 失敗・?share= で他人の結果を見ているとき)は旧形式 ?with=<自分の主命slug> で送る const inviteUrl = withLcRef( "https://lifecodereading.com/?with=" + selfSlug + (LANG !== "ja" ? "&lang=" + LANG : ""), "invite", ); const mainName = getSixTypeNameFromOS(main.osName); // 称号名があれば使い、無ければ主命タイプ名でグループへの招待文を作る。 const comboTitle = getTypeComboTitle(main.osName, sub.osName); const inviteTitle = comboTitle?.name || t(mainName); const inviteText = tf( "私は「{title}」だった!みんなは何タイプ?生年月日は私には見えないから気軽にやってみて", { title: inviteTitle }, ); let invited = null; try { const s = localStorage.getItem("lc_invited_with"); const V = [ "igniter", "nurturer", "creator", "seer", "liberator", "harmonizer", ]; if (s && V.indexOf(s) !== -1) invited = s; } catch (e) {} const compatPath = invited && invited !== selfSlug ? compatPairPath(selfSlug, invited) + (LANG === "ja" ? "" : `?lang=${LANG}`) : null; return ( ); })()} {/* 総合解釈 */}
診断結果
{narrative.catchphrase && (
{narrative.catchphrase}
)} {narrative.body.map((paragraph, pIdx) => (

{paragraph}

))} {narrative.affirmation && (

{narrative.affirmation}

)} {narrative.closer && (
『{narrative.closer.replace(/^私は/, "あなたは")}』
)}
); })()}
人生の転機
年齢と西暦年の節目
年運の波が大きい年齢を中心に、人生が動きやすい節目をピックアップしてるよ。断定じゃなくて「頃」として見てね。
{turningPointHighlights.map((item, idx) => (
{item.label}
{item.emoji} {item.phase}
{item.summary}
根拠: {item.detail}
))}
転機は「必ず起こる断定」じゃなくて、「流れが変わりやすい年」として読んでね。
{/* ══════════════════════════════════════════ 2. 運気診断 (年運・月運) ══════════════════════════════════════════ */}
{showYear2027Card && !showYear2027CardNearTop && ( )} {/* 年運(スコアは他機能の内部計算に残し、表示は外×内の3段構成に統一) */} {/* 月運 */}
{t(isTimeMachine ? "その月の月運" : "今月の月運")}
{monthF.emoji}
{monthF.phase}
{monthF.desc}
{getMonthPhase(monthF.score).label}
{getMonthPhase(monthF.score).desc}
{/* ══════════════════════════════════════════ 3. キャリア指針 ══════════════════════════════════════════ */}
{(() => { const ca = getCareerAdvice(monthF.score, yearF.score); const combined = Math.round(monthF.score * 0.6 + yearF.score * 0.4); const levelColor = combined >= 15 ? "#c8a84b" : combined >= 11 ? "#5a9e8a" : combined >= 6 ? "var(--text-dim)" : "#8a8a9e"; return (
{/* ヘッダー: インジケーター + 月フェーズラベル + ヘッドライン */}
{ca.indicator}
{getMonthPhase(monthF.score).label}
{ca.headline}
{ca.signal && (
{ca.signal.label} {ca.signal.note}
)} {/* アドバイス本文 */}
{ca.advice}
{/* 推奨アクション */}
推奨アクション
{ca.actions.map((action, i) => ( {action} ))}
{/* 注意事項(低スコア時のみ) */} {ca.caution && (
⚠️ {ca.caution}
)} {/* 力が出る役割と解説 */} {(() => { const sorted = Object.entries(osResult.scores) .sort((a, b) => b[1] - a[1]) .slice(0, 2); const mainOSName = sorted[0]?.[0]; const subOSName = sorted[1]?.[0]; const careerInfo = mainOSName ? getOSCareerInfo(mainOSName, subOSName) : null; const mainPower = { Drive: "止まっている物事に最初の一手を入れる力", Care: "相手の本当の困りごとを聞き出す力", Vision: "構想や考えを伝わる形にする力", Logic: "情報から構造と原因を読み取る力", Quest: "外から選択肢を持ち込んで道を広げる力", Balance: "全体の配置と進行を整える力", }[mainOSName] || ""; const subTypeDesc = { Drive: "早く試す", Care: "相手の状況に合わせる", Vision: "伝わる形にする", Logic: "筋道を立てる", Quest: "新しい場や人につなげる", Balance: "役割と手順に落とし込む", }[subOSName] || ""; return careerInfo ? (
{t("💼 力が出る役割")}
{t(careerInfo.career)}
{Array.isArray(typePattern?.jobs) && typePattern.jobs.length > 0 && (
{t("職業の例")}:{" "} {typePattern.jobs.join(LANG === "ja" ? "、" : ", ")}
)}
{tf( "主命タイプ「{primary}」の「{mainPower}」を、副命タイプ「{secondary}」の「{subTypeDesc}」という使い方で形にすると、「{career}」として力が出やすいよ。", { primary: t(OS_META[mainOSName]?.jpName), mainPower: t(mainPower), secondary: t(OS_META[subOSName]?.jpName), subTypeDesc: t(subTypeDesc), career: t(careerInfo.career), }, )}
) : null; })()}
); })()}
{/* ══════════════════════════════════════════ 会員登録のすすめ ══════════════════════════════════════════ */} {!isLoggedIn && (
会員登録のすすめ
タイムマシン機能を使うには「会員登録」が必要です。
{[ { icon: "⏳", text: "過去・未来の日付を指定して診断する" }, { icon: "🔍", text: "「あの日」の運勢や流れを振り返る" }, { icon: "📅", text: "大事な日のライフコードを確認する" }, { icon: "💫", text: "誕生日・転職日・告白日・別れた日・入社日を見返す", }, ].map((item, idx) => (
{item.icon} {item.text}
))}
)} {/* ══════════════════════════════════════════ 5. 六命解析について ══════════════════════════════════════════ */}
{/* 六命解析の説明 */}
<>

{t("六命解析では、生年月日から6つの傾向スコアを出して、上から順に「主命」「副命」「内命」として読み解くよ。")}

{t("主命タイプは、あなたの力の出方の中心。意識しなくても、いちばん自然に出る動き方だよ。")}

{t("副命タイプは、その力の使い方。主命の力をどんな手順・どんな形にして外に出すかに表れやすいよ。")}

{t("内命タイプは、ふだんは目立ちにくい面。一人で考えるときや、追い込まれたときに出やすいよ。")}

{t("「主命の力 × 副命の使い方」で読むと、なぜその動き方になるのかが見えてくるはず。")}

{/* 6つの六命タイプ説明 */}
6つの六命タイプとは
{typePatterns?.types?.slice(0, 6).map((type, idx) => (
{type.type_emoji} {type.type_name}
{type.catchphrase}
))}
{!isLoggedIn && (
気になるテーマの入口だけを短くまとめています。詳しい診断は無料会員登録後にご覧いただけます。
{oneLineDiagnosticItems.map((item) => (
{item.title}
{item.summary}
{tf("今日できる小さな行動: {action}", { action: t(item.action) })}
{item.cta}
))}
)} {/* 無料登録の見返り紹介(ゲスト専用・静的、個人化なし) */} {!isLoggedIn && (
無料会員になると、あなたの生年月日から作る7つの診断の「さわり」を読めるようになるよ。
{Object.keys(PAID_TEASER_META).map((key) => (
{PAID_TEASER_META[key].emoji} {t(PAID_TEASER_META[key].title)}
))}
💞 {t("相性診断")}
)} {/* ══════════════════════════════════════════ 6. 個別占い (6システム詳細) - 無料会員から表示 診断の根拠にあたるブロックなので有料の壁の内側には置かない。 未ログインには出さず、会員登録の見返りとして位置づける。 ══════════════════════════════════════════ */} {isLoggedIn && ( <>
{/* 四柱推命 */}

{t(ELEMENT_DATA[domEl]?.desc)} {t("あなたの本質的なエネルギーは")} {tf("{el}気", { el: t(domEl) })} {t("が握ってるよ。")}

{[ { label: "年柱", stem: yp.stem, branch: yp.branch, sub: t(yp.animal + "年"), }, { label: "月柱", stem: mp.stem, branch: mp.branch, sub: mp.el + "気", }, { label: "日柱", stem: dp.stem, branch: dp.branch, sub: dp.el + "気", }, ].map(({ label, stem, branch, sub }) => (
{label}
{stem && (
{stem}
)}
{branch}
{sub}
))}
{/* 数秘術 */}
{lifePath}
{lpData.title}
{lpData.keywords.map((k) => ( {k} ))}
{/* 九星気学 */}
{kyusei} ★

{KYUSEI_NAMES[kyusei - 1]}の気質。恵方 {KYUSEI_DIRS[kyusei - 1]} を意識すると運気が整うよ。

{/* 12星座 */}

{zodiac.name} {t("に生まれたあなたは、星々の配置が織りなす独自の運命の糸を持ってるよ。")}{" "} {t("本来の自分を信じて動くと、隠れた才能が開いていくよ。")}

{/* バイオリズム */}

{t("誕生から")}{" "} {bio.days.toLocaleString()} {" "} {tf("日。 肉体{p}期 · 感情{e}期 · 知性{i}期。", { p: t(bio.physical > 0 ? "上昇" : "調整"), e: t(bio.emotional > 0 ? "高揚" : "内省"), i: t(bio.intellectual > 0 ? "活性" : "蓄積"), })}

{/* マヤ暦 */}
KIN {maya.kinNum} /260

マヤ暦KIN {maya.kinNum}「 {maya.kin} 」。 {maya.tone}の波動があなたの使命を決めるよ。

)} {renderSectionTeaser("calendar", 2.9)} {hasCalendarEntitlement && calendarSection && (
{calendarSection.intro}
今年の動きやすい流れ
{calendarSection.active_summary}
整えるタイミングのヒント
{calendarSection.maintenance_summary}
流れの切り替わりを読む
{calendarSection.transition_summary}
12か月の運気カレンダー
{calendarSection.months.map((item) => (
= 70 ? "rgba(255,138,61,0.28)" : "rgba(255,138,61,0.14)", background: item.score100 >= 70 ? "rgba(255,138,61,0.055)" : "rgba(255,255,255,0.6)", }} >
{item.label}
{item.emoji}
{item.phase_label}
{item.action} / {item.score_label}
))}
)} {/* ══════════════════════════════════════════ 10. 自分の診断結果に対して ══════════════════════════════════════════ */} {renderSectionTeaser("love", 2.95)} {hasLoveEntitlement && loveSection && (
あなたの六命タイプと、診断時点の年運・月運・日運の流れから、恋人ができる時期や結婚のタイミングを読み解いていくよ。
今の流れ
{loveSection.band}
恋愛の流れを読む
{loveSection.timing}
恋人ができない理由
{loveSection.reason}
結婚につながる出会いのヒント
{loveSection.meeting}
結婚との向き合い方
{loveSection.marriage}
)} {renderSectionTeaser("career", 3.05)} {hasCareerEntitlement && careerSection && (
あなたの六命タイプと、診断時点の年運・月運・日運の流れから、転職・昇進・独立の流れを読み解いていくよ。
{t("今の流れ")}: {careerSection.band}
転職すべき時期
{careerSection.timing}
転職するべきかどうか
{careerSection.switch}
職場の人間関係を改善するには?
{careerSection.relations}
仕事でもっと成果をあげる方法
{careerSection.results}
今の仕事を続けるべきかやめるべきか
{careerSection.continue}
昇進・昇給のチャンスはありそう?
{careerSection.promotion}
独立してうまくやっていけるか
{careerSection.independent}
独立すべき時期
{careerSection.independentTiming}
)} {renderSectionTeaser("relations", 3.15)} {hasRelationsEntitlement && relationsSection && (
あなたの六命タイプと、診断時点の年運・月運・日運の流れから、人との距離感、周りからの見え方、トラブルを避ける動きを読み解いていくよ。
今の対人運の流れ
{relationsSection.flow}
友達との関係を改善するには?
{relationsSection.improveFriends}
もっと社交的になるには?
{relationsSection.social}
あの人は自分をどう思っているのか
{relationsSection.howSeenByPerson}
周囲からどう思われているか
{relationsSection.howSeenByOthers}
対人関係のストレスをなくすには?
{relationsSection.stress}
嫌いな人・苦手な人との付き合い方
{relationsSection.difficultPerson}
今後人間関係のトラブルはありそうか
{relationsSection.trouble}
トラブルに巻き込まれない方法
{relationsSection.avoidTrouble}
マウンティングする人から身を守る方法
{relationsSection.mounting}
)} {renderSectionTeaser("family", 3.25)} {hasFamilyEntitlement && familySection && (
あなたの六命タイプと、診断時点の年運・月運・日運の流れから、家族との距離感、受け止められ方、支え方を読み解いていくよ。
今の家族運の流れ
{familySection.flow}
家族との関係を改善する方法
{familySection.improve}
子供の進路について
{familySection.childPath}
子供の素行について
{familySection.childBehavior}
家族が何を考えているか
{familySection.familyThought}
家族にどう思われているか
{familySection.howSeen}
家族の言うことを聞いた方がいい?
{familySection.listen}
連絡の取れない家族は今元気にしているか
{familySection.unreachable}
家族に何をしてあげられるのか
{familySection.support}
)} {renderSectionTeaser("money", 3.35)} {hasMoneyEntitlement && moneySection && (
あなたの六命タイプと、診断時点の年運・月運・日運・九星気学の方角から、金銭面の注意点、引っ越し判断、今後気をつけたいトラブル傾向を読み解いていくよ。
今のお金・住まい運の流れ
{moneySection.flow}
金銭トラブルを避けるには、どうしたらいい?
{moneySection.avoidMoney}
金銭トラブルを解決するには、どうしたらいい?
{moneySection.resolveMoney}
今、引っ越しするべき?
{moneySection.shouldMove}
引っ越すなら、どの方角がいい?
{moneySection.moveDirection}
これから起こるトラブルは何?
{moneySection.futureTrouble}
)} {/* ══════════════════════════════════════════ 15. 相性診断 ══════════════════════════════════════════ */}
生年月日から、お相手との六命タイプを比較できます。ログインしていなくても診断できます(ログイン中はあなたの情報を自動で使用)。
{isLoggedIn && (
この診断、しっくりきましたか?
{[ ["かなりしっくりきた", "spark"], ["一部しっくりきた", "partial"], ["まだわからない", "unknown"], ["あまりしっくりこない", "low"], ].map(([label, value]) => ( ))}
{shareMessage}
)} {/* Reset */}

六命解析は、四柱推命・数秘術・九星気学などの占術要素をもとに、独自のスコアリングで傾向を読み解いたものです。

結果は、自己理解や日々の行動を考えるための参考情報としてお楽しみください。
恋愛・仕事・お金・健康・法律などに関する重要な判断は、占い結果だけで決めず、必要に応じて専門家や信頼できる人に相談してください。

{!isLoggedIn && (
)} {isLoggedIn && (
)} {/* Footer */}
setShowPlanCompare(false)} products={purchaseProducts} sales={sales} ownedProducts={{ reading: hasPurchasedProduct(user, "reading") }} onPurchase={(key) => { if (key === "reading") { openReadingPurchase(null); } else if (key === "compat" && onCompatibilityClick) { // 相性の鑑定書はお相手ごとの商品なので、相性診断の画面でお相手を選んでから買う setShowPlanCompare(false); onCompatibilityClick(); } }} /> { setPurchaseProduct(null); const y = purchaseReturnYRef.current; purchaseReturnYRef.current = null; if (y != null && purchaseDoneRef.current) { // 購入した章(さわりカードがあった位置)へ戻る window.setTimeout(() => window.scrollTo({ top: Math.max(0, y - 20), behavior: "smooth" }), 300); } }} onPurchased={() => { purchaseDoneRef.current = true; setPaidReloadNonce((n) => n + 1); onRefreshMe && onRefreshMe(); }} onSalesStale={() => onRefreshMe && onRefreshMe()} />
); }; // ═══ TWEAKS ═══ // ═══ LOGIN MODAL ═══ const LoginModal = ({ isOpen, onClose, initialTab = "login", onLoginSuccess, prefilledBirthDate = "", }) => { const [tab, setTab] = React.useState(initialTab); const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); const [username, setUsername] = React.useState(""); const [birthDate, setBirthDate] = React.useState(""); const [gender, setGender] = React.useState(""); const [region, setRegion] = React.useState(""); const [occupationCategory, setOccupationCategory] = React.useState(""); const [interestCategories, setInterestCategories] = React.useState([]); const [error, setError] = React.useState(""); const [pendingVerificationEmail, setPendingVerificationEmail] = React.useState(""); const [loading, setLoading] = React.useState(false); const [resetEmail, setResetEmail] = React.useState(""); const [resetStep, setResetStep] = React.useState("email"); const [resetCode, setResetCode] = React.useState(""); const [newPassword, setNewPassword] = React.useState(""); const [resetNotice, setResetNotice] = React.useState(""); React.useEffect(() => { if (isOpen) { setTab(initialTab); setError(""); setResetNotice(""); if (prefilledBirthDate) { setBirthDate(prefilledBirthDate); } } }, [isOpen, initialTab, prefilledBirthDate]); if (!isOpen) return null; const INTEREST_OPTIONS = [ "恋愛", "結婚・パートナー", "仕事・キャリア", "お金", "人間関係", "家族", "自己理解", "将来・人生", "健康・コンディション", "学業・試験", "引っ越し・環境変化", "特にない", "その他", ]; const OCCUPATION_OPTIONS = [ "会社員・公務員", "経営者・役員", "個人事業主・フリーランス", "専門職", "クリエイター", "販売・接客・サービス", "営業・マーケティング", "IT・エンジニア", "教育・福祉", "学生", "主婦・主夫", "無職・休職中", "その他", "回答しない", ]; const inputSt = { width: "100%", padding: "11px 14px", background: "rgba(255,255,255,0.78)", border: "1px solid rgba(255,138,61,0.25)", color: "var(--text)", fontSize: "0.85rem", fontFamily: "var(--serif-jp)", outline: "none", borderRadius: "14px", boxSizing: "border-box", colorScheme: "dark", }; const handleLogin = async (e) => { e.preventDefault(); if (!email || !password) { setError("メールアドレスとパスワードを入力してください"); return; } setLoading(true); setError(""); setPendingVerificationEmail(""); try { const fd = new FormData(); fd.append("action", "login"); fd.append("lang", LANG); fd.append("email", email); fd.append("password", password); const res = await fetch("./api/auth.php", { method: "POST", body: fd, credentials: "include", }); const raw = await res.text(); let data = null; try { data = JSON.parse(raw); } catch { data = { error: raw || `HTTP ${res.status}` }; } if (data.success) { onLoginSuccess(data.user); onClose(); } else { setError(t(data.error || "ログインに失敗しました")); if (data.code === "EMAIL_NOT_VERIFIED") { setPendingVerificationEmail(data.email || email); } } } catch (err) { setError(t("接続エラー: ") + (err?.message || "unknown")); } finally { setLoading(false); } }; const handleRegister = async (e) => { e.preventDefault(); if (!email || !password || !username || !birthDate) { setError("全ての必須項目を入力してください"); return; } if (interestCategories.length === 0) { setError("今知りたいテーマを1つ以上選択してください"); return; } if (password.length < 6) { setError("パスワードは6文字以上必要です"); return; } if (!isSupportedBirthDate(birthDate)) { setError("生年月日は1900年1月1日から2100年12月31日の間で入力してください"); return; } setLoading(true); setError(""); setPendingVerificationEmail(""); try { const fd = new FormData(); fd.append("action", "register"); fd.append("lang", LANG); fd.append("email", email); fd.append("password", password); fd.append("username", username); fd.append("birth_date", birthDate); fd.append("gender", gender); fd.append("region", region); fd.append("occupation_category", occupationCategory); fd.append("interest_categories", JSON.stringify(interestCategories)); const res = await fetch("./api/auth.php", { method: "POST", body: fd, credentials: "include", }); const raw = await res.text(); let data = null; try { data = JSON.parse(raw); } catch { data = { error: raw || `HTTP ${res.status}` }; } if (data.success) { if (data.requires_email_verification) { const localHint = data.verification_url ? `(ローカル確認URL: ${data.verification_url})` : ""; setError( `${t("登録完了: 認証メールを送信しました。メール内のURLを開いてからログインしてください。")}${localHint}`, ); setPendingVerificationEmail(email); setTab("login"); setPassword(""); return; } onLoginSuccess(data.user); onClose(); } else { setError(data.error || "登録に失敗しました"); } } catch (err) { setError(t("接続エラー: ") + (err?.message || "unknown")); } finally { setLoading(false); } }; const handleResendVerification = async () => { if (!pendingVerificationEmail) { setError("再送対象のメールアドレスがありません"); return; } setLoading(true); setError(""); try { const fd = new FormData(); fd.append("action", "resend_verification"); fd.append("lang", LANG); fd.append("email", pendingVerificationEmail); const res = await fetch("./api/auth.php", { method: "POST", body: fd, credentials: "include", }); const raw = await res.text(); let data = null; try { data = JSON.parse(raw); } catch { data = { error: raw || `HTTP ${res.status}` }; } if (data.success) { const localHint = data.verification_url ? ` ローカル確認URL: ${data.verification_url}` : ""; setError( `${t(data.message || "未認証の登録があれば認証メールを送信しました")}${localHint}`, ); } else { setError(data.error || "認証メールの再送に失敗しました"); } } catch (err) { setError(t("接続エラー: ") + (err?.message || "unknown")); } finally { setLoading(false); } }; const handleForgot = async (e) => { e.preventDefault(); if (!resetEmail) { setError("メールアドレスを入力してください"); return; } setLoading(true); setError(""); setResetNotice(""); try { const fd = new FormData(); fd.append("action", "forgot_password"); fd.append("lang", LANG); fd.append("email", resetEmail); const res = await fetch("./api/auth.php", { method: "POST", body: fd, credentials: "include", }); const data = await res.json(); if (data.success) { setResetCode(""); setNewPassword(""); setResetStep("code"); setResetNotice( data.message || "パスワード再設定メールを送信しました。", ); setError(""); } else { setError(data.error || "送信に失敗しました"); } } catch { setError("送信に失敗しました"); } finally { setLoading(false); } }; const handleReset = async (e) => { e.preventDefault(); setLoading(true); setError(""); try { const fd = new FormData(); fd.append("action", "reset_password"); fd.append("lang", LANG); fd.append("email", resetEmail); fd.append("reset_code", resetCode); fd.append("new_password", newPassword); const res = await fetch("./api/auth.php", { method: "POST", body: fd, credentials: "include", }); const data = await res.json(); if (data.success) { alert(t("パスワードをリセットしました。再度ログインしてください。")); setTab("login"); setResetStep("email"); setResetNotice(""); } else { setError(data.error || "リセットに失敗しました"); } } catch { setError("リセットに失敗しました"); } finally { setLoading(false); } }; return (
e.stopPropagation()} style={{ background: "linear-gradient(160deg,#FFFFFF,#FFF6EC)", border: "1px solid rgba(255,138,61,0.3)", borderRadius: "14px", padding: "36px 32px", width: "100%", maxWidth: "380px", maxHeight: "calc(100vh - 40px)", overflowY: "auto", boxShadow: "0 20px 60px rgba(61,52,40,0.25)", position: "relative", }} > {/* Close */} {/* Header */}
LIFE CODE
{tab !== "forgot" && (
{["login", "register"].map((t) => ( ))}
)} {tab === "forgot" && (
パスワードをお忘れの方
)}
{tab !== "forgot" && (
または
)} {/* Login form */} {tab === "login" && (
setEmail(e.target.value)} style={inputSt} autoComplete="email" /> setPassword(e.target.value)} style={inputSt} autoComplete="current-password" /> {error && (
{error}
)} {pendingVerificationEmail && ( )}
)} {/* Register form */} {tab === "register" && (
{/* 登録メリットの明示 */}
無料登録でできること
生年月日から作る7つの診断の「さわり」が読めて、過去・未来の運気を見るタイムマシン診断も使えるようになるよ。
{Object.keys(PAID_TEASER_META).map((key) => (
{PAID_TEASER_META[key].emoji} {t(PAID_TEASER_META[key].title)}
))}
💞 {t("相性診断")}
setUsername(e.target.value)} style={inputSt} autoComplete="username" /> setEmail(e.target.value)} style={inputSt} autoComplete="email" />
setBirthDate(e.target.value)} style={inputSt} min="1900-01-01" max="2100-12-31" autoComplete="bday" />
setRegion(e.target.value)} style={inputSt} />
今、特に知りたいテーマ *(複数選択可)
{INTEREST_OPTIONS.map((opt) => ( ))}
setPassword(e.target.value)} style={inputSt} autoComplete="new-password" />
{error && (
{error}
)}

入力いただいた生年月日や任意のプロフィール情報は、診断結果の作成、結果の保存、サービス改善、統計的な分析のために利用します。

個人が特定される形で診断データを公開することはありません。
{/* 文を分割すると TH_DICT のキー(文単位で登録済み)に一致しなく なるため、文全体をリンクにして単一のテキストノードのまま保つ */} 詳しくはプライバシーポリシーをご確認ください。

)} {/* Forgot password */} {tab === "forgot" && resetStep === "email" && (

登録済みのメールアドレスを入力してください。

setResetEmail(e.target.value)} style={inputSt} autoComplete="email" /> {error && (
{error}
)}
)} {tab === "forgot" && resetStep === "code" && (
{resetNotice && (

{resetNotice}
メールに届いたリセットコードを入力してください。

)} setResetCode(e.target.value)} style={inputSt} /> setNewPassword(e.target.value)} style={inputSt} autoComplete="new-password" /> {error && (
{error}
)}
)}
); }; // ═══════════════════════════════════════════════════════ // OSHI SCREEN — 呼び名はこのコンポーネントの state だけで使用する // ═══════════════════════════════════════════════════════ const OshiScreen = ({ initialSelfBirth, onBack }) => { const ui = OSHI_UI; const [selfBirth, setSelfBirth] = React.useState(initialSelfBirth || ""); const [oshiName, setOshiName] = React.useState(""); const [oshiBirth, setOshiBirth] = React.useState(""); const [result, setResult] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(""); const [shareStatus, setShareStatus] = React.useState("idle"); React.useEffect(() => { if (!result && initialSelfBirth) setSelfBirth(initialSelfBirth); }, [initialSelfBirth, result]); const inputStyle = { width: "100%", padding: "12px 14px", border: "1px solid var(--border)", background: "rgba(255,255,255,0.8)", color: "var(--text)", fontSize: "0.98rem", borderRadius: "14px", boxSizing: "border-box", minWidth: 0, }; const panelStyle = { border: "1px solid var(--border)", borderRadius: "14px", background: "rgba(255,255,255,0.68)", padding: "22px", marginBottom: "18px", }; const slugToOS = (slug) => INVITE_OS_BY_SLUG[slug] || null; const titleForSlugs = (mainSlug, subSlug) => inviteTypeLabel(mainSlug, subSlug); const displayName = oshiName.trim() || ui.oshi_fallback_name; const compatibility = result?.compatibility || null; const oshiTitle = result?.oshi ? titleForSlugs(result.oshi.main_slug, result.oshi.sub_slug) : ""; const selfOS = result ? getOSResultFromBirth(selfBirth) : null; const selfTitle = selfOS ? titleForSlugs(getTypeSlugFromOS(selfOS.mainOS), getTypeSlugFromOS(selfOS.subOS)) : ""; const summary = compatibility?.summary ? String(compatibility.summary).replace(t("相手"), () => displayName) : ""; const palace = result?.oshi ? yearFortuneLocalized(YEAR_FORTUNE_CONTENT.palaces?.[result.oshi.palace2027]) : null; const submit = async (event) => { event.preventDefault(); if (!selfBirth || !oshiBirth || !isSupportedBirthDate(selfBirth) || !isSupportedBirthDate(oshiBirth)) { setError(t("生年月日は1900年1月1日から2100年12月31日の間で入力してください")); return; } setLoading(true); setError(""); setResult(null); try { const response = await fetch( "./api/diagnoses.php?action=oshi_compat&lang=" + encodeURIComponent(LANG), { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, // 呼び名は意図的に含めない。 body: JSON.stringify({ self_birthday: selfBirth, oshi_birthday: oshiBirth, lang: LANG }), }, ); const data = await response.json(); if (!response.ok || !data.success) { setError(data.error === "rate_limited" ? t("しばらく時間をおいてからお試しください") : t("相性診断に失敗しました")); return; } setResult(data); if (typeof gtag === "function") { try { gtag("event", "oshi_result", { oshi_main_slug: data.oshi?.main_slug || "", compatibility_type: data.compatibility?.compatibility_type || "", }); } catch (_) {} } } catch (_) { setError(t("通信エラーが発生しました")); } finally { setLoading(false); } }; const share = async () => { if (!result || !compatibility) return; setShareStatus("building"); if (typeof gtag === "function") { try { gtag("event", "oshi_share", { oshi_main_slug: result.oshi?.main_slug || "", compatibility_type: compatibility.compatibility_type || "", }); } catch (_) {} } const shareText = String(ui.share_text) .replace(/\{score\}/g, String(compatibility.score)) .replace(/\{type\}/g, String(compatibility.compatibility_type || compatibility.headline || "")); const baseUrl = "https://lifecodereading.com/oshi/" + (LANG === "ja" ? "" : "?lang=" + encodeURIComponent(LANG)); const shareUrl = withLcRef(baseUrl, "oshi"); try { const canvas = await buildOshiShareCardCanvas({ score: compatibility.score, compatibilityType: compatibility.compatibility_type || compatibility.headline || "", oshiTitle, selfTitle, ui, }); const file = await canvasToPngFile(canvas, "lifecode-oshi-compatibility.png"); if (navigator.canShare && navigator.canShare({ files: [file] })) { try { await navigator.share({ files: [file], text: shareText, url: shareUrl }); } catch (shareError) { if (shareError?.name !== "AbortError") throw shareError; } setShareStatus("idle"); return; } const objectUrl = URL.createObjectURL(file); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = "lifecode-oshi-compatibility.png"; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); setTimeout(() => URL.revokeObjectURL(objectUrl), 4000); if (navigator.clipboard) { try { await navigator.clipboard.writeText(`${shareText}\n${shareUrl}`); } catch (_) {} } setShareStatus("downloaded"); } catch (_) { setShareStatus("idle"); } }; const another = () => { setResult(null); setOshiName(""); setOshiBirth(""); setError(""); setShareStatus("idle"); window.scrollTo({ top: 0, behavior: "smooth" }); }; return (
{!result ? (
🔒 {ui.privacy_inline}
{error &&
{error}
}
) : ( <>

{ui.result_heading_type}

{oshiTitle}
{t("主命タイプ")}: {t(getSixTypeNameFromOS(slugToOS(result.oshi.main_slug)))} × {t("副命タイプ")}: {t(getSixTypeNameFromOS(slugToOS(result.oshi.sub_slug)))}

{ui.result_heading_compat}

{compatibility.score}{t("点")}
{compatibility.compatibility_type}
{compatibility.headline}
{summary}
{compatibility.strengths?.[0] &&
✨ {compatibility.strengths[0]}
}

{ui.result_heading_2027}

{palace && <>
{palace.title}
{tf("推しの本命星・{star}は、2027年に{palace}に入るよ。", { star: yearKyuseiName(result.oshi.honmei), palace: yearPalaceName(result.oshi.palace2027) })}
} {t("2027年の詳しい運勢を見る")} →
)}
); }; // ═══════════════════════════════════════════════════════ // COMPATIBILITY SCREEN // ═══════════════════════════════════════════════════════ const CompatibilityScreen = ({ user, isLoggedIn, onBack, onLoginClick, onRegisterClick, onMyPageClick, onUserUpdated, typePatterns, sales = null, onRefreshMe = null, }) => { const [partnerName, setPartnerName] = React.useState(user?.partner_name || ""); // 購入(compat): 表示中の結果を出したときの入力(お相手の名前・生年月日)で買う const salesOpen = isSalesOpen(sales); const [compatPurchaseProduct, setCompatPurchaseProduct] = React.useState(null); const lastCompatRequestRef = React.useRef(null); const [partnerBirthDate, setPartnerBirthDate] = React.useState( user?.partner_birth_date || "", ); const [selfBirthDate, setSelfBirthDate] = React.useState( user?.birth_date || "", ); const [compatibilityResult, setCompatibilityResult] = React.useState(null); const [compatibilityLoading, setCompatibilityLoading] = React.useState(false); const [compatibilityError, setCompatibilityError] = React.useState(""); const [compatibilityShareStatus, setCompatibilityShareStatus] = React.useState("idle"); const [showPlanCompare, setShowPlanCompare] = React.useState(false); const [isSmallScreen, setIsSmallScreen] = React.useState( window.innerWidth <= 768, ); const savedPartnerAvailable = Boolean( user?.partner_name || user?.partner_birth_date, ); const [dailyMood, setDailyMood] = React.useState(null); React.useEffect(() => { const handleResize = () => setIsSmallScreen(window.innerWidth <= 768); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); React.useEffect(() => { if (!isLoggedIn) { setDailyMood(null); return; } fetch( "./api/diagnoses.php?action=get_compatibility_daily_mood&lang=" + encodeURIComponent(LANG), { credentials: "include" }, ) .then((r) => r.json()) .then((data) => { if (data.available) { setDailyMood(data); } else { setDailyMood(null); } }) .catch(() => setDailyMood(null)); }, [isLoggedIn, user?.partner_birth_date]); React.useEffect(() => { setPartnerName(user?.partner_name || ""); setPartnerBirthDate(user?.partner_birth_date || ""); }, [user?.partner_name, user?.partner_birth_date]); React.useEffect(() => { setSelfBirthDate(user?.birth_date || ""); }, [user?.birth_date]); const restoreSavedPartnerInfo = () => { if (!savedPartnerAvailable) return; setPartnerName(user?.partner_name || ""); setPartnerBirthDate(user?.partner_birth_date || ""); setCompatibilityError(""); }; const savePartnerInfoSilently = async (name, birthDate) => { try { const fd = new FormData(); fd.append("action", "update_partner_info"); fd.append("partner_name", name || ""); fd.append("partner_birth_date", birthDate || ""); const res = await postWithCsrf("./api/auth.php", { method: "POST", credentials: "include", body: fd, }); const data = await res.json(); if (data.success && onUserUpdated) { onUserUpdated({ ...user, partner_name: data.partner?.partner_name ?? name, partner_birth_date: data.partner?.partner_birth_date ?? birthDate, }); } } catch { // 自動保存の失敗は診断結果の表示を妨げないよう無視する } }; const handleCompatibilitySubmit = async (e) => { e.preventDefault(); const effectiveSelfBirthDate = isLoggedIn && user?.birth_date ? user.birth_date : selfBirthDate; if (!effectiveSelfBirthDate) { setCompatibilityError("自分の生年月日を入力してください"); return; } if (!partnerBirthDate) { setCompatibilityError("相手の生年月日を入力してください"); return; } if (!isSupportedBirthDate(effectiveSelfBirthDate)) { setCompatibilityError("自分の生年月日は1900年1月1日から2100年12月31日の間で入力してください"); return; } if (!isSupportedBirthDate(partnerBirthDate)) { setCompatibilityError("相手の生年月日は1900年1月1日から2100年12月31日の間で入力してください"); return; } await runCompatibilityRequest( { self_birthday: effectiveSelfBirthDate, partner_name: partnerName, partner_birthday: partnerBirthDate, }, true, ); }; // 有料相性の本文を取得する(送信時と、購入完了後の取り直しで使う)。 const runCompatibilityRequest = async (req, savePartner) => { lastCompatRequestRef.current = req; setCompatibilityLoading(true); setCompatibilityError(""); try { const res = await fetch( "./api/diagnoses.php?action=get_paid_compatibility&lang=" + encodeURIComponent(LANG), { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ self_birthday: req.self_birthday, partner_name: req.partner_name, partner_birthday: req.partner_birthday, lang: LANG, }), }, ); const raw = await res.text(); let data = null; try { data = JSON.parse(raw); } catch { data = { error: raw || "HTTP " + res.status }; } if (!res.ok || !data.success) { setCompatibilityResult(null); setCompatibilityError(data.error || "相性診断に失敗しました"); return; } setCompatibilityResult(data); if (isLoggedIn && savePartner) { savePartnerInfoSilently(req.partner_name, req.partner_birthday); } } catch (err) { setCompatibilityResult(null); setCompatibilityError( t("通信エラー: ") + (err?.message || "unknown"), ); } finally { setCompatibilityLoading(false); } }; const partnerTypeLabel = compatibilityResult && compatibilityResult.partner ? compatibilityResult.partner.primary_label ? compatibilityResult.partner.primary_label + t("タイプ") : compatibilityResult.partner.type || "未判定" : ""; const partnerTypeEmoji = compatibilityResult && compatibilityResult.partner && typePatterns && typePatterns.types ? ( typePatterns.types.find( (type) => type.type_name === compatibilityResult.partner.primary_label, ) || {} ).type_emoji || "👤" : "👤"; const compatibilityScoreMood = compatibilityResult && compatibilityResult.compatibility ? (() => { const score = Number(compatibilityResult.compatibility.score || 0); if (score >= 88) return { emoji: "💖", label: "強い共鳴" }; if (score >= 72) return { emoji: "💞", label: "好相性" }; if (score >= 58) return { emoji: "✨", label: "安定相性" }; if (score >= 44) return { emoji: "🧭", label: "調整相性" }; return { emoji: "🌱", label: "育成相性" }; })() : { emoji: "✨", label: "相性" }; const compatibilityTypeLabel = compatibilityResult && compatibilityResult.compatibility ? compatibilityResult.compatibility.compatibility_type || compatibilityResult.compatibility.headline || "未判定" : ""; const handleCompatibilityShare = async () => { if (!compatibilityResult || !compatibilityTypeLabel) return; const selfSlug = getTypeSlugFromTendency(compatibilityResult.self?.primary_type) || getPrimaryTypeSlugFromBirth( isLoggedIn && user?.birth_date ? user.birth_date : selfBirthDate, ); const partnerSlug = getTypeSlugFromTendency(compatibilityResult.partner?.primary_type) || getPrimaryTypeSlugFromBirth(partnerBirthDate); const pairPath = compatPairPath(selfSlug, partnerSlug); const shareUrl = withLcRef( pairPath ? `https://lifecodereading.com/${pairPath}${LANG === "ja" ? "" : `?lang=${LANG}`}` : `https://lifecodereading.com/compatibility/${LANG === "ja" ? "" : `?lang=${LANG}`}`, "compat", ); const shareText = tf("私と相手の相性は『{type}』だったよ #六命解析", { type: compatibilityTypeLabel, }); setCompatibilityShareStatus("sharing"); if (navigator.share) { try { await navigator.share({ text: shareText, url: shareUrl }); setCompatibilityShareStatus("shared"); return; } catch (error) { if (error?.name === "AbortError") { setCompatibilityShareStatus("idle"); return; } } } if (navigator.clipboard) { try { await navigator.clipboard.writeText(`${shareText}\n${shareUrl}`); setCompatibilityShareStatus("copied"); return; } catch (_) {} } window.open( "https://twitter.com/intent/tweet?text=" + encodeURIComponent(shareText) + "&url=" + encodeURIComponent(shareUrl), "_blank", ); setCompatibilityShareStatus("idle"); }; const compatibilityTypeReason = compatibilityResult && compatibilityResult.compatibility ? compatibilityResult.compatibility.compatibility_reason || "" : ""; const compatibilityCalculationBasis = compatibilityResult && compatibilityResult.compatibility ? compatibilityResult.compatibility.calculation_basis || "" : ""; const partnerTypeDetail = compatibilityResult && compatibilityResult.compatibility ? compatibilityResult.compatibility.partner_type_detail || "" : ""; const compatibilityTypeDetail = compatibilityResult && compatibilityResult.compatibility ? compatibilityResult.compatibility.compatibility_type_detail || "" : ""; const strengthsDetail = compatibilityResult && compatibilityResult.compatibility ? compatibilityResult.compatibility.strengths_detail || "" : ""; const cautionsDetail = compatibilityResult && compatibilityResult.compatibility ? compatibilityResult.compatibility.cautions_detail || "" : ""; const partnerDescription = compatibilityResult && compatibilityResult.partner && compatibilityResult.partner.description ? compatibilityResult.partner.description.replace(/あなた/g, "お相手") : ""; const loveCompatibilityInsight = compatibilityResult && compatibilityResult.compatibility ? (() => { const compatibility = compatibilityResult.compatibility; if (compatibility.primary_match) { return { thought: "お相手から見たあなたは、「気負わず話せて波長が合う人」。", approach: "無理にいい感じを演出するより、自然体でテンポを合わせるほうが好印象だよ。", }; } if (compatibility.primary_complement) { return { thought: "お相手から見たあなたは、「自分にない強みを持つ頼れる人」。", approach: "主導権を争うより、役割分担をはっきりさせると関係が進みやすいよ。", }; } if (compatibility.secondary_match) { return { thought: "お相手から見たあなたは、「生活感覚や細かな好みが近い人」。", approach: "約束をきちんと守って、日常の安心感を積み上げると好かれやすいよ。", }; } if (Number(compatibility.score || 0) >= 72) { return { thought: "お相手から見たあなたは、「すぐに合わなくても関係を育てていける人」かもしれないよ。", approach: "お相手のテンポを急かさず、相性の違いを埋める説明をきちんと入れると進みやすいよ。", }; } return { thought: "お相手はまだ距離感を探っている段階。", approach: "自分の気持ちを先に押し出しすぎず、お相手の反応を見ながら接点を増やすのが無難だよ。", }; })() : null; const paidResultPanelStyle = { padding: "14px", border: "1px solid rgba(255,138,61,0.14)", background: "rgba(255,255,255,0.6)", borderRadius: "14px", boxSizing: "border-box", }; const paidBasisPanelStyle = { padding: "14px", border: "1px solid rgba(255,138,61,0.22)", borderLeft: "4px solid var(--gold)", background: "rgba(255,138,61,0.06)", borderRadius: "14px", boxSizing: "border-box", }; const paidSectionDividerStyle = { marginTop: "14px", paddingTop: "14px", borderTop: "1px dashed rgba(255,138,61,0.22)", }; const inputStyle = { width: "100%", padding: "10px 12px", border: "1px solid var(--border)", background: "rgba(255,255,255,0.78)", color: "var(--text)", fontSize: "0.98rem", borderRadius: "14px", boxSizing: "border-box", minWidth: 0, maxWidth: "100%", display: "block", }; return (
{dailyMood && (
{tf("🔮 {date}の相性占い", { date: dailyMood.date_label })}
{dailyMood.partner_name && (
{tf("{name}さんとの今日", { name: dailyMood.partner_name })}
)}
{dailyMood.today_mood}
)}
あなたとお相手の生年月日を使って、二人の六命タイプを比較する相性診断です。
あなた
{!isLoggedIn ? (
setSelfBirthDate(e.target.value)} min="1900-01-01" max="2100-12-31" style={{ ...inputStyle, WebkitAppearance: "none", appearance: "none" }} />
) : !user?.birth_date ? ( <>
マイページで生年月日を登録してください
) : (
生年月日: {user.birth_date}
)}
お相手
setPartnerName(e.target.value)} placeholder="相手の名前(任意)" style={inputStyle} /> setPartnerBirthDate(e.target.value)} min="1900-01-01" max="2100-12-31" style={{ ...inputStyle, WebkitAppearance: "none", appearance: "none" }} /> {savedPartnerAvailable && ( )}
{compatibilityError && (
{t(compatibilityError)}
)} {compatibilityResult && (
相性診断
{compatibilityResult.access_level === "paid" ? "相性の鑑定書(全文)" : compatibilityResult.access_level === "free" ? "無料会員向け診断結果(プレビュー)" : "お試し診断結果(簡易)"}
{/* 全文かどうかはサーバーの access_level が正。表示名はお相手の呼び名で照合する */} {compatibilityResult.access_level === "paid" && findCompatPurchaseLabel(user, compatibilityResult.partner_label || partnerName) && (
{tf("{name}さんの鑑定書をお持ちです", { name: findCompatPurchaseLabel(user, compatibilityResult.partner_label || partnerName), })}
)}
{compatibilityResult.compatibility.compatibility_type && (
{compatibilityResult.compatibility.compatibility_type}
)}
{compatibilityScoreMood.emoji} 相性スコア
{compatibilityResult.compatibility.score} 点
{compatibilityResult.compatibility.headline && (
{compatibilityResult.compatibility.headline}
)}
{compatibilityScoreMood.label} ・ {compatibilityResult.compatibility.level}
{compatibilityResult.compatibility.summary && (
{compatibilityResult.compatibility.summary}
)} {compatibilityResult.compatibility.advice && (
{compatibilityResult.compatibility.advice}
)}
{compatibilityCalculationBasis && (
算定基準
説明
{compatibilityCalculationBasis}
)} {compatibilityResult.partner && (
{partnerTypeEmoji} お相手のタイプ
診断結果
{partnerTypeLabel}
主命タイプ:{" "} {compatibilityResult.partner.primary_label || "未判定"}
副命タイプ:{" "} {compatibilityResult.partner.secondary_label || "未判定"}
内命タイプ:{" "} {compatibilityResult.partner.inner_label || "未判定"}
{partnerTypeDetail && (
{splitJapaneseSentences(partnerTypeDetail).map( (line, idx) => (
{line}
), )}
)}
{partnerDescription}
あなたとお相手との相性タイプ
相性診断結果
{compatibilityTypeLabel}
{compatibilityTypeDetail && (
{splitJapaneseSentences(compatibilityTypeDetail).map( (line, idx) => (
{line}
), )}
)}
)} {compatibilityResult.compatibility.strengths && (
相性の強み
{strengthsDetail && (
{strengthsDetail}
)}
{compatibilityResult.compatibility.strengths.map( (item, idx) => (
・{item}
), )}
注意点
{cautionsDetail && (
{cautionsDetail}
)}
{compatibilityResult.compatibility.cautions.map( (item, idx) => (
・{item}
), )}
)} {(compatibilityResult.access_level === "free" || compatibilityResult.access_level === "anonymous") && (
💫 もっと知りたい、2人のこれから(プレビュー)
{[ [ "恋人になったらどんな関係?", compatibilityResult.compatibility.dating_style_preview, ], [ "結婚したら、どんな家庭に?", compatibilityResult.compatibility .marriage_style_preview, ], [ "すれ違いやすいポイント", compatibilityResult.compatibility .friction_points_preview, ], [ "2人の距離を縮める方法", compatibilityResult.compatibility .closeness_actions_preview, ], [ "2人の関係が変わるかもしれない転機", compatibilityResult.compatibility .relationship_turning_point_preview, ], [ "今、2人の恋を進めるためにできること", compatibilityResult.compatibility .next_step_actions_preview, ], [ "2人に向いているデートプラン", compatibilityResult.compatibility .date_plan_suggestion_preview, ], [ "2人の相性を高めるためにNGな行動", compatibilityResult.compatibility .things_to_avoid_preview, ], [ "1年後の2人はどうなっている?", compatibilityResult.compatibility .one_year_later_preview, ], [ "あの人があなたに一番求めていること", compatibilityResult.compatibility .partner_wants_most_preview, ], [ "この相性を最大限活かす方法", compatibilityResult.compatibility .how_to_maximize_preview, ], [ "2人の相性を象徴する一言", compatibilityResult.compatibility .symbolic_message_preview, ], ].map( ([title, text]) => text && (
{title}
{text}
), )}
)} {compatibilityResult.access_level !== "paid" && (
{compatibilityResult.access_level === "anonymous" ? "会員登録すると、強み・注意点をすべて見れて、プレビューのテーマも増えるよ。" : "「相性の鑑定書」なら、判定理由や詳しい解説まで全部読めるよ。"}
{compatibilityResult.access_level === "anonymous" ? ( <> ) : (
{salesOpen && isLoggedIn ? (() => { const compatPrice = purchaseProductInfo(user?.purchase_products, "compat").price; return compatPrice != null ? tf("この続きは「相性の鑑定書」({price})で読めるよ。お相手1人ごとの買い切りだよ。", { price: formatPurchasePrice(compatPrice), }) : t("この続きは「相性の鑑定書」で読めるよ。お相手1人ごとの買い切りだよ。"); })() : COMPAT_SUSPENDED_CTA}
{salesOpen && isLoggedIn && lastCompatRequestRef.current && ( )}
)}
)} {compatibilityResult.access_level === "paid" && (
参考類似度:{" "} {Math.round( (compatibilityResult.compatibility.cosine_similarity || 0) * 100, )} % · 主命タイプ一致:{" "} {compatibilityResult.compatibility.primary_match ? "あり" : "なし"}{" "} · 副命タイプ一致:{" "} {compatibilityResult.compatibility.secondary_match ? "あり" : "なし"}
)} {compatibilityResult.access_level === "paid" && (
💫 もっと知りたい、2人のこれから
{[ [ "恋人になったらどんな関係?", compatibilityResult.compatibility.dating_style, ], [ "結婚したら、どんな家庭に?", compatibilityResult.compatibility.marriage_style, ], [ "すれ違いやすいポイント", compatibilityResult.compatibility.friction_points, ], [ "2人の距離を縮める方法", compatibilityResult.compatibility.closeness_actions, ], [ "2人の関係が変わるかもしれない転機", compatibilityResult.compatibility .relationship_turning_point, ], [ "今、2人の恋を進めるためにできること", compatibilityResult.compatibility.next_step_actions, ], [ "2人に向いているデートプラン", compatibilityResult.compatibility .date_plan_suggestion, ], [ "2人の相性を高めるためにNGな行動", compatibilityResult.compatibility.things_to_avoid, ], [ "1年後の2人はどうなっている?", compatibilityResult.compatibility.one_year_later, ], [ "あの人があなたに一番求めていること", compatibilityResult.compatibility.partner_wants_most, ], [ "この相性を最大限活かす方法", compatibilityResult.compatibility.how_to_maximize, ], [ "2人の相性を象徴する一言", compatibilityResult.compatibility.symbolic_message, ], ].map( ([title, text]) => text && (
{title}
{text}
), )}
)} {compatibilityResult.access_level === "paid" && (
💕 相性診断で調べた人に対して
相性診断結果と一緒に読む
自分をどう思っているか
{loveCompatibilityInsight && compatibilityResult.compatibility ? (
{loveCompatibilityInsight.thought}
) : (
相性診断を実行すると、ここに「お相手の方が自分をどう思っているか」が出るよ。
)}
お相手に好かれるためには
{loveCompatibilityInsight && compatibilityResult.compatibility ? (
{loveCompatibilityInsight.approach}
) : (
相性診断を実行すると、ここに「お相手の方に好かれるための接し方」が出るよ。
)}
{loveCompatibilityInsight && compatibilityResult.compatibility && (
根拠: {compatibilityTypeLabel} {compatibilityTypeReason ? ` / ${compatibilityTypeReason}` : ""}
)}
)}
)}
setShowPlanCompare(false)} products={user?.purchase_products || null} sales={sales} ownedProducts={{ reading: hasPurchasedProduct(user, "reading") }} onPurchase={(key) => { if (!isLoggedIn) return; if (key === "reading") { setShowPlanCompare(false); setCompatPurchaseProduct("reading"); } else if (key === "compat" && lastCompatRequestRef.current) { setShowPlanCompare(false); setCompatPurchaseProduct("compat"); } }} /> setCompatPurchaseProduct(null)} onPurchased={() => { onRefreshMe && onRefreshMe(); if (compatPurchaseProduct === "compat" && lastCompatRequestRef.current) { runCompatibilityRequest(lastCompatRequestRef.current, false); } }} onSalesStale={() => onRefreshMe && onRefreshMe()} />
); }; // ═══════════════════════════════════════════════════════ // ACCOUNT DELETION MODAL(退会・確認モーダル) // LoginModal(app.jsx:9628〜)のオーバーレイ実装(9943-9971付近)を踏襲。 // ═══════════════════════════════════════════════════════ const AccountDeletionModal = ({ isOpen, onClose, user, onDeleted }) => { const hasPassword = user?.has_password !== false; const [password, setPassword] = React.useState(""); const [codeSent, setCodeSent] = React.useState(false); const [code, setCode] = React.useState(""); const [codeNotice, setCodeNotice] = React.useState(""); const [sendingCode, setSendingCode] = React.useState(false); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(""); React.useEffect(() => { if (isOpen) { setPassword(""); setCode(""); setCodeSent(false); setCodeNotice(""); setError(""); setSendingCode(false); setLoading(false); } }, [isOpen]); if (!isOpen) return null; const canSubmit = hasPassword ? password.trim().length > 0 : codeSent && code.trim().length > 0; const inputSt = { width: "100%", padding: "11px 14px", background: "rgba(255,255,255,0.78)", border: "1px solid var(--border)", color: "var(--text)", fontSize: "0.85rem", fontFamily: "var(--serif-jp)", outline: "none", borderRadius: "14px", boxSizing: "border-box", colorScheme: "dark", }; const handleSendCode = async () => { if (sendingCode) return; setSendingCode(true); setError(""); try { const res = await postWithCsrf( `./api/auth.php?action=request_account_deletion_code&lang=${LANG}`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }, ); const raw = await res.text(); let data = null; try { data = JSON.parse(raw); } catch { data = { error: raw || `HTTP ${res.status}` }; } if (data.success) { setCodeSent(true); setCodeNotice(data.message || "確認コードをメールで送信しました"); } else { setError(data.error || "確認コードの送信に失敗しました"); } } catch (err) { setError(t("接続エラー: ") + (err?.message || "unknown")); } finally { setSendingCode(false); } }; const handleDelete = async () => { if (!canSubmit || loading) return; setLoading(true); setError(""); try { const payload = hasPassword ? { password } : { code: code.trim() }; const res = await postWithCsrf( `./api/auth.php?action=delete_account&lang=${LANG}`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }, ); const raw = await res.text(); let data = null; try { data = JSON.parse(raw); } catch { data = { error: raw || `HTTP ${res.status}` }; } if (data.success) { onDeleted(); } else { setError(data.error || "退会に失敗しました"); } } catch (err) { setError(t("接続エラー: ") + (err?.message || "unknown")); } finally { setLoading(false); } }; return (
e.stopPropagation()} style={{ background: "linear-gradient(160deg,#FFFFFF,#FFF6EC)", border: "1px solid rgba(224,96,96,0.35)", borderRadius: "14px", padding: "32px 28px", width: "100%", maxWidth: "420px", maxHeight: "calc(100vh - 40px)", overflowY: "auto", boxShadow: "0 20px 60px rgba(61,52,40,0.25)", position: "relative", }} > {/* Close */}
本当に退会しますか?

退会すると、以下のデータがすべて削除され、二度と元に戻すことはできません。

  • 診断結果の履歴
  • 登録した出来事
  • お相手の情報
  • 共有リンク(開けなくなります)
  • 購入した鑑定書の利用権

購入記録は会計上の理由で保管されます(お相手の情報は削除されます)。

この操作は取り消せません。

{hasPassword ? (
setPassword(e.target.value)} placeholder="パスワード" autoComplete="current-password" style={inputSt} />
) : (
{!codeSent ? ( <>

パスワードを設定していないため、本人確認の確認コードをメールで送信します。

) : ( <>

{codeNotice}
メールに届いた確認コードを入力してください。

setCode(e.target.value)} placeholder="確認コード" style={{ ...inputSt, marginBottom: "10px" }} /> )}
)} {error && (
{error}
)}
); }; const MyPageScreen = ({ user, userStatus = "無料", onBack, onLogout, onUserUpdated, onUseEventDate, sales = null, onRefreshMe = null, onCompatibilityClick = null, }) => { const [username, setUsername] = React.useState(user?.username || ""); const salesOpen = isSalesOpen(sales); const [myPagePurchaseProduct, setMyPagePurchaseProduct] = React.useState(null); const [email, setEmail] = React.useState(user?.email || ""); const [birthDate, setBirthDate] = React.useState(user?.birth_date || ""); const [partnerName, setPartnerName] = React.useState( user?.partner_name || "", ); const [partnerBirthDate, setPartnerBirthDate] = React.useState( user?.partner_birth_date || "", ); const [showPlanCompare, setShowPlanCompare] = React.useState(false); const [savedPartnerInfo, setSavedPartnerInfo] = React.useState({ name: user?.partner_name || "", birthDate: user?.partner_birth_date || "", }); const [profileMessage, setProfileMessage] = React.useState(""); const [partnerMessage, setPartnerMessage] = React.useState(""); const [currentPassword, setCurrentPassword] = React.useState(""); const [newPassword, setNewPassword] = React.useState(""); const [passwordMessage, setPasswordMessage] = React.useState(""); const [eventDate, setEventDate] = React.useState(""); const [eventType, setEventType] = React.useState(""); const [eventMemo, setEventMemo] = React.useState(""); const [eventMessage, setEventMessage] = React.useState(""); const [events, setEvents] = React.useState([]); const [editingEventId, setEditingEventId] = React.useState(null); const [editEventDate, setEditEventDate] = React.useState(""); const [editEventType, setEditEventType] = React.useState(""); const [editEventMemo, setEditEventMemo] = React.useState(""); // 「自分の鑑定書」を持っているか(購入 reading、または既存の手動付与 有料会員)。 // 出来事の登録・通知の詳細設定はこの判定で開く(相手情報の編集はログインのみで開放)。 const isManualPaidMember = userStatus === "有料会員"; const isPaidMember = isManualPaidMember || hasPurchasedProduct(user, "reading"); const purchases = userPurchases(user); const compatPurchaseCount = purchases.filter((p) => p.product === "compat").length; const [notificationPreference, setNotificationPreference] = React.useState( user?.notification_preference || "off", ); const [notificationSaving, setNotificationSaving] = React.useState(false); const [notificationMessage, setNotificationMessage] = React.useState(""); const [showDeleteModal, setShowDeleteModal] = React.useState(false); // 相性の全文はお相手ごとにサーバー(access_level)で判定する。 // ここはマイページの表示用:手動付与(相手を問わず全文)か、購入済みの相性の鑑定書があるか。 const isCompatibilityManualPaid = user?.membership_status === "有料会員" || user?.compatibility_status === "有料会員"; const isCompatibilityPaid = isCompatibilityManualPaid || compatPurchaseCount > 0; const isCompatibilityStandalonePaid = user?.membership_status !== "有料会員" && user?.compatibility_status === "有料会員"; React.useEffect(() => { setNotificationPreference(user?.notification_preference || "off"); }, [user?.notification_preference]); const saveNotificationPreference = async (nextValue) => { setNotificationPreference(nextValue); setNotificationSaving(true); setNotificationMessage(""); try { const fd = new FormData(); fd.append("action", "update_notification_preference"); fd.append("notification_preference", nextValue); const res = await postWithCsrf("./api/auth.php", { method: "POST", credentials: "include", body: fd, }); const data = await res.json(); if (data.success) { setNotificationMessage("通知設定を保存しました"); onUserUpdated && onUserUpdated({ ...user, notification_preference: data.notification_preference }); } else { setNotificationMessage(data.error || "保存に失敗しました"); } } catch { setNotificationMessage("通信エラーで保存に失敗しました"); } finally { setNotificationSaving(false); } }; const toggleGranularNotification = (key) => { const current = notificationPreference && notificationPreference !== "off" ? notificationPreference.split(",").filter(Boolean) : []; const next = current.includes(key) ? current.filter((item) => item !== key) : [...current, key]; saveNotificationPreference(next.length ? next.join(",") : "off"); }; React.useEffect(() => { setUsername(user?.username || ""); setEmail(user?.email || ""); setBirthDate(user?.birth_date || ""); setPartnerName(user?.partner_name || ""); setPartnerBirthDate(user?.partner_birth_date || ""); setSavedPartnerInfo({ name: user?.partner_name || "", birthDate: user?.partner_birth_date || "", }); }, [user]); const displayedPartnerName = savedPartnerInfo.name || user?.partner_name || ""; const displayedPartnerBirthDate = savedPartnerInfo.birthDate || user?.partner_birth_date || ""; const savedPartnerSummary = [ displayedPartnerName ? `名前: ${displayedPartnerName}` : null, displayedPartnerBirthDate ? `生年月日: ${displayedPartnerBirthDate}` : null, ] .filter(Boolean) .join(" / "); const boxInput = { width: "100%", marginBottom: "8px", background: "rgba(255,255,255,0.78)", color: "var(--text)", border: "1px solid var(--border)", padding: "8px", boxSizing: "border-box", display: "block", maxWidth: "100%", }; const loadEvents = async () => { try { const res = await fetch( "./api/diagnoses.php?action=get_user_events&limit=50", { credentials: "include" }, ); const data = await res.json(); if (data.success && Array.isArray(data.events)) { setEvents(data.events); } } catch {} }; React.useEffect(() => { loadEvents(); }, []); const deleteEvent = async (eventId) => { try { const fd = new FormData(); fd.append("event_id", String(eventId)); const res = await postWithCsrf("./api/diagnoses.php?action=delete_user_event", { method: "POST", credentials: "include", body: fd, }); const data = await res.json(); if (data.success) { setEventMessage("イベントを削除しました"); loadEvents(); } else { setEventMessage(data.error || "削除に失敗しました"); } } catch { setEventMessage("通信エラーで削除に失敗しました"); } }; const startEditEvent = (ev) => { setEditingEventId(ev.id); setEditEventDate(ev.event_date || ""); setEditEventType(ev.event_type || ""); setEditEventMemo(ev.memo || ""); }; const cancelEditEvent = () => { setEditingEventId(null); setEditEventDate(""); setEditEventType(""); setEditEventMemo(""); }; const updateEvent = async () => { if (!editingEventId || !editEventDate || !editEventType) { setEventMessage("更新には日付とイベント種別が必要です"); return; } try { const fd = new FormData(); fd.append("event_id", String(editingEventId)); fd.append("event_date", editEventDate); fd.append("event_type", editEventType); fd.append("memo", editEventMemo); const res = await postWithCsrf("./api/diagnoses.php?action=update_user_event", { method: "POST", credentials: "include", body: fd, }); const data = await res.json(); if (data.success) { setEventMessage("イベントを更新しました"); cancelEditEvent(); loadEvents(); } else { setEventMessage(data.error || "更新に失敗しました"); } } catch { setEventMessage("通信エラーで更新に失敗しました"); } }; const updateProfile = async () => { if (!username || !email || !birthDate) { setProfileMessage("ユーザー名・メール・生年月日は必須です"); return; } if (!isSupportedBirthDate(birthDate)) { setProfileMessage("生年月日は1900年1月1日から2100年12月31日の間で入力してください"); return; } if (partnerBirthDate && !isSupportedBirthDate(partnerBirthDate)) { setProfileMessage("相手の生年月日は1900年1月1日から2100年12月31日の間で入力してください"); return; } try { const fd = new FormData(); fd.append("action", "update_profile"); fd.append("lang", LANG); fd.append("username", username); fd.append("email", email); fd.append("birth_date", birthDate); if (partnerName) { fd.append("partner_name", partnerName); } if (partnerBirthDate) { fd.append("partner_birth_date", partnerBirthDate); } const res = await postWithCsrf("./api/auth.php", { method: "POST", credentials: "include", body: fd, }); const data = await res.json(); if (data.success) { setProfileMessage(data.message || "プロフィールを更新しました"); onUserUpdated && onUserUpdated({ ...user, ...data.user }); } else { setProfileMessage(data.error || "更新に失敗しました"); } } catch { setProfileMessage("通信エラーで更新に失敗しました"); } }; const savePartnerInfo = async () => { if (partnerBirthDate && !isSupportedBirthDate(partnerBirthDate)) { setPartnerMessage("相手の生年月日は1900年1月1日から2100年12月31日の間で入力してください"); return; } try { const fd = new FormData(); fd.append("action", "update_partner_info"); fd.append("partner_name", partnerName); fd.append("partner_birth_date", partnerBirthDate); const res = await postWithCsrf("./api/auth.php", { method: "POST", credentials: "include", body: fd, }); const data = await res.json(); if (data.success) { setPartnerMessage(data.message || "相手情報を保存しました"); setSavedPartnerInfo({ name: data.partner && data.partner.partner_name ? data.partner.partner_name : partnerName, birthDate: data.partner && data.partner.partner_birth_date ? data.partner.partner_birth_date : partnerBirthDate, }); if (data.partner) { const nextUser = { ...user, partner_name: data.partner.partner_name || null, partner_birth_date: data.partner.partner_birth_date || null, }; onUserUpdated && onUserUpdated(nextUser); } } else { setPartnerMessage(data.error || "相手情報の保存に失敗しました"); } } catch { setPartnerMessage("通信エラーで相手情報の保存に失敗しました"); } }; const updatePassword = async () => { if (!currentPassword || !newPassword) { setPasswordMessage( "現在のパスワードと新しいパスワードを入力してください", ); return; } try { const fd = new FormData(); fd.append("action", "change_password"); fd.append("lang", LANG); fd.append("current_password", currentPassword); fd.append("new_password", newPassword); const res = await postWithCsrf("./api/auth.php", { method: "POST", credentials: "include", body: fd, }); const data = await res.json(); if (data.success) { setPasswordMessage("パスワードを変更しました"); setCurrentPassword(""); setNewPassword(""); } else { setPasswordMessage(data.error || "変更に失敗しました"); } } catch { setPasswordMessage("通信エラーで変更に失敗しました"); } }; const saveEvent = async () => { if (!eventDate || !eventType) { setEventMessage("日付とイベント種別を入力してください"); return; } try { const res = await postWithCsrf("./api/diagnoses.php?action=save_user_event", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ event_date: eventDate, event_type: eventType, memo: eventMemo, }), }); const data = await res.json(); setEventMessage( data.success ? "イベントを保存しました" : data.error || "保存に失敗しました", ); if (data.success) { setEventDate(""); setEventType(""); setEventMemo(""); loadEvents(); } } catch { setEventMessage("通信エラーで保存に失敗しました"); } }; const handleAccountDeleted = () => { setShowDeleteModal(false); window.alert(t("退会が完了しました。ご利用ありがとうございました。")); onLogout(); }; return (
マイページ
{/* G2a: 招待への回答(ログイン中は自分の招待、同じブラウザで作った招待も含む) */} {/* 会員種別とお持ちの鑑定書 */}
会員種別
現在の会員種別: {isManualPaidMember ? "有料会員" : isPaidMember ? "鑑定書あり" : "無料会員"}
{purchases.length > 0 && (
お持ちの鑑定書
{purchases.map((p, i) => (
{t(PURCHASE_PRODUCT_NAMES[p.product] || p.product)}
{[ p.target_year ? tf("{year}年分", { year: p.target_year }) : "", p.product === "compat" && p.partner_label ? tf("お相手:{name}", { name: p.partner_label }) : "", formatPurchaseDate(p.purchased_at) ? tf("購入日:{date}", { date: formatPurchaseDate(p.purchased_at) }) : "", ] .filter(Boolean) .join(LANG === "ja" ? " " : " · ")}
))}
)} {!isPaidMember && (
{salesOpen ? t("「自分の鑑定書」で、恋愛・仕事・人間関係・家族・お金のことと、今年の運気カレンダーが読めるよ。買い切りで、月額の料金はかからないよ。") : "有料の鑑定書は、いま準備中だよ。無料の診断はこれまでどおり使えるよ。"}
)}
{/* 相性の鑑定書 */}
相性の鑑定書
現在の状態: {user?.membership_status === "有料会員" ? "六命診断有料会員特典" : isCompatibilityStandalonePaid ? "有料会員" : compatPurchaseCount > 0 ? tf("{count}人分の鑑定書あり", { count: compatPurchaseCount }) : "未購入"}
{user?.membership_status === "有料会員" && (
六命診断の有料会員特典として無料でご利用いただけます
)} {!isCompatibilityPaid && (
{salesOpen ? t("相性の鑑定書は、お相手1人ごとの買い切りだよ。相性診断の画面でお相手を入力すると購入できるよ。") : "相性の鑑定書(お相手1人ごとの買い切り)は、いま準備中だよ。"}
)}
通知設定
{isPaidMember ? ( <>
自分の鑑定書をお持ちの方は通知内容を細かく選べます(配信は準備中です)
{[ { key: "daily", label: "毎日の通知" }, { key: "weekly", label: "週次レポート" }, { key: "monthly", label: "月初レポート" }, { key: "caution", label: "注意日だけ通知" }, { key: "opportunity", label: "好機だけ通知" }, ].map((option) => ( ))}
) : ( <>
{isCompatibilityPaid ? "購入機能に関する通知のみ届きます(配信は準備中です)" : "お知らせ通知のON/OFFを設定できます(配信は準備中です)"}
)} {notificationMessage && (
{notificationMessage}
)}
プロフィール編集
setUsername(e.target.value)} style={boxInput} /> setEmail(e.target.value)} style={boxInput} /> setBirthDate(e.target.value)} style={boxInput} min="1900-01-01" max="2100-12-31" /> {t(profileMessage)} {/* 相手情報の編集は相性機能の一部なので、ログインしていれば誰でも使える(計画 pricing_redesign 2-4) */} {user && (
相性診断の相手情報
setPartnerName(e.target.value)} style={boxInput} placeholder="相手の名前" /> setPartnerBirthDate(e.target.value)} style={boxInput} min="1900-01-01" max="2100-12-31" /> {t(partnerMessage)} {savedPartnerSummary && (
保存済み: {savedPartnerSummary}
)}
)}
{user?.has_password !== false && (
パスワード変更
setCurrentPassword(e.target.value)} style={boxInput} /> setNewPassword(e.target.value)} style={boxInput} /> {passwordMessage}
)} {isPaidMember && (
タイムマシン用イベント登録
setEventDate(e.target.value)} style={boxInput} /> setEventType(e.target.value)} style={boxInput} />