CODE · 24.1 KB
src/lp-model.js
Workspace snapshot · 08/31 23:51
export const stages = [
{ id: "hearing", no: "01", label: "ヒアリング" },
{ id: "structure", no: "02", label: "構成" },
{ id: "wireframe", no: "03", label: "WF" },
{ id: "tone", no: "04", label: "トンマナ" },
{ id: "implementation", no: "05", label: "実装" },
];
export const initialProject = {
name: "新規BtoB LP",
sourceName: "資料はまだありません",
sourceText: "",
sourceSummary: "",
sourceFilled: [],
sourceNeedsConfirmation: [],
answers: {},
selectedTone: null,
};
const option = (id, title, note) => ({ id, title, note });
export const interviewQuestions = [
{
id: "goal",
invalidates: ["traffic", "conversionSettings", "launchSettings"],
eyebrow: "01 / ゴール",
title: "LPで増やしたい行動は?",
options: [
option("download", "資料ダウンロード", "見込み顧客を獲得"),
option("inquiry", "問い合わせ", "相談の入口をつくる"),
option("demo", "デモ・相談予約", "商談につなげる"),
option("trial", "無料トライアル", "利用を開始してもらう"),
option("purchase", "申込・購入", "ページ内で成約"),
option("event", "イベント申込", "セミナーや説明会へ誘導"),
],
},
{
id: "service",
eyebrow: "02 / 商材",
title: "何を、誰に届けますか?",
type: "text",
placeholder:
"例:制作会社の進行責任者向けに、会議から担当者と期限を整理するSaaS",
},
{
id: "category",
invalidates: ["industry", "proof", "offerFacts", "complianceSettings"],
eyebrow: "03 / 種類",
title: "商材に近いものは?",
options: [
option("saas", "SaaS・IT", "業務システム、アプリ"),
option("service", "法人向け支援", "コンサル、代行、制作"),
option("product", "法人向け製品", "設備、機器、業務用品"),
option("education", "研修・教育", "講座、育成、資格"),
option("recruitment", "採用", "求人、採用広報"),
option("event", "イベント", "展示会、セミナー"),
option("ec", "EC・通販", "物販、定期購入"),
option("other", "その他", "選択後に補足可能"),
],
},
{
id: "industry",
eyebrow: "04 / 顧客",
title: "主な顧客の業界は?",
options: [
option("web", "Web・広告・制作", "制作会社、代理店"),
option("it", "IT・情報通信", "SaaS、開発会社"),
option("manufacturing", "製造・建設", "メーカー、現場企業"),
option("professional", "士業・コンサル", "専門サービス"),
option("medical", "医療・介護", "法人、施設"),
option("retail", "小売・店舗", "店舗、EC、卸"),
option("education", "教育・人材", "学校、研修、人材"),
option("general", "業界横断", "複数業界を対象"),
],
},
{
id: "role",
eyebrow: "05 / 読み手",
title: "最初に読む人は?",
options: [
option("owner", "経営者", "最終判断をする"),
option("executive", "役員・事業責任者", "投資対効果を判断"),
option("manager", "部門責任者", "比較・導入を推進"),
option("operator", "現場担当者", "課題を強く感じる"),
option("technical", "技術・管理部門", "安全性や運用を確認"),
option("mixed", "担当者から決裁者へ", "社内共有して稟議"),
],
},
{
id: "companySize",
eyebrow: "06 / 規模",
title: "想定する会社規模は?",
options: [
option("micro", "1〜20名", "意思決定が速い"),
option("small", "21〜100名", "部門単位で導入"),
option("mid", "101〜500名", "稟議が必要"),
option("large", "501〜2,000名", "複数部門で検討"),
option("enterprise", "2,001名以上", "全社基準で判断"),
],
},
{
id: "problem",
eyebrow: "07 / 課題",
title: "顧客が困っていることは?",
type: "text",
optional: true,
placeholder:
"顧客が実際に口にする言葉を1〜3個。資料にあれば自動入力済みです",
},
{
id: "differentiator",
eyebrow: "08 / 選ばれる理由",
title: "他の方法ではなく、これを選ぶ理由は?",
type: "text",
optional: true,
placeholder:
"独自機能、専門性、速さ、価格、実績など。未定ならスキップできます",
},
{
id: "traffic",
eyebrow: "09 / 流入",
title: "どこから読みに来ますか?",
multiple: true,
options: [
option("search", "Google検索", "課題・比較検索"),
option("ads", "Web広告", "広告訴求から流入"),
option("social", "SNS・動画", "投稿から流入"),
option("sales", "営業・展示会", "商談前後に共有"),
option("partner", "紹介・代理店", "信頼経路から流入"),
option("email", "メール・既存顧客", "リストへ案内"),
],
},
{
id: "proof",
eyebrow: "10 / 根拠",
title: "使える証拠は?",
multiple: true,
options: [
option("numbers", "実績数値", "導入数、継続率"),
option("cases", "導入事例", "課題、施策、成果"),
option("voices", "顧客の声", "実名、役職、原文"),
option("screen", "画面・製品写真", "仕組みを具体化"),
option("process", "工程・体制", "進め方を明示"),
option("security", "資格・安全性", "認証、規格、権限"),
option("none", "まだない", "試用や透明性で補う"),
],
},
{
id: "price",
eyebrow: "11 / 価格",
title: "価格の見せ方は?",
options: [
option("fixed", "定額を掲載", "具体価格を見せる"),
option("from", "最低価格から", "〜円からと表示"),
option("plans", "複数プラン", "比較表で選ばせる"),
option("estimate", "個別見積もり", "条件を説明"),
option("free", "無料・無料体験", "開始障壁を下げる"),
option("hidden", "まだ非公開", "価格以外で判断を進める"),
],
},
{
id: "offerFacts",
eyebrow: "12 / オファーと証拠",
title: "掲載できる事実を教えてください",
type: "fields",
optional: true,
fields: [
{ id: "priceAmount", label: "価格の実数値", placeholder: "例:月額9,800円(税込)" },
{ id: "evidenceDetails", label: "実績・事例・顧客の声と出典", placeholder: "確認できる事実だけを入力" },
],
},
{
id: "conversionSettings",
eyebrow: "13 / コンバージョン",
title: "送信後まで設計します",
type: "fields",
when: (answers) => ["download", "inquiry", "demo", "trial", "event"].includes(answers.goal),
fields: [
{ id: "formFields", label: "フォーム項目", placeholder: "会社名, お名前, メールアドレス, 相談内容" },
{ id: "formEndpoint", label: "送信先URL", placeholder: "https://...(未定なら空欄)" },
{ id: "formSuccess", label: "送信後の案内", placeholder: "受付完了後に何が起きるか" },
{ id: "privacyUrl", label: "個人情報の取扱いURL", placeholder: "https://..." },
],
},
{
id: "brandSettings",
eyebrow: "14 / 正式表記とブランド",
title: "公開時に間違えられない情報は?",
type: "fields",
optional: true,
fields: [
{ id: "formalCompanyName", label: "正式な会社名", placeholder: "株式会社を含む正式表記" },
{ id: "formalServiceName", label: "正式なサービス名", placeholder: "商標・大文字小文字を含む" },
{ id: "brandAssets", label: "ロゴ・色・書体・写真の指定", placeholder: "URLや利用条件も記載" },
],
},
{
id: "complianceSettings",
eyebrow: "15 / 法務と対象外",
title: "言ってはいけないことを先に決めます",
type: "fields",
optional: true,
fields: [
{ id: "compliance", label: "業種規制・確認事項", placeholder: "景表法、薬機法、特商法など" },
{ id: "forbiddenClaims", label: "NGワード・禁止表現", placeholder: "保証できない効果など" },
{ id: "excludedAudience", label: "対象外の顧客", placeholder: "向いていない条件" },
],
},
{
id: "launchSettings",
eyebrow: "16 / 競合・公開・計測",
title: "公開後に判断できる状態へ",
type: "fields",
optional: true,
fields: [
{ id: "competition", label: "競合・既存LP URL", placeholder: "1行に1URL" },
{ id: "canonicalUrl", label: "公開予定URL", placeholder: "https://..." },
{ id: "ga4Id", label: "GA4測定ID", placeholder: "G-XXXXXXXXXX" },
{ id: "conversionDefinition", label: "CV定義", placeholder: "例:フォーム送信完了" },
],
},
{
id: "toneIntent",
eyebrow: "17 / 印象",
title: "最初に感じてほしい印象は?",
options: [
option("credible", "信頼できる", "証拠、秩序、精度"),
option("advanced", "新しい", "意外性、先進性"),
option("clear", "分かりやすい", "図解、比較、具体語"),
option("warm", "相談しやすい", "人物、言葉、温度"),
option("premium", "上質・専門的", "品格、余白、質感"),
option("bold", "強く記憶に残る", "大胆な文字と構図"),
],
},
];
const adaptiveOptions = {
industry: {
saas: [
option("marketing", "マーケ・営業", "獲得、商談、CRM、分析"),
option("hr", "人事・採用", "採用、労務、育成"),
option("backoffice", "管理・バックオフィス", "経理、法務、総務"),
option("production", "制作・開発", "Web、広告、システム開発"),
],
service: [
option("web", "Web・広告・制作", "制作会社、代理店、事業会社"),
option("professional", "士業・コンサル", "専門サービス企業"),
option("hr", "採用・人材", "人材紹介、研修、組織支援"),
option("general", "業界を限定しない", "複数業界を対象"),
],
product: [
option("manufacturing", "製造・建設", "設備、資材、現場向け製品"),
option("medical", "医療・介護", "施設、医療法人、ケア現場"),
option("retail", "小売・店舗", "チェーン、EC、卸"),
option("office", "オフィス・業務用品", "法人の業務環境"),
],
education: [
option("hr", "企業人事・育成", "研修、リスキリング"),
option("it", "IT・DX", "技術・デジタル教育"),
option("medical", "医療・介護", "資格・専門教育"),
option("manufacturing", "製造・現場", "安全・技能教育"),
],
},
traffic: {
download: [
option("search", "Google検索", "課題・比較検索"),
option("ads", "Web広告", "広告訴求から流入"),
option("sales", "営業・展示会", "商談前後に共有"),
option("social", "SNS・動画", "発信から理解を深める"),
],
demo: [
option("sales", "営業・紹介", "担当者から案内"),
option("search", "Google検索", "具体的な解決策を探索"),
option("ads", "Web広告", "課題訴求から相談"),
option("partner", "代理店・パートナー", "第三者の推奨から流入"),
],
trial: [
option("search", "Google検索", "今すぐ試せる手段を探索"),
option("comparison", "比較サイト・レビュー", "候補を比較して流入"),
option("social", "SNS・動画", "利用イメージから流入"),
option("product", "別機能・無料ツール", "プロダクト内から送客"),
],
purchase: [
option("search", "Google検索", "購入候補を探索"),
option("ads", "Web広告", "商品訴求から流入"),
option("comparison", "比較・口コミ", "条件を比較して流入"),
option("partner", "紹介・販売店", "信頼経路から流入"),
],
},
proof: {
saas: [
option("screen", "画面・操作動画", "仕組みと使いやすさ"),
option("numbers", "導入・継続数値", "利用実績と変化"),
option("cases", "導入事例", "課題・導入・成果"),
option("security", "安全性・連携仕様", "権限、認証、データ管理"),
option("none", "まだない", "試用・透明性で補う"),
],
service: [
option("cases", "支援事例", "背景・施策・成果"),
option("voices", "顧客の声", "担当者の実感"),
option("process", "支援プロセス", "進め方と成果物"),
option("expert", "担当者・専門性", "体制、経験、資格"),
option("none", "まだない", "サンプルと透明性で補う"),
],
},
};
function adaptQuestion(question, answers) {
const mergeOptions = (specific) =>
[
...specific,
...question.options.filter(
(base) => !specific.some((item) => item.id === base.id),
),
].slice(0, 8);
if (question.id === "industry" && adaptiveOptions.industry[answers.category])
return {
...question,
options: mergeOptions(adaptiveOptions.industry[answers.category]),
};
if (question.id === "traffic" && adaptiveOptions.traffic[answers.goal])
return {
...question,
options: mergeOptions(adaptiveOptions.traffic[answers.goal]),
};
if (question.id === "proof" && adaptiveOptions.proof[answers.category])
return {
...question,
options: mergeOptions(adaptiveOptions.proof[answers.category]),
};
if (question.id === "service" && answers.goal)
return {
...question,
title: `${labelsForGoal[answers.goal] || "成果"}につなげたい商材は、何を提供しますか?`,
};
return question;
}
const labelsForGoal = {
download: "資料ダウンロード",
inquiry: "問い合わせ",
demo: "相談・デモ",
trial: "無料トライアル",
purchase: "申込・購入",
event: "イベント申込",
};
export function getActiveQuestions(answers) {
return interviewQuestions
.filter((q) => !q.when || q.when(answers))
.map((question) => adaptQuestion(question, answers));
}
export function getNextQuestion(answers) {
const skipped = Array.isArray(answers._skipped) ? answers._skipped : [];
const active = getActiveQuestions(answers);
const review = active.find((question) => question.id === answers._cursor);
if (review) return review;
return active.find((q) => answers[q.id] == null && !skipped.includes(q.id)) || null;
}
export function answerQuestion(answers, question, value) {
const invalidated = question.invalidates || [];
const next = { ...answers };
delete next._cursor;
for (const id of invalidated) delete next[id];
next._skipped = (Array.isArray(next._skipped) ? next._skipped : []).filter((id) => id !== question.id && !invalidated.includes(id));
if (value == null) return { ...next, _skipped: [...next._skipped, question.id] };
if (!question.multiple) return { ...next, [question.id]: value };
const current = Array.isArray(next[question.id])
? next[question.id]
: [];
return {
...next,
[question.id]: current.includes(value)
? current.filter((item) => item !== value)
: [...current, value],
};
}
export function rewindAnswers(answers) {
const active = getActiveQuestions(answers);
const current = getNextQuestion(answers);
const currentIndex = current
? active.findIndex((question) => question.id === current.id)
: active.length;
const candidates = active
.slice(0, currentIndex < 0 ? active.length : currentIndex)
.filter((question) => answers[question.id] != null || (answers._skipped || []).includes(question.id));
const target = candidates.at(-1);
if (!target) return answers;
return { ...answers, _cursor: target.id };
}
export const toneDirections = [
{
id: "signal",
code: "A",
name: "SIGNAL / 精密な信頼",
thesis: "複雑な判断を、鮮明な秩序へ。",
palette: ["#0a0a0b", "#23d18b", "#f3f4f6"],
type: "グロテスク × 明朝",
visual: "黒い情報面に判断のシグナルが走る、精密な生成コラージュ",
avoid: "青一色のSaaSテンプレート",
},
{
id: "momentum",
code: "B",
name: "MOMENTUM / 動く組織",
thesis: "止まっていた仕事が、ひと息で動き出す。",
palette: ["#ff4f00", "#2447ff", "#ffd928"],
type: "極太ゴシック × 可変欧文",
visual: "人物・紙・UI断片が前方へ展開する、高彩度の生成KV",
avoid: "意味のない未来的グラデーション",
},
{
id: "human",
code: "C",
name: "HUMAN PROOF / 人から始まる",
thesis: "仕組みの先にいる、人を見せる。",
palette: ["#3d1cff", "#ff76a8", "#ffffff"],
type: "端正なゴシック × セリフ",
visual: "人物像と大胆な色面を組み合わせた、編集的コラージュ",
avoid: "親しみだけで弱く見える表現",
},
];
export const initialSections = [
{
id: "hero",
type: "hero",
label: "FIRST VIEW",
role: "3秒で対象・価値・行動を一致",
title: "進捗確認を、仕事にしない。",
body: "散らばる会話から、次に動く人と期限を整える。",
proof: "14日間のパイロット",
cta: "無料で試す",
},
{
id: "problem",
type: "problem",
label: "PROBLEM",
role: "現場と決裁者の痛みを自分事化",
title: "チームが遅いのではない。確認の往復が多すぎる。",
body: "今どこ、次は誰、いつまで。小さな確認が集中を切り続けます。",
proof: "現状計測から開始",
cta: "自社の往復を診断",
},
{
id: "insight",
type: "mechanism",
label: "INSIGHT",
role: "問題の見方を転換",
title: "必要なのは、管理を増やすことではなく、決定を残すこと。",
body: "会話を管理表へ写すのではなく、決定・担当・期限だけを仕事の流れへ戻します。",
proof: "会話→決定→次の一手",
cta: "仕組みを見る",
},
{
id: "proof",
type: "proof",
label: "PROOF",
role: "約束を証拠へ変換",
title: "まず1案件。変わった時間だけを測る。",
body: "会議時間、確認メッセージ、期限超過を導入前後で比較します。",
proof: "根拠なしの効果表現 0",
cta: "計測項目を見る",
},
{
id: "fit",
type: "fit",
label: "FIT",
role: "対象・非対象を明確化",
title: "10〜50名。制作と判断が同時に走るチームへ。",
body: "複数案件と複数職種をまたぎ、責任者に確認が集中する組織に向いています。",
proof: "1案件から導入",
cta: "対象条件を確認",
},
{
id: "final",
type: "cta",
label: "ACTION",
role: "不安を解き次行動へ",
title: "次の会議を、最後の進捗確認会に。",
body: "15分だけ現状を共有してください。合わなければ、その場で終わります。",
proof: "強引な営業なし",
cta: "15分の相談を予約",
},
];
export const initialMarkdown = `# MinuteFlow LP構成\n\n## 01. FIRST VIEW\n**目的:** 3秒で対象・価値・行動を一致させる\n- 主張: 進捗確認を、仕事にしない。\n- 根拠: 14日間のパイロットで実測\n- CTA: 無料で試す\n\n## 02. PROBLEM\n**目的:** 現場と決裁者の痛みを自分事化する\n- 確認の往復で集中が切れる\n- 責任者に判断が集中する\n\n## 03. INSIGHT\n**目的:** 「管理を増やす」から「決定を残す」へ見方を変える\n\n## 04. PROOF\n**目的:** 未検証の約束ではなく、導入前後の比較を示す\n\n## 05. FIT\n**目的:** 対象・非対象を明示し、自社適合を判断可能にする\n\n## 06. ACTION\n**目的:** 残る不安を解き、15分相談へつなぐ`;
export function sectionsToMarkdown(sections) {
return `# BtoB LP構成\n\n${sections.map((section, index) => `## ${String(index + 1).padStart(2, "0")}. ${section.label}\n**役割:** ${section.role || "読者の判断を前へ進める"}\n- 種別: ${section.type || "content"}\n- 読者の疑問: ${section.userQuestion || "ここで何を判断するか"}\n- 主張: ${section.title}\n- 本文: ${section.body}\n- 根拠: ${section.proof || "要確認"}\n- 具体コンテンツ: ${section.content || "要設計"}\n- 必要素材: ${section.asset || "要確認"}\n- 実装条件: ${section.implementation || "PC/SPで情報優先順位を維持"}\n- レイアウト型: ${section.layoutVariant || "内容に応じて自動選択"}\n- レイアウト: ${section.layout || "内容に応じて設計"}\n- CTA: ${section.cta || "なし"}${section.imageBrief ? `\n- KV画像生成: ${[section.imageBrief.scene, section.imageBrief.subject, section.imageBrief.style, section.imageBrief.composition, section.imageBrief.constraints].filter(Boolean).join(" / ")}` : ""}`).join("\n\n")}`;
}
export function markdownToSections(markdown, fallback = initialSections) {
const normalized = String(markdown || "").replace(
/^[\s\S]*?(?=^##\s+)/mu,
"",
);
const blocks = normalized
.split(/\n(?=##\s+)/u)
.filter((block) => /^##\s+/u.test(block.trim()));
if (!blocks.length) return fallback;
return blocks.map((block, index) => {
const section = fallback[index] || {
id: `section-${index + 1}`,
label: `SECTION ${index + 1}`,
};
const heading = block.match(/^##\s+\d+\.\s+(.+)$/mu)?.[1]?.trim();
const title = block.match(/-\s*主張:\s*(.+)$/mu)?.[1]?.trim();
const body = block.match(/-\s*本文:\s*(.+)$/mu)?.[1]?.trim();
const proof = block.match(/-\s*根拠:\s*(.+)$/mu)?.[1]?.trim();
const cta = block.match(/-\s*CTA:\s*(.+)$/mu)?.[1]?.trim();
const role = block.match(/\*\*(?:役割|目的):\*\*\s*(.+)$/mu)?.[1]?.trim();
const type = block.match(/-\s*種別:\s*(.+)$/mu)?.[1]?.trim();
const userQuestion = block.match(/-\s*読者の疑問:\s*(.+)$/mu)?.[1]?.trim();
const content = block.match(/-\s*具体コンテンツ:\s*(.+)$/mu)?.[1]?.trim();
const asset = block.match(/-\s*必要素材:\s*(.+)$/mu)?.[1]?.trim();
const implementation = block.match(/-\s*実装条件:\s*(.+)$/mu)?.[1]?.trim();
const layout = block.match(/-\s*レイアウト:\s*(.+)$/mu)?.[1]?.trim();
const layoutVariant = block.match(/-\s*レイアウト型:\s*(.+)$/mu)?.[1]?.trim();
return {
...section,
id: section.id || `section-${index + 1}`,
type: type || section.type,
label: heading || section.label,
role: role || section.role,
userQuestion: userQuestion || section.userQuestion,
title: title || section.title,
body: body || section.body,
proof: proof || section.proof,
content: content || section.content,
asset: asset || section.asset,
implementation: implementation || section.implementation,
layout: layout || section.layout,
layoutVariant: layoutVariant || section.layoutVariant,
cta: cta || section.cta,
};
});
}
export function createComment(sectionId, x, y, message, element = "section") {
return {
id: `comment-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
sectionId,
x,
y,
element,
message,
status: "open",
};
}
export function mergeTargetedSections(current, generated, feedbackItems) {
if (!feedbackItems.length) return generated;
const normalizeId = (value) =>
String(value || "")
.toLowerCase()
.normalize("NFKC")
.replace(/[^a-z0-9\p{L}\p{N}]+/gu, "");
const targetEntries = feedbackItems.map((item) => ({
original: item.sectionId,
normalized: normalizeId(item.sectionId),
}));
const generatedById = new Map(
generated.map((section) => [normalizeId(section.id), section]),
);
const targetMap = new Map(targetEntries.map((item) => [item.normalized, item]));
return current.map((section) => {
const normalized = normalizeId(section.id);
if (!targetMap.has(normalized)) return section;
return generatedById.get(normalized) || section;
});
}
export { exportHtml } from "./export-html.js";