← GPT-AI-LP
CODE · 73.6 KB

src/main.jsx

Workspace snapshot · 08/31 23:51

import React, { useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
  answerQuestion,
  createComment,
  exportHtml,
  getActiveQuestions,
  getNextQuestion,
  initialMarkdown,
  initialProject,
  initialSections,
  markdownToSections,
  mergeTargetedSections,
  rewindAnswers,
  sectionsToMarkdown,
  stages,
  toneDirections,
} from "./lp-model.js";
import {
  normalizeWireframeType,
  resolveWireframeLayout,
} from "./wireframe-layouts.js";
import "./product-v3.css";
import "./wireframe-variants.css";
import { wireframeVariantPreviewSections } from "./wireframe-preview.js";
import { buildExportBundle } from "./export-bundle.js";
import { normalizeContentModel } from "./section-content.js";
import { buildVisualPlan, mergeVisualRecords } from "./visual-strategy.js";

const STORAGE_KEY = "landing-page-studio:v5";
const ACCESS_KEY = "landing-page-studio:access";
const SESSION_KEY = "landing-page-studio:session";
const sessionId = (() => {
  const current = window.sessionStorage.getItem(SESSION_KEY);
  if (current) return current;
  const next = `lp-session:${crypto.randomUUID()}`;
  window.sessionStorage.setItem(SESSION_KEY, next);
  return next;
})();
const generationHeaders = () => ({
  "Content-Type": "application/json",
  "x-ai-lp-access-token": window.sessionStorage.getItem(ACCESS_KEY) || "",
  "x-ai-lp-session": sessionId,
});
function readDraft() {
  try {
    return JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "null");
  } catch {
    return null;
  }
}
function StudioMark({ compact = false }) {
  return (
    <span
      className={`studio-mark ${compact ? "compact" : ""}`}
      aria-label="Landing Page Studio"
    >
      <svg viewBox="0 0 48 48" role="img">
        <path d="M8 8h14v8H16v16h6v8H8V8Z" />
        <path d="M26 8h7c8 0 12 4 12 11 0 8-5 12-13 12h-1v9h-5V8Zm5 8v8h2c4 0 6-1 6-4 0-3-2-4-6-4h-2Z" />
      </svg>
      {!compact && (
        <b>
          <span>LANDING PAGE</span>STUDIO
        </b>
      )}
    </span>
  );
}

function AccessGate({ onUnlock }) {
  const [value, setValue] = useState("");
  return (
    <main className="access-gate">
      <StudioMark />
      <small>PRIVATE BETA</small>
      <h1>制作環境へ入る</h1>
      <p>生成コストと制作データを守るため、現在はアクセスキー制です。</p>
      <form onSubmit={(event) => {
        event.preventDefault();
        if (!value.trim()) return;
        window.sessionStorage.setItem(ACCESS_KEY, value.trim());
        onUnlock(value.trim());
      }}>
        <label>アクセスキー<input type="password" value={value} onChange={(event) => setValue(event.target.value)} autoComplete="current-password" /></label>
        <button type="submit" disabled={!value.trim()}>制作を始める</button>
      </form>
    </main>
  );
}

async function runGeneration(kind, payload, onStatus) {
  const targetedRevision =
    kind === "wireframe" &&
    typeof payload?.instruction === "string" &&
    /"sectionId"\s*:/u.test(payload.instruction);
  const messages =
    kind === "brief"
      ? [
          "資料の文章を読み取り",
          "商材・顧客・訴求を抽出",
          "ヒアリングの下書きを作成",
        ]
      : kind === "tone"
        ? [
            "構成から感情の軸を抽出",
            "ビジュアル言語を3方向へ展開",
            "書体・色・動きを統合",
          ]
        : [
            "資料と回答を理解",
            "競合・代替とCV心理を整理",
            "説得順とコピーを設計",
            "品質ゲートで監査",
          ];
  for (let generationAttempt = 0; generationAttempt < 3; generationAttempt += 1) {
    onStatus?.(generationAttempt ? `出力を整え直しています(${generationAttempt}/2)` : "準備しています");
    const start = await fetch("/api/generate-page", {
      method: "POST",
      headers: generationHeaders(),
      body: JSON.stringify({ ...payload, kind, requestId: crypto.randomUUID() }),
    });
    const started = await start.json();
    if (!start.ok) throw new Error(started.error || "生成を開始できませんでした");
    let retryOutput = false;
    // Strategy jobs are allowed to run for 600 seconds on the VPS. Do not
    // enqueue another job while the first one is still legitimately running.
    for (let pollAttempt = 0; pollAttempt < 420; pollAttempt += 1) {
      onStatus?.(messages[Math.min(Math.floor(pollAttempt / 5), messages.length - 1)]);
      await new Promise((resolve) => window.setTimeout(resolve, 1600));
      const partial = targetedRevision || generationAttempt === 2 ? "&partial=1" : "";
      const response = await fetch(
        `/api/generate-page?jobId=${encodeURIComponent(started.jobId)}&kind=${kind}${partial}`,
        { headers: generationHeaders() },
      );
      const result = await response.json();
      if (response.status === 422) {
        retryOutput = true;
        break;
      }
      if (!response.ok) throw new Error(result.error || "生成結果を取得できませんでした");
      if (result.status === "COMPLETED") return result;
      if (result.status === "FAILED") {
        retryOutput = true;
        break;
      }
    }
    if (retryOutput) continue;
    throw new Error("生成が10分を超えました。実行状態を確認してください");
  }
  throw new Error("生成結果を整形できませんでした。入力内容は保持されています");
}

async function runWireframeAudit(sections, answers, qualityGate = {}) {
  const response = await fetch("/api/audit-wireframe", {
    method: "POST",
    headers: generationHeaders(),
    body: JSON.stringify({ sections, answers, qualityGate }),
  });
  const result = await response.json();
  if (!response.ok) throw new Error(result.error || "品質監査を実行できませんでした");
  return result;
}

async function runImageGeneration(
  { project, sections, direction, purpose = "tone", sectionIds = null },
  onStatus,
) {
  const requested = Array.isArray(sectionIds) ? new Set(sectionIds) : null;
  const plan = buildVisualPlan({ project, sections, direction, purpose })
    .filter((visual) => !requested || requested.has(visual.sectionId));
  if (!plan.length && requested) return [];
  if (!plan.length) throw new Error("画像を使うセクションを特定できませんでした");
  const queued = { jobs: [] };
  for (let index = 0; index < plan.length; index += 7) {
    const batch = plan.slice(index, index + 7);
    onStatus?.(`画像生成を準備 ${Math.min(index + batch.length, plan.length)} / ${plan.length}`);
    const response = await fetch("/api/design-images", {
      method: "POST",
      headers: generationHeaders(),
      body: JSON.stringify({
        visuals: batch.map((visual) => ({
          ...visual,
          colorScheme: "カラフル",
          useCase: visual.type === "hero" ? "Webサイト メインビジュアル" : "Webサイト セクション画像",
          useCaseId: visual.type === "hero" ? "web-hero" : "web-banner",
          directions: [visual.direction],
        })),
      }),
    });
    const result = await response.json();
    if (!response.ok)
      throw new Error(result.error || "画像生成を開始できませんでした");
    queued.jobs.push(...(result.jobs || []));
  }
  const ids = queued.jobs
    .filter((item) => item.jobId)
    .map((item) => item.jobId);
  if (!ids.length) throw new Error("画像生成ジョブを作成できませんでした");
  const visualById = new Map(plan.map((visual) => [visual.visualId, visual]));
  const visualByJob = new Map(queued.jobs.map((job) => [job.jobId, {
    sectionId: job.sectionId,
    visualId: job.visualId || `${job.sectionId}:${job.layer || "content"}`,
    layer: job.layer || "content",
  }]));
  // サブスク画像Workerは同一セッションを直列処理するため、7枚で10分前後を許容する。
  for (let attempt = 0; attempt < 420; attempt += 1) {
    onStatus?.(attempt === 0 ? "画像生成エンジンへ接続" : `${plan.length}枚を生成しています`);
    await new Promise((resolve) => window.setTimeout(resolve, 2200));
    const polled = await fetch(
      `/api/design-images?jobIds=${encodeURIComponent(ids.join(","))}`,
      { headers: generationHeaders() },
    );
    const data = await polled.json();
    const jobs = data.jobs || [];
    const completedCount = jobs.filter((job) => job.status === "COMPLETED").length;
    onStatus?.(`画像 ${completedCount} / ${ids.length}枚 完了`);
    if (jobs.some((job) => job.status === "FAILED"))
      throw new Error(
        jobs.find((job) => job.status === "FAILED")?.error_summary ||
          "画像生成に失敗しました",
      );
    if (
      jobs.length === ids.length &&
      jobs.every((job) => job.status === "COMPLETED")
    ) {
      const images = jobs.map((job) => {
        const meta = visualByJob.get(job.id || job.jobId) || {
          sectionId: job.sectionId,
          visualId: job.visualId || `${job.sectionId}:${job.layer || "content"}`,
          layer: job.layer || "content",
        };
        const visual = visualById.get(meta.visualId);
        return {
          sectionId: meta.sectionId,
          visualId: meta.visualId,
          layer: meta.layer,
          textIntegrated: visual?.textIntegrated === true,
          src:
            job.output?.artifacts?.[0]?.signedUrl ||
            job.artifact?.signedUrl ||
            job.output?.artifact?.signedUrl,
        };
      }).filter((image) => image.src && image.sectionId);
      if (images.length !== ids.length)
        throw new Error("生成画像の取得に失敗しました");
      return images;
    }
  }
  throw new Error("画像生成がタイムアウトしました");
}

function Header({ stage, setStage, saveStatus, versions, onRestore }) {
  const active = stages.findIndex((item) => item.id === stage);
  return (
    <header className="studio-header">
      <button className="brand" onClick={() => setStage("hearing")}>
        <StudioMark />
      </button>
      <nav>
        {stages.map((item, index) => (
          <button
            key={item.id}
            className={
              index === active ? "active" : index < active ? "done" : ""
            }
            onClick={() => index <= active && setStage(item.id)}
          >
            <span>{index < active ? "✓" : item.no}</span>
            <b>{item.label}</b>
          </button>
        ))}
      </nav>
      <details className="version-menu">
        <summary className={`save ${saveStatus}`}>
          <i />
          {saveStatus === "saving"
            ? "SAVING"
            : saveStatus === "error"
              ? "SAVE ERROR"
              : "SAVED"}
        </summary>
        <div>
          <small>VERSION HISTORY</small>
          {versions.length ? (
            [...versions].reverse().map((version) => (
              <button key={version.id} onClick={() => onRestore(version)}>
                <b>{version.label}</b>
                <span>
                  {new Date(version.createdAt).toLocaleTimeString("ja-JP", {
                    hour: "2-digit",
                    minute: "2-digit",
                  })}
                </span>
              </button>
            ))
          ) : (
            <p>生成後の版がここに残ります。</p>
          )}
        </div>
      </details>
    </header>
  );
}

function SourceDrop({ project, setProject, onLoaded }) {
  const input = useRef(null);
  const [reading, setReading] = useState(false);
  const [drag, setDrag] = useState(false);
  const [readError, setReadError] = useState("");
  const load = async (file) => {
    if (!file) return;
    setReading(true);
    setReadError("");
    try {
      const { readDocument } = await import("./document-reader.js");
      const result = await readDocument(file);
      const next = {
        ...project,
        sourceName: file.name,
        sourceText: result.text,
        sourceReadStatus: result.status,
        sourceTruncated: result.truncated,
      };
      setProject(next);
      await onLoaded?.(next);
    } catch (error) {
      setReadError(error instanceof Error ? error.message : "資料を読み込めませんでした");
    } finally {
      setReading(false);
      setDrag(false);
    }
  };
  return (
    <label
      className={`source-pill ${drag ? "drag" : ""}`}
      tabIndex="0"
      role="button"
      onKeyDown={(event) => {
        if (event.key === "Enter" || event.key === " ") input.current?.click();
      }}
      onDragOver={(event) => {
        event.preventDefault();
        setDrag(true);
      }}
      onDragLeave={() => setDrag(false)}
      onDrop={(event) => {
        event.preventDefault();
        load(event.dataTransfer.files[0]);
      }}
    >
      <input
        ref={input}
        type="file"
        accept=".pdf,.pptx,.docx,.txt,.md,.csv"
        onChange={(event) => load(event.target.files[0])}
      />
      <span>{reading ? "···" : "+"}</span>
      <div>
        <b>{reading ? "資料を分析中" : project.sourceName}</b>
        <small>
          {readError
            ? readError
            : project.sourceReadStatus
              ? project.sourceReadStatus
              : project.sourceFilled?.length
            ? `${project.sourceFilled.length}項目を資料から下書き済み`
            : "資料からヒアリングを自動作成"}
        </small>
      </div>
    </label>
  );
}

function ChoiceCard({ option, index, selected, onClick }) {
  return (
    <button
      className={`taste-card option-card ${selected ? "selected" : ""}`}
      onClick={onClick}
    >
      <span className="option-symbol">
        <i>{String(index + 1).padStart(2, "0")}</i>
      </span>
      <span className="taste-copy">
        <b>{option.title}</b>
        <small>{option.note}</small>
      </span>
      <span className="pick-check">✓</span>
    </button>
  );
}

function Generating({ title, status }) {
  return (
    <div className="generating">
      <div className="generation-brand">
        <StudioMark compact />
        <i />
        <i />
      </div>
      <small>LANDING PAGE STUDIO</small>
      <h2>{title}</h2>
      <p>
        {status}
        <span>...</span>
      </p>
      <div className="generation-line">
        <i />
      </div>
    </div>
  );
}

function Hearing({
  project,
  setProject,
  onComplete,
  onSourceLoaded,
  generating,
  generationKind,
  generationStatus,
}) {
  const answers = project.answers;
  const question = getNextQuestion(answers);
  const active = getActiveQuestions(answers);
  const [picked, setPicked] = useState(null);
  const [multiPicked, setMultiPicked] = useState([]);
  const [custom, setCustom] = useState("");
  const [fieldValues, setFieldValues] = useState({});
  const [confirmationValues, setConfirmationValues] = useState(() =>
    Object.fromEntries((project.sourceNeedsConfirmation || []).map((item) => [item, ""])),
  );
  useEffect(() => {
    setPicked(null);
    const existing = question ? answers[question.id] : null;
    const selected = Array.isArray(existing) ? existing : [];
    setMultiPicked(selected.filter((item) => !String(item).startsWith("自由記述: ")));
    setCustom(
      question?.multiple
        ? String(selected.find((item) => String(item).startsWith("自由記述: ")) || "").replace(/^自由記述:\s*/u, "")
        : question?.type === "text" && typeof existing === "string"
          ? existing
          : "",
    );
    setFieldValues(
      question?.type === "fields"
        ? Object.fromEntries(question.fields.map((field) => [field.id, answers[field.id] || ""]))
        : {},
    );
  }, [question?.id]);
  const commit = (value) => {
    setProject((current) => ({
      ...current,
      answers: question.type === "fields" && value
        ? { ...answerQuestion(current.answers, question, true), ...value }
        : question.multiple && Array.isArray(value)
          ? answerQuestion(current.answers, { ...question, multiple: false }, value)
          : answerQuestion(current.answers, question, value),
    }));
    setPicked(null);
    setCustom("");
  };
  const choose = (value) => {
    if (question.multiple) {
      setMultiPicked((items) =>
        items.includes(value)
          ? items.filter((item) => item !== value)
          : [...items, value],
      );
      return;
    }
    if (picked) return;
    setPicked(value);
    commit(value);
  };
  const continueMultiple = () => {
    const values = [
      ...multiPicked,
      ...(custom.trim() ? [`自由記述: ${custom.trim()}`] : []),
    ];
    if (values.length) {
      commit(values);
      setMultiPicked([]);
    }
  };
  const currentSelection = multiPicked;
  const currentIndex = question
    ? active.findIndex((item) => item.id === question.id)
    : active.length;
  const canBack = active
    .slice(0, currentIndex)
    .some((item) => answers[item.id] != null);
  if (generating)
    return (
      <Generating
        title={
          generationKind === "brief"
            ? "資料から下書きを作成しています"
            : "構成を作成しています"
        }
        status={generationStatus}
      />
    );
  return (
    <main className="onboarding">
      <section className="taste-stage">
        <div className="taste-head">
          <div>
            <small>LP BRIEF</small>
            <b>
              {project.sourceSummary ||
                "資料と最小限の回答から構成をつくります。"}
            </b>
          </div>
          <SourceDrop
            project={project}
            setProject={setProject}
            onLoaded={onSourceLoaded}
          />
        </div>
        {project.sourceNeedsConfirmation?.length > 0 && (
          <aside className="source-confirmation">
            <div><small>資料からの要確認</small><b>不明点を修正してから進めます</b></div>
            <div className="confirmation-fields">
              {project.sourceNeedsConfirmation.map((item) => (
                <label key={item}>
                  <span>{item}</span>
                  <input
                    value={confirmationValues[item] || ""}
                    onChange={(event) => setConfirmationValues((current) => ({ ...current, [item]: event.target.value }))}
                    placeholder="正しい内容、または『未定』"
                  />
                </label>
              ))}
            </div>
            <button
              disabled={project.sourceNeedsConfirmation.some((item) => !String(confirmationValues[item] || "").trim())}
              onClick={() => setProject((current) => ({
                ...current,
                sourceConfirmationNotes: confirmationValues,
                sourceNeedsConfirmation: [],
              }))}
            >修正内容を反映</button>
          </aside>
        )}
        {question ? (
          <div className="question-scene" key={question.id}>
            <div className="question-tools">
              <button
                onClick={() =>
                  setProject((current) => ({
                    ...current,
                    answers: rewindAnswers(current.answers),
                  }))
                }
                disabled={!canBack}
              >
                ← 戻る
              </button>
              <span>
                {currentIndex + 1} / {active.length}
              </span>
            </div>
            <div className="question-count">
              <span
                style={{ width: `${((currentIndex + 1) / active.length) * 100}%` }}
              />
            </div>
            <small>{question.eyebrow}</small>
            <h1>{question.title}</h1>
            <p>
              {question.type === "text"
                ? question.optional
                  ? "資料にない場合だけ補足してください。スキップもできます。"
                  : "一文で十分です。資料から分かる項目は自動で省略されます。"
                : question.multiple
                  ? "複数選べます。回答に応じて候補を変えています。"
                  : "最も近いものを選んでください。"}
            </p>
            {question.type === "fields" ? (
              <div className="field-answer-grid">
                {question.fields.map((field) => (
                  <label key={field.id}>
                    <span>{field.label}</span>
                    <textarea value={fieldValues[field.id] || ""} onChange={(event) => setFieldValues((current) => ({ ...current, [field.id]: event.target.value }))} placeholder={field.placeholder} />
                  </label>
                ))}
                <div className="field-actions">
                  {question.optional && <button className="secondary" onClick={() => commit(null)}>今回はスキップ</button>}
                  <button onClick={() => commit(Object.fromEntries(Object.entries(fieldValues).filter(([, value]) => value.trim())))} disabled={!question.optional && !Object.values(fieldValues).some((value) => value.trim())}>次へ →</button>
                </div>
              </div>
            ) : question.type === "text" ? (
              <div className="free-answer primary-answer">
                <textarea
                  autoFocus
                  value={custom}
                  onChange={(event) => setCustom(event.target.value)}
                  placeholder={question.placeholder}
                />
                <button
                  onClick={() =>
                    commit(custom.trim() || (question.optional ? null : ""))
                  }
                  disabled={!custom.trim() && !question.optional}
                >
                  {question.optional && !custom.trim() ? "スキップ" : "次へ"}
                  <i>→</i>
                </button>
              </div>
            ) : (
              <>
                <div
                  className={`taste-grid options-${question.options.length}`}
                >
                  {question.options.map((option, index) => (
                    <ChoiceCard
                      key={option.id}
                      option={option}
                      index={index}
                      selected={
                        picked === option.id ||
                        currentSelection.includes(option.id) ||
                        answers[question.id] === option.id
                      }
                      onClick={() => choose(option.id)}
                    />
                  ))}
                </div>
                <details className="custom-answer">
                  <summary>選択肢にない場合だけ入力</summary>
                  <div className="free-answer">
                    <textarea
                      value={custom}
                      onChange={(event) => setCustom(event.target.value)}
                      placeholder="その他の内容"
                    />
                    <button
                      onClick={() =>
                        question.multiple
                          ? continueMultiple()
                          : commit(`自由記述: ${custom.trim()}`)
                      }
                      disabled={!custom.trim() && !question.multiple}
                    >
                      {question.multiple ? "選択内容で次へ" : "この内容で次へ"}
                      <i>→</i>
                    </button>
                  </div>
                </details>
                {question.multiple && (
                  <button
                    className="multiple-next"
                    disabled={!multiPicked.length}
                    onClick={continueMultiple}
                  >
                    選択した内容で次へ →
                  </button>
                )}
              </>
            )}
          </div>
        ) : (
          <div className="ready-scene">
            <small>BRIEF COMPLETE</small>
            <h1>
              入力内容をもとに、
              <br />
              <em>LP構成を作成します。</em>
            </h1>
            <p>
              資料の事実と未確認情報を分け、キャッチコピー3案と必要なセクションを生成します。
            </p>
            <button onClick={onComplete}>
              <span>構成を生成</span>
              <i>→</i>
            </button>
          </div>
        )}
      </section>
    </main>
  );
}

function StructuredContentEditor({ section, onChange }) {
  const model = normalizeContentModel(section.contentModel);
  const type = normalizeWireframeType(section);
  const setModel = (next) => onChange({ ...model, ...next });
  const updateArray = (key, index, field, value) =>
    setModel({
      [key]: model[key].map((entry, entryIndex) =>
        entryIndex === index ? { ...entry, [field]: value } : entry,
      ),
    });
  const itemKey = ["mechanism", "process"].includes(type) ? "steps" : "items";

  if (type === "faq")
    return <div className="structured-fields"><b>質問と回答</b>{model.faqs.map((entry, index) => <div className="structured-row" key={`${section.id}-faq-${index}`}><input value={entry.question} aria-label={`質問 ${index + 1}`} onChange={(event) => updateArray("faqs", index, "question", event.target.value)} /><textarea value={entry.answer} aria-label={`回答 ${index + 1}`} onChange={(event) => updateArray("faqs", index, "answer", event.target.value)} /></div>)}</div>;

  if (type === "proof" && model.metrics.length)
    return <div className="structured-fields"><b>実績・根拠</b>{model.metrics.map((entry, index) => <div className="structured-row metrics" key={`${section.id}-metric-${index}`}><input value={entry.value} aria-label={`実績値 ${index + 1}`} onChange={(event) => updateArray("metrics", index, "value", event.target.value)} /><input value={entry.label} aria-label={`実績ラベル ${index + 1}`} onChange={(event) => updateArray("metrics", index, "label", event.target.value)} /><input value={entry.source} aria-label={`実績出典 ${index + 1}`} onChange={(event) => updateArray("metrics", index, "source", event.target.value)} /></div>)}</div>;

  if (type === "pricing")
    return <div className="structured-fields"><b>プラン</b>{model.plans.map((entry, index) => <div className="structured-row metrics" key={`${section.id}-plan-${index}`}><input value={entry.name} aria-label={`プラン名 ${index + 1}`} onChange={(event) => updateArray("plans", index, "name", event.target.value)} /><input value={entry.price} aria-label={`価格 ${index + 1}`} onChange={(event) => updateArray("plans", index, "price", event.target.value)} /><textarea value={entry.description} aria-label={`プラン説明 ${index + 1}`} onChange={(event) => updateArray("plans", index, "description", event.target.value)} /></div>)}</div>;

  if (type === "comparison" && model.comparison)
    return <div className="structured-fields"><b>比較表</b>{model.comparison.rows.map((row, index) => <div className="structured-row metrics" key={`${section.id}-comparison-${index}`}><input value={row.label} aria-label={`比較軸 ${index + 1}`} onChange={(event) => { const rows = model.comparison.rows.map((entry, entryIndex) => entryIndex === index ? { ...entry, label: event.target.value } : entry); setModel({ comparison: { ...model.comparison, rows } }); }} />{row.cells.map((cell, cellIndex) => <textarea key={cellIndex} value={cell} aria-label={`比較内容 ${index + 1}-${cellIndex + 1}`} onChange={(event) => { const rows = model.comparison.rows.map((entry, entryIndex) => entryIndex === index ? { ...entry, cells: entry.cells.map((value, valueIndex) => valueIndex === cellIndex ? event.target.value : value) } : entry); setModel({ comparison: { ...model.comparison, rows } }); }} />)}</div>)}</div>;

  if (model[itemKey].length)
    return <div className="structured-fields"><b>{itemKey === "steps" ? "手順" : "表示項目"}</b>{model[itemKey].map((entry, index) => <div className="structured-row" key={`${section.id}-${itemKey}-${index}`}><input value={entry.title} aria-label={`項目見出し ${index + 1}`} onChange={(event) => updateArray(itemKey, index, "title", event.target.value)} /><textarea value={entry.description} aria-label={`項目説明 ${index + 1}`} onChange={(event) => updateArray(itemKey, index, "description", event.target.value)} /></div>)}</div>;

  return null;
}

function BlockStructureEditor({ sections, onChange }) {
  const update = (id, key, value) =>
    onChange(
      sections.map((section) =>
        section.id === id ? { ...section, [key]: value } : section,
      ),
    );
  return (
    <div className="block-editor">
      {sections.map((section, index) => (
        <article key={section.id}>
          <header>
            <span>{String(index + 1).padStart(2, "0")}</span>
            <input
              aria-label={`${index + 1} セクション名`}
              value={section.label}
              onChange={(event) =>
                update(section.id, "label", event.target.value)
              }
            />
          </header>
          <input
            className="block-title"
            aria-label={`${index + 1} 見出し`}
            value={section.title}
            onChange={(event) =>
              update(section.id, "title", event.target.value)
            }
          />
          <textarea
            aria-label={`${index + 1} 本文`}
            value={section.body}
            onChange={(event) => update(section.id, "body", event.target.value)}
          />
          <div className="block-fields">
            <label>
              役割
              <input
                value={section.role || ""}
                onChange={(event) =>
                  update(section.id, "role", event.target.value)
                }
              />
            </label>
            <label>
              根拠
              <input
                value={section.proof || ""}
                onChange={(event) =>
                  update(section.id, "proof", event.target.value)
                }
              />
            </label>
            <label>
              CTA
              <input
                value={section.cta || ""}
                onChange={(event) =>
                  update(section.id, "cta", event.target.value)
                }
              />
            </label>
          </div>
          <StructuredContentEditor
            section={section}
            onChange={(contentModel) => update(section.id, "contentModel", contentModel)}
          />
        </article>
      ))}
    </div>
  );
}

function Structure({
  markdown,
  setMarkdown,
  sections,
  onChangeSections,
  headlines,
  qualityGate,
  selectedHeadline,
  onSelectHeadline,
  onRegenerate,
  onNext,
  busyKind,
  status,
}) {
  const [instruction, setInstruction] = useState("");
  const [editorMode, setEditorMode] = useState("blocks");
  if (busyKind)
    return (
      <Generating
        title={
          busyKind === "wireframe"
            ? "構成をWFへ変換しています"
            : "構成を組み直しています"
        }
        status={status}
      />
    );
  return (
    <main className="workspace structure-work">
      <div className="work-title compact-title">
        <div>
          <small>02 / 構成</small>
          <h1>構成とキャッチコピー</h1>
          <p>読者の検討順に並べ、文章を直接編集できます。</p>
        </div>
        <div className="step-actions">
          <button
            onClick={() => onRegenerate(instruction)}
            disabled={!instruction.trim()}
          >
            指示を反映
          </button>
          <button className="primary" onClick={onNext}>
            WFを生成 <i>→</i>
          </button>
        </div>
      </div>
      <section className="evidence-brief">
        <div>
          <small>SOURCE MAP</small>
          <b>AIが判断に使った前提</b>
        </div>
        {[
          ["確認済み", qualityGate.known],
          ["仮説", qualityGate.hypotheses],
          ["未確認", qualityGate.unconfirmed],
        ].map(([label, items]) => (
          <section key={label}>
            <strong>
              {label}
              <i>{items.length}</i>
            </strong>
            {items.length ? (
              items.slice(0, 4).map((item) => <p key={item}>{item}</p>)
            ) : (
              <p>該当なし</p>
            )}
          </section>
        ))}
      </section>
      <section className="headline-lab">
        <div>
          <small>CATCH COPY</small>
          <b>ファーストビューを選択</b>
        </div>
        {headlines.map((item, index) => (
          <button
            key={item.id}
            className={selectedHeadline === item.id ? "selected" : ""}
            onClick={() => onSelectHeadline(item)}
          >
            <i>
              案 {index + 1} / {item.axis || "訴求"}
            </i>
            <strong>{item.copy}</strong>
            <span>{item.reason}</span>
          </button>
        ))}
      </section>
      <div className="structure-grid">
        <section className="markdown-editor notion-editor">
          <div className="editor-top">
            <div className="editor-tabs">
              <button
                className={editorMode === "blocks" ? "active" : ""}
                onClick={() => setEditorMode("blocks")}
              >
                読みやすく編集
              </button>
              <button
                className={editorMode === "markdown" ? "active" : ""}
                onClick={() => setEditorMode("markdown")}
              >
                Markdown
              </button>
            </div>
            <b>{sections.length} sections</b>
          </div>
          {editorMode === "blocks" ? (
            <BlockStructureEditor
              sections={sections}
              onChange={onChangeSections}
            />
          ) : (
            <textarea
              value={markdown}
              onChange={(event) => setMarkdown(event.target.value)}
            />
          )}
          <div className="prompt-dock">
            <textarea
              value={instruction}
              onChange={(event) => setInstruction(event.target.value)}
              placeholder="例:導入リスクと費用対効果を前半へ移動"
            />
            <button
              onClick={() => onRegenerate(instruction)}
              disabled={!instruction.trim()}
            >
              ↗
            </button>
          </div>
        </section>
        <aside className="story-map">
          <small>SECTION MAP</small>
          {sections.map((section, index) => (
            <article key={section.id}>
              <i>{String(index + 1).padStart(2, "0")}</i>
              <div>
                <b>{section.label}</b>
                <p>{section.userQuestion || section.title}</p>
              </div>
            </article>
          ))}
        </aside>
      </div>
    </main>
  );
}

const wireframeItems = (section, minimum = 3) => {
  const model = normalizeContentModel(section.contentModel);
  const typed = [...model.items, ...model.steps]
    .map((item) => item.title)
    .filter(Boolean);
  if (typed.length) return typed.slice(0, Math.max(minimum, 6));
  return String(section.content || "")
    .split(/\r?\n/u)
    .map((item) => item.replace(/^\s*[-・*]\s*/u, "").trim())
    .filter((item) => item.length > 2)
    .slice(0, Math.max(minimum, 6));
};

function WireframeVisual({ label, variant = "screen" }) {
  return (
    <div className={`wf-visual wf-visual-${variant}`} data-comment-element="visual">
      <span>{label || "必要素材を確認"}</span>
      <i />
      <i />
      <i />
    </div>
  );
}

function WireframeSectionBody({ section, index }) {
  const type = normalizeWireframeType(section);
  const variant = resolveWireframeLayout(section, index);
  const model = normalizeContentModel(section.contentModel);
  const items = wireframeItems(section);
  const lead = (
    <div className="wf-copy">
      <h2 data-comment-element="headline">{section.title}</h2>
      <p data-comment-element="body">{section.body}</p>
      {section.proof && <em data-comment-element="proof">{section.proof}</em>}
      {section.cta && !["cta", "form"].includes(type) && (
        <button data-comment-element="cta">{section.cta}</button>
      )}
    </div>
  );

  if (type === "hero")
    return (
      <div className={`wf-layout wf-layout-hero wf-variant-${variant}`}>
        {lead}
        <WireframeVisual label={section.asset} variant="hero" />
        <div className="wf-hero-proof">
          {items.slice(0, 3).map((item, itemIndex) => (
            <span key={item}><b>{String(itemIndex + 1).padStart(2, "0")}</b>{item}</span>
          ))}
        </div>
      </div>
    );

  if (type === "trust")
    return (
      <div className={`wf-layout wf-layout-trust wf-variant-${variant}`}>
        {lead}
        <div className="wf-logo-wall" data-comment-element="visual">
          {model.metrics.map((metric) => <span key={`${metric.value}-${metric.label}`}><b>{metric.value}</b>{metric.label}</span>)}
          {model.items.map((item) => <span key={item.title}><b>{item.title}</b>{item.description}</span>)}
        </div>
      </div>
    );

  if (type === "problem" || type === "risk")
    return (
      <div className={`wf-layout wf-layout-${type} wf-variant-${variant}`}>
        {lead}
        <div className="wf-pain-grid" data-comment-element="visual">
          {items.slice(0, 3).map((item, itemIndex) => (
            <article key={item}><i>0{itemIndex + 1}</i><b>{item}</b><span>{type === "risk" ? "放置時の影響" : "現場で起きること"}</span></article>
          ))}
        </div>
      </div>
    );

  if (type === "mechanism" || type === "process")
    return (
      <div className={`wf-layout wf-layout-${type} wf-variant-${variant}`}>
        {lead}
        <ol className="wf-flow" data-comment-element="visual">
          {items.slice(0, 4).map((item, itemIndex) => (
            <li key={item}><span>{itemIndex + 1}</span><b>{item}</b><small>{type === "process" ? "導入ステップ" : "変化の仕組み"}</small></li>
          ))}
        </ol>
      </div>
    );

  if (["differentiation", "usecase"].includes(type))
    return (
      <div className={`wf-layout wf-layout-${type} wf-variant-${variant}`}>
        <header>{lead}</header>
        <div className="wf-feature-grid" data-comment-element="visual">
          {items.slice(0, type === "usecase" ? 4 : 3).map((item, itemIndex) => (
            <article key={item}><span>{type === "usecase" ? `SCENE ${itemIndex + 1}` : `POINT ${itemIndex + 1}`}</span><b>{item}</b><i /></article>
          ))}
        </div>
      </div>
    );

  if (type === "comparison")
    return (
      <div className={`wf-layout wf-layout-comparison wf-variant-${variant}`}>
        {lead}
        <div className="wf-compare" data-comment-element="visual">
          <div><b>比較軸</b>{(model.comparison?.columns || []).map((column) => <b key={column}>{column}</b>)}</div>
          {(model.comparison?.rows || []).map((row) => <div key={row.label}><span>{row.label}</span>{row.cells.map((cell, cellIndex) => <small key={`${row.label}-${cellIndex}`}>{cell}</small>)}</div>)}
        </div>
      </div>
    );

  if (type === "proof")
    return (
      <div className={`wf-layout wf-layout-proof wf-variant-${variant}`}>
        {lead}
        <div className="wf-metrics" data-comment-element="visual">
          {model.metrics.map((metric) => <article key={`${metric.value}-${metric.label}`}><strong>{metric.value}</strong><span>{metric.label}</span>{metric.source && <small>{metric.source}</small>}</article>)}
        </div>
        <WireframeVisual label={section.asset} variant="case" />
      </div>
    );

  if (type === "pricing")
    return (
      <div className={`wf-layout wf-layout-pricing wf-variant-${variant}`}>
        {lead}
        <div className="wf-price-grid" data-comment-element="visual">
          {model.plans.map((plan, itemIndex) => <article key={plan.name} className={itemIndex === 1 ? "featured" : ""}><small>{plan.name}</small><strong>{plan.price}{plan.period}</strong><span>{plan.description}</span><button>{plan.cta || section.cta}</button></article>)}
        </div>
      </div>
    );

  if (type === "faq")
    return (
      <div className={`wf-layout wf-layout-faq wf-variant-${variant}`}>
        {lead}
        <div className="wf-faq-list" data-comment-element="visual">
          {model.faqs.slice(0, 6).map((item, itemIndex) => <article key={item.question}><span>Q{itemIndex + 1}</span><b>{item.question}</b><small>{item.answer}</small><i>+</i></article>)}
        </div>
      </div>
    );

  if (type === "fit")
    return (
      <div className={`wf-layout wf-layout-fit wf-variant-${variant}`}>
        {lead}
        <div className="wf-fit-grid" data-comment-element="visual">
          <article><small>RECOMMENDED</small>{items.slice(0, 2).map((item) => <b key={item}>✓ {item}</b>)}</article>
          <article><small>NOT A FIT</small>{items.slice(2, 4).map((item) => <b key={item}>— {item}</b>)}</article>
        </div>
      </div>
    );

  if (type === "form" || type === "cta")
    return (
      <div className={`wf-layout wf-layout-${type} wf-variant-${variant}`}>
        {lead}
        {type === "form" ? (
          <div className="wf-form" data-comment-element="visual"><label>会社名<input readOnly /></label><label>メールアドレス<input readOnly /></label><label>相談内容<textarea readOnly /></label><button>{section.cta || "送信する"}</button><small>入力時間・送信後・個人情報の扱いを明記</small></div>
        ) : <div className="wf-cta-actions" data-comment-element="visual"><button>{section.cta || "次へ進む"}</button><span>{section.proof || "行動前の不安を解消"}</span></div>}
      </div>
    );

  if (type === "message")
    return <div className={`wf-layout wf-layout-message wf-variant-${variant}`}><blockquote data-comment-element="headline">{section.title}</blockquote><div>{section.body}<strong>{section.proof}</strong></div></div>;

  return (
    <div className={`wf-layout wf-layout-split wf-variant-${variant} ${index % 2 ? "reverse" : ""}`}>
      {lead}<WireframeVisual label={section.asset} />
    </div>
  );
}

function WireframeCanvas({ sections, comments, setComments, viewport }) {
  const [draft, setDraft] = useState(null);
  const [message, setMessage] = useState("");
  const pin = (event, section) => {
    const rect = event.currentTarget.getBoundingClientRect();
    const target = event.target.closest("[data-comment-element]");
    setDraft({
      sectionId: section.id,
      x: ((event.clientX - rect.left) / rect.width) * 100,
      y: ((event.clientY - rect.top) / rect.height) * 100,
      element: target?.dataset.commentElement || "section",
    });
  };
  const save = () => {
    if (!draft || !message.trim()) return;
    setComments((items) => [
      ...items,
      createComment(
        draft.sectionId,
        draft.x,
        draft.y,
        message.trim(),
        draft.element,
      ),
    ]);
    setDraft(null);
    setMessage("");
  };
  return (
    <div className={`wf-page ${viewport}`}>
      {sections.map((section, index) => (
        <section
          key={section.id}
          className={`wf-section wf-${section.id}`}
          onClick={(event) => pin(event, section)}
        >
          <div className="wf-label">
            <span>{String(index + 1).padStart(2, "0")}</span>
            {section.label}
            <small>{section.role}</small>
          </div>
          <div className="wf-question" data-comment-element="reader-question">
            <i>READER QUESTION</i>
            {section.userQuestion || "このセクションで何を判断するか"}
          </div>
          <WireframeSectionBody section={section} index={index} />
          <dl className="wf-spec">
            <div>
              <dt>CONTENT</dt>
              <dd>{section.content || "要設計"}</dd>
            </div>
            <div>
              <dt>LAYOUT</dt>
              <dd>{section.layout || "内容に応じて設計"}</dd>
            </div>
            <div>
              <dt>BUILD</dt>
              <dd>{section.implementation || "PC/SP差分と計測を設計"}</dd>
            </div>
          </dl>
          {comments
            .filter((comment) => comment.sectionId === section.id)
            .map((comment) => (
              <b
                className={`wf-pin ${comment.status}`}
                key={comment.id}
                style={{ left: `${comment.x}%`, top: `${comment.y}%` }}
              >
                {comments.findIndex((item) => item.id === comment.id) + 1}
              </b>
            ))}
          {draft?.sectionId === section.id && (
            <div
              className="pin-form"
              style={{ left: `${Math.min(draft.x, 68)}%`, top: `${draft.y}%` }}
              onClick={(event) => event.stopPropagation()}
            >
              <small>{draft.element}</small>
              <textarea
                autoFocus
                value={message}
                onChange={(event) => setMessage(event.target.value)}
                placeholder="この箇所をどう変える?"
              />
              <button onClick={save}>コメント追加</button>
            </div>
          )}
        </section>
      ))}
    </div>
  );
}

function Wireframe({
  sections,
  audit,
  comments,
  setComments,
  onRegenerate,
  onNext,
  busyKind,
  status,
}) {
  const [viewport, setViewport] = useState("desktop");
  const gate = audit || { checks: [], humanChecks: [], mustViolations: [], passed: 0, total: 0 };
  if (busyKind)
    return (
      <Generating
        title={
          busyKind === "tone"
            ? "トンマナ3案を設計しています"
            : "WFを再設計しています"
        }
        status={status}
      />
    );
  return (
    <main className="review-space">
      <div className="canvas-bar">
        <div>
          <small>03 / WF</small>
          <b>ワイヤーフレーム確認</b>
        </div>
        <div className="viewport-switch">
          <button
            className={viewport === "desktop" ? "active" : ""}
            onClick={() => setViewport("desktop")}
          >
            PC
          </button>
          <button
            className={viewport === "mobile" ? "active" : ""}
            onClick={() => setViewport("mobile")}
          >
            SP
          </button>
        </div>
        <button
          className="primary"
          disabled={gate.mustViolations.length > 0}
          onClick={onNext}
        >
          トンマナ3案を生成 <i>→</i>
        </button>
      </div>
      <div className="review-body">
        <div className="canvas-bed">
          <WireframeCanvas
            sections={sections}
            comments={comments}
            setComments={setComments}
            viewport={viewport}
          />
        </div>
        <aside className="feedback">
          <section
            className={`qa-panel ${gate.mustViolations.length ? "has-errors" : "passed"}`}
          >
            <div>
              <small>DELIVERY QUALITY</small>
              <b>
                {gate.passed} / {gate.total}
              </b>
            </div>
            {gate.checks.map((check) => (
              <p key={check.id} className={check.pass ? "pass" : "fail"}>
                <i>{check.pass ? "✓" : "!"}</i>
                {check.label}
              </p>
            ))}
            {gate.humanChecks?.length > 0 && (
              <details>
                <summary>公開前の人間確認 {gate.humanChecks.length}項目</summary>
                {gate.humanChecks.map((check) => (
                  <p key={check.id}><i>?</i>{check.label}</p>
                ))}
              </details>
            )}
          </section>
          <small>POSITIONAL COMMENTS</small>
          <p>
            見出し、本文、根拠、画像、CTAを直接クリックしてください。セクション・要素・座標を修正指示へ渡します。
          </p>
          {comments.map((comment, index) => (
            <article key={comment.id} className={comment.status}>
              <i>{index + 1}</i>
              <div>
                <b>
                  {
                    sections.find((section) => section.id === comment.sectionId)
                      ?.label
                  }{" "}
                  / {comment.element}
                </b>
                <p>{comment.message}</p>
                <small>
                  {comment.status === "applied"
                    ? "反映済み"
                    : `x ${Math.round(comment.x)} / y ${Math.round(comment.y)}`}
                </small>
              </div>
            </article>
          ))}
          <button
            disabled={!comments.some((item) => item.status === "open")}
            onClick={() =>
              onRegenerate(comments.filter((item) => item.status === "open"))
            }
          >
            コメントを反映して再生成
          </button>
        </aside>
      </div>
    </main>
  );
}

function DirectionPreview({ direction, sections, selected, onSelect, index }) {
  const hero = sections[0];
  return (
    <article
      className={`direction-card direction-${index} ${selected ? "selected" : ""}`}
      style={{
        "--tone-ink": direction.palette[0],
        "--tone-accent": direction.palette[1],
        "--tone-paper": direction.palette[2],
      }}
      onClick={onSelect}
    >
      <div className="direction-browser">
        {direction.previewImage ? (
          <img
            className="direction-generated"
            src={direction.previewImage}
            alt={`${direction.name}の生成メインビジュアル`}
          />
        ) : (
          <>
            <div className="direction-nav">
              <b>
                {index === 0
                  ? "DOCUMENTARY"
                  : index === 1
                    ? "TYPE IMPACT"
                    : "PRODUCT PROOF"}
              </b>
              <span>0{index + 1}</span>
            </div>
            <div className="direction-hero">
              <small>{direction.name}</small>
              <h2>{direction.thesis}</h2>
              <p>{hero.body}</p>
              <button>{hero.cta} ↗</button>
              <i className="direction-image">
                <b>{direction.code}</b>
                <span />
              </i>
            </div>
          </>
        )}
      </div>
      <div className="direction-meta">
        <span>0{index + 1}</span>
        <div>
          <small>{direction.concept || direction.name}</small>
          <h3>{direction.name}</h3>
          <p>{direction.visual}</p>
          <dl>
            <div>
              <dt>LAYOUT</dt>
              <dd>{direction.layout}</dd>
            </div>
            <div>
              <dt>TYPE</dt>
              <dd>{direction.type}</dd>
            </div>
            <div>
              <dt>MOTION</dt>
              <dd>{direction.motion || "スクロールに合わせた段階表示"}</dd>
            </div>
          </dl>
        </div>
        <button>{selected ? "選択中 ✓" : "この方向を選ぶ"}</button>
      </div>
    </article>
  );
}

function Tone({
  directions,
  sections,
  selected,
  setSelected,
  onRegenerate,
  onNext,
  busyKind,
  status,
}) {
  const [instruction, setInstruction] = useState("");
  if (busyKind)
    return (
      <Generating
        title={
          busyKind === "design"
            ? "選んだ世界観をLPへ展開しています"
            : "3つの世界観を画像でつくっています"
        }
        status={status}
      />
    );
  return (
    <main className="workspace tone-work">
      <div className="work-title">
        <div>
          <small>04 / デザイン</small>
          <h1>デザイン方向を選択</h1>
        </div>
        <button className="primary" disabled={!selected} onClick={onNext}>
          選択して実装 <i>→</i>
        </button>
      </div>
      <div className="directions">
        {directions.map((direction, index) => (
          <DirectionPreview
            key={direction.id}
            direction={direction}
            sections={sections}
            index={index}
            selected={selected === direction.id}
            onSelect={() => setSelected(direction.id)}
          />
        ))}
      </div>
      <div className="tone-prompt">
        <input
          value={instruction}
          onChange={(event) => setInstruction(event.target.value)}
          placeholder="例:Aの余白感、Bの色、Cの人物表現でもう3案"
        />
        <button
          onClick={() => onRegenerate(instruction)}
          disabled={!instruction.trim()}
        >
          3案を作り直す
        </button>
      </div>
    </main>
  );
}

function Implementation({
  project,
  sections,
  tone,
  images,
  audit,
  comments,
  setComments,
  onRegenerate,
  busyKind,
  status,
}) {
  const [viewport, setViewport] = useState("desktop");
  const [mode, setMode] = useState("preview");
  const [draft, setDraft] = useState(null);
  const [message, setMessage] = useState("");
  const [downloadError, setDownloadError] = useState("");
  const frameRef = useRef(null);
  const html = useMemo(
    () => exportHtml(project, sections, tone, images),
    [project, sections, tone, images],
  );
  const download = async () => {
    setDownloadError("");
    const violations = audit?.mustViolations || [];
    if (violations.length) {
      setDownloadError(`品質ゲート未完了: ${violations.map((item) => item.label).join(" / ")}`);
      return;
    }
    try {
      const blob = await buildExportBundle({ project, sections, tone, images });
      const url = URL.createObjectURL(blob);
      const anchor = document.createElement("a");
      anchor.href = url;
      anchor.download = "landing-page-package.zip";
      anchor.click();
      URL.revokeObjectURL(url);
    } catch (error) {
      setDownloadError(error.message);
    }
  };
  const connectFrame = () => {
    const doc = frameRef.current?.contentDocument;
    if (!doc) return;
    doc.querySelectorAll("section").forEach((section) => {
      section.style.cursor = "crosshair";
      section.addEventListener("click", (event) => {
        event.preventDefault();
        const rect = section.getBoundingClientRect();
        const target = event.target?.closest?.(
          "h1,h2,h3,p,img,a,button,form,input,textarea,section",
        );
        const className =
          typeof target?.className === "string"
            ? target.className.trim().split(/\s+/u)[0]
            : "";
        setDraft({
          sectionId: section.id,
          x: ((event.clientX - rect.left) / rect.width) * 100,
          y: ((event.clientY - rect.top) / rect.height) * 100,
          element: `${target?.tagName?.toLowerCase() || "section"}${className ? `.${className}` : ""}`,
        });
      });
    });
  };
  useEffect(() => {
    const doc = frameRef.current?.contentDocument;
    if (!doc) return;
    doc.querySelectorAll(".studio-review-pin").forEach((pin) => pin.remove());
    comments.forEach((comment, index) => {
      const section = doc.getElementById(comment.sectionId);
      if (!section) return;
      if (getComputedStyle(section).position === "static") section.style.position = "relative";
      const pin = doc.createElement("b");
      pin.className = `studio-review-pin ${comment.status}`;
      pin.textContent = String(index + 1);
      Object.assign(pin.style, {
        position: "absolute",
        left: `${comment.x}%`,
        top: `${comment.y}%`,
        zIndex: "99",
        display: "grid",
        placeItems: "center",
        width: "28px",
        height: "28px",
        borderRadius: "50%",
        color: "#fff",
        background: comment.status === "applied" ? "#526056" : "#ff4f36",
        border: "2px solid #fff",
        boxShadow: "0 4px 14px rgba(0,0,0,.22)",
        font: "700 12px/1 Arial,sans-serif",
        transform: "translate(-50%,-50%)",
        pointerEvents: "none",
      });
      section.appendChild(pin);
    });
  }, [comments, html]);
  const save = () => {
    if (!draft || !message.trim()) return;
    setComments((items) => [
      ...items,
      createComment(
        draft.sectionId,
        draft.x,
        draft.y,
        message.trim(),
        draft.element,
      ),
    ]);
    setDraft(null);
    setMessage("");
  };
  if (busyKind)
    return <Generating title="デザインを再生成しています" status={status} />;
  return (
    <main className="implementation">
      <div className="canvas-bar">
        <div>
          <small>05 / 実装</small>
          <b>LPプレビューと書き出し</b>
        </div>
        <div className="viewport-switch">
          <button
            className={mode === "preview" ? "active" : ""}
            onClick={() => setMode("preview")}
          >
            PREVIEW
          </button>
          <button
            className={mode === "code" ? "active" : ""}
            onClick={() => setMode("code")}
          >
            CODE
          </button>
          <button
            className={viewport === "desktop" ? "active" : ""}
            onClick={() => setViewport("desktop")}
          >
            PC
          </button>
          <button
            className={viewport === "mobile" ? "active" : ""}
            onClick={() => setViewport("mobile")}
          >
            SP
          </button>
        </div>
        <button className="primary" onClick={download} disabled={Boolean(audit?.mustViolations?.length)}>
          納品ZIPをダウンロード ↓
        </button>
        {downloadError && <p className="download-error">{downloadError}</p>}
      </div>
      <div className="implementation-review">
        <div className={`implementation-bed ${viewport}`}>
          {mode === "preview" ? (
            <iframe
              ref={frameRef}
              onLoad={connectFrame}
              title="生成LPプレビュー"
              srcDoc={html}
            />
          ) : (
            <pre>
              <code>{html}</code>
            </pre>
          )}
        </div>
        <aside className="feedback implementation-feedback">
          <small>DESIGN COMMENTS</small>
          <p>プレビュー内の修正したい場所をクリックしてください。</p>
          {draft && (
            <div className="implementation-comment">
              <b>
                {draft.sectionId} / {draft.element}
              </b>
              <textarea
                autoFocus
                value={message}
                onChange={(event) => setMessage(event.target.value)}
                placeholder="この箇所をどう変える?"
              />
              <button onClick={save}>コメント追加</button>
            </div>
          )}
          {comments.map((comment, index) => (
            <article key={comment.id} className={comment.status}>
              <i>{index + 1}</i>
              <div>
                <b>
                  {comment.sectionId} / {comment.element}
                </b>
                <p>{comment.message}</p>
                <small>
                  {comment.status === "applied" ? "反映済み" : "未反映"}
                </small>
              </div>
            </article>
          ))}
          <button
            disabled={!comments.some((item) => item.status === "open")}
            onClick={() =>
              onRegenerate(comments.filter((item) => item.status === "open"))
            }
          >
            コメントを反映して再生成
          </button>
        </aside>
      </div>
    </main>
  );
}

function App() {
  const restored = useMemo(() => readDraft(), []);
  const localPreview =
    import.meta.env.DEV &&
    new URLSearchParams(window.location.search).get("preview") === "wireframe";
  const layoutPreview =
    import.meta.env.DEV &&
    new URLSearchParams(window.location.search).get("preview") === "wireframe-library";
  const [accessToken, setAccessToken] = useState(() => window.sessionStorage.getItem(ACCESS_KEY) || "");
  const emptyGate = {
    applied: [],
    skipped: [],
    known: [],
    hypotheses: [],
    unconfirmed: [],
  };
  const [stage, setStage] = useState(
    localPreview || layoutPreview
      ? "wireframe"
      : stages.some((item) => item.id === restored?.stage)
      ? restored.stage
      : "hearing",
  );
  const [project, setProject] = useState(restored?.project || initialProject);
  const [markdown, setMarkdown] = useState(
    restored?.markdown || initialMarkdown,
  );
  const [sections, setSections] = useState(
    layoutPreview
      ? wireframeVariantPreviewSections
      : restored?.sections || initialSections,
  );
  const [headlines, setHeadlines] = useState(
    restored?.headlines || [
      { id: "initial-1", copy: initialSections[0].title, reason: "初期案" },
    ],
  );
  const [headlineId, setHeadlineId] = useState(
    restored?.headlineId || "initial-1",
  );
  const [qualityGate, setQualityGate] = useState(
    restored?.qualityGate || emptyGate,
  );
  const [wireframeAudit, setWireframeAudit] = useState(
    restored?.wireframeAudit || null,
  );
  const [directions, setDirections] = useState(
    restored?.directions || toneDirections,
  );
  const [toneId, setToneId] = useState(restored?.toneId || null);
  const [designImages, setDesignImages] = useState(
    restored?.designImages || [],
  );
  const [comments, setComments] = useState(restored?.comments || []);
  const [versions, setVersions] = useState(restored?.versions || []);
  const [saveStatus, setSaveStatus] = useState(restored ? "saved" : "draft");
  const [busyKind, setBusyKind] = useState(null);
  const [status, setStatus] = useState("");
  const [errorMessage, setErrorMessage] = useState("");
  const tone = useMemo(
    () => directions.find((item) => item.id === toneId) || directions[0],
    [directions, toneId],
  );
  useEffect(() => {
    window.scrollTo({ top: 0, behavior: "smooth" });
  }, [stage]);
  useEffect(() => {
    setSaveStatus("saving");
    const timer = window.setTimeout(() => {
      try {
        window.localStorage.setItem(
          STORAGE_KEY,
          JSON.stringify({
            stage,
            project: {
              ...project,
              sourceText: String(project.sourceText || "").slice(0, 400000),
            },
            markdown,
            sections,
            headlines,
            headlineId,
            qualityGate,
            wireframeAudit,
            directions,
            toneId,
            designImages,
            comments,
            versions,
          }),
        );
        setSaveStatus("saved");
      } catch {
        setSaveStatus("error");
      }
    }, 350);
    return () => window.clearTimeout(timer);
  }, [
    stage,
    project,
    markdown,
    sections,
    headlines,
    headlineId,
    qualityGate,
    wireframeAudit,
    directions,
    toneId,
    designImages,
    comments,
    versions,
  ]);
  const pushVersion = (label, snapshot = {}) =>
    setVersions((items) => [
      ...items.slice(-7),
      {
        id: `version-${Date.now()}`,
        label,
        createdAt: new Date().toISOString(),
        sections: snapshot.sections || sections,
        markdown: snapshot.markdown || markdown,
        headlines: snapshot.headlines || headlines,
        headlineId: snapshot.headlineId || headlineId,
        qualityGate: snapshot.qualityGate || qualityGate,
        wireframeAudit: snapshot.wireframeAudit || wireframeAudit,
        directions: snapshot.directions || directions,
        toneId: snapshot.toneId ?? toneId,
        designImages: snapshot.designImages || designImages,
      },
    ]);
  const restoreVersion = (version) => {
    setSections(version.sections || sections);
    setMarkdown(version.markdown || markdown);
    setHeadlines(version.headlines || headlines);
    setHeadlineId(version.headlineId || headlineId);
    setQualityGate(version.qualityGate || emptyGate);
    setWireframeAudit(version.wireframeAudit || null);
    setDirections(version.directions || directions);
    setToneId(version.toneId ?? toneId);
    setDesignImages(version.designImages || []);
    setStage("structure");
  };
  const selectHeadline = (item) => {
    setHeadlineId(item.id);
    const next = sections.map((section, index) =>
      index === 0 ? { ...section, title: item.copy } : section,
    );
    setSections(next);
    setMarkdown(sectionsToMarkdown(next));
  };
  const analyzeSource = async (nextProject) => {
    setErrorMessage("");
    setBusyKind("brief");
    try {
      const result = await runGeneration("brief", nextProject, setStatus);
      setProject((current) => ({
        ...current,
        name: result.brief.projectName || current.name,
        sourceSummary: result.brief.summary,
        sourceFilled: result.brief.filled,
        sourceNeedsConfirmation: result.brief.needsConfirmation,
        answers: { ...result.brief.answers, ...current.answers },
      }));
    } catch (error) {
      setErrorMessage(error.message);
    } finally {
      setBusyKind(null);
    }
  };
  const generateStructure = async (instruction = "") => {
    setErrorMessage("");
    setBusyKind("structure");
    try {
      const result = await runGeneration(
        "structure",
        { ...project, markdown: instruction ? markdown : "", instruction },
        setStatus,
      );
      const nextMarkdown = sectionsToMarkdown(result.sections);
      setSections(result.sections);
      setMarkdown(nextMarkdown);
      setHeadlines(result.headlines);
      setHeadlineId(result.headlines[0]?.id);
      setQualityGate(result.qualityGate || emptyGate);
      const audit = await runWireframeAudit(result.sections, project.answers, result.qualityGate || emptyGate);
      setWireframeAudit(audit);
      pushVersion(instruction ? "構成を再生成" : "初回構成", {
        sections: result.sections,
        markdown: nextMarkdown,
        headlines: result.headlines,
        headlineId: result.headlines[0]?.id,
        qualityGate: result.qualityGate || emptyGate,
        wireframeAudit: audit,
      });
      setStage("structure");
    } catch (error) {
      setErrorMessage(error.message);
    } finally {
      setBusyKind(null);
    }
  };
  const generateWireframe = async (feedback = "") => {
    const feedbackItems = Array.isArray(feedback) ? feedback : [];
    const instruction = feedbackItems.length
      ? JSON.stringify(
          feedbackItems.map(({ id, sectionId, x, y, element, message }) => ({
            id,
            sectionId,
            x: Math.round(x),
            y: Math.round(y),
            element,
            message,
          })),
        )
      : feedback;
    setErrorMessage("");
    setBusyKind("wireframe");
    try {
      const result = await runGeneration(
        "wireframe",
        { ...project, markdown, instruction },
        setStatus,
      );
      const nextSections = mergeTargetedSections(
        sections,
        result.sections,
        feedbackItems,
      );
      const nextMarkdown = sectionsToMarkdown(nextSections);
      setSections(nextSections);
      setMarkdown(nextMarkdown);
      setQualityGate(result.qualityGate || qualityGate);
      const audit = await runWireframeAudit(nextSections, project.answers, result.qualityGate || qualityGate);
      setWireframeAudit(audit);
      pushVersion(
        feedbackItems.length
          ? `WF部分修正 ${new Set(feedbackItems.map((item) => item.sectionId)).size}箇所`
          : "WF生成",
        {
          sections: nextSections,
          markdown: nextMarkdown,
          qualityGate: result.qualityGate || qualityGate,
          wireframeAudit: audit,
        },
      );
      if (feedbackItems.length)
        setComments((items) =>
          items.map((item) =>
            feedbackItems.some((feedbackItem) => feedbackItem.id === item.id)
              ? { ...item, status: "applied" }
              : item,
          ),
        );
      setStage("wireframe");
    } catch (error) {
      setErrorMessage(error.message);
    } finally {
      setBusyKind(null);
    }
  };
  const generateTone = async (instruction = "") => {
    setErrorMessage("");
    setBusyKind("tone");
    try {
      const result = await runGeneration(
        "tone",
        { ...project, markdown, instruction },
        setStatus,
      );
      setStatus("3方向の実画像を生成しています");
      const imageSets = await Promise.all(
        result.directions.map((direction) =>
          runImageGeneration(
            { project, sections, direction, purpose: "tone" },
            setStatus,
          ),
        ),
      );
      const withImages = result.directions.map((direction, index) => ({
        ...direction,
        previewImage: imageSets[index]?.[0]?.src,
      }));
      setDirections(withImages);
      setToneId(null);
      pushVersion("トンマナ3案", { directions: withImages, toneId: null });
      setStage("tone");
    } catch (error) {
      setErrorMessage(error.message);
    } finally {
      setBusyKind(null);
    }
  };
  const generateDesign = async () => {
    setErrorMessage("");
    setBusyKind("design");
    try {
      const images = await runImageGeneration(
        { project, sections, direction: tone, purpose: "implementation" },
        setStatus,
      );
      setDesignImages(images);
      pushVersion("実装生成", { designImages: images, toneId: tone.id });
      setStage("implementation");
    } catch (error) {
      setErrorMessage(error.message);
    } finally {
      setBusyKind(null);
    }
  };
  const regenerateImplementation = async (feedbackItems) => {
    const instruction = JSON.stringify(
      feedbackItems.map(({ id, sectionId, x, y, element, message }) => ({
        id,
        sectionId,
        x: Math.round(x),
        y: Math.round(y),
        element,
        message,
      })),
    );
    setErrorMessage("");
    setBusyKind("implementation");
    try {
      const result = await runGeneration(
        "wireframe",
        { ...project, markdown, instruction },
        setStatus,
      );
      const nextSections = mergeTargetedSections(
        sections,
        result.sections,
        feedbackItems,
      );
      const regeneratedImages = await runImageGeneration(
        {
          project,
          sections: nextSections,
          direction: {
            ...tone,
            concept: `${tone.concept}。修正指示: ${feedbackItems.map((item) => item.message).join(" / ")}`,
          },
          purpose: "implementation",
          sectionIds: [...new Set(feedbackItems.map((item) => item.sectionId))],
        },
        setStatus,
      );
      const images = mergeVisualRecords(designImages, regeneratedImages);
      const nextMarkdown = sectionsToMarkdown(nextSections);
      setSections(nextSections);
      setMarkdown(nextMarkdown);
      setDesignImages(images);
      setQualityGate(result.qualityGate || qualityGate);
      pushVersion(
        `実装を部分修正 ${new Set(feedbackItems.map((item) => item.sectionId)).size}箇所`,
        {
          sections: nextSections,
          markdown: nextMarkdown,
          designImages: images,
          qualityGate: result.qualityGate || qualityGate,
        },
      );
      setComments((items) =>
        items.map((item) =>
          feedbackItems.some((feedbackItem) => feedbackItem.id === item.id)
            ? { ...item, status: "applied" }
            : item,
        ),
      );
    } catch (error) {
      setErrorMessage(error.message);
    } finally {
      setBusyKind(null);
    }
  };
  const syncMarkdown = (value) => {
    setMarkdown(value);
    setSections((current) => markdownToSections(value, current));
  };
  const syncSections = (value) => {
    setSections(value);
    setMarkdown(sectionsToMarkdown(value));
  };
  let content;
  if (stage === "hearing")
    content = (
      <Hearing
        project={project}
        setProject={setProject}
        onComplete={() => generateStructure()}
        onSourceLoaded={analyzeSource}
        generating={Boolean(busyKind)}
        generationKind={busyKind}
        generationStatus={status}
      />
    );
  else if (stage === "structure")
    content = (
      <Structure
        markdown={markdown}
        setMarkdown={syncMarkdown}
        sections={sections}
        onChangeSections={syncSections}
        headlines={headlines}
        qualityGate={qualityGate}
        selectedHeadline={headlineId}
        onSelectHeadline={selectHeadline}
        onRegenerate={generateStructure}
        onNext={() => generateWireframe()}
        busyKind={busyKind}
        status={status}
      />
    );
  else if (stage === "wireframe")
    content = (
      <Wireframe
        sections={sections}
        audit={wireframeAudit}
        comments={comments}
        setComments={setComments}
        onRegenerate={generateWireframe}
        onNext={() => generateTone()}
        busyKind={busyKind}
        status={status}
      />
    );
  else if (stage === "tone")
    content = (
      <Tone
        directions={directions}
        sections={sections}
        selected={toneId}
        setSelected={setToneId}
        onRegenerate={generateTone}
        onNext={generateDesign}
        busyKind={busyKind}
        status={status}
      />
    );
  else
    content = (
      <Implementation
        project={project}
        sections={sections}
        tone={tone}
        images={designImages}
        audit={wireframeAudit}
        comments={comments}
        setComments={setComments}
        onRegenerate={regenerateImplementation}
        busyKind={busyKind}
        status={status}
      />
    );
  if (!accessToken && !localPreview && !layoutPreview)
    return <AccessGate onUnlock={setAccessToken} />;
  return (
    <div className="studio">
      <Header
        stage={stage}
        setStage={setStage}
        saveStatus={saveStatus}
        versions={versions}
        onRestore={restoreVersion}
      />
      {content}
      {errorMessage && (
        <aside className="runtime-error">
          <i>!</i>
          <div>
            <b>生成を完了できませんでした</b>
            <p>{errorMessage}。入力内容と現在の版は保持されています。</p>
          </div>
          <button
            aria-label="エラーを閉じる"
            onClick={() => setErrorMessage("")}
          >
            ×
          </button>
        </aside>
      )}
    </div>
  );
}

createRoot(document.getElementById("root")).render(<App />);