Files
AriWeb-Suite/modules/webmaker/public/wizard.js
T

609 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// 기획 위저드: 방식 선택 → [AI에게 맡기기] 또는 [내가 고르기] → Brief 확정 → 생성 → 검증 → 수정 → 발행
(() => {
let CAT = null; let running = false; let editingBrief = false; let manualStep = 1; let structEdit = false; let showBriefJson = false;
const refs = { ai: [], manual: [] };
let saveTimer = null;
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
const B = () => current && current.plan && current.plan.brief;
const stageOf = () => (current && current.plan ? current.plan.stage : null);
const POST_STAGES = ['generating', 'generated', 'published'];
const lbl = (list, id) => { const x = (list || []).find((i) => i.id === id); return x ? x.label : id; };
async function loadCatalog() { if (!CAT) CAT = await api('/api/catalog'); return CAT; }
/**
* 기획 단계 요청. v1.3.17 (B26): 요청을 보낸 사이트 ID 를 붙잡아 두고, 응답이 왔을 때 화면이 이미 다른 사이트로 바뀌었으면
* 그 응답을 지금 사이트(current)에 합치지 않는다 (A 의 응답이 B 화면에 섞이던 문제). r.stale 로 알려 준다.
*/
async function planReq(method, step, body, siteId) { const id = siteId || current.id; const r = await api(`/api/sites/${id}/plan/${step}`, { method, body: JSON.stringify(body || {}) }); if (!current || current.id !== id) return { ...r, stale: true }; if (r.plan) current.plan = r.plan; if (r.site) Object.assign(current, r.site); return r; }
const planPost = (step, body) => planReq('POST', step, body);
const planPut = (step, body, siteId) => planReq('PUT', step, body, siteId);
function saveBriefSoon(immediate) {
clearTimeout(saveTimer);
// v1.3.17 (B26): 저장할 사이트와 기획(brief)을 예약할 때 붙잡는다 — 0.5초 안에 다른 사이트로 바꿔도 원래 사이트에 저장된다
const id = current && current.id; const brief = B(); if (!id || !brief) return;
const run = async () => { try { const r = await planPut('brief', { brief }, id); if (!r.stale) renderWarnings(); } catch (e) { logLine({ level: 'warn', text: '기획 저장 실패: ' + e.message }); } };
if (immediate) return run(); saveTimer = setTimeout(run, 500);
}
// ---------- 참고 사이트 편집기 (최대 10개) ----------
function renderRefs(key, box) {
box = box || $(key === 'ai' ? 'refsAi' : 'refsManual'); box.innerHTML = '';
refs[key].forEach((r, i) => {
const d = el('div', 'ref');
const u = el('input'); u.placeholder = 'https://example.com'; u.value = r.url; u.oninput = () => { r.url = u.value; };
const rm = el('button', 'rm', '×'); rm.type = 'button'; rm.title = '삭제'; rm.onclick = () => { refs[key].splice(i, 1); renderRefs(key, box); };
const n = el('textarea'); n.rows = 2; n.placeholder = '이 사이트에서 참고·반영할 점 (예: 상단 메뉴 구성, 색감, 가격표 형식, 예약 버튼 위치)'; n.value = r.notes; n.oninput = () => { r.notes = n.value; };
d.appendChild(u); d.appendChild(rm); d.appendChild(n); box.appendChild(d);
});
if (refs[key].length < 10) { const add = el('button', 'ghost small-btn add', `+ 참고 사이트 추가 (${refs[key].length}/10)`); add.type = 'button'; add.onclick = () => { refs[key].push({ url: '', notes: '' }); renderRefs(key, box); }; box.appendChild(add); }
else box.appendChild(el('div', 'muted small', '최대 10개까지 추가할 수 있습니다.'));
}
const cleanRefs = (list) => list.filter((r) => r.url.trim()).map((r) => ({ url: r.url.trim(), notes: r.notes.trim() })).slice(0, 10);
// ---------- 방식 선택 / 시작 폼 ----------
// v1.2.0: 방식 선택은 "1 어떻게 만들까요?"(modeCard) 와 "2 기존 사이트 관리"(existingCard) 두 카드로 나뉜다
const START_CARDS = ['aiStartCard', 'manualStartCard', 'copyStartCard', 'templateStartCard']; // v1.3.4: importStartCard 삭제, v1.3.10: 템플릿
function showModeChooser() { for (const id of [...START_CARDS, 'planCard']) $(id).classList.add('hidden'); $('modeCard').classList.remove('hidden'); $('existingCard').classList.remove('hidden'); editingBrief = false; }
function hideStart() { for (const id of ['modeCard', 'existingCard', ...START_CARDS]) $(id).classList.add('hidden'); }
function fillSelect(sel, items, value, placeholder) { sel.innerHTML = ''; if (placeholder) sel.appendChild(new Option(placeholder, '')); for (const it of items) sel.appendChild(new Option(it, it)); if (value !== undefined) sel.value = value; }
document.querySelectorAll('.mode-opt').forEach((b) => b.onclick = async () => {
$('modeCard').classList.add('hidden'); $('existingCard').classList.add('hidden');
if (b.dataset.mode === 'copy') { if (window.Importer) Importer.showCopyStart(); return; }
if (b.dataset.mode === 'template') { if (window.TemplatesUI) TemplatesUI.showStart(); return; }
await loadCatalog();
if (b.dataset.mode === 'ai') { $('aiStartCard').classList.remove('hidden'); renderRefs('ai'); const ts = $('aiTone'); if (ts.options.length <= 1) for (const t of CAT.TONES) ts.appendChild(new Option(`${T(t.label)} — ${T(t.desc)}`, t.id)); }
else {
$('manualStartCard').classList.remove('hidden'); renderRefs('manual');
fillSelect($('mMajor'), CAT.INDUSTRIES.map((i) => i.major), '', '선택'); fillSelect($('mMinor'), [], '', '선택');
fillSelect($('mSido'), Object.keys(CAT.REGIONS), '', '선택'); fillSelect($('mSigungu'), [], '', '선택');
}
});
$('aiColor').addEventListener('input', () => { $('aiColor').dataset.touched = '1'; $('aiColorText').value = $('aiColor').value; });
$('btnBackMode').onclick = showModeChooser; $('btnBackMode2').onclick = showModeChooser; $('btnBackMode4').onclick = showModeChooser; $('btnBackMode5').onclick = showModeChooser;
$('mMajor').onchange = () => { const ind = CAT.INDUSTRIES.find((i) => i.major === $('mMajor').value); fillSelect($('mMinor'), ind ? ind.minors : [], '', '선택'); };
$('mSido').onchange = () => fillSelect($('mSigungu'), CAT.REGIONS[$('mSido').value] || [], '', '선택');
$('btnAiStart').onclick = async () => {
const text = $('aiText').value.trim(), url = $('aiExistingUrl').value.trim();
if (!text && !url && !pending.create.length) { alert('만들고 싶은 사이트를 설명하거나 자료를 첨부해 주세요.'); return; }
const fd = new FormData(); fd.append('mode', 'ai'); fd.append('tier', $('tierSelect').value || ''); fd.append('text', text); fd.append('existingUrl', url); fd.append('references', JSON.stringify(cleanRefs(refs.ai))); fd.append('optimize', JSON.stringify(seoOptsOf('aiStartCard'))); fd.append('langs', JSON.stringify(langOptsOf('aiStartCard'))); appendFiles(fd, 'create');
const design = { color: ($('aiColorText').value.trim() || ($('aiColor').dataset.touched ? $('aiColor').value : '')), tone: $('aiTone').value, direction: $('aiDesignDir').value.trim() };
fd.append('design', JSON.stringify(design)); fd.append('pageCount', $('aiPageCount').value || '0');
$('btnAiStart').disabled = true;
try {
const site = await api('/api/sites', { method: 'POST', body: fd }); clearPending('create');
$('aiText').value = ''; $('aiExistingUrl').value = ''; $('aiDesignDir').value = ''; $('aiColorText').value = ''; refs.ai = [];
await openSite(site.id); await refreshSiteList();
if (design.color || design.tone || design.direction) { try { await planPut('input', { design }); } catch (_) {} }
await runStep('analyze');
} catch (e) { alert('시작 실패: ' + e.message); }
$('btnAiStart').disabled = false;
};
$('btnManualStart').onclick = async () => {
const name = $('mName').value.trim(); if (!name) { alert('상호명을 입력하세요.'); $('mName').focus(); return; }
$('btnManualStart').disabled = true;
try {
const site = await api('/api/sites', { method: 'POST', body: JSON.stringify({ mode: 'manual', tier: $('tierSelect').value || '', businessName: name, references: cleanRefs(refs.manual), optimize: seoOptsOf('manualStartCard'), langs: langOptsOf('manualStartCard') }) });
await openSite(site.id); await refreshSiteList();
await planPut('brief', { brief: { business: { name, industry: { major: $('mMajor').value, minor: $('mMinor').value }, region: { sido: $('mSido').value, sigungu: $('mSigungu').value } } }, applyIndustryDefaults: !!$('mMajor').value });
$('mName').value = ''; refs.manual = []; manualStep = $('mMajor').value ? 2 : 1;
render();
} catch (e) { alert('시작 실패: ' + e.message); }
$('btnManualStart').disabled = false;
};
async function runStep(step, body) {
setBusy(true, step);
try { await planPost(step, body); } catch (e) { const cancelled = /중단/.test(e.message); logLine({ level: 'warn', text: `${step} ${cancelled ? '중단됨' : '실패: ' + e.message}` }); if (!autoRunning && !cancelled) alert(e.message); if (cancelled) autoRunning = false; }
setBusy(false); render();
}
let busy = null;
function setBusy(on, step) { busy = on ? step : null; render(); }
function setRunning(r) { if (running === r) return; running = r; if ($('planCard') && !$('planCard').classList.contains('hidden')) render(); }
let pendingRender = false; let localDirty = false;
function typingInPlan() { const a = document.activeElement; return !!(a && $('planBody').contains(a) && /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName)); }
$('planBody').addEventListener('input', () => { localDirty = true; });
$('planBody').addEventListener('focusout', () => { setTimeout(() => { if (pendingRender && !typingInPlan()) { pendingRender = false; render(); } }, 50); });
/** 서버에서 온 plan 이벤트: 편집 중(미저장)이고 단계가 같으면 로컬 편집을 유지 */
function onPlanEvent(ev) {
if (!current || !ev.plan) return;
const sameStage = current.plan && current.plan.stage === ev.plan.stage;
if (sameStage && localDirty) { current.plan.warnings = ev.plan.warnings; current.plan.verification = ev.plan.verification; return; }
current.plan = ev.plan; if (ev.site) { const keep = current.plan; Object.assign(current, ev.site); current.plan = keep; }
localDirty = false; onSiteOpened(current);
}
// ---------- 진입점 ----------
function onSiteOpened(site) {
hideStart();
if (site && current && site.id !== current.id) localDirty = false;
if (!site || !site.plan) { $('planCard').classList.add('hidden'); $('publishCard').classList.add('hidden'); if (site) $('updateCard').classList.remove('hidden'); return; }
const st = site.plan.stage;
// v1.2.0: 복사한 사이트(plan.mode 'copy')는 기획 단계가 없다 — 기획 카드는 숨기고, 복사가 끝나면 수정·발행만 보인다
if (site.plan.mode === 'copy' || site.plan.mode === 'template') { /* v1.3.10: 템플릿으로 시작한 사이트도 기획 단계가 없다 */ $('planCard').classList.add('hidden'); $('updateCard').classList.toggle('hidden', st === 'importing'); if (POST_STAGES.includes(st)) renderPublishCard(); else $('publishCard').classList.add('hidden'); return; }
$('updateCard').classList.toggle('hidden', !(st === 'generated' || st === 'published'));
$('planCard').classList.remove('hidden');
render();
if (POST_STAGES.includes(st)) renderPublishCard(); else $('publishCard').classList.add('hidden');
}
// ---------- 렌더 ----------
const STAGE_LABEL = { input: '요구 입력', questions: '추가 질문', understanding: '이해 확인', structure: '구조 제안', design: '디자인 선택', brief: 'Brief 확인', confirmed: 'Brief 확정', generating: '생성 중', generated: '생성 완료', published: '최종 확정됨', manual: '직접 선택' };
function render() {
if (!current || !current.plan) return;
if (typingInPlan()) { pendingRender = true; return; } // 입력 중에는 다시 그리지 않음(포커스 유지)
const plan = current.plan; const body = $('planBody'); body.innerHTML = '';
$('planTitle').textContent = plan.mode === 'ai' ? 'AI에게 맡기기' : '내가 고르기';
const pill = $('planStage'); pill.textContent = busy ? '처리 중…' : (STAGE_LABEL[plan.stage] || plan.stage); pill.className = 'pill ' + (busy || running || plan.stage === 'generating' ? 'running' : plan.stage === 'published' ? 'done' : 'idle');
const auto = plan.autoRun && !['done', 'error'].includes(plan.autoRun.stage);
if (busy || running || plan.stage === 'generating' || auto) {
const AUTO = { structure: '구조 제안', design: '디자인 결정', confirm: 'Brief 확정', generate: '페이지 생성', verify: '검증', 'waiting-credit': '캐시 충전 대기 (충전되면 멈춘 단계부터 자동으로 이어서 진행)' };
body.appendChild(el('p', 'muted small', current.status === 'waiting-credit' ? '캐시가 소진되어 작업이 잠시 멈췄습니다. 지금까지의 작업은 저장되었고, 캐시가 충전되면 자동으로 이어서 진행합니다.' : auto ? `자동 생성 진행 중 — ${AUTO[plan.autoRun.stage] || plan.autoRun.stage} 단계. 끝나면 자동으로 갱신됩니다.` : busy === 'publish' ? '배포가 진행 중입니다. 아래 "진행 상태" 상자에서 단계와 완료 여부를 확인할 수 있습니다.' : busy ? 'AI 가 처리하고 있습니다. 아래 진행 로그를 확인하세요.' : '작업이 실행 중입니다. 완료되면 자동으로 갱신됩니다.'));
if (plan.stage === 'generating' || busy || auto) return;
}
if (plan.autoRun && plan.autoRun.stage === 'error') { const w = el('div', 'progress-warn'); w.textContent = `자동 생성이 "${plan.autoRun.stage}" 에서 멈췄습니다: ${plan.autoRun.error || ''} — 아래 버튼으로 이어서 진행하세요.`; body.appendChild(w); }
const st = plan.stage;
try {
if (editingBrief && POST_STAGES.includes(st)) { renderManualWizard(body, true); }
else if (plan.mode === 'ai') {
if (st === 'input') renderAiInput(body);
else if (st === 'questions') renderQuestions(body);
else if (st === 'understanding') renderUnderstanding(body);
else if (st === 'structure') renderStructure(body);
else if (st === 'design') renderDesign(body);
else if (st === 'brief' || st === 'confirmed') renderBriefConfirm(body);
else renderPost(body);
} else {
if (st === 'manual') renderManualWizard(body, false);
else if (st === 'confirmed') renderBriefConfirm(body);
else renderPost(body);
}
if (POST_STAGES.includes(st) && !editingBrief) renderPublishCard();
} catch (e) {
body.appendChild(el('p', 'small', '화면을 그리는 중 오류가 났습니다: ' + e.message + ' — 아래 버튼으로 계속 진행할 수 있습니다.'));
console.error(e);
}
if (plan.mode === 'ai' && !POST_STAGES.includes(st) && !editingBrief && !['input', 'understanding', 'questions'].includes(st)) body.appendChild(progressBar(st));
try { if (!POST_STAGES.includes(st)) showWireframe(B()); } catch (_) {}
}
// 항상 보이는 진행 바: 현재 단계 → 다음 단계 / 끝까지 자동 진행
const NEXT_LABEL = { input: 'AI 이해 확인 요청', questions: '답변 없이 다음 단계', understanding: '다음: 구조 제안 받기', structure: '다음: 디자인 3안 받기', design: '다음: Brief 확인', brief: 'Brief 확정', confirmed: '생성 시작', manual: 'Brief 확정' };
async function generateNow() {
if (!current || running) return;
const hasGen = (current.commands || []).some((c) => c.kind === 'create' && c.status === 'done');
if (!confirm(hasGen ? '현재 설정(지금까지 입력·수정한 내용)으로 전체 페이지를 다시 생성할까요?' : '아직 확정하지 않은 항목은 기본값으로 채워 현재 설정으로 바로 생성할까요?')) return;
setBusy(true, 'generate');
try { await planPost('generate', { force: true, brief: B() }); localDirty = false; }
catch (e) { logLine({ level: 'warn', text: '생성 요청 실패: ' + e.message }); alert(e.message); }
setBusy(false); render();
}
async function nextStep() {
const st = stageOf(); const plan = current.plan;
if (st === 'input') return runStep('analyze');
if (st === 'questions') { plan.stage = 'understanding'; return render(); }
if (st === 'understanding') return runStep('understanding', { brief: B() });
if (st === 'structure') return runStep('structure', { pages: B().pages, force: true });
if (st === 'design') { const d = plan.designOptions || {}; return runStep('design', { optionId: B().design.optionId || d.recommended || (d.options && d.options[0] && d.options[0].id) }); }
if (st === 'brief' || st === 'manual') { if (st === 'manual') await saveBriefSoon(true); return runStep('confirm'); }
if (st === 'confirmed') return runStep('generate');
}
let autoRunning = false;
async function autoRun() {
if (autoRunning) return; autoRunning = true;
try {
let guard = 0;
while (current && current.plan && !POST_STAGES.includes(current.plan.stage) && guard++ < 10) {
const before = current.plan.stage;
await nextStep();
if (current.plan.stage === before && before !== 'questions') break; // 진행 실패 시 중단
}
} finally { autoRunning = false; render(); }
}
function progressBar(st) {
const bar = el('div', 'progress-bar');
const order = current.plan.mode === 'ai' ? ['input', 'understanding', 'structure', 'design', 'brief', 'confirmed'] : ['manual', 'confirmed'];
const idx = Math.max(0, order.indexOf(st === 'questions' ? 'understanding' : st));
bar.appendChild(el('span', 'muted small', current.plan.mode === 'ai' ? `기획 ${idx + 1}/${order.length} 단계` : (st === 'manual' ? `직접 선택 ${manualStep}/9` : 'Brief 확정됨')));
const row = el('div', 'row');
row.appendChild(btn(autoRunning ? '자동 진행 중…' : (NEXT_LABEL[st] || '다음 단계'), 'primary small-btn', nextStep, autoRunning));
row.appendChild(btn('나머지 자동 진행', 'ghost small-btn', autoRun, autoRunning));
bar.appendChild(row);
return bar;
}
function btn(text, cls, fn, disabled) { const b = el('button', cls || 'ghost small-btn', text); b.type = 'button'; b.onclick = fn; b.disabled = !!disabled || !!busy || running; return b; }
function h3(text) { return el('div', 'field-label', text); }
// ---- AI: 입력 재편집 / 질문 ----
function renderAiInput(body) {
const inp = current.plan.input;
body.appendChild(h3('만들고 싶은 사이트 (자유 서술)'));
const t = el('textarea'); t.rows = 4; t.value = inp.text || ''; body.appendChild(t);
body.appendChild(h3('기존 사이트 URL')); const u = el('input'); u.value = inp.existingUrl || ''; body.appendChild(u);
body.appendChild(h3('참고 사이트 (최대 10개)')); refs.ai = (inp.references || []).map((r) => ({ ...r })); const box = el('div', 'refs'); body.appendChild(box); renderRefs('ai', box);
body.appendChild(btn('AI 이해 확인 요청', 'primary small-btn', async () => { await planPut('input', { text: t.value, existingUrl: u.value, references: cleanRefs(refs.ai) }); await runStep('analyze'); }));
}
function renderQuestions(body) {
const qs = (current.plan.understanding && current.plan.understanding.questions) || [];
body.appendChild(el('p', 'small', '업종이나 목표가 분명하지 않아 AI 가 확인을 요청했습니다. 답변 후 다시 분석합니다.'));
const inputs = qs.map((q) => { const box = el('div', 'qbox'); box.appendChild(el('div', 'small', q)); const i = el('input'); i.placeholder = '답변'; box.appendChild(i); body.appendChild(box); return { q, i }; });
body.appendChild(btn('답변 보내고 다시 분석', 'primary small-btn', () => runStep('answers', { answers: inputs.map((x) => ({ q: x.q, a: x.i.value })) })));
body.appendChild(btn('답변 없이 진행', 'ghost small-btn', () => { current.plan.stage = 'understanding'; render(); }));
}
// ---- 공통 위젯 ----
function originBadge(key) { const o = (B().origin || {})[key]; if (!o) return null; return el('span', 'origin ' + o, o === 'user' ? '사용자 명시' : 'AI 추론'); }
function chips(container, items, selected, onToggle, disabledIds = []) {
const box = el('div', 'chips');
for (const it of items) { const id = typeof it === 'string' ? it : it.id; const label = typeof it === 'string' ? it : it.label; const c = el('span', 'chip' + (selected.includes(id) ? ' on' : '') + (disabledIds.includes(id) ? ' disabled' : ''), label); if (!disabledIds.includes(id)) c.onclick = () => onToggle(id); box.appendChild(c); }
container.appendChild(box); return box;
}
function kvRow(grid, k, node) { grid.appendChild(el('div', 'k', k)); grid.appendChild(node); }
function regulatedDisabled() { const r = B().regulated; return r && r.flag && CAT.REGULATIONS[r.category] ? CAT.REGULATIONS[r.category].disableFeatures : []; }
function renderWarnings(box) {
const w = (current.plan && current.plan.warnings) || []; box = box || $('planWarnings'); if (!box) return; box.innerHTML = '';
for (const x of w) box.appendChild(el('li', x.level, x.text));
}
function businessFields(body) {
const b = B(); const g = el('div', 'kv');
const name = el('input'); name.value = b.business.name || ''; name.oninput = () => { b.business.name = name.value; };
const nk = el('div', 'k', '상호명'); const ob = originBadge('business.name'); if (ob) nk.appendChild(ob); g.appendChild(nk); g.appendChild(name);
const maj = el('select'); fillSelect(maj, CAT.INDUSTRIES.map((i) => i.major), b.business.industry.major || '', '선택');
const min = el('select'); const fillMin = () => { const ind = CAT.INDUSTRIES.find((i) => i.major === maj.value); fillSelect(min, ind ? ind.minors : [], b.business.industry.minor || '', '선택'); }; fillMin();
maj.onchange = () => { b.business.industry.major = maj.value; b.business.industry.minor = ''; fillMin(); onIndustryChanged && onIndustryChanged(); }; min.onchange = () => { b.business.industry.minor = min.value; };
const ik = el('div', 'k', '업종'); const oi = originBadge('business.industry'); if (oi) ik.appendChild(oi); g.appendChild(ik); const iw = el('div', 'two'); iw.appendChild(maj); iw.appendChild(min); g.appendChild(iw);
const sido = el('select'); fillSelect(sido, Object.keys(CAT.REGIONS), b.business.region.sido || '', '선택'); const sgg = el('select'); fillSelect(sgg, CAT.REGIONS[b.business.region.sido] || [], b.business.region.sigungu || '', '선택');
sido.onchange = () => { b.business.region.sido = sido.value; b.business.region.sigungu = ''; fillSelect(sgg, CAT.REGIONS[sido.value] || [], '', '선택'); }; sgg.onchange = () => { b.business.region.sigungu = sgg.value; };
kvRow(g, '지역', (() => { const w = el('div', 'two'); w.appendChild(sido); w.appendChild(sgg); return w; })());
const desc = el('textarea'); desc.rows = 2; desc.value = b.business.description || ''; desc.oninput = () => { b.business.description = desc.value; }; kvRow(g, '사업 요약', desc);
body.appendChild(g);
return { maj };
}
let onIndustryChanged = null;
function goalsAndFeatures(body, onChange) {
const b = B();
body.appendChild(h3('목표 (복수 선택)'));
const rerender = () => { body.querySelectorAll('.gf').forEach((x) => x.remove()); draw(); };
const draw = () => {
const wrap = el('div', 'gf');
chips(wrap, CAT.GOALS, b.goals, (id) => { b.goals = b.goals.includes(id) ? b.goals.filter((x) => x !== id) : [...b.goals, id]; for (const f of CAT.GOALS.find((g) => g.id === id).features) if (b.goals.includes(id)) { b.features[f] = true; addFeatureSection(f); } onChange(); rerender(); });
wrap.appendChild(h3('기능 on/off (목표에 따라 자동 체크됨)'));
const dis = regulatedDisabled(); for (const f of dis) b.features[f] = false;
chips(wrap, CAT.FEATURES, Object.keys(b.features).filter((k) => b.features[k]), (id) => { b.features[id] = !b.features[id]; if (b.features[id]) addFeatureSection(id); else removeFeatureSections(id); onChange(); rerender(); }, dis);
if (dis.length) wrap.appendChild(el('div', 'muted small', `규제 업종 제한으로 비활성: ${dis.map((d) => lbl(CAT.FEATURES, d)).join(', ')}`));
body.appendChild(wrap);
};
draw();
}
function addFeatureSection(fid) { const b = B(); if (!b.pages.length) return; if (b.pages.some((p) => p.sections.some((s) => s.feature === fid))) return; const f = CAT.FEATURES.find((x) => x.id === fid); const page = b.pages[b.pages.length - 1]; page.sections.push({ id: f.section, title: CAT.SECTIONS[f.section].label, feature: fid }); }
function removeFeatureSections(fid) { for (const p of B().pages) p.sections = p.sections.filter((s) => s.feature !== fid); }
function regulatedField(body, onChange) {
const b = B(); body.appendChild(h3('규제 업종'));
const sel = el('select'); sel.appendChild(new Option('해당 없음', '')); for (const [k, v] of Object.entries(CAT.REGULATIONS)) sel.appendChild(new Option(v.label, k)); sel.value = b.regulated.category || '';
sel.onchange = () => { const c = sel.value; b.regulated = c ? { flag: true, category: c, constraints: CAT.REGULATIONS[c].constraints } : { flag: false, category: null, constraints: [] }; for (const f of regulatedDisabled()) b.features[f] = false; onChange(); render(); };
body.appendChild(sel);
if (b.regulated.flag) { const ul = el('ul', 'warn-list'); for (const c of b.regulated.constraints) ul.appendChild(el('li', 'info', c)); body.appendChild(ul); }
}
function customersField(body, onChange) {
const b = B(); body.appendChild(h3('고객 유형'));
const base = CAT.INDUSTRIES.find((i) => i.major === b.business.industry.major); const types = [...new Set([...(base ? base.defaults.customers : []), ...b.customers.types])];
const redraw = () => { body.querySelectorAll('.cu').forEach((x) => x.remove()); draw(); };
const draw = () => { const w = el('div', 'cu'); chips(w, types, b.customers.types, (id) => { b.customers.types = b.customers.types.includes(id) ? b.customers.types.filter((x) => x !== id) : [...b.customers.types, id]; onChange(); redraw(); });
const add = el('input'); add.placeholder = '직접 입력 후 Enter'; add.onkeydown = (e) => { if (e.key === 'Enter' && add.value.trim()) { types.push(add.value.trim()); b.customers.types.push(add.value.trim()); onChange(); redraw(); } }; w.appendChild(add);
w.appendChild(h3('연령대')); chips(w, CAT.AGES, b.customers.ages, (id) => { b.customers.ages = b.customers.ages.includes(id) ? b.customers.ages.filter((x) => x !== id) : [...b.customers.ages, id]; onChange(); redraw(); }); body.appendChild(w); };
draw();
}
/**
* 사실정보 입력 표 (v1.0.3): 값을 입력한 항목만 홈페이지에 들어가고, 비워 둔 항목은 홈페이지에서 빠진다.
* (이전의 내용유지/빈항목유지/항목삭제 선택은 없앴다 — 규칙이 하나뿐이라 선택이 필요 없음)
*/
function factsTable(body, onChange) {
const b = B(); const t = el('table', 'facts-table');
const hdr = el('tr'); for (const h of ['항목', '내용 (비우면 홈페이지에서 제외)']) { const th = el('td', 'muted small', h); hdr.appendChild(th); } t.appendChild(hdr);
for (const f of CAT.FACT_FIELDS) {
const fact = b.facts[f.id] || { value: '', source: null, status: 'removed' }; b.facts[f.id] = fact;
const tr = el('tr'); const td1 = el('td', '', f.label); td1.style.width = '96px';
const td2 = el('td'); const inp = el(f.id === 'prices' || f.id === 'reviews' || f.id === 'hours' ? 'textarea' : 'input'); if (inp.tagName === 'TEXTAREA') inp.rows = 2; inp.value = fact.value || ''; inp.dataset.k = 'fact-' + f.id;
inp.placeholder = f.id === 'reviews' ? '실제 고객 후기만 (없으면 비워 두세요)' : '없으면 비워 두세요';
inp.oninput = () => { fact.value = inp.value; fact.status = inp.value.trim() ? 'provided' : 'removed'; if (inp.value.trim() && !fact.source) fact.source = 'user_text'; onChange(); };
td2.appendChild(inp); if (fact.source && fact.value) td2.appendChild(el('div', 'src', `출처: ${fact.source}`));
tr.appendChild(td1); tr.appendChild(td2); t.appendChild(tr);
}
body.appendChild(t);
body.appendChild(el('p', 'muted small', '입력한 값만 홈페이지에 그대로 사용합니다. 비워 둔 항목은 홈페이지에서 해당 항목 자체가 빠지며, AI 가 값을 지어내지 않습니다.'));
}
// ---- AI 2단계: 이해 확인 카드 ----
/**
* AI에게 맡기기 2단계 (v1.0.3): AI 가 이해한 요약을 짧게 보여 주고, 사용자는 "추가 입력이 필요한 항목"(사실정보)만 채운다.
* 입력 완료를 누르면 구조·디자인·확정·생성·검증이 서버에서 자동으로 이어진다(POST /plan/auto).
*/
function renderUnderstanding(body) {
const u = current.plan.understanding || {}; const b = B();
body.appendChild(el('p', 'small', `AI 이해: ${b.business.name || '(상호 미정)'} · ${b.business.industry.major}${b.business.industry.minor ? '/' + b.business.industry.minor : ''} · 목표 ${b.goals.map((g) => lbl(CAT.GOALS, g)).join(', ') || '-'}${u.recommendedStyle ? ' · 추천 톤 ' + lbl(CAT.TONES, u.recommendedStyle.tone) : ''}`));
const det = el('details'); det.appendChild(el('summary', 'small', '이해한 내용 자세히 보기 / 고치기 (상호·업종·지역·목표·기능·고객)')); const inner = el('div');
onIndustryChanged = null; businessFields(inner); goalsAndFeatures(inner, () => {}); customersField(inner, () => {}); regulatedField(inner, () => {}); det.appendChild(inner); body.appendChild(det);
body.appendChild(h3('추가 입력이 필요한 항목 — 입력한 것만 홈페이지에 들어갑니다'));
factsTable(body, () => {});
if (u.referenceInsights && u.referenceInsights.length) { body.appendChild(h3('참고 사이트에서 반영할 점')); const ul = el('ul', 'warn-list'); for (const r of u.referenceInsights) ul.appendChild(el('li', 'info', `${r.url}: ${r.adopt}`)); body.appendChild(ul); }
const row = el('div', 'row');
row.appendChild(btn('입력 완료 — 끝까지 자동 생성', 'primary small-btn', async () => { setBusy(true, 'auto'); try { await planPost('auto', { brief: B() }); localDirty = false; } catch (e) { alert(e.message); } setBusy(false); render(); }));
row.appendChild(btn('다시 분석', 'ghost small-btn', () => runStep('analyze')));
body.appendChild(row);
body.appendChild(el('p', 'muted small', '자동 생성은 구조 제안 → 디자인(입력한 컨셉 반영) → 확정 → 생성 → 검증 순서로 진행되며 중간에 멈추지 않습니다. 끝난 뒤에는 "수정" 탭에서 수정 지시를 보낼 수 있습니다.'));
}
// ---- 페이지 구조 편집기 (AI 3단계 / 내가 고르기 4단계 공통) ----
function pagesEditor(body, onChange) {
const b = B(); const box = el('div', 'pages-edit'); let dragIdx = null;
const draw = () => {
box.innerHTML = '';
b.pages.forEach((p, i) => {
const row = el('div', 'page-row'); row.draggable = true;
row.ondragstart = () => { dragIdx = i; }; row.ondragover = (e) => { e.preventDefault(); row.classList.add('dragover'); }; row.ondragleave = () => row.classList.remove('dragover');
row.ondrop = (e) => { e.preventDefault(); row.classList.remove('dragover'); if (dragIdx === null || dragIdx === i) return; const [m] = b.pages.splice(dragIdx, 1); b.pages.splice(i, 0, m); dragIdx = null; onChange(); draw(); };
const ph = el('div', 'ph'); ph.appendChild(el('span', 'handle', '⋮⋮'));
const t = el('input'); t.value = p.title; t.oninput = () => { p.title = t.value; onChange(); }; ph.appendChild(t);
ph.appendChild(el('span', 'muted small', i === 0 ? 'index.html' : `${p.id}.html`));
const rm = el('button', 'rm', '×'); rm.type = 'button'; rm.onclick = () => { b.pages.splice(i, 1); onChange(); draw(); }; ph.appendChild(rm); row.appendChild(ph);
if (p.purpose) row.appendChild(el('div', 'muted small', p.purpose));
const secs = el('div', 'secs'); const dis = regulatedDisabled();
for (const [sid, sd] of Object.entries(CAT.SECTIONS)) {
const on = p.sections.some((s) => s.id === sid); const disabled = sd.feature && (dis.includes(sd.feature) || !b.features[sd.feature]);
const c = el('span', 'chip' + (on ? ' on' : '') + (disabled && !on ? ' disabled' : ''), sd.label + (sd.feature ? ' ⚙' : '')); c.title = sd.feature ? `기능: ${lbl(CAT.FEATURES, sd.feature)}` : '';
if (!(disabled && !on)) c.onclick = () => { if (on) p.sections = p.sections.filter((s) => s.id !== sid); else p.sections.push({ id: sid, title: sd.label, feature: sd.feature || null }); onChange(); draw(); };
secs.appendChild(c);
}
row.appendChild(secs); box.appendChild(row);
});
const add = el('button', 'ghost small-btn', '+ 페이지 추가'); add.type = 'button'; add.onclick = () => { const id = prompt('페이지 ID (영문, 예: about)'); if (!id) return; const cid = id.replace(/[^a-z0-9-]/gi, '').toLowerCase() || 'page'; b.pages.push({ id: cid, title: CAT.PAGE_TITLES[cid] || id, sections: [] }); onChange(); draw(); }; box.appendChild(add);
};
draw(); body.appendChild(box);
}
function renderStructure(body) {
const s = current.plan.structure || {};
if (s.rationale) body.appendChild(el('p', 'small', s.rationale));
const wl = el('ul', 'warn-list'); wl.id = 'planWarnings'; body.appendChild(wl); renderWarnings(wl);
if (structEdit) { pagesEditor(body, () => { showWireframe(B()); }); }
else { const ul = el('ul', 'summary-list'); for (const p of B().pages) { const li = el('li'); li.appendChild(el('span', 'k', p.title)); li.appendChild(el('span', '', p.sections.map((x) => x.title + (x.goal ? `→${lbl(CAT.GOALS, x.goal)}` : '')).join(' · '))); li.appendChild(el('span', 'muted small', p.sections.filter((x) => x.feature).map((x) => lbl(CAT.FEATURES, x.feature)).join(', '))); ul.appendChild(li); } body.appendChild(ul); }
const row = el('div', 'row');
row.appendChild(btn('이 구조로 진행 — 디자인 3안 받기', 'primary small-btn', async () => { structEdit = false; await runStep('structure', { pages: B().pages }); }));
row.appendChild(btn(structEdit ? '편집 닫기' : '페이지 수정', 'ghost small-btn', () => { structEdit = !structEdit; render(); }));
body.appendChild(row);
}
function renderDesign(body) {
const d = current.plan.designOptions || { options: [] }; const b = B();
body.appendChild(el('p', 'small', `3안 중 하나를 고르세요. 추천안(${d.recommended || 'A'})이 기본 선택되어 있습니다.`));
const list = el('div', 'card-list'); let selected = b.design.optionId || d.recommended || (d.options[0] && d.options[0].id);
const draw = () => { list.innerHTML = ''; for (const o of d.options) { const c = el('div', 'opt-card' + (o.id === selected ? ' on' : '')); c.appendChild(el('div', 't', `${o.id}. ${o.name} (${lbl(CAT.TONES, o.tone)}${o.id === d.recommended ? ' · 추천' : ''})`)); c.appendChild(el('div', 'd', o.summary || '')); const sw = el('div', 'swatches'); for (const col of (o.palette && o.palette.colors) || []) { const s = el('span'); s.style.background = col; sw.appendChild(s); } c.appendChild(sw); c.appendChild(el('div', 'd', `${o.palette ? o.palette.name : ''} · 폰트 ${lbl(CAT.FONT_SETS, o.fonts)} · ${lbl(CAT.DENSITY, o.density)} · ${lbl(CAT.IMAGERY, o.imagery)}`)); if (o.heroIdea) c.appendChild(el('div', 'd', '히어로: ' + o.heroIdea)); c.onclick = () => { selected = o.id; const tone = CAT.TONES.find((t) => t.id === o.tone) || CAT.TONES[0]; b.design = { ...b.design, tone: tone.id, palette: o.palette, fonts: CAT.FONT_SETS.find((f) => f.id === o.fonts) || CAT.FONT_SETS[0], density: o.density, imagery: o.imagery, optionId: o.id }; showWireframe(b); draw(); }; list.appendChild(c); } };
draw(); body.appendChild(list);
body.appendChild(btn('이 디자인으로 — Brief 확인', 'primary small-btn', () => runStep('design', { optionId: selected })));
}
function briefSummary(body, gotoStep) {
const b = B(); const ul = el('ul', 'summary-list');
const row = (k, v, step) => { const li = el('li'); li.appendChild(el('span', 'k', k)); li.appendChild(el('span', '', v || '-')); if (gotoStep && step) { const e = el('button', 'ghost', '수정'); e.type = 'button'; e.onclick = () => gotoStep(step); li.appendChild(e); } else li.appendChild(el('span')); ul.appendChild(li); };
row('상호·업종', `${b.business.name} · ${b.business.industry.major}${b.business.industry.minor ? '/' + b.business.industry.minor : ''} · ${b.business.region.sido} ${b.business.region.sigungu}`, 1);
row('목표·기능', `${b.goals.map((g) => lbl(CAT.GOALS, g)).join(', ')} / ${Object.keys(b.features).filter((k) => b.features[k]).map((k) => lbl(CAT.FEATURES, k)).join(', ')}`, 2);
row('고객', `${b.customers.types.join(', ')} ${b.customers.ages.join('·')}`, 3);
row('페이지', b.pages.map((p) => `${p.title}(${p.sections.length})`).join(' → '), 4);
row('디자인', `${lbl(CAT.TONES, b.design.tone)} · ${b.design.palette ? b.design.palette.name : ''} · ${b.design.fonts ? b.design.fonts.label : ''} · ${lbl(CAT.DENSITY, b.design.density)} · ${lbl(CAT.IMAGERY, b.design.imagery)}`, 5);
row('문구', `${lbl(CAT.VOICE, b.copy.voice)} · ${lbl(CAT.LENGTH, b.copy.length)} · ${lbl(CAT.LANGUAGE, b.copy.language)}`, 6);
row('사실정보', CAT.FACT_FIELDS.map((f) => { const x = b.facts[f.id] || {}; return `${f.label}:${x.status === 'provided' ? '✓' : (x.status === 'removed' ? '삭제' : '빈칸')}`; }).join(' '), 7);
row('자료', `로고 ${b.assets.logo || '없음'} · 사진 ${b.assets.photos.length}장${b.assets.useStock ? ' · 스톡 이미지 사용' : ''}`, 8);
row('참고 사이트', (b.references || []).length ? b.references.map((r) => r.url).join(', ') : '없음');
if (b.regulated.flag) row('규제', CAT.REGULATIONS[b.regulated.category].label);
body.appendChild(ul);
}
function renderBriefConfirm(body) {
const st = stageOf();
body.appendChild(el('p', 'small', st === 'confirmed' ? 'Brief 가 확정되었습니다. 이 JSON 만을 입력으로 페이지를 생성합니다.' : '2~4단계 결과를 JSON 명세(Site Brief)로 고정합니다. 확정 후 생성 에이전트는 이 Brief 만 참고합니다.'));
briefSummary(body, null);
const wl = el('ul', 'warn-list'); wl.id = 'planWarnings'; body.appendChild(wl); renderWarnings(wl);
const tg = btn(showBriefJson ? 'JSON 숨기기' : 'Brief JSON 보기', 'ghost small-btn', () => { showBriefJson = !showBriefJson; render(); }); body.appendChild(tg);
if (showBriefJson) body.appendChild(el('pre', 'brief-json', JSON.stringify(B(), null, 1)));
const row = el('div', 'row');
if (st === 'confirmed') row.appendChild(btn('생성', 'primary small-btn', () => runStep('generate')));
else row.appendChild(btn('Brief 확정', 'primary small-btn', () => runStep('confirm')));
if (current.plan.mode === 'ai') row.appendChild(btn('이해 확인으로 돌아가기', 'ghost small-btn', () => { current.plan.stage = 'understanding'; render(); }));
else row.appendChild(btn('선택 수정', 'ghost small-btn', () => { current.plan.stage = 'manual'; manualStep = 1; render(); }));
body.appendChild(row);
}
// ---- 내가 고르기: 9단계 ----
const MSTEPS = ['기본', '목적', '고객', '페이지', '디자인', '문구', '사실정보', '자료', '요약'];
function renderManualWizard(body, reopened) {
const b = B(); const nav = el('div', 'steps');
MSTEPS.forEach((n, i) => { const s = i + 1; const bt = el('button', s === manualStep ? 'active' : (s < manualStep ? 'done' : ''), `${s}. ${n}`); bt.type = 'button'; bt.onclick = () => { manualStep = s; render(); }; nav.appendChild(bt); });
body.appendChild(nav);
const change = () => { saveBriefSoon(); showWireframe(b); };
if (manualStep === 1) { onIndustryChanged = async () => { await planPut('brief', { brief: { business: b.business }, applyIndustryDefaults: true }); render(); }; businessFields(body); }
else if (manualStep === 2) { goalsAndFeatures(body, change); regulatedField(body, change); }
else if (manualStep === 3) customersField(body, change);
else if (manualStep === 4) { body.appendChild(el('p', 'small', '추천 페이지가 체크되어 있습니다. 드래그로 순서를 바꾸고, 페이지별 섹션을 고르세요.')); if (!b.pages.length) body.appendChild(btn('업종 추천 페이지 불러오기', 'ghost small-btn', async () => { await planPut('brief', { brief: {}, applyIndustryDefaults: true }); render(); })); pagesEditor(body, change); }
else if (manualStep === 5) renderDesignPicker(body, change);
else if (manualStep === 6) {
// 단일 선택 칩: 전체 화면을 다시 그리지 않고 선택 표시만 바꾼다 (느린 반응 개선)
const single = (items, get, set) => { const box = chips(body, items, [get()], (id) => { set(id); change(); box.querySelectorAll('.chip').forEach((c, i) => c.classList.toggle('on', items[i].id === id)); }); };
body.appendChild(h3('어조')); single(CAT.VOICE, () => b.copy.voice, (id) => { b.copy.voice = id; });
body.appendChild(h3('문장 길이')); single(CAT.LENGTH, () => b.copy.length, (id) => { b.copy.length = id; });
body.appendChild(h3('언어')); single(CAT.LANGUAGE, () => b.copy.language, (id) => { b.copy.language = id; });
}
else if (manualStep === 7) { body.appendChild(el('p', 'small', '항목별로 내용을 입력하거나 처리 방식을 고르세요. 입력하지 않은 값은 지어내지 않습니다.')); factsTable(body, change); }
else if (manualStep === 8) {
body.appendChild(el('p', 'small', '로고·사진은 위 첨부 파일 패널이나 아래 드롭존으로 올리면 자동으로 자료에 연결됩니다.'));
const g = el('div', 'kv'); const logo = el('select'); logo.appendChild(new Option('없음', '')); for (const a of current.attachments.filter((a) => a.kind === 'image')) logo.appendChild(new Option(a.name, a.rel)); logo.value = b.assets.logo || ''; logo.onchange = () => { b.assets.logo = logo.value; b.assets.photos = b.assets.photos.filter((p) => p !== logo.value); change(); }; kvRow(g, '로고', logo);
const ph = el('div', 'small', current.attachments.filter((a) => a.kind === 'image' && a.rel !== b.assets.logo).map((a) => a.name).join(', ') || '없음'); kvRow(g, '사진', ph); body.appendChild(g);
{ const box = chips(body, [{ id: 'stock', label: '스톡 이미지 사용(플레이스홀더 대신)' }], b.assets.useStock ? ['stock'] : [], () => { b.assets.useStock = !b.assets.useStock; change(); box.querySelector('.chip').classList.toggle('on', b.assets.useStock); }); }
const dz = el('div', 'dropzone'); dz.dataset.target = 'now'; dz.appendChild(el('div', 'dz-text', '여기에 로고·사진을 끌어다 놓으면 바로 업로드됩니다')); dz.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); dz.classList.add('dragover'); }); dz.addEventListener('dragleave', () => dz.classList.remove('dragover')); dz.addEventListener('drop', async (e) => { e.preventDefault(); e.stopPropagation(); dz.classList.remove('dragover'); await uploadNow([...e.dataTransfer.files]); const r = await api(`/api/sites/${current.id}`); Object.assign(current, r); render(); }); body.appendChild(dz);
}
else if (manualStep === 9) {
body.appendChild(el('p', 'small', '전체 선택값입니다. 항목의 [수정]을 누르면 해당 단계로 돌아갑니다.'));
briefSummary(body, (s) => { manualStep = s; render(); });
const wl = el('ul', 'warn-list'); wl.id = 'planWarnings'; body.appendChild(wl); renderWarnings(wl);
}
const row = el('div', 'row');
if (manualStep < 9) { row.appendChild(btn('다음', 'primary small-btn', async () => { await saveBriefSoon(true); manualStep += 1; render(); })); row.appendChild(btn('건너뛰기', 'ghost small-btn', () => { manualStep += 1; render(); })); if (manualStep > 1) row.appendChild(btn('이전', 'ghost small-btn', () => { manualStep -= 1; render(); })); }
else if (reopened) { row.appendChild(btn('생성 (현재 선택으로 재생성)', 'primary small-btn', async () => { await saveBriefSoon(true); editingBrief = false; await generateNow(); })); row.appendChild(btn('취소', 'ghost small-btn', () => { editingBrief = false; render(); })); }
else {
// v1.0.3: 요약 단계는 '선택 수정' 과 '생성' 두 가지만 — 선택한 내용 그대로 확정하고 끝까지 생성한다
row.appendChild(btn('생성 (선택한 내용으로 끝까지)', 'primary small-btn', async () => { await saveBriefSoon(true); setBusy(true, 'generate'); try { await planPost('confirm', {}); await planPost('generate', {}); localDirty = false; } catch (e) { alert(e.message); } setBusy(false); render(); }));
row.appendChild(btn('선택 수정', 'ghost small-btn', () => { manualStep = 1; render(); }));
}
body.appendChild(row);
}
/**
* 디자인 선택 (내가 고르기 5단계). 선택할 때 전체 카드를 다시 그리지 않고 해당 영역만 갱신한다.
* 톤을 바꾸면 팔레트 목록만 다시 그리고, 팔레트·폰트·밀도·이미지는 선택 표시(.on)만 바꾼다. 와이어프레임은 디바운스된다.
*/
function renderDesignPicker(body, change) {
const b = B();
body.appendChild(h3('톤 (5종)')); const tg = el('div', 'tone-grid'); body.appendChild(tg);
body.appendChild(h3('컬러 팔레트 (3안)')); const pl = el('div', 'card-list'); body.appendChild(pl);
const drawPalettes = () => {
pl.innerHTML = ''; const tone = CAT.TONES.find((t) => t.id === b.design.tone) || CAT.TONES[0];
for (const p of tone.palettes) { const c = el('div', 'opt-card' + (b.design.palette && b.design.palette.name === p.name ? ' on' : '')); c.appendChild(el('div', 't', p.name)); const sw = el('div', 'swatches'); for (const col of p.colors) { const sp = el('span'); sp.style.background = col; sw.appendChild(sp); } c.appendChild(sw); c.onclick = () => { b.design.palette = p; change(); pl.querySelectorAll('.opt-card').forEach((x) => x.classList.toggle('on', x === c)); }; pl.appendChild(c); }
};
const drawTones = () => {
tg.innerHTML = '';
for (const t of CAT.TONES) { const c = el('div', 'opt-card' + (b.design.tone === t.id ? ' on' : '')); c.appendChild(el('div', 't', t.label + (b.design.recommendedTone === t.id ? ' · 추천' : ''))); c.appendChild(el('div', 'd', t.desc)); c.onclick = () => { b.design.tone = t.id; b.design.palette = t.palettes[0]; change(); tg.querySelectorAll('.opt-card').forEach((x) => x.classList.toggle('on', x === c)); drawPalettes(); }; tg.appendChild(c); }
};
if (!b.design.tone) b.design.tone = (CAT.TONES[0] || {}).id;
drawTones(); drawPalettes();
const single = (items, get, set) => { const box = chips(body, items, [get()], (id) => { set(id); change(); box.querySelectorAll('.chip').forEach((c, i) => c.classList.toggle('on', items[i].id === id)); }); };
body.appendChild(h3('폰트 세트 (3안)')); single(CAT.FONT_SETS, () => (b.design.fonts ? b.design.fonts.id : ''), (id) => { b.design.fonts = CAT.FONT_SETS.find((f) => f.id === id); });
body.appendChild(h3('레이아웃 밀도')); single(CAT.DENSITY, () => b.design.density, (id) => { b.design.density = id; });
body.appendChild(h3('이미지 유형')); single(CAT.IMAGERY, () => b.design.imagery, (id) => { b.design.imagery = id; });
}
// ---- 생성 후: 검증 리포트 / 수정 / 발행 ----
/** 생성 탭의 생성 완료 안내. 발행·배포와 수정 지시는 수정 탭(발행·배포 카드 / 수정 지시 카드)에서 한다 (v1.4.14: 관리 탭 → 수정 탭) */
function renderPost(body) {
const plan = current.plan; const st = plan.stage;
body.appendChild(el('p', 'small', st === 'published' ? `발행 완료 · ${new Date(plan.published.at).toLocaleString(uiLocale())}. 수정과 재배포는 "수정" 탭에서 합니다.` : '페이지가 생성되었습니다. 우측 미리보기로 확인한 뒤 "수정" 탭에서 수정 지시를 보내거나 발행(배포)하세요.'));
const row = el('div', 'row');
row.appendChild(btn('수정 탭으로 이동', 'primary small-btn', () => { if (window.finishCreate) finishCreate(current.id); else showTab('edit'); }));
row.appendChild(btn('새 사이트 만들기', 'ghost small-btn', () => { if (window.resetCreate) resetCreate(); }));
if (current.mode !== 'ai') row.appendChild(btn('선택값 수정 후 재생성', 'ghost small-btn', () => { editingBrief = true; manualStep = 1; render(); }));
body.appendChild(row);
}
/**
* 수정 탭 "발행 · 배포" 카드 (v1.1.0, v1.4.14 부터 수정 탭)
* - 발행됨 + 변경 없음(deployState=deployed): 결과(운영 주소·접속 확인)와 "발행 취소"만 표시 — 같은 배포를 두 번 하지 않게
* - 발행됨 + 수정으로 변경 생김(pending): "재배포(변경 내용 반영)" 표시
* - 발행 취소됨(unpublishedAt): 운영 주소에 "수정 중" 안내가 올라가 있음을 알리고 "발행" 으로 되돌림
*/
function renderPublishCard() {
const card = $('publishCard'); const body = $('publishBody'); if (!current || !current.plan || !POST_STAGES.includes(current.plan.stage)) { card.classList.add('hidden'); return; }
card.classList.remove('hidden'); body.innerHTML = '';
const plan = current.plan; const v = plan.verification; const st = plan.stage; const s = v ? v.rules.summary : null; const issues = s ? (s.pagesMissing + s.extraneous + s.regulated) : 0;
const dsObj = current.deployState || {}; const changedAfterDeploy = !!(dsObj.lastChangeAt && dsObj.lastDeployAt && dsObj.lastChangeAt > dsObj.lastDeployAt) || !dsObj.lastDeployAt;
const ds = dsObj.state === 'pending' && changedAfterDeploy ? 'pending' : (dsObj.state === 'pending' ? 'deployed' : dsObj.state); const unpublished = !!current.unpublishedAt; const locked = current.editLimit && current.editLimit.locked;
const pill = $('publishState');
// v1.6.31 (요구 14·16): 전송만 되고 준비가 안 된 단계는 그 단계 이름으로 보인다 (발행됨으로 보이지 않게)
const PHASE = { 'vhost-wait': '발행됨 · 웹서버 설정 적용 대기', 'dns-wait': '발행됨 · DNS 설정 대기', 'cert-wait': '발행됨 · 인증서 준비 중(HTTP)', 'check-failed': '발행됨 · 접속 확인 실패', 'vhost-failed': '발행됨 · 도메인 연결 실패', failed: '배포 실패' };
if (st === 'published') { pill.textContent = ds === 'pending' ? '발행됨 · 변경 있음' : (PHASE[dsObj.phase] || '발행됨'); pill.className = 'pill ' + (ds === 'pending' || (dsObj.phase && dsObj.phase !== 'ready' && dsObj.phase !== 'transferred') ? 'waiting' : 'done'); if (PHASE[dsObj.phase]) pill.title = dsObj.phaseDetail || ''; }
else if (unpublished) { pill.textContent = '발행 취소됨'; pill.className = 'pill error'; }
else { pill.textContent = locked ? '발행 대기' : '발행 전'; pill.className = 'pill ' + (locked ? 'waiting' : 'idle'); }
if (st === 'published') {
// v1.1.4: 운영 주소 한 줄 + "이 주소로 배포되었습니다" 만 보이고, 세부(도메인·SSL·접속 확인·취소 효과)는 ? 풍선으로
const box = el('div', 'publish-box'); const dep = plan.published.deploy || {}; const acc = (dep.access && dep.access.items) || [];
const line = el('div', 'publish-line'); line.appendChild(el('span', '', '운영 주소'));
if (current.liveUrl) { const a = el('a'); a.href = current.liveUrl; a.target = '_blank'; a.rel = 'noopener'; a.textContent = current.liveUrl; line.appendChild(a); } else line.appendChild(el('span', 'muted', '(공개 주소 없음)'));
const tipParts = [`${new Date(plan.published.at).toLocaleString(uiLocale())} 에 이 주소로 배포되었습니다${plan.published.forced ? ' (점검 경고 무시)' : ''}.`, dep.domains && dep.domains.length ? `연결 도메인: ${dep.domains.join(', ')}${dep.vhostOk === false ? ' (연결 실패 — 관리자 문의)' : ''}.` : '연결한 도메인 없음(서버 주소로 접속).', dep.sslOk === true ? 'HTTPS(SSL) 적용됨.' : (dep.sslOk === false && dep.sslDetail && !/건너뜀/.test(dep.sslDetail) ? 'SSL 발급 실패(관리자 문의).' : ''), acc.length ? '접속 확인: ' + acc.map((it) => `${it.url} — ${it.ok ? '접속 가능' : '접속 불가(' + it.note + ')'}`).join(' / ') : '', ds === 'pending' ? '발행 후 수정된 내용이 있어 재배포하면 반영됩니다.' : '수정 지시로 변경이 생기면 재배포 버튼이 나타납니다.', '발행 취소를 누르면 운영 주소에는 "사이트 수정 중입니다" 안내만 보이고 사이트 내용은 보이지 않습니다. 다시 발행하면 정상 내용이 올라갑니다.'].filter(Boolean);
const hp = el('button', 'help', '?'); hp.type = 'button'; hp.dataset.tip = tipParts.join(' '); line.appendChild(hp); box.appendChild(line);
box.appendChild(el('div', 'small', dep.ok === false ? '배포 서버 전송 실패 — 관리자에게 문의하세요' : (PHASE[dsObj.phase] ? `${PHASE[dsObj.phase].replace('발행됨 · ', '')}: ${dsObj.phaseDetail || ''}` : '이 주소로 배포되었습니다.')));
const row = el('div', 'row');
if (ds === 'pending') row.appendChild(btn('재배포 (변경 내용 반영)', 'primary small-btn', async () => { setBusy(true, 'publish'); try { await planPost('publish', { force: true, domains: current.domains || [] }); } catch (e) { alert(e.message); } setBusy(false); render(); }));
if (PHASE[dsObj.phase] && dsObj.phase !== 'failed') row.appendChild(btn('다시 확인', 'ghost small-btn', async () => { setBusy(true, 'publish'); try { const r = await planPost('recheck', {}); pushMsg && pushMsg(r.ready ? 'info' : 'warn', (r.changed ? '상태가 바뀌었습니다: ' : '아직 같은 상태입니다: ') + (r.detail || r.phase)); } catch (e) { alert(e.message); } setBusy(false); render(); })); // v1.6.33: 해결된 오류가 계속 표시되지 않게
if (dsObj.hasPrev) row.appendChild(btn('이전 배포본으로', 'ghost small-btn', async () => { if (!confirm('운영 주소를 직전 배포본으로 되돌릴까요? (여기서 보는 사이트 내용은 바뀌지 않고, 다음 발행 때 다시 올라갑니다)')) return; setBusy(true, 'publish'); try { const r = await planPost('rollback', {}); pushMsg && pushMsg('info', '이전 배포본으로 되돌렸습니다 — ' + (r.detail || '')); } catch (e) { alert(e.message); } setBusy(false); render(); })); // v1.6.31 (요구 15)
row.appendChild(btn('발행 취소', 'ghost small-btn', async () => { if (!confirm('발행을 취소하면 운영 주소에는 "사이트 수정 중" 안내만 보이고 사이트 내용은 보이지 않습니다. 계속할까요?')) return; await runStep('unpublish'); }));
box.appendChild(row); body.appendChild(box);
} else if (unpublished) {
const box = el('div', 'publish-box'); box.appendChild(el('div', '', `발행 취소됨 · ${new Date(current.unpublishedAt).toLocaleString(uiLocale())}`));
if (current.liveUrl) box.appendChild(el('div', 'small', `운영 주소(${current.liveUrl})에는 "사이트 수정 중입니다" 안내가 표시되고 있습니다. 수정을 마친 뒤 아래에서 다시 발행하세요.`));
body.appendChild(box);
}
const line = el('div', 'small');
if (!v) line.textContent = '검증: 아직 실행되지 않음'; else if (v.stale) line.textContent = '검증: 수정 후 재검증 필요'; else line.textContent = issues || s.placeholders ? `검증: 점검 항목 ${issues}건${s.placeholders ? ` · 입력이 필요한 곳 ${s.placeholders}곳` : ''}` : '검증: 이상 없음';
body.appendChild(line);
const row = el('div', 'row');
const rep = el('a', 'btn ghost small-btn', '검증 리포트 보기'); rep.href = `/report/${current.id}`; rep.target = '_blank'; rep.rel = 'noopener'; row.appendChild(rep);
row.appendChild(btn('재검증', 'ghost small-btn', () => runStep('verify')));
row.appendChild(btn('검증 결과 자동 수정', 'ghost small-btn', () => runStep('fix'), !(v && (issues || (v.ai && v.ai.fixes && v.ai.fixes.length)))));
body.appendChild(row);
if (st !== 'published') {
const hl = h3('연결할 도메인 (선택, 여러 개 가능)'); const hp = el('button', 'help', '?'); hp.type = 'button'; hp.dataset.tip = '도메인을 넣으면 배포 서버의 nginx 에 함께 등록되어 어느 주소로 접속해도 이 사이트가 열립니다. 도메인의 DNS(A 레코드)가 배포 서버를 가리키고 있어야 하며, 가리키는 도메인에는 HTTPS 인증서가 자동 발급됩니다. 비우면 서버 주소로만 접속합니다.'; hl.appendChild(hp); body.appendChild(hl);
const dwrap = el('div'); renderDomainChips(dwrap); body.appendChild(dwrap);
const row3 = el('div', 'row'); row3.appendChild(btn(unpublished ? '다시 발행 (배포)' : '발행 (배포)', 'primary small-btn', publish)); body.appendChild(row3);
}
if (current.mode !== 'ai') { const row2 = el('div', 'row'); row2.appendChild(btn('선택값 수정 후 재생성', 'ghost small-btn', () => { editingBrief = true; manualStep = 1; if (window.setCreating) setCreating(current.id); showTab('create'); $('planCard').classList.remove('hidden'); render(); })); body.appendChild(row2); }
}
function renderDomainChips(wrap) {
wrap.innerHTML = ''; const list = current.domains || [];
const chipsBox = el('div', 'chips');
for (const d of list) { const c = el('span', 'chip on', d + ' ×'); c.title = '제거'; c.onclick = async () => { current.domains = list.filter((x) => x !== d); await saveDomains(); renderDomainChips(wrap); }; chipsBox.appendChild(c); }
wrap.appendChild(chipsBox);
const inp = el('input'); inp.placeholder = '도메인 입력 후 Enter (쉼표로 여러 개 가능)'; inp.dataset.k = 'domain-input';
inp.onkeydown = async (e) => { if (e.key !== 'Enter' && e.key !== ',') return; e.preventDefault(); const v = inp.value.trim(); if (!v) return; try { current.domains = [...(current.domains || []), ...v.split(/[\s,]+/).filter(Boolean)]; await saveDomains(); inp.value = ''; renderDomainChips(wrap); } catch (err) { alert(err.message); current.domains = list; } };
inp.onblur = () => { if (inp.value.trim()) inp.onkeydown({ key: 'Enter', preventDefault() {} }); };
wrap.appendChild(inp);
}
async function saveDomains() { const r = await api(`/api/sites/${current.id}/domains`, { method: 'PUT', body: JSON.stringify({ domains: current.domains || [] }) }); current.domains = r.domains; }
async function publish() {
const firstDeploy = !((current.deploy && current.deploy.history) || []).some((d) => d.ok);
if (firstDeploy) {
// 첫 배포: 배포 정보 확인 단계
const info = `배포 서버: ${(current.deploy && current.deploy.targetName) || '운영자가 지정한 배포 서버'}\n도메인: ${(current.domains || []).length ? current.domains.join(', ') : '(없음 — 서버 주소로만 접속)'}\n\n이 정보로 최종 확정하고 배포할까요? 도메인을 바꾸려면 취소 후 위의 도메인 칸을 수정하세요.`;
if (!confirm(info)) return;
}
setBusy(true, 'publish');
try { await planPost('publish', { domains: current.domains || [] }); }
catch (e) {
const msg = e.message; let bl = null; try { const r = await fetch(`/api/sites/${current.id}/plan/publish`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); bl = (await r.json()).blockers; } catch (_) {}
const lines = bl ? [...bl.placeholders.map((p) => `${p.file}: [입력 필요] ${p.count}곳`), ...bl.pagesMissing.map((p) => `누락 페이지 ${p.file}`), ...bl.regulated.map((p) => `${p.file}: "${p.value}"`)] : [msg];
setBusy(false);
if (confirm(`최종 확정이 보류되었습니다:\n- ${lines.join('\n- ')}\n\n그래도 확정할까요?`)) { setBusy(true, 'publish'); try { await planPost('publish', { force: true, domains: current.domains || [] }); } catch (e2) { alert(e2.message); } }
}
setBusy(false); render();
}
// ---------- 와이어프레임 ----------
function esc(s) { return String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
function wireframeHtml(b) {
const tone = CAT.TONES.find((t) => t.id === b.design.tone) || CAT.TONES[0];
const pal = (b.design.palette && b.design.palette.colors) || tone.palettes[0].colors; const [text, sub, bg, accent] = pal;
const fonts = b.design.fonts || CAT.FONT_SETS[0]; const gap = b.design.density === 'wide' ? 56 : b.design.density === 'dense' ? 20 : 36;
const pages = b.pages.length ? b.pages : [{ id: 'home', title: '홈', sections: [{ id: 'hero', title: '히어로' }] }];
const nav = pages.map((p, i) => `<a href="#" data-p="${i}" class="${i === 0 ? 'on' : ''}">${esc(p.title)}</a>`).join('');
const secHtml = (s) => { const f = s.feature ? ` <em>⚙ ${esc(lbl(CAT.FEATURES, s.feature))}</em>` : ''; const g = s.goal ? ` <em>→ ${esc(lbl(CAT.GOALS, s.goal))}</em>` : '';
if (s.id === 'hero') return `<section class="hero"><div class="ph-img"></div><h1>${esc(b.business.name || '상호명')}</h1><p>${esc(b.business.description || '대표 문구 (어조: ' + lbl(CAT.VOICE, b.copy.voice) + ')')}</p><button>${esc(b.goals[0] ? lbl(CAT.GOALS, b.goals[0]) : 'CTA')}</button>${f}${g}</section>`;
const fact = { map: 'address', contact: 'phone', hours: 'hours', pricing: 'prices', credentials: 'credentials', reviews: 'reviews' }[s.id]; const missing = fact && !(b.facts[fact] && (b.facts[fact].status === 'provided' || b.facts[fact].status === 'removed'));
return `<section><h2>${esc(s.title)}${f}${g}</h2><div class="blocks">${['gallery', 'portfolio', 'products', 'services', 'menu', 'team'].includes(s.id) ? '<div class="b"></div><div class="b"></div><div class="b"></div>' : '<div class="line"></div><div class="line short"></div>'}</div>${missing ? '<div class="need">[입력 필요] ' + esc(lbl(CAT.FACT_FIELDS, fact)) + '</div>' : ''}</section>`; };
const pageHtml = pages.map((p, i) => `<main data-p="${i}" ${i ? 'hidden' : ''}>${p.sections.map(secHtml).join('')}</main>`).join('');
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><link href="https://fonts.googleapis.com/css2?family=${fonts.google}&display=swap" rel="stylesheet"><style>
body{margin:0;background:${bg};color:${text};font-family:'${fonts.body}',sans-serif}h1,h2{font-family:'${fonts.heading}',sans-serif;margin:0 0 8px}header{display:flex;align-items:center;gap:16px;padding:14px 24px;border-bottom:1px solid ${sub}33}header b{font-size:18px}nav a{color:${sub};text-decoration:none;margin-right:12px;font-size:14px;padding:4px 0}nav a.on{color:${accent};border-bottom:2px solid ${accent}}
main{max-width:960px;margin:0 auto;padding:0 24px}section{padding:${gap}px 0;border-bottom:1px dashed ${sub}44}.hero{text-align:center}.hero h1{font-size:34px}.hero p{color:${sub}}.hero button{background:${accent};color:#fff;border:0;padding:12px 22px;border-radius:8px;font-size:15px}.ph-img{height:${b.design.density === 'dense' ? 120 : 200}px;border-radius:12px;background:linear-gradient(135deg,${accent}55,${sub}33);margin-bottom:18px}
h2{font-size:20px}em{font-size:11px;color:${accent};font-style:normal;background:${accent}18;padding:1px 6px;border-radius:999px;margin-left:6px}.blocks{display:flex;gap:12px}.b{flex:1;height:110px;border-radius:10px;background:${sub}22}.line{height:12px;border-radius:6px;background:${sub}33;margin:6px 0;width:90%}.short{width:55%}.need{margin-top:8px;font-size:12px;color:#b45309;background:#fef3c7;display:inline-block;padding:3px 8px;border-radius:6px}
footer{padding:20px 24px;color:${sub};font-size:12px;text-align:center}.wf{position:fixed;top:8px;right:8px;background:#0f172a;color:#fff;font-size:11px;padding:3px 8px;border-radius:999px;opacity:.85}
</style></head><body><div class="wf">와이어프레임 미리보기 · ${esc(tone.label)}</div><header><b>${esc(b.business.name || '상호명')}</b><nav>${nav}</nav></header>${pageHtml}<footer>${esc(b.business.name || '')} · ${esc([b.business.region.sido, b.business.region.sigungu].filter(Boolean).join(' '))} · ${b.facts.phone && b.facts.phone.status === 'provided' ? esc(b.facts.phone.value) : '전화: [입력 필요]'}</footer>
<script>document.querySelectorAll('nav a').forEach(a=>a.onclick=e=>{e.preventDefault();document.querySelectorAll('nav a').forEach(x=>x.classList.remove('on'));a.classList.add('on');document.querySelectorAll('main').forEach(m=>m.hidden=m.dataset.p!==a.dataset.p)});</script></body></html>`;
}
// 와이어프레임은 iframe srcdoc 교체(폰트 재로딩 포함)라 비싸다. 짧은 시간의 연속 선택은 한 번만 반영하고, 내용이 같으면 건너뛴다.
let wfTimer = null; let wfLast = '';
function showWireframe(b) {
if (!b || !CAT) return;
clearTimeout(wfTimer);
wfTimer = setTimeout(() => {
let html; try { html = wireframeHtml(b); } catch (_) { return; }
if (html === wfLast) return; wfLast = html;
$('emptyState').classList.add('hidden'); $('frame').classList.remove('hidden');
$('frame').removeAttribute('src'); $('frame').srcdoc = html;
$('siteUrl').textContent = '와이어프레임 (기획 단계) — 생성 후 실제 페이지가 표시됩니다';
}, 180);
}
loadCatalog().catch(() => {});
window.Wizard = { onSiteOpened, showModeChooser, hideStart, setRunning, render, onPlanEvent };
})();