'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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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) => `${esc(p.title)}`).join(''); const secHtml = (s) => { const f = s.feature ? ` ⚙ ${esc(lbl(CAT.FEATURES, s.feature))}` : ''; const g = s.goal ? ` → ${esc(lbl(CAT.GOALS, s.goal))}` : ''; if (s.id === 'hero') return `

${esc(b.business.name || '상호명')}

${esc(b.business.description || '대표 문구 (어조: ' + lbl(CAT.VOICE, b.copy.voice) + ')')}

${f}${g}
`; 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 `

${esc(s.title)}${f}${g}

${['gallery', 'portfolio', 'products', 'services', 'menu', 'team'].includes(s.id) ? '
' : '
'}
${missing ? '
[입력 필요] ' + esc(lbl(CAT.FACT_FIELDS, fact)) + '
' : ''}
`; }; const pageHtml = pages.map((p, i) => `
${p.sections.map(secHtml).join('')}
`).join(''); return `
와이어프레임 미리보기 · ${esc(tone.label)}
${esc(b.business.name || '상호명')}
${pageHtml} `; } // 와이어프레임은 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 }; })();