← GPT-AI-LP
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";