const { useState, useEffect, useRef } = React;

/* ════════ 토큰 ════════ */
// 앱(로그인 후)은 눈 편한 라이트 테마. 랜딩은 CSS로 다크 유지.
const C = {
  ink: "#17171F", ink2: "#42424F", brass: "#E23D57", brassSoft: "#C81D3D",
  paper: "#F5F5FA", card: "#FFFFFF", line: "#E6E6EE", text: "#28282F",
  sub: "#72727E", danger: "#DC2B3D", ok: "#0F9E62", blue: "#6D4BFF",
  elev: "#F0F0F6", violet: "#6D4BFF",
};
const FONT = '"Pretendard Variable","Pretendard","Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif';
const MONO = 'ui-monospace,"SF Mono","JetBrains Mono","Roboto Mono",Menlo,Consolas,monospace';
const ACCENT_GRAD = "linear-gradient(135deg,#FF4D6D 0%,#B14BFF 55%,#7C5CFF 100%)";
const NAVY_GRAD = "linear-gradient(135deg,#1A1A26,#0E0E16)";

/* ════════ 저장소 (localStorage) ════════ */
const LS = {
  get(k) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : null; } catch { return null; } },
  set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} },
  remove(k) { try { localStorage.removeItem(k); } catch {} },
};
const USERS_KEY = "inskyblog:users", PROMOS_KEY = "inskyblog:promos", SESSION_KEY = "inskyblog:session", USER_PREFIX = "inskyblog:user:";
const userKey = (name) => USER_PREFIX + name;
const USER_PREFIX_NOTE = 1; // (회원 저장은 회원당 개별 문서: inskyblog:user:{username})
const loadPromos = () => LS.get(PROMOS_KEY) || {};
const savePromos = (p) => LS.set(PROMOS_KEY, p);

async function hash(s) {
  try {
    const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
    return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
  } catch { return "p_" + s; }
}
const genCode = () => { const p = () => Math.random().toString(36).slice(2, 6).toUpperCase(); return `PROMO-${p()}-${p()}`; };
const fmtDate = (iso) => { if (!iso) return "-"; const d = new Date(iso); return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`; };
const isExpired = (iso) => (iso ? Date.now() > new Date(iso).getTime() : false);
const monthKey = () => new Date().toISOString().slice(0, 7);
const usedThisMonth = (u) => (u && u.usage && u.usage.ym === monthKey() ? u.usage.used : 0);
// ── 크레딧(횟수제) ── 월 갱신 없음. credits = 남은 생성 횟수(선불·누적). admin/카리나(posting 99999)는 무제한.
const isUnlimited = (u) => !!(u && u.plan && u.plan.posting === 99999);
const creditsOf = (u) => {
  if (!u) return 0;
  if (isUnlimited(u)) return Infinity;
  if (typeof u.credits === "number") return u.credits;
  // 백워드 호환: credits 없던 옛 회원은 "플랜 건수 − 이번 달 사용"으로 1회 이월
  return Math.max(0, ((u.plan && u.plan.posting) || 0) - usedThisMonth(u));
};

/* ════════ 요금제 ════════ */
const TIERS = { 10: 10000, 20: 18000, 30: 25000 };
const ADDON = { cardnews: 4000, thumbnail: 3000, set: 5000 };
const DEFAULT_PRICES = { 10: TIERS[10], 20: TIERS[20], 30: TIERS[30], cardnews: ADDON.cardnews, thumbnail: ADDON.thumbnail, set: ADDON.set };
function planPriceWith(p, prices) {
  if (!p) return 0;
  const T = prices || DEFAULT_PRICES;
  let v = Number(T[p.posting]) || 0;
  if (p.cardnews && p.thumbnail) v += Number(T.set) || 0;
  else { if (p.cardnews) v += Number(T.cardnews) || 0; if (p.thumbnail) v += Number(T.thumbnail) || 0; }
  return v;
}
const planPrice = (p) => planPriceWith(p, null);
const userPrice = (u) => (u && typeof u.price === "number") ? u.price : planPriceWith(u && u.plan, u && u.prices);
const won = (n) => "₩" + (Number(n) || 0).toLocaleString();
function composition(p) {
  if (!p) return "포스팅만";
  if (p.cardnews && p.thumbnail) return "풀패키지";
  if (p.cardnews) return "포스팅+카드뉴스";
  if (p.thumbnail) return "포스팅+썸네일";
  return "포스팅만";
}

/* ════════ Claude 호출 (백엔드 프록시) ════════ */
const API_BASE = ((window.INSKY_CONFIG && window.INSKY_CONFIG.apiBase) || "").replace(/\/$/, "");
// 긴 생성만 Cloud Run 직접 URL(60초 우회). 없으면 같은 오리진 fallback.
const MSG_BASE = ((window.INSKY_CONFIG && window.INSKY_CONFIG.messagesBase) || "").replace(/\/$/, "") || API_BASE;
try { window.__STUDIO_TOKEN__ = localStorage.getItem("inskyblog:token") || null; } catch {}
function setToken(t) { window.__STUDIO_TOKEN__ = t || null; try { if (t) localStorage.setItem("inskyblog:token", t); else localStorage.removeItem("inskyblog:token"); } catch {} }
async function callClaude(body, opts, _retry) {
  opts = opts || {}; _retry = _retry || 0;
  const headers = { "Content-Type": "application/json" };
  if (opts.bill) headers["x-studio-bill"] = "post"; // 과금 생성 → 서버가 성공 시 크레딧 1 차감
  if (window.__STUDIO_TOKEN__) headers["Authorization"] = "Bearer " + window.__STUDIO_TOKEN__; // 직접 URL은 쿠키 대신 토큰
  let res;
  try { res = await fetch(MSG_BASE + "/api/messages", { method: "POST", headers, body: JSON.stringify(body) }); }
  catch (netErr) { const e = new Error("network"); e.code = "NETWORK"; throw e; }
  if (!res.ok) {
    let detail = ""; try { const j = await res.json(); detail = (j.error && (j.error.message || j.error.type)) || j.detail || ""; } catch {}
    // 과금 생성(bill)은 재시도 금지 — 중복 차감 방지. 비과금(제목·검사 등)만 혼잡 시 재시도.
    if (!opts.bill && (res.status === 429 || res.status === 529 || res.status === 503 || res.status === 502) && _retry < 2) { await new Promise((r) => setTimeout(r, 1500 * (_retry + 1))); return callClaude(body, opts, _retry + 1); }
    const e = new Error("api"); e.code = "API"; e.status = res.status; e.detail = detail; throw e;
  }
  const cl = res.headers.get("x-credits-left"); if (cl != null && cl !== "") window.__CREDITS_LEFT__ = Number(cl); // 서버가 알려준 잔여 크레딧
  const data = await res.json();
  return (data.content || []).filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
}
function salvageFields(s) {
  const out = { optimizedTitle: "", post: "", photos: [], hashtags: [], legalNote: "", osmu: null };
  const tm = s.match(/"optimizedTitle"\s*:\s*"([^"]*)"/);
  if (tm) out.optimizedTitle = tm[1];
  const pi = s.indexOf('"post"');
  if (pi !== -1) {
    const q = s.indexOf('"', s.indexOf(":", pi) + 1);
    if (q !== -1) {
      const rest = s.slice(q + 1);
      const m = rest.search(/"\s*,\s*"(photos|hashtags|legalNote|osmu|scores|thumbnail)"/);
      let body = m !== -1 ? rest.slice(0, m) : rest;
      body = body.replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
      out.post = body.trim();
    }
  }
  const tags = s.match(/"#[^"]+"/g);
  if (tags) out.hashtags = tags.map((x) => x.replace(/"/g, ""));
  return out.post ? out : null;
}
function salvageInspect(s) {
  if (!s) return null;
  const num = (k) => { const m = s.match(new RegExp('"' + k + '"\\s*:\\s*(\\d+)')); return m ? Number(m[1]) : 0; };
  const str = (k) => { const m = s.match(new RegExp('"' + k + '"\\s*:\\s*"([^"]*)"')); return m ? m[1] : ""; };
  const goodM = s.match(/"good"\s*:\s*\[([\s\S]*?)\]/);
  const good = goodM ? (goodM[1].match(/"([^"]+)"/g) || []).map((x) => x.replace(/"/g, "")) : [];
  const res = { scores: { title: num("title"), search: num("search"), notes: str("notes") }, summary: str("summary"), good, fixes: [] };
  return (res.scores.title || res.scores.search || res.summary || good.length) ? res : null;
}
function extractJSON(t) {
  if (!t) return null;
  const s = t.replace(/```json/gi, "").replace(/```/g, "").trim();
  const a = s.indexOf("{"), b = s.lastIndexOf("}");
  if (a === -1 || b === -1) return null;
  const body = s.slice(a, b + 1);
  try { return JSON.parse(body); } catch {}
  return salvageFields(body);
}
function extractArray(t) { if (!t) return null; const s = t.replace(/```json/gi, "").replace(/```/g, "").trim(); const a = s.indexOf("["), b = s.lastIndexOf("]"); if (a !== -1 && b !== -1) { try { return JSON.parse(s.slice(a, b + 1)); } catch {} } return s.split("\n").map((l) => l.replace(/^[\s\d.\-"'•]+|["']+$/g, "").trim()).filter(Boolean).slice(0, 6); }
function extractArrJSON(t) { if (!t) return null; const s = t.replace(/```json/gi, "").replace(/```/g, "").trim(); const a = s.indexOf("["), b = s.lastIndexOf("]"); if (a === -1 || b === -1) return null; try { return JSON.parse(s.slice(a, b + 1)); } catch { return null; } }
async function dbGet(key) { try { const r = await fetch(API_BASE + "/api/db?key=" + encodeURIComponent(key)); if (!r.ok) throw 0; const d = await r.json(); return d.value; } catch { return LS.get(key); } }
async function dbSet(key, value) { try { const r = await fetch(API_BASE + "/api/db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value }) }); if (!r.ok) throw 0; } catch { LS.set(key, value); } }
async function dbList(prefix) { try { const r = await fetch(API_BASE + "/api/db_list?prefix=" + encodeURIComponent(prefix)); if (!r.ok) throw 0; const d = await r.json(); return d.items || []; } catch { return null; } }
async function dbDel(key) { try { const r = await fetch(API_BASE + "/api/db_del", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }) }); if (!r.ok) throw 0; } catch { LS.remove(key); } }
// 회원맵 불러오기: 회원당 개별 문서 → 없으면 옛 통짜 블롭에서 1회 마이그레이션
async function loadUsers() {
  const items = await dbList(USER_PREFIX);
  if (items && items.length) { const u = {}; items.forEach((it) => { if (it.value && it.value.username) u[it.value.username] = it.value; }); return u; }
  const old = await dbGet(USERS_KEY);
  if (old && typeof old === "object" && Object.keys(old).length) { for (const name in old) { try { await dbSet(userKey(name), old[name]); } catch {} } return { ...old }; }
  return {};
}

/* ════════ 프롬프트 ════════ */
const CATEGORIES = [
  { id: "daily", name: "일상", icon: "📝", desc: "학원의 하루·원장의 생각. 신뢰를 남기는 글" },
  { id: "student", name: "학생 이야기", icon: "🎓", desc: "성장·지도 사례. 진정성 있는 후기형 글" },
  { id: "exam", name: "입시 정보", icon: "📈", desc: "검증된 사실 중심. 홍보 없는 정보성 글" },
  { id: "etc", name: "기타", icon: "✨", desc: "자유 주제. 최적화는 그대로 적용" },
];
const CAT_RULE = {
  daily: "[종류: 일상 — 원장 에세이] · 목적: 정보도 사례도 아닌 '사람'을 남긴다. 이 원장이 어떤 사람이고 아이들을 어떻게 바라보는지가 은은히 배게 한다. · 목소리: 담백한 습니다체, 혼잣말하듯, 자랑하지 않는다. 직접 홍보 최소(학원 이름도 억지로 넣지 않는다). · 소재는 딱 하나 — 오늘 있었던 한 장면 또는 요즘 드는 한 생각. · 사례를 길게 늘어놓지 않는다(그건 '학생 이야기'다). 장면은 짧게 스치고 무게는 원장의 생각으로 옮긴다.",
  student: "[종류: 학생 이야기 — 한 아이의 변화 서사] · 목적: 읽는 학부모가 '우리 아이 같다'며 몰입하고 마음이 움직이는 후기형 드라마. · ★★학생은 반드시 'A'(또는 '그 아이','한 학생','중2 A')로만 부른다 — 이름을 절대 붙이지 마라. 민준·서연·지훈·수아 등 어떤 이름(가명 포함)도 금지, 실명·학교+실명은 당연히 금지. 이름을 붙이는 순간 개인정보·명예훼손 위험이며 규칙 위반이다. 예: '민준이는 교재를 폈습니다'(X) → '중2 A는 교재를 폈습니다'(O). 어머니도 'A의 어머니'로. · 한 아이(A)의 '한 가지' 변화에만 집중(여러 아이·여러 변화 섞지 않는다). · 아이 대사는 실제 말투로 살린다. · 반드시 현장 디테일로 진짜임을 증명한다 — 어느 단원 몇 번 유형에서 어떻게 막혔고 무엇을 바꾸니 어떻게 됐는지 구체적으로(막연한 '열심히 했더니 올랐어요' 금지). · 성적·결과 보장/과장 절대 금지. 학원 홍보는 '지도 방식이 이야기 속에 자연히 드러나는' 선까지만.",
  exam: "[종류: 입시 정보 — 홍보 0 정보구조] · 목적: 학부모가 저장·공유하고 AI·스마트블록이 통째로 인용해가는 '진짜 정보'. · 학원 홍보를 100% 뺀다 — 본문은 물론 말미의 상담·문의 유도도 금지(정보인 척하다 학원 얘기로 끝나면 스마트블록에서 1순위로 배제된다). · 반드시 웹검색으로 최신 제도·일정·트렌드를 확인해 반영. 오래된 정보·추측 금지, 불확실하면 [확인 필요: ...]. · 특정 학원·교재·인강 추천 금지. 결론을 강요하지 않고 판단 근거를 준다.",
  etc: "[종류: 기타 — 지정 스타일 우선] · 사용자가 준 '원하는 글 스타일'을 최우선으로 따른다. · 정보성이면 입시정보처럼 홍보 0 + 질문답변 구조로, 감성이면 일상/학생처럼 장면형으로. 둘을 섞지 않는다(섞이면 메시지가 흐려진다). · 지정이 없으면 정보성으로 깔끔하게.",
};
const CAT_FORM = {
  daily: "오프닝(첫 3줄): 그날의 장면이나 원장의 혼잣말로 훅 — '오늘 ○○를 하다가 문득' 처럼('안녕하세요/오늘 날씨가/요즘 학부모님들' 류 인사 절대 금지). 구조: 소제목은 거의 쓰지 않고 일기처럼 흐른다, 첫 문단 안에 이 글이 남길 한 가지가 드러나게. 키워드: 억지로 넣지 말고 지역+주제를 첫 문단과 본문에 1~2회만 자연히. 엔딩: 담백한 소회 한 줄로 여운.",
  student: "오프닝(첫 3줄): 한 장면으로 연다 — '어느 날 A가 ~한 표정으로 앉아 있었습니다' 처럼 소설처럼(요약·인사로 시작 금지). 구조: 장면 → 무엇이 문제였나 → 무엇을 바꿨나 → 아이의 변화 → 소회. 정보 나열·소제목 나열식 금지(서사 흐름 유지). 증거: 중간에 반드시 구체 디테일 1~2개(단원명·유형·틀리던 이유 등). 키워드: 제목엔 비포→애프터 후킹+지역, 본문엔 지역·과목 키워드 2~3회 자연 분산. 엔딩: 담백한 한 줄 소회, 신뢰만 남기고 대놓고 홍보하지 않는다.",
  exam: "오프닝: 맨 앞에 '이 글을 읽으면 알게 되는 것' 3~4개를 구체적으로 제시(막연한 항목 금지 — 네이버가 첫 화면을 요약하므로 결론이 앞에 있어야 한다). 구조: 학부모가 실제로 검색할 '질문형 소제목'으로 나눈다(그 소제목이 곧 검색 쿼리). 각 소제목 바로 아래 첫 2~3문장은 그 질문의 '자기완결 답변'(이 덩어리만 떼도 답이 되게 — AI·스마트블록 인용용) → 그 뒤 근거·설명. 소주제 4~5개 이하. 키워드: 질문 소제목과 첫 문단에 핵심 키워드, 지역명은 정보성이라 최소한만. 엔딩: 정보로 담백하게 닫는다(홍보로 꺾지 않기).",
  etc: "지정 스타일에 맞춰 오프닝·구조를 바꾼다. 정보성=‘알게 되는 것’+질문답변 구조, 감성=장면형 오프닝. 스타일 지정이 뚜렷할수록 그 결을 강하게 민다.",
};
const SCHEMA = `{
  "optimizedTitle":"제목 한 줄. 후킹 앞부분 + 끝에 핵심·지역 키워드 탑재 형식(예: ○○ 모르면 손해보는 3가지, 덕풍동수학학원). 과장·표시광고법 위반 금지",
  "post":"본문 전체. 공백 포함 최소 2,000자 이상(반드시 2,000자를 넘길 것, 모자라면 사례·팁·자주 묻는 질문을 더 풀어 채운다). 매우 중요: 이 문자열 안에서 큰따옴표(\")를 절대 쓰지 말 것. 대화·인용·강조는 작은따옴표(')나 한글 따옴표(‘ ’, “ ”)만 사용한다(큰따옴표를 쓰면 JSON이 깨져 글이 안 보임). 마크다운 기호(**, ##, -, *, > 등) 절대 사용 금지. 1~2문장마다 줄을 바꾸고 문단 사이에 빈 줄을 넣어 자주 줄바꿈. 사진 위치마다 【사진① 무엇을 찍은 사진인지 — 넣는 이유】 인라인 삽입. 확인 필요 수치는 [확인 필요: ...]",
  "photos":[{"label":"①","subject":"무슨 사진","reason":"넣는 이유/위치"}],
  "hashtags":["#키워드","..."],
  "legalNote":"표시광고법·학원법·개인정보 주의. 없으면 빈 문자열",
  "osmu":{"recommended":"카드뉴스 | 릴스(숏폼) | 둘 다 | 불필요","reason":"이유","storyboard":[{"no":1,"scene":"표지/훅","text":"문구","visual":"비주얼"}]},
  "scores":{"title":0,"search":0,"notes":"부족한 부분 개선 제안"},
  "thumbnail":{"headline":"8자 내외 카피","sub":"보조 문구(없으면 빈 문자열)","template":"center | top | bottom","bgColor":"#1A1414","textColor":"#FFFFFF"}
}`;
function buildSystem(catId, styleProfile) {
  return `당신은 대한민국 네이버 블로그 전문 카피라이터입니다. 학원 원장이 직접 쓴 것처럼 보이는 글을 씁니다.

${styleProfile ? `[최우선 규칙 — 원장 문체] 아래 문체 프로파일을 무엇보다 먼저, 그대로 재현한다. 아래 메뉴 성격과 충돌하면 '문체'를 우선한다.\n${styleProfile}\n` : `[톤] 원장 1인칭, 진정성 있고 신뢰감 있는 톤. 광고처럼 보이지 않게. 독자는 정보를 찾는 학부모. 서술 어미는 '습니다/했습니다/입니다'체로 통일한다(요체 '~해요' 금지). 단, 등장인물의 대사는 원래 말투를 그대로 살린다(예: 아이가 '쌤 이거 왜 틀렸어요?'라고 했으면 그대로).`}

[공통 규칙]
- ★학생 익명(절대): 학생을 이름으로 부르지 않는다. 실제든 가상이든 'A'·'B'·'그 아이'·'한 학생'으로만(민준이·서연이 같은 가명도 금지). 학부모는 'A의 어머니'.
- 네이버 SEO·AEO·GEO·스마트블록 최적화를 동시에. 우선순위 SEO > AEO > GEO. 키워드·지역명을 자연스럽게 분산(스터핑 금지).
- 본문은 공백 포함 최소 2,000자 이상으로 충분히 길게 쓴다(2,000자 미만 금지). 단, 모바일 가독성을 위해 줄바꿈은 자주 한다.
- 사실만. 확인 필요한 부분은 [확인 필요: ...]로 표시.
- 사진 위치에 【사진① 무엇 — 이유】 마커 인라인 삽입.
- 표시광고법·학원법·개인정보 문제 소지가 있으면 legalNote에 경고.

[모바일 가독성 — 반드시]
- 독자는 핸드폰으로 읽는 학부모. 마크다운 기호(**, ##, -, *, > 등)는 절대 금지(글자 그대로 노출됨). 강조는 줄바꿈·따옴표로.
- 1~2문장마다 줄을 바꾸고, 문단마다 빈 줄을 넣는다.

[하나의 글 = 하나의 메시지 (가장 중요)]
- 이 글이 하려는 말은 딱 한 문장으로 떨어져야 한다. 의도가 여러 개면 스마트블록·AI 어디에도 안 걸린다. 소재가 여러 개면 가장 강한 하나만 남기고 나머지는 과감히 버린다.
- 소주제(소제목)는 4~5개 이하. 6개가 넘으면 메시지가 둘이라는 신호다 — 하나로 좁힌다.
- 페르소나를 좁힌다. '학부모'가 아니라 '중2 아이가 수학을 놓기 시작해 불안한 어머니'처럼 한 사람을 정해 그 사람에게 말하듯 쓴다.

[스마트블록·AI 인용 구조 — SEO·AEO·GEO 동시 (반드시)]
- 정보성 글은 맨 앞에 '이 글을 읽으면 알게 되는 것' 3~4개를 구체적으로 제시한다(네이버가 첫 화면을 요약해 보여주므로 결론이 앞에 있어야 요약될 게 생긴다).
- 본문은 학부모가 실제로 검색할 '질문형 소제목'으로 나눈다. 각 소제목 바로 아래 첫 2~3문장은 그 질문에 대한 '자기완결 답변'으로 쓴다 — 이 문장만 떼어 읽어도 답이 되게(AI·스마트블록이 그대로 인용해 갈 덩어리). 그 뒤에 원장의 경험·근거를 붙인다.
- 경험의 증거를 남긴다. '이 문제는 중2 1학기 연립방정식 활용 3번 유형입니다' 같은 구체적 디테일이 좋은 말 열 줄보다 강하다.
- 필요하면 관련 글로 잇는 내부 링크 자리를 (링크: 관련 글 주제) 형태로 표시한다.

[글의 품질 — 독자가 '와, 이건 다르다' 느끼게]
- 첫 3줄에서 스크롤을 멈추게 한다(공감 한 방·의외의 사실·핵심 질문 중 하나). 밋밋한 인사·자기소개로 시작하지 않는다.
- 학부모가 실제로 검색한 의도에 '실질적으로' 답한다. 두루뭉술 금지, 구체적 사례·숫자·예시(과장 없이 사실 기반).
- 한 문단은 짧게. 가끔 한 줄짜리 임팩트 문장으로 리듬을 준다.
- optimizedTitle은 '후킹 앞부분 + 끝에 핵심·지역 키워드 탑재' 형식으로 만든다(과장·표시광고법 위반 금지).
- 마무리: 홍보성 글이면 신뢰가 남는 한마디 + 자연스러운 다음 행동 유도. 정보성 글이면 홍보로 꺾지 말고 정보로 담백하게 닫는다.

[AI가 쓴 티 나는 표현 금지]
- '~하는 것이 중요합니다', '결론적으로', '오늘은 ~에 대해 알아보겠습니다' 같은 상투구 금지. 과한 소제목 남발 금지. 22년 현장에서 아이들을 본 원장의 말처럼, 교과서·마케터 문체가 아니게 쓴다.

[사실 정확성 — 환각 방지 (매우 중요)]
- AI는 실제 작품(드라마·영화·책·예능)·뉴스·시사·통계·실존 인물의 발언을 직접 확인할 수 없다. 그러니 확실하지 않은 외부 사실을 '지어내지' 말 것.
- 특정 작품의 대사·장면·줄거리를 인용하듯 창작 금지(예: 드라마 속 대사를 만들어 따옴표로 넣지 말 것). 기억에 의존한 구체 묘사 금지.
- 외부 작품·이슈를 언급해야 하면, 구체적 내용은 모호하게(‘한 교육 드라마를 보다가’ 정도) 쓰고, 글의 무게는 원장 본인의 생각·경험으로 채운다.
- 꼭 들어가야 하는 외부 사실(작품 속 구체 장면/대사, 통계 수치, 날짜, 인물 발언)은 지어내지 말고 [확인 필요: 무엇을 확인할지]로 표시해 원장이 직접 채우게 한다.
- 학원 자체 수치(합격 인원·점수·기간·인원 등)도 임의로 만들지 말 것. 주어지지 않았으면 [확인 필요: ...]로 비워둔다.

[이 글의 종류 — 종류가 다르면 '전혀 다른 글'이 나와야 한다]
※ 같은 소재·같은 주제를 줘도 종류가 다르면 오프닝·구조·목소리·마무리가 완전히 달라야 한다. 일상=흐르는 에세이(사람이 남는다) / 학생 이야기=한 장면의 서사(마음이 움직이는 드라마) / 입시 정보=질문답변 정보구조(홍보 0, 스마트블록 인용용) / 기타=지정 스타일. 독자가 첫 3줄만 봐도 '아 이건 저 글과 다른 종류구나' 느끼게 하라.
※ 추상적인 좋은 말 열 줄보다, 구체적인 장면·숫자·유형 한 줄이 낫다. 뻔한 문장은 과감히 버리고 이 원장만 쓸 수 있는 디테일로 채운다.
${CAT_RULE[catId]}
형식) ${CAT_FORM[catId]}

[원소스 멀티유즈] 카드뉴스/릴스 재활용 적합성을 판단해 추천(둘 다/불필요 포함)하고 콘티를 장면별로 작성.
[채점] scores는 두 지표만. title=제목의 핵심 키워드 반영도(0~25점, 후킹성이 아니라 노출 키워드를 제목에 잘 담았는지). search=검색 최적화 종합 점수(0~75점, SEO·AEO·GEO를 모두 합산해 하나의 점수로). 합 100점. 부족한 부분은 notes에 구체적으로 개선 제안.
[썸네일] thumbnail에 어울리는 추천(headline 8자 내외, template·색상).

[출력] 아래 JSON 객체 하나만. 마크다운 펜스/설명 없이 JSON만.
[JSON 무결성 — 반드시] 모든 문자열 값(특히 post) 안에서는 큰따옴표(")를 쓰지 말 것. 대화·인용은 작은따옴표(')나 한글 따옴표(‘ ’ “ ”)로. 줄바꿈은 \\n으로. 그래야 JSON이 깨지지 않는다.
${SCHEMA}`;
}
const buildUser = (p, i) => `[원장/학원 정보]
- 원장명: ${p.director || "(미입력)"} / 학원명: ${p.academy || "(미입력)"} / 지역: ${p.region || "(미입력)"}
- 노출 키워드: ${p.keywords || "(미입력)"} / 톤·특징: ${p.tone || "(없음)"}

[작성 요청]
- 제목(초안): ${i.title}
- 간단 내용: ${i.brief}${i.style ? `\n- 원하는 글 스타일: ${i.style}` : ""}

최소 2,000자 이상의, 모바일에서 읽기 좋은(줄바꿈 잦은) 블로그 글을 작성해줘. 2,000자는 반드시 넘겨줘.`;
const buildTitleSystem = () => `당신은 학원 원장이 '직접' 운영하는 블로그의 제목 카피라이터입니다. 이 블로그는 '우리 학원' 이야기를 1인칭으로 쓰는 곳입니다.

[가장 중요 — 절대 금지 · 법적 검토 먼저]
- 여러 학원을 비교·나열·추천하는 제3자 시점 제목 금지. 예) '덕풍동 괜찮은 수학학원 3곳', '하남 수학학원 추천 BEST 5', '○○동 학원 비교' → 이런 건 우리 블로그에 안 맞는 쓰레기 제목이다. 절대 쓰지 말 것.
- 최상급·단정(최고·1등·NO.1) 금지, 경쟁 학원 지목·비방 금지, 성적/결과 보장(3개월 1등급 보장 등) 금지, 학생 실명·학교+실명 금지, 근거 없는 수치(합격률 98% 등) 금지. 모두 표시광고법·명예훼손 리스크.
- 과장·낚시(보장·100%·무조건·최고 등) 금지.

[지향 — 이런 느낌으로]
- 1인칭(저/우리 학원) 시점. 우리 학원에서 실제 있었던 일처럼.
- 학생의 변화(비포→애프터)·사연·드라마를 후킹으로. 감정이 묻어나게.
- 좋은 예시(느낌만 참고, 사실 범위 내에서):
  · '사춘기로 방황하던 아이가 저와 3개월 만에 전교 1등 한 사연, 덕풍동수학학원'
  · '수학을 포기했던 우리 반 아이, 지금은 미적분을 즐깁니다 | 미사수학학원'
  · '엄마도 두 손 든 중2, 제가 다시 책상에 앉힌 이야기, 풍산고수학학원'
  · '성적표 보고 울던 그날 — 6개월 뒤 이 아이는 달라졌습니다, 신장동수학학원'

[형식]
- '[감정·사연·변화가 담긴 후킹] + 끝에 [핵심·지역 키워드 탑재]'. 키워드는 끝에 자연스럽게 1~2개.
- 길이 24~42자.
- 글 종류에 맞춰: '학생 이야기'면 비포→애프터 사연을 최우선. '일상'이면 원장의 진심·관점이 드러나는 후킹. '입시 정보'면 학부모가 궁금해 검색할 정보형 후킹(단, 비교·추천 나열 금지). '기타'는 주제에 맞게.
- 5개를 서로 다른 각도로(사연/반전/공감/숫자/궁금증갭).
- 출력은 JSON 배열만(설명 없이). 예: ["제목1","제목2","제목3","제목4","제목5"]`;
const buildTitleUser = (p, cat, draft) => `[학원] ${p.academy || "-"} / 지역 ${p.region || "-"}\n[노출 키워드] ${p.keywords || "-"}\n[글 종류] ${cat}\n[초안·소재] ${draft || "(없음)"}\n\n우리 학원 1인칭 시점, 학생의 변화·사연이 느껴지는 후킹 + 끝에 키워드 탑재 형식으로 제목 5개를 JSON 배열로. 비교·추천 나열형 제목은 절대 쓰지 마.`;
const buildInspectSystem = () => `당신은 네이버 블로그 최적화 심사관입니다. 학원 원장이 직접 쓴 블로그 글(제목+본문)을 우리 기준으로 평가하고, 고칠 점을 콕 집어줍니다.

[평가 기준] 두 지표, 합 100점.
- title: 제목의 핵심 키워드 반영도(0~25점). 후킹성이 아니라 노출 키워드를 제목에 잘 담았는지.
- search: 검색 최적화 종합(0~75점). SEO(키워드·지역 자연 분산, 스터핑 없음, 분량·구조) + AEO(질문-답변형 정보 구조) + GEO(지역성)를 모두 합산해 하나의 점수로.

[구조·인용 적합성 점검 — search 점수에 반영]
- 이 글의 메시지가 하나로 떨어지는가(의도가 여러 개면 감점). 소주제가 5개 이하인가.
- 정보성 글이면 '이 글을 읽으면 알게 되는 것'이 앞에 있는가, 질문형 소제목 아래 첫 2~3문장이 자기완결 답변(AI가 인용할 덩어리)인가.
- 정보성 글에 홍보가 섞이지 않았는가(섞였으면 스마트블록 배제 리스크로 강하게 감점).
- 경험의 구체적 증거(단원·유형·장면 디테일)가 있는가.

[가독성·리스크도 함께 점검]
- 문체: '습니다'체로 통일됐는가(요체 혼용 지적, 단 인물 대사는 예외). AI 티 나는 상투구('~하는 것이 중요합니다', '결론적으로') 지적.
- 인물 표기: 가명 대신 A·B·C 익명인가, 실명·학교+실명 노출은 없는가.
- 모바일 가독성: 문장이 너무 길거나 줄바꿈이 부족하면 지적. 마크다운 기호(**, ## 등) 사용 지적.
- 표시광고법·학원법·과장(보장/100%/최고 등) 리스크 지적.

[출력] JSON 객체 하나만. 마크다운/설명 없이 JSON만.
[JSON 무결성 — 반드시] 모든 문자열 값(summary·issue·how·notes 등) 안에서 큰따옴표(")를 쓰지 말 것. 본문을 인용할 때도 작은따옴표(')나 한글 따옴표(‘ ’ “ ”)로. 줄바꿈 대신 공백. 그래야 JSON이 안 깨진다.
{
 "scores":{"title":0,"search":0,"notes":"한 줄 총평"},
 "summary":"2~3문장 총평. 무엇이 좋고 무엇이 약한지.",
 "good":["잘된 점 2~3개"],
 "fixes":[{"where":"어느 부분(제목/도입부/특정 문장 등)","issue":"무엇이 문제인지","how":"어떻게 고치면 되는지 구체적으로"}]
}
- fixes는 우선순위 높은 것부터 최대 5개. 막연한 말 대신 구체적으로.`;
const buildInspectUser = (title, body) => `[검사할 블로그 글]\n제목: ${title || "(없음)"}\n\n본문:\n${body}`;
const buildStyleSystem = () => `당신은 문체 분석가입니다. 아래 글들을 분석해 이 사람의 문체를 재현할 '문체 프로파일'을 한국어로 간결히 작성합니다.
- 문장 길이·호흡, 어미·말투, 자주 쓰는 표현·어휘, 단락 습관, 도입/마무리 패턴, 이모지·기호, 톤을 관찰해 요약.
- 관찰되는 특징만. 출력은 200~400자 프로파일 텍스트만.`;
const buildStyleUser = (s) => `다음은 같은 사람이 쓴 글들입니다(--- 구분). 문체 프로파일을 작성해줘.\n\n${s}`;
const buildCardSystem = () => `당신은 카드뉴스 카피라이터입니다. 블로그 본문을 원문에 충실하게 카드뉴스로 변환합니다.
- 총 10장 이하. 표지+내용+마무리 권장.
- 배경색+글자만으로 가독성이 충분하도록 짧고 굵은 핵심 문장.
- title은 큰 글자(8~14자), body는 보조(20자 내외, 없으면 빈 문자열).
- 원문 핵심·순서 유지, 새 사실 금지, 사진 마커 무시.
- 출력은 JSON 배열만. 예: [{"role":"cover","title":"...","body":"..."}]`;
const buildCardUser = (p, post) => `[학원] ${p.academy || "-"} / ${p.region || "-"}\n[본문]\n${post}\n\n10장 이하 카드뉴스로 변환해 JSON 배열로.`;

/* ════════ 공용 UI ════════ */
const inputStyle = { width: "100%", boxSizing: "border-box", border: `1px solid ${C.line}`, borderRadius: 12, padding: "12px 14px", fontSize: 15, color: C.text, background: "#FBFBFE", outline: "none", fontFamily: FONT };
const primaryBtn = { width: "100%", background: ACCENT_GRAD, color: "#fff", border: "none", borderRadius: 13, padding: "14px 18px", fontSize: 15, fontWeight: 700, cursor: "pointer", fontFamily: FONT, marginTop: 4, boxShadow: "0 8px 22px rgba(177,75,255,.28), 0 3px 10px rgba(226,61,87,.22)" };
const smallBtn = { border: `1px solid ${C.line}`, background: "#fff", color: C.text, fontSize: 12.5, fontWeight: 600, padding: "7px 12px", borderRadius: 10, cursor: "pointer", fontFamily: FONT };
const heroBtn = { border: `1px solid ${C.line}`, background: "#fff", color: C.ink, fontSize: 13, fontWeight: 700, padding: "10px 16px", borderRadius: 11, cursor: "pointer", fontFamily: FONT, boxShadow: "0 1px 2px rgba(20,20,40,.05)" };
const card = { background: C.card, border: `1px solid ${C.line}`, borderRadius: 20, padding: 22, boxShadow: "0 1px 2px rgba(20,20,45,.04), 0 10px 30px rgba(20,20,45,.07)" };
const hStyle = { fontSize: 23, fontWeight: 800, color: C.ink, margin: "0 0 6px", letterSpacing: "-0.02em" };
const pStyle = { fontSize: 14, color: C.sub, margin: "0 0 18px", lineHeight: 1.6 };
const BG_SWATCHES = ["#1A1414", "#8E2329", "#C39A3E", "#A4452E", "#2E8B57", "#1F2937", "#000000", "#7C3AED", "#2A63B8", "#0EA5E9", "#DB2777", "#F4F6FA", "#FFFFFF"];
const TEXT_SWATCHES = ["#FFFFFF", "#000000", "#1A1414", "#C39A3E", "#FFE9A8", "#2A63B8", "#0EA5E9", "#2E8B57", "#A4452E", "#DB2777", "#7C3AED", "#F59E0B"];

/* ════════ 썸네일·카드뉴스 공용 프리미엄 테마 ════════ */
const SERIF_FONT = '"Nanum Myeongjo","Batang","바탕","Apple Myungjo",serif';
const STUDIO_THEMES = [
  { id: "navy", name: "고급 미니멀", bg: ["#1f3455", "#0b1524"], ink: "#f5efe0", sub: "#9db2cf", accent: "#d8b567", stroke: null, frame: "gold", font: SERIF_FONT, weight: 700, dark: true },
  { id: "bold", name: "선명 대비", bg: ["#17161c", "#0e0d12"], ink: "#ffffff", sub: "#9a9aa5", accent: "#FF4D5E", stroke: "#000000", frame: "slash", font: FONT, weight: 900, dark: true },
  { id: "warm", name: "따뜻한 감성", bg: ["#f7f0e6", "#eccbb8"], ink: "#3c342b", sub: "#8a7968", accent: "#bd6a46", stroke: null, frame: "blob", font: SERIF_FONT, weight: 700, dark: false },
];
const themeById = (id) => STUDIO_THEMES.find((t) => t.id === id) || STUDIO_THEMES[0];
const themeCss = (t) => `linear-gradient(152deg, ${t.bg[0]}, ${t.bg[1]})`;
function fillThemeBg(ctx, t, S) { const g = ctx.createLinearGradient(0, 0, S, S); g.addColorStop(0, t.bg[0]); g.addColorStop(1, t.bg[1]); ctx.fillStyle = g; ctx.fillRect(0, 0, S, S); }
function drawThumbAccent(ctx, t, S) {
  if (t.frame === "gold") {
    const m = S * 0.05; ctx.strokeStyle = t.accent; ctx.lineWidth = Math.max(1.5, S * 0.0025); ctx.strokeRect(m, m, S - 2 * m, S - 2 * m);
    const rg = ctx.createRadialGradient(S * 0.85, S * 0.86, 0, S * 0.85, S * 0.86, S * 0.42); rg.addColorStop(0, "rgba(216,181,103,.18)"); rg.addColorStop(1, "rgba(216,181,103,0)"); ctx.fillStyle = rg; ctx.fillRect(0, 0, S, S);
  } else if (t.frame === "slash") {
    ctx.save(); ctx.globalAlpha = 0.15; ctx.fillStyle = t.accent; ctx.translate(S * 0.12, S * 0.92); ctx.rotate(-16 * Math.PI / 180); ctx.fillRect(-S * 0.35, 0, S * 0.95, S * 0.34); ctx.restore();
  } else if (t.frame === "blob") {
    const rg = ctx.createRadialGradient(S * 0.86, S * 0.14, 0, S * 0.86, S * 0.14, S * 0.46); rg.addColorStop(0, "rgba(192,113,79,.30)"); rg.addColorStop(1, "rgba(192,113,79,0)"); ctx.fillStyle = rg; ctx.fillRect(0, 0, S, S);
  }
}
const badge = (color) => ({ fontSize: 11, fontWeight: 700, color, border: `1px solid ${color}55`, background: `${color}12`, borderRadius: 99, padding: "3px 9px", marginLeft: 6 });

function Field({ label, hint, children }) {
  let req = null, rest = hint || "";
  if (typeof hint === "string") {
    if (hint.indexOf("필수") === 0) { req = "req"; rest = hint.replace(/^필수\s*[·|]?\s*/, ""); }
    else if (hint.indexOf("선택") === 0) { req = "opt"; rest = hint.replace(/^선택\s*[·|]?\s*/, ""); }
  }
  const badgeStyle = req === "req"
    ? { color: "#fff", background: ACCENT_GRAD, border: "none", boxShadow: "0 3px 10px rgba(177,75,255,.35)" }
    : { color: C.sub, background: "rgba(255,255,255,.05)", border: `1px solid ${C.line}` };
  return (
    <label style={{ display: "block", marginBottom: 16 }}>
      <div style={{ fontSize: 13.5, fontWeight: 700, color: C.ink, marginBottom: 7, display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
        <span>{label}</span>
        {req && <span style={{ fontSize: 10.5, fontWeight: 800, padding: "2.5px 9px", borderRadius: 99, letterSpacing: ".03em", ...badgeStyle }}>{req === "req" ? "필수" : "선택"}</span>}
        {rest && <span style={{ fontWeight: 500, color: C.sub, fontSize: 12 }}>{rest}</span>}
      </div>
      {children}
    </label>
  );
}
function CopyBtn({ text, label = "복사" }) {
  const [d, setD] = useState(false);
  return <button onClick={async () => { try { await navigator.clipboard.writeText(text); setD(true); setTimeout(() => setD(false), 1400); } catch {} }} style={smallBtn}>{d ? "복사됨 ✓" : label}</button>;
}
function PhotoSlot({ label }) {
  return <span style={{ display: "block", margin: "14px 0", padding: "12px 14px", border: `1.5px dashed ${C.brass}`, borderRadius: 10, background: "rgba(255,77,94,0.10)", color: C.ink2, fontSize: 13.5, lineHeight: 1.55 }}><span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.08em", color: C.brass, marginRight: 8 }}>📷 사진</span>{label}</span>;
}
const renderPost = (post) => String(post).split(/(【[^】]*】)/g).map((p, i) => /^【[^】]*】$/.test(p) ? <PhotoSlot key={i} label={p.replace(/[【】]/g, "")} /> : <span key={i} style={{ whiteSpace: "pre-wrap" }}>{p}</span>);
const cleanPost = (t) => !t ? t : String(t).replace(/\*\*/g, "").replace(/__/g, "").replace(/^\s{0,3}#{1,6}\s+/gm, "").replace(/^\s{0,3}[-*]\s+/gm, "").replace(/^\s{0,3}>\s?/gm, "");
function Panel({ title, right, children }) {
  return <div style={{ background: C.card, border: `1px solid ${C.line}`, borderRadius: 16, padding: "16px 18px", marginBottom: 14, boxShadow: "0 1px 3px rgba(17,17,28,.04), 0 8px 24px rgba(17,17,28,.05)" }}><div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: `1px solid ${C.line}`, paddingBottom: 9, marginBottom: 12 }}><span style={{ fontSize: 12.5, fontWeight: 800, letterSpacing: "0.04em", color: C.ink }}>{title}</span>{right}</div>{children}</div>;
}
function LockNote({ text }) { return <div style={{ background: C.paper, border: `1px dashed ${C.line}`, borderRadius: 11, padding: "13px 16px", fontSize: 13, color: C.sub }}>🔒 {text}</div>; }
// 초보 원장님을 위한 친절 안내 박스
function GuideBox({ icon = "💡", title, children }) {
  return (
    <div style={{ background: "rgba(177,75,255,.07)", border: "1px solid rgba(177,75,255,.26)", borderRadius: 14, padding: "14px 16px", marginBottom: 16, fontSize: 13.5, color: C.ink2, lineHeight: 1.75 }}>
      {title && <div style={{ fontWeight: 800, color: C.brassSoft, marginBottom: 7, fontSize: 13.5 }}>{icon} {title}</div>}
      {children}
    </div>
  );
}
// 관리자 승인 페이지 (문자 링크 ?approve=토큰 로 접속)
function ApprovePage({ token }) {
  const [info, setInfo] = useState(null);
  const [loading, setLoading] = useState(false);
  const [done, setDone] = useState(null);
  const [err, setErr] = useState("");
  useEffect(() => {
    fetch(API_BASE + "/api/approve?t=" + encodeURIComponent(token)).then((r) => r.json()).then(setInfo).catch(() => setInfo({ valid: false, error: "불러오지 못했어요. 다시 시도해 주세요." }));
  }, []);
  async function doApprove() {
    setLoading(true); setErr("");
    try {
      const r = await fetch(API_BASE + "/api/approve", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ t: token }) });
      const d = await r.json();
      if (!r.ok) { setErr(d.error || "승인에 실패했어요."); setLoading(false); return; }
      setDone(d);
    } catch { setErr("네트워크 오류예요. 다시 시도해 주세요."); }
    setLoading(false);
  }
  return (
    <div style={{ minHeight: "100vh", background: C.paper, display: "grid", placeItems: "center", padding: 20 }}>
      <div style={{ ...card, maxWidth: 420, width: "100%", textAlign: "center", padding: 32 }}>
        <div style={{ fontFamily: MONO, fontSize: 11, letterSpacing: ".2em", color: C.brassSoft, marginBottom: 16 }}>ADMIN · 신청 승인</div>
        {!info && <div style={{ color: C.sub }}>불러오는 중…</div>}
        {info && !info.valid && <div style={{ color: C.danger, fontSize: 15, fontWeight: 700, lineHeight: 1.6 }}>{info.error}</div>}
        {info && info.valid && !done && (
          <>
            <div style={{ fontSize: 21, fontWeight: 800, color: C.ink, marginBottom: 6 }}>{info.name || info.username} <span style={{ fontSize: 14, color: C.sub, fontWeight: 600 }}>({info.username})</span></div>
            <div style={{ fontSize: 15, color: C.ink2, marginBottom: 4 }}>{info.act === "signup" ? "가입" : "충전"} 신청 · <b style={{ color: C.brassSoft }}>{info.grant}건</b>{info.price ? ` (${won(info.price)})` : ""}</div>
            <div style={{ fontSize: 13, color: C.sub, marginBottom: 22 }}>현재 크레딧 {info.currentCredits}건 → 승인 시 <b style={{ color: C.ink }}>{info.currentCredits + info.grant}건</b></div>
            {info.done && <div style={{ fontSize: 13, color: C.ok, marginBottom: 14 }}>이미 처리된 신청이에요. 다시 눌러도 중복 충전되지 않아요.</div>}
            {err && <div style={{ color: C.danger, fontSize: 13, marginBottom: 12 }}>{err}</div>}
            <button style={{ ...primaryBtn, opacity: loading ? 0.6 : 1 }} disabled={loading} onClick={doApprove}>{loading ? "처리 중…" : "✓ 입금 확인 · 승인하기"}</button>
            <div style={{ fontSize: 12, color: C.sub, marginTop: 12 }}>💰 입금이 확인됐을 때만 눌러주세요.</div>
          </>
        )}
        {done && (
          <>
            <div style={{ fontSize: 46, marginBottom: 10 }}>✅</div>
            <div style={{ fontSize: 20, fontWeight: 800, color: C.ink, marginBottom: 8 }}>승인 완료!</div>
            <div style={{ fontSize: 14.5, color: C.ink2, lineHeight: 1.6 }}>{done.name}님 크레딧이 <b style={{ color: C.brassSoft }}>{done.credits}건</b>이 됐어요.{done.already ? " (이미 처리된 신청)" : ""}</div>
          </>
        )}
      </div>
    </div>
  );
}
function Ornament({ children }) { return <div className="ornament"><span className="orn-x">✦</span><span className="orn-t">{children}</span><span className="orn-x">✦</span></div>; }
function SectionHead({ kicker, title, sub }) {
  return (
    <div className="sec-head fade-up">
      <div className="kicker"><span className="kicker-dot" />{kicker}</div>
      <h2 className="sec-title">{title}</h2>
      {sub && <p className="sec-sub">{sub}</p>}
    </div>
  );
}
function backendError(e) {
  if (!e) return "처리 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.";
  if (e.code === "NETWORK") return "서버에 연결하지 못했어요. 인터넷 연결 또는 서버 상태를 확인해 주세요.";
  if (e.code === "API") {
    if (e.status === 401 || e.status === 403) return "AI API 키 인증에 실패했어요. 관리자 설정(API 키)을 확인해 주세요.";
    if (e.status === 429) return "지금 요청이 많아 잠시 제한됐어요. 30초쯤 뒤 다시 시도해 주세요.";
    if (e.status === 529 || e.status === 503 || e.status === 502) return "AI 서버가 일시적으로 혼잡해요. 잠시 후 다시 시도해 주세요.";
    if (e.status === 400) return "요청 형식 문제로 생성에 실패했어요." + (e.detail ? " (" + e.detail + ")" : "");
    return "생성 중 오류가 발생했어요" + (e.status ? " [" + e.status + "]" : "") + ". 잠시 후 다시 시도해 주세요.";
  }
  return "처리 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.";
}

/* ════════ 캔버스 ════════ */
function wrapCtx(ctx, text, maxW) { const out = []; String(text).split("\n").forEach((par) => { let line = ""; for (const ch of par) { const t = line + ch; if (ctx.measureText(t).width > maxW && line) { out.push(line); line = ch; } else line = t; } out.push(line); }); return out; }
function drawCenteredText(ctx, text, S, xf, yf, sizef, color, stroke) {
  if (!text) return;
  const fs = sizef * S; ctx.font = `900 ${fs}px ${FONT}`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.lineJoin = "round";
  const lines = wrapCtx(ctx, text, S * 0.86); const lh = fs * 1.18; const start = yf * S - ((lines.length - 1) * lh) / 2;
  lines.forEach((ln, i) => { const y = start + i * lh; if (stroke) { ctx.lineWidth = Math.max(2, fs * 0.12); ctx.strokeStyle = "#000000"; ctx.strokeText(ln, xf * S, y); } ctx.fillStyle = color; ctx.fillText(ln, xf * S, y); });
}
// 줄별 색상 + 글자별 색·크기를 반영해 그리는 버전 (수동 줄바꿈 \n 기준)
function drawRichText(ctx, el, S) {
  const text = el.text || ""; if (!text) return;
  const base = el.sizef * S; const lines = text.split("\n");
  const lc = el.lineColors || {}, cs = el.charStyles || {}, ls = el.lineScales || {}; const strokeCol = el.strokeColor || "#000000";
  const ff = el.font || FONT; const fw = el.weight || 900;
  const infos = lines.map((ln, li) => {
    let width = 0, maxSize = base;
    const items = [...ln].map((ch, ci) => {
      const st = cs[li + ":" + ci] || {}; const size = base * (st.scale || ls[li] || 1);
      ctx.font = `${fw} ${size}px ${ff}`; const w = ctx.measureText(ch).width;
      width += w; if (size > maxSize) maxSize = size;
      return { ch, size, w, color: st.color || lc[li] || el.color };
    });
    return { items, width, lh: maxSize * 1.18 };
  });
  const totalH = infos.reduce((a, l) => a + l.lh, 0); let y = el.yf * S - totalH / 2; const cx = el.xf * S;
  infos.forEach((info) => {
    const midY = y + info.lh / 2; let x = cx - info.width / 2;
    info.items.forEach((it) => {
      ctx.font = `${fw} ${it.size}px ${ff}`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.lineJoin = "round";
      if (el.stroke) { ctx.lineWidth = Math.max(2, it.size * 0.12); ctx.strokeStyle = strokeCol; ctx.strokeText(it.ch, x, midY); }
      ctx.fillStyle = it.color; ctx.fillText(it.ch, x, midY); x += it.w;
    });
    y += info.lh;
  });
}

/* ════════ 메인 ════════ */
function App() {
  const [ready, setReady] = useState(false);
  const [users, setUsers] = useState({});
  const [promos, setPromos] = useState({});
  const [me, setMe] = useState(null);
  const [guest, setGuest] = useState(false);
  const [view, setView] = useState("landing"); // landing | auth | app | admin | reset
  const usersRef = useRef({});

  useEffect(() => {
    (async () => {
      try {
        const r = await fetch(API_BASE + "/api/session");
        const sj = await r.json(); const user = sj.user; if (sj.token) setToken(sj.token);
        if (user && user.username) {
          setMe(user.username); setGuest(false);
          if (user.role === "admin") {
            const all = await loadUsers(); const merged = { ...all }; if (!merged[user.username]) merged[user.username] = user; // db_list 전체문서(pass 포함) 우선
            usersRef.current = merged; setUsers(merged);
            setPromos((await dbGet(PROMOS_KEY)) || {});
            setView(user.mustReset ? "reset" : "admin");
          } else {
            const m = { [user.username]: user }; usersRef.current = m; setUsers(m);
            setView(user.mustReset ? "reset" : (user.status === "active" ? "app" : "pending"));
          }
        }
      } catch {}
      setReady(true);
    })();
  }, []);

  // 관리자 화면: 새 가입·결제가 들어오면 자동 반영 (주기적 새로고침)
  useEffect(() => {
    if (view !== "admin") return;
    let alive = true;
    const tick = async () => {
      const u = await loadUsers(); if (alive && u) setUsers((prev) => { const same = JSON.stringify(prev) === JSON.stringify(u); if (!same) usersRef.current = u; return same ? prev : u; });
      const p = await dbGet(PROMOS_KEY); if (alive && p) setPromos((prev) => (JSON.stringify(prev) === JSON.stringify(p) ? prev : p));
    };
    const id = setInterval(tick, 8000);
    return () => { alive = false; clearInterval(id); };
  }, [view]);

  // 회원당 개별 문서로 저장 — 바뀐 회원만 쓰고, 사라진 회원만 지움 (동시 가입 충돌 방지)
  const persistUsers = (u) => {
    const prev = usersRef.current || {};
    usersRef.current = u;
    setUsers(u);
    for (const name in u) { if (prev[name] !== u[name]) dbSet(userKey(name), u[name]); }
    for (const name in prev) { if (!(name in u)) dbDel(userKey(name)); }
  };
  const persistPromos = (p) => { setPromos(p); dbSet(PROMOS_KEY, p); };
  const refreshNow = async () => { const u = await loadUsers(); usersRef.current = u; setUsers(u); const p = await dbGet(PROMOS_KEY); if (p) setPromos(p); };
  const meUser = me ? users[me] : null;

  async function doLogout() { try { await fetch(API_BASE + "/api/logout", { method: "POST" }); } catch {} setToken(null); setMe(null); setGuest(false); setView("landing"); }
  async function withdrawMe(password, deletePosts) {
    if (!me) return "로그인이 필요해요.";
    try {
      const r = await fetch(API_BASE + "/api/withdraw", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: password || "", deletePosts: !!deletePosts }) });
      const d = await r.json();
      if (!r.ok) return d.error || "탈퇴에 실패했어요.";
    } catch { return "네트워크 오류예요. 잠시 후 다시 시도해 주세요."; }
    setMe(null); setGuest(false); setView("landing");
    return null;
  }
  function enterAfterLogin(user) {
    if (!user || !user.username) return;
    const uname = user.username;
    const merged = { ...usersRef.current, [uname]: user }; usersRef.current = merged; setUsers(merged);
    setMe(uname); setGuest(false);
    if (user.mustReset) { setView("reset"); return; }
    if (user.role === "admin") { loadUsers().then((all) => { const m = { ...all }; if (!m[uname]) m[uname] = user; usersRef.current = m; setUsers(m); }); dbGet(PROMOS_KEY).then((p) => p && setPromos(p)); setView(user.status === "active" ? "admin" : "pending"); return; }
    setView(user.status === "active" ? "app" : "pending");
  }

  const approveTok = (() => { try { return new URLSearchParams(location.search).get("approve"); } catch { return null; } })();
  if (approveTok) return <ApprovePage token={approveTok} />;
  if (!ready) return <div style={{ minHeight: "100vh", display: "grid", placeItems: "center", color: C.sub }}>불러오는 중…</div>;

  const showHeader = view !== "landing";
  return (
    <div style={{ minHeight: "100vh", background: C.paper, color: C.text }}>
      {showHeader && <Header me={meUser} guest={guest} view={view} setView={setView} onLogout={doLogout} onHome={() => { setMe(null); setGuest(false); setView("landing"); }} onLoginCta={() => { setGuest(false); setView("auth"); }} />}
      {view === "landing" && <Landing onLogin={() => setView("auth")} onGuest={() => { setGuest(true); setView("app"); }} />}

      {view !== "landing" && (
        <main className="app-main">
          {view === "auth" && (
            <Auth users={users} promos={promos}
              onLogin={enterAfterLogin}
              onSignup={() => {}}
              onBack={() => setView("landing")} />
          )}

          {view === "reset" && meUser && (
            <ForceReset onReset={async (pw) => {
              try {
                const r = await fetch(API_BASE + "/api/set-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pw }) });
                if (!r.ok) return;
                const fresh = await dbGet(userKey(me));
                if (fresh) { const m = { ...usersRef.current, [me]: fresh }; usersRef.current = m; setUsers(m); }
                setView((fresh && fresh.role === "admin") ? "admin" : "app");
              } catch {}
            }} />
          )}

          {view === "pending" && meUser && meUser.status !== "active" && <Pending user={meUser} />}

          {view === "admin" && meUser && meUser.role === "admin" && meUser.status === "active" && !meUser.mustReset && (
            <Admin users={users} promos={promos} persistUsers={persistUsers} persistPromos={persistPromos} onRefresh={refreshNow} goWrite={() => setView("app")} />
          )}

          {view === "app" && (guest || (meUser && meUser.status === "active")) && (
            <Writer
              guest={guest}
              profile={guest ? {} : (meUser.profile || {})}
              onSaveProfile={(p) => { if (!guest) persistUsers({ ...users, [me]: { ...users[me], profile: p } }); }}
              plan={guest ? { posting: 99999, cardnews: true, thumbnail: true } : (meUser.role === "admin" ? { posting: 99999, cardnews: true, thumbnail: true } : (meUser.plan || { posting: 10 }))}
              credits={guest || (meUser && meUser.role === "admin") ? Infinity : creditsOf(meUser)}
              recharge={guest || (meUser && meUser.role === "admin") ? null : (meUser.recharge || null)}
              onRequestRecharge={guest ? null : async (pkg) => {
                if (meUser.role === "admin") return;
                try {
                  await fetch(API_BASE + "/api/recharge", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pkg: pkg || 0 }) });
                  const fresh = await dbGet(userKey(me));
                  if (fresh) { const m = { ...usersRef.current, [me]: fresh }; usersRef.current = m; setUsers(m); }
                } catch {}
              }}
              stats={guest ? {} : (meUser.stats || {})}
              username={guest ? null : me}
              onWithdraw={guest ? null : withdrawMe}
              onPosted={(category) => {
                if (guest || meUser.role === "admin") return;
                const u = users[me];
                const s = u.stats || { posts: 0, cat: {}, titles: 0, cards: 0, thumbs: 0, lastUsed: null };
                const ns = { ...s, cat: { ...s.cat }, posts: (s.posts || 0) + 1, lastUsed: new Date().toISOString() }; ns.cat[category] = (ns.cat[category] || 0) + 1;
                // 크레딧은 서버가 /api/messages(x-studio-bill:post) 성공 시 차감. 서버가 준 잔여값이 있으면 화면에 반영.
                const patch = { stats: ns };
                if (typeof window.__CREDITS_LEFT__ === "number") { patch.credits = window.__CREDITS_LEFT__; window.__CREDITS_LEFT__ = undefined; }
                persistUsers({ ...users, [me]: { ...u, ...patch } });
              }}
              onTrack={(type) => {
                if (guest || meUser.role === "admin") return;
                const u = users[me]; const s = u.stats || { posts: 0, cat: {}, titles: 0, cards: 0, thumbs: 0, lastUsed: null };
                const ns = { ...s, cat: { ...s.cat } }; ns[type] = (ns[type] || 0) + 1; persistUsers({ ...users, [me]: { ...u, stats: ns } });
              }} />
          )}
        </main>
      )}
    </div>
  );
}

function Header({ me, guest, view, setView, onLogout, onHome, onLoginCta }) {
  return (
    <header style={{ position: "sticky", top: 0, zIndex: 50, background: "rgba(255,255,255,.82)", backdropFilter: "blur(14px)", WebkitBackdropFilter: "blur(14px)", borderBottom: `1px solid ${C.line}`, color: C.ink, padding: "12px clamp(16px,4vw,32px)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
      <div style={{ display: "flex", alignItems: "center", gap: 11, cursor: "pointer" }} onClick={onHome}>
        <div style={{ width: 34, height: 34, borderRadius: 10, background: ACCENT_GRAD, display: "grid", placeItems: "center", fontWeight: 900, fontSize: 16, color: "#fff", boxShadow: "0 6px 18px rgba(124,92,255,.4)" }}>B</div>
        <div>
          <div style={{ fontFamily: MONO, fontSize: 10, letterSpacing: "0.2em", color: C.sub, fontWeight: 600 }}>BLOG STUDIO</div>
          <div style={{ fontSize: 15.5, fontWeight: 800, marginTop: 1, letterSpacing: "-0.01em" }}>블로그 스튜디오</div>
        </div>
      </div>
      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        {guest && <><span style={{ fontSize: 12.5, color: C.brassSoft }}>둘러보기 모드</span><button onClick={onLoginCta} style={{ ...smallBtn, background: ACCENT_GRAD, color: "#fff", borderColor: "transparent" }}>로그인</button></>}
        {me && me.role === "admin" && !me.mustReset && <button onClick={() => setView(view === "admin" ? "app" : "admin")} style={{ ...smallBtn, background: "transparent", color: C.brassSoft, borderColor: C.line }}>{view === "admin" ? "글쓰기" : "관리자"}</button>}
        {me && <><span style={{ fontSize: 13, color: C.ink2 }}>{me.name || me.username}</span><button onClick={onLogout} style={{ ...smallBtn, background: "transparent", color: C.sub, borderColor: C.line }}>로그아웃</button></>}
      </div>
    </header>
  );
}

/* ──────── 랜딩 ──────── */
const FEATURES = [
  ["✍️", "네이버 최적화 본문", "SEO 최우선 + AEO·GEO를 동시에 반영해 2,000자 이상 풍부하게. 사진 들어갈 자리까지 표시해 드려요.", "big"],
  ["🪝", "제목 추천", "초안 제목과 키워드를 조합해 클릭을 부르는 제목 5개 제안.", ""],
  ["📊", "최적화 점수", "제목·SEO·AEO·GEO 각 25점, 합 100점으로 완성도를 한눈에.", ""],
  ["🃏", "카드뉴스 자동 변환", "본문을 10장 이하 카드뉴스로. PNG로 저장.", ""],
  ["🖼️", "썸네일 제작기", "템플릿 추천 + 드래그 편집. 1080×1080 PNG.", ""],
  ["✨", "문체 개인화", "원장님의 예전 글을 학습해, 그 계정에만 원장님 문체로 써 드립니다. 쓸수록 더 ‘원장님답게’.", "big"],
];
const FAQS = [
  ["글은 얼마나 길게 나오나요?", "공백 포함 2,000자 이상으로 풍부하게 나옵니다. 소제목과 단락이 나뉘어 짧게 느껴지지 않게 구성돼요."],
  ["네이버에 자동으로 올라가나요?", "현재는 원고를 복사해 네이버 블로그에 붙여넣고 다듬는 방식입니다. 사진 자리·해시태그가 표시돼 있어 그대로 활용하면 됩니다."],
  ["제 글이 다른 학원에 쓰이지 않나요?", "네. 글은 매번 원장님의 정보·문체·입력으로만 독립적으로 생성됩니다. 다른 원장의 내용을 가져다 쓰는 구조가 없습니다."],
  ["작성한 내용이 관리자에게 보이나요?", "본문 내용은 저장하지 않습니다. 운영을 위해 사용 횟수·글 종류 같은 통계만 집계합니다."],
  ["요금은 어떻게 되나요?", "매달 빠져나가는 구독이 아니라, 쓴 만큼 차감되는 충전형 횟수제입니다. 10/20/30건 중 충전 패키지를 고르면 해당 건수만큼 크레딧이 쌓이고, 글을 만들 때마다 1건씩 차감돼요. 소진되면 다시 충전하면 됩니다. 카드뉴스·썸네일은 선택 부가기능입니다."],
];
function Landing({ onLogin, onGuest }) {
  return (
    <div className="lp">
      <section className="hero">
        <div className="hero-aurora" aria-hidden="true"><span className="blob b1" /><span className="blob b2" /><span className="blob b3" /></div>
        <div className="hero-grid">
          <div className="hero-copy">
            <div className="eyebrow fade-up"><span className="dot" />AI BLOG STUDIO · 학원 원장님 전용</div>
            <h1 className="fade-up d1">제목부터 썸네일까지,<br /><span className="accent">한 문장이면 글이 됩니다.</span></h1>
            <p className="lead fade-up d2">주제 한 줄만 넣으면 네이버 검색 최적화(SEO·AEO·GEO)에 맞춘 2,000자 원고와 제목·해시태그·카드뉴스·썸네일까지. 원장님의 문체까지 학습해 ‘직접 쓴 글’처럼.</p>
            <div className="cta-row fade-up d2">
              <button className="btn btn-grad" onClick={onLogin}>무료로 시작하기 <span className="arr">→</span></button>
              <button className="btn btn-glass" onClick={onGuest}>먼저 둘러보기</button>
            </div>
            <div className="trust fade-up d3">
              <span><b>2,000자+</b> 본문</span><i /><span><b>100점</b> 최적화 점수</span><i /><span><b>문체</b> 개인화</span>
            </div>
          </div>
          <div className="hero-demo fade-up d2" aria-hidden="true"><MockPost /></div>
        </div>
      </section>

      <section className="section">
        <SectionHead kicker="HOW IT WORKS" title="세 단계면 끝납니다" sub="복잡한 설정 없이, 로그인하고 바로." />
        <div className="steps">
          {[["학원 정보 한 번 입력", "원장명·학원명·지역·키워드를 한 번만. 원하면 예전 글로 문체까지 학습시켜요."], ["글 종류 선택 + 내용 입력", "일상·학생 이야기·입시 정보·기타 중 고르고, 제목과 간단한 내용만 적습니다."], ["원고·카드뉴스·썸네일 받기", "최적화된 본문과 점수, 카드뉴스, 썸네일까지. 복사해서 바로 활용하세요."]].map(([t, d], i) => (
            <div className="step fade-up" key={i}><div className="n">{String(i + 1).padStart(2, "0")}</div><div><div className="t">{t}</div><div className="d">{d}</div></div></div>
          ))}
        </div>
      </section>

      <section className="section">
        <SectionHead kicker="FEATURES" title="원장님 한 분이 마케팅팀 없이" sub="글쓰기부터 카드뉴스·썸네일까지 한곳에서." />
        <div className="bento">
          {FEATURES.map(([ic, t, d, big], i) => (
            <div className={"feat" + (big ? " feat-big" : "")} key={i}>
              <div className="feat-ic">{ic}</div>
              <div className="feat-body"><div className="t">{t}</div><div className="d">{d}</div></div>
            </div>
          ))}
        </div>
      </section>

      <section className="section">
        <SectionHead kicker="PRICING" title="쓴 만큼만, 충전형 횟수제" sub="매달 빠져나가는 구독 아님. 필요할 때 충전, 글 만들 때 1건씩 차감." />
        <PricingTable />
      </section>

      <section className="section">
        <SectionHead kicker="FAQ" title="자주 묻는 질문" />
        <div className="faq-wrap">{FAQS.map(([q, a], i) => <FaqItem key={i} q={q} a={a} />)}</div>
      </section>

      <section className="band">
        <div className="band-glow" aria-hidden="true" />
        <h2>오늘부터, 블로그 걱정 없이.</h2>
        <p>주제 한 줄이면 완성된 원고가 나옵니다.</p>
        <div className="cta-row" style={{ justifyContent: "center" }}>
          <button className="btn btn-light" onClick={onLogin}>무료로 시작하기 <span className="arr">→</span></button>
          <button className="btn btn-glass" onClick={onGuest}>둘러보기</button>
        </div>
      </section>
      <footer className="foot">학원 원장님을 위한 AI 블로그 작성 도구 · 블로그 스튜디오</footer>
    </div>
  );
}
// 히어로 플로팅 프리뷰(더미 목업 — 실제 데이터/생성 없음)
function MockPost() {
  return (
    <div className="mock">
      <div className="mock-bar"><span className="dotr" /><span className="dotr" /><span className="dotr" /><span className="mock-url">insky.kr/studio</span></div>
      <div className="mock-body">
        <div className="mock-tag">일상 · 생성 완료</div>
        <div className="mock-title">포기하려던 아이가 수학을 다시 잡은 날</div>
        <div className="mock-lines"><i style={{ width: "96%" }} /><i style={{ width: "88%" }} /><i style={{ width: "92%" }} /><i style={{ width: "70%" }} /></div>
        <div className="mock-photo">📷 사진 자리 ①</div>
        <div className="mock-lines"><i style={{ width: "90%" }} /><i style={{ width: "82%" }} /></div>
        <div className="mock-foot">
          <div className="mock-score"><span className="ring">96</span><span>최적화 점수</span></div>
          <div className="mock-tags"><span>#덕풍동수학학원</span><span>#미사수학</span></div>
        </div>
      </div>
    </div>
  );
}
function FaqItem({ q, a }) {
  const [open, setOpen] = useState(false);
  return <div className={"faq" + (open ? " open" : "")}><q onClick={() => setOpen(!open)}><span>{q}</span><span className="faq-ic">{open ? "−" : "+"}</span></q>{open && <div className="a">{a}</div>}</div>;
}
function PricingTable() {
  const packs = [
    { n: 10, tag: "가볍게 시작", feat: false },
    { n: 20, tag: "가장 인기", feat: true },
    { n: 30, tag: "가장 이득", feat: false },
  ];
  return (
    <>
      <div className="price-grid">
        {packs.map((p) => (
          <div className={"price-card" + (p.feat ? " feat" : "")} key={p.n}>
            {p.feat && <div className="price-badge">추천</div>}
            <div className="price-tag">{p.tag}</div>
            <div className="price-amt">{won(TIERS[p.n])}</div>
            <div className="price-unit">포스팅 <b>{p.n}건</b> 충전 · 월 갱신 없음</div>
            <div className="price-per">글 1건당 약 {won(Math.round(TIERS[p.n] / p.n))}</div>
            <ul className="price-list">
              <li>2,000자+ 네이버 최적화 원고</li>
              <li>제목 추천 · 최적화 점수</li>
              <li>문체 개인화 학습</li>
            </ul>
          </div>
        ))}
      </div>
      <div className="addons">
        <span className="addons-label">추가 기능</span>
        <span className="addon">카드뉴스 <b>+{won(ADDON.cardnews)}</b></span>
        <span className="addon">썸네일 <b>+{won(ADDON.thumbnail)}</b></span>
        <span className="addon hot">세트 <b>+{won(ADDON.set)}</b></span>
      </div>
    </>
  );
}

/* ──────── 강제 비번 변경 ──────── */
function ForceReset({ onReset }) {
  const [p1, setP1] = useState(""); const [p2, setP2] = useState(""); const [err, setErr] = useState("");
  return (
    <section style={{ maxWidth: 440, margin: "0 auto" }}>
      <h2 style={hStyle}>비밀번호를 변경하세요</h2>
      <p style={pStyle}>보안을 위해 처음 로그인 시 비밀번호를 바꿔야 합니다. 변경 전에는 진행할 수 없어요.</p>
      <div style={card}>
        <Field label="새 비밀번호"><input style={inputStyle} type="password" value={p1} onChange={(e) => setP1(e.target.value)} /></Field>
        <Field label="새 비밀번호 확인"><input style={inputStyle} type="password" value={p2} onChange={(e) => setP2(e.target.value)} /></Field>
        {err && <div style={{ color: C.danger, fontSize: 13, marginBottom: 10 }}>{err}</div>}
        <button style={primaryBtn} onClick={() => { if (p1.length < 4) return setErr("4자 이상으로 설정해 주세요."); if (p1 !== p2) return setErr("비밀번호가 일치하지 않아요."); onReset(p1); }}>변경하고 시작</button>
      </div>
    </section>
  );
}

/* ──────── 로그인 / 회원가입 ──────── */
function Auth({ users, promos, onLogin, onSignup, onBack }) {
  const [tab, setTab] = useState("login");
  const [lf, setLf] = useState({ username: "", pass: "" });
  const [sf, setSf] = useState({ code: "", username: "", name: "", academy: "", pass: "", pass2: "" });
  const [plan, setPlan] = useState({ posting: 10, cardnews: false, thumbnail: false });
  const [showPricing, setShowPricing] = useState(false);
  const [err, setErr] = useState(""); const [ok, setOk] = useState(""); const [paying, setPaying] = useState(false);

  const [promoInfo, setPromoInfo] = useState(null); // 서버 단건 검증 결과 {valid, plan, price}
  useEffect(() => {
    const code = (sf.code || "").trim().toUpperCase();
    if (!code) { setPromoInfo(null); return; }
    let cancelled = false;
    fetch(API_BASE + "/api/promo?code=" + encodeURIComponent(code))
      .then((r) => r.json()).then((d) => { if (!cancelled) setPromoInfo(d); })
      .catch(() => { if (!cancelled) setPromoInfo(null); });
    return () => { cancelled = true; };
  }, [sf.code]);

  async function doLogin() {
    setErr("");
    try {
      const r = await fetch(API_BASE + "/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: lf.username.trim(), password: lf.pass }) });
      const d = await r.json();
      if (!r.ok) return setErr(d.error || "로그인에 실패했어요.");
      setToken(d.token);
      onLogin(d.user);
    } catch { setErr("네트워크 오류예요. 잠시 후 다시 시도해 주세요."); }
  }
  async function doSignup() {
    setErr(""); setOk("");
    const code = sf.code.trim().toUpperCase();
    if (!sf.username.trim() || !sf.pass) return setErr("아이디와 비밀번호를 넣어주세요.");
    if (sf.pass !== sf.pass2) return setErr("비밀번호가 일치하지 않아요.");
    setPaying(true);
    try {
      const r = await fetch(API_BASE + "/api/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: sf.username.trim(), name: sf.name.trim(), academy: sf.academy.trim(), password: sf.pass, promoCode: code, plan }) });
      const d = await r.json();
      setPaying(false);
      if (!r.ok) return setErr(d.error || "가입에 실패했어요.");
      if (d.token) setToken(d.token);
      if (d.status === "active") {
        setOk(`가입 완료! ${d.grant}건 크레딧이 충전됐어요. 아이디와 비밀번호로 로그인하면 바로 이용할 수 있어요.`);
      } else {
        const bk = (window.INSKY_CONFIG && window.INSKY_CONFIG.bank) || {};
        const price = _promoValid ? (promoInfo.price || 0) : planPriceWith(plan, null);
        setOk(`가입 신청이 접수됐어요! 아래 계좌로 ${won(price)} 입금해 주세요.\n\n${bk.bankName || ""} ${bk.account || ""} (예금주 ${bk.holder || ""})\n\n입금이 확인되면 관리자가 ${d.grant}건 크레딧을 충전해 드려요. (입금자명을 가입한 이름/아이디와 같게 해주세요.)`);
      }
      setTab("login");
    } catch { setPaying(false); setErr("네트워크 오류예요. 잠시 후 다시 시도해 주세요."); }
  }
  const _promoValid = !!(promoInfo && promoInfo.valid);
  const _promo = _promoValid ? { plan: promoInfo.plan, price: promoInfo.price } : null;
  const payAmount = _promoValid ? (promoInfo.price || 0) : planPriceWith(plan, null);

  return (
    <section style={{ maxWidth: 440, margin: "0 auto" }}>
      <button onClick={onBack} style={{ background: "transparent", border: "none", color: C.sub, fontSize: 13, cursor: "pointer", marginBottom: 12 }}>← 홈으로</button>
      <div style={{ display: "flex", gap: 8, marginBottom: 18 }}>
        {[["login", "로그인"], ["signup", "회원가입"]].map(([id, lbl]) => <button key={id} onClick={() => { setTab(id); setErr(""); }} style={{ flex: 1, padding: 10, borderRadius: 10, border: `1px solid ${tab === id ? C.brass : C.line}`, background: tab === id ? C.brass : C.elev, color: tab === id ? "#fff" : C.sub, fontWeight: 700, fontSize: 14, cursor: "pointer", fontFamily: FONT }}>{lbl}</button>)}
      </div>
      {ok && <div style={{ background: "rgba(60,122,78,0.08)", border: `1px solid ${C.ok}`, color: C.ok, borderRadius: 10, padding: "12px 14px", fontSize: 13.5, marginBottom: 14 }}>{ok}</div>}
      <div style={card}>
        {tab === "login" ? (
          <>
            <Field label="아이디"><input style={inputStyle} value={lf.username} onChange={(e) => setLf({ ...lf, username: e.target.value })} /></Field>
            <Field label="비밀번호"><input style={inputStyle} type="password" value={lf.pass} onChange={(e) => setLf({ ...lf, pass: e.target.value })} /></Field>
            {err && <div style={{ color: C.danger, fontSize: 13, marginBottom: 10 }}>{err}</div>}
            <button style={primaryBtn} onClick={doLogin}>로그인</button>
          </>
        ) : (
          <>
            <Field label="프로모션코드" hint="선택 · 없어도 가입돼요"><input style={inputStyle} value={sf.code} onChange={(e) => setSf({ ...sf, code: e.target.value })} placeholder="PROMO-XXXX-XXXX (없으면 비워두기)" /></Field>
            {sf.code.trim() && !_promoValid && <div style={{ fontSize: 12, color: C.danger, marginTop: -10, marginBottom: 12 }}>사용할 수 없는 코드예요 (없거나 기한 만료·수량 소진).</div>}
            {_promoValid && <div style={{ fontSize: 12, color: C.ok, marginTop: -10, marginBottom: 12 }}>✓ 적용된 코드 — 아래 플랜이 자동 적용됩니다.</div>}
            <Field label="아이디"><input style={inputStyle} value={sf.username} onChange={(e) => setSf({ ...sf, username: e.target.value })} /></Field>
            <Field label="이름"><input style={inputStyle} value={sf.name} onChange={(e) => setSf({ ...sf, name: e.target.value })} /></Field>
            <Field label="학원명" hint="선택"><input style={inputStyle} value={sf.academy} onChange={(e) => setSf({ ...sf, academy: e.target.value })} /></Field>
            <Field label="비밀번호"><input style={inputStyle} type="password" value={sf.pass} onChange={(e) => setSf({ ...sf, pass: e.target.value })} /></Field>
            <Field label="비밀번호 확인"><input style={inputStyle} type="password" value={sf.pass2} onChange={(e) => setSf({ ...sf, pass2: e.target.value })} /></Field>
            {_promoValid ? (
              <div style={{ borderTop: `1px solid ${C.line}`, paddingTop: 14, marginBottom: 4 }}>
                <div style={{ fontSize: 13, fontWeight: 700, color: C.ink, marginBottom: 8 }}>코드로 제공되는 플랜</div>
                <div style={{ background: "rgba(46,139,87,0.07)", border: `1px solid ${C.ok}`, borderRadius: 10, padding: "12px 14px" }}>
                  <div style={{ fontSize: 14, fontWeight: 800, color: C.ink }}>{(_promo.plan && _promo.plan.posting) || "-"}건 충전{_promo.plan && _promo.plan.cardnews ? " + 카드뉴스" : ""}{_promo.plan && _promo.plan.thumbnail ? " + 썸네일" : ""}</div>
                  <div style={{ fontSize: 20, fontWeight: 900, color: C.ink, marginTop: 4 }}>{won(_promo.price || 0)}</div>
                </div>
              </div>
            ) : (
              <div style={{ borderTop: `1px solid ${C.line}`, paddingTop: 14, marginBottom: 4 }}>
                <div style={{ fontSize: 13, fontWeight: 700, color: C.ink, marginBottom: 8 }}>충전 패키지 선택 <span style={{ fontWeight: 500, color: C.sub }}>· 횟수제(월 갱신 없음)</span></div>
                <div style={{ fontSize: 12, color: C.sub, marginBottom: 7 }}>포스팅 충전 건수</div>
                <div style={{ display: "flex", gap: 7, marginBottom: 12 }}>
                  {[10, 20, 30].map((t) => <button key={t} onClick={() => setPlan({ ...plan, posting: t })} style={{ flex: 1, padding: "9px 0", borderRadius: 9, border: `1px solid ${plan.posting === t ? C.brass : C.line}`, background: plan.posting === t ? C.brass : C.elev, color: plan.posting === t ? "#fff" : C.sub, fontWeight: 700, fontSize: 13, cursor: "pointer", fontFamily: FONT }}>{t}건</button>)}
                </div>
                <div style={{ fontSize: 12, color: C.sub, marginBottom: 7 }}>추가 기능 (선택)</div>
                <div style={{ display: "flex", gap: 7, marginBottom: 12 }}>
                  {[["cardnews", "카드뉴스"], ["thumbnail", "썸네일"]].map(([k, lbl]) => <button key={k} onClick={() => setPlan({ ...plan, [k]: !plan[k] })} style={{ flex: 1, padding: "9px 0", borderRadius: 9, border: `1px solid ${plan[k] ? C.brass : C.line}`, background: plan[k] ? "rgba(255,77,94,0.16)" : C.elev, color: plan[k] ? C.ink : C.sub, fontWeight: 700, fontSize: 13, cursor: "pointer", fontFamily: FONT }}>{plan[k] ? "✓ " : ""}{lbl}</button>)}
                </div>
                {plan.cardnews && plan.thumbnail && <div style={{ fontSize: 12, color: C.brass, marginBottom: 8 }}>세트 묶음가 적용</div>}
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", background: C.paper, borderRadius: 10, padding: "10px 13px" }}>
                  <span style={{ fontSize: 13, color: C.sub }}>충전 금액 (1회)</span>
                  <span style={{ fontSize: 20, fontWeight: 900, color: C.ink }}>{won(planPriceWith(plan, null))}</span>
                </div>
              </div>
            )}
            {err && <div style={{ color: C.danger, fontSize: 13, marginBottom: 10 }}>{err}</div>}
            <button style={{ ...primaryBtn, opacity: paying ? 0.6 : 1 }} disabled={paying} onClick={doSignup}>{paying ? "처리 중…" : (payAmount > 0 ? ((window.INSKY_CONFIG && window.INSKY_CONFIG.payMode) === "bank" ? `가입 신청 (${won(payAmount)} 계좌이체)` : `결제하고 가입 (${won(payAmount)})`) : "가입하기")}</button>
            <div style={{ fontSize: 12.5, color: C.sub, marginTop: 10, lineHeight: 1.5 }}>{payAmount > 0 ? "결제가 완료되면 바로 로그인하여 이용할 수 있습니다." : "가입 후 바로 로그인하여 이용할 수 있습니다."}</div>
          </>
        )}
      </div>
      {tab === "login" && <div style={{ marginTop: 14, textAlign: "center" }}><button onClick={() => setShowPricing(!showPricing)} style={{ background: "transparent", border: "none", color: C.brass, fontSize: 13, fontWeight: 700, cursor: "pointer", fontFamily: FONT }}>{showPricing ? "요금제 닫기" : "요금제 안내 보기"}</button>{showPricing && <div style={{ marginTop: 12 }}><PricingTable /></div>}</div>}
    </section>
  );
}

function Pending({ user }) {
  const bk = (window.INSKY_CONFIG && window.INSKY_CONFIG.bank) || {};
  const isBank = (window.INSKY_CONFIG && window.INSKY_CONFIG.payMode) === "bank";
  return <section style={{ maxWidth: 440, margin: "0 auto", textAlign: "center", paddingTop: 40 }}>
    <div style={{ fontSize: 40, marginBottom: 12 }}>⏳</div>
    <h2 style={hStyle}>승인 대기 중</h2>
    <p style={pStyle}>{user.name || user.username} 님, 가입 신청이 접수됐어요.<br />입금 확인 후 관리자가 승인하면 바로 이용할 수 있습니다.</p>
    {isBank && (bk.account ? <div style={{ marginTop: 18, background: C.card, border: `1px solid ${C.line}`, borderRadius: 14, padding: "16px 18px", textAlign: "left" }}>
      <div style={{ fontSize: 12.5, color: C.sub, marginBottom: 6 }}>입금 계좌</div>
      <div style={{ fontSize: 16, fontWeight: 800, color: C.ink }}>{bk.bankName} {bk.account}</div>
      <div style={{ fontSize: 13, color: C.text, marginTop: 3 }}>예금주 {bk.holder}</div>
      <div style={{ fontSize: 12, color: C.sub, marginTop: 10, lineHeight: 1.6 }}>입금자명을 가입한 이름/아이디와 같게 해주세요. 입금이 확인되면 승인해 드립니다.</div>
    </div> : null)}
  </section>;
}

/* ──────── 관리자 ──────── */
function Admin({ users, promos, persistUsers, persistPromos, onRefresh, goWrite }) {
  const [tab, setTab] = useState("dash");
  const [q, setQ] = useState(""); const [mFilter, setMFilter] = useState("all");
  const [days, setDays] = useState(7); const [qty, setQty] = useState(1);
  const [cPosting, setCPosting] = useState(10); const [cCard, setCCard] = useState(false); const [cThumb, setCThumb] = useState(false); const [cPrice, setCPrice] = useState(10000);
  const list = Object.values(users).filter((u) => u.role !== "admin");
  const promoList = Object.values(promos).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  const upd = (uname, patch) => persistUsers({ ...users, [uname]: { ...users[uname], ...patch } });
  const del = (uname) => { const c = { ...users }; delete c[uname]; persistUsers(c); };
  const delCode = (code) => { const c = { ...promos }; delete c[code]; persistPromos(c); };
  const [editCode, setEditCode] = useState(null);
  const [viewPosts, setViewPosts] = useState(null); const [memberPosts, setMemberPosts] = useState(null); const [postsLoading, setPostsLoading] = useState(false); const [openPost, setOpenPost] = useState(null);
  const [allPosts, setAllPosts] = useState(null); const [allLoading, setAllLoading] = useState(false);
  const loadAllPosts = async () => {
    setAllLoading(true); setOpenPost(null);
    try {
      const members = Object.values(users).filter((u) => u.role !== "admin");
      const out = [];
      for (const u of members) { const arr = await dbGet("inskyblog:posts:" + u.username); if (Array.isArray(arr)) arr.forEach((p) => out.push({ ...p, _username: u.username, _name: u.name || u.username })); }
      out.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
      setAllPosts(out);
    } catch { setAllPosts([]); } finally { setAllLoading(false); }
  };
  useEffect(() => { if (tab === "posts" && allPosts === null && !allLoading) loadAllPosts(); }, [tab]);
  useEffect(() => { setOpenPost(null); }, [tab]);
  const openMemberPosts = async (u) => { setViewPosts({ username: u.username, name: u.name || u.username }); setMemberPosts(null); setOpenPost(null); setPostsLoading(true); try { const arr = await dbGet("inskyblog:posts:" + u.username); setMemberPosts(arr || []); } catch { setMemberPosts([]); } finally { setPostsLoading(false); } };
  const [period, setPeriod] = useState("today");
  const [postAgg, setPostAgg] = useState(null); const [aggLoading, setAggLoading] = useState(false);
  const loadPostStats = async () => { setAggLoading(true); try { const members = Object.values(users).filter((u) => u.role !== "admin"); const stamps = []; for (const u of members) { const arr = await dbGet("inskyblog:posts:" + u.username); if (Array.isArray(arr)) arr.forEach((p) => { if (p && p.createdAt) stamps.push(p.createdAt); }); } setPostAgg(stamps); } catch { setPostAgg([]); } finally { setAggLoading(false); } };
  useEffect(() => { if (tab === "dash" && postAgg === null && !aggLoading) loadPostStats(); }, [tab]);
  const [health, setHealth] = useState(null);
  const checkHealth = async () => { setHealth("loading"); try { const r = await fetch(API_BASE + "/api/health"); setHealth(await r.json()); } catch { setHealth({ ok: false, neterr: true }); } };
  useEffect(() => { if (tab === "dash") checkHealth(); }, [tab]);
  const [aiTest, setAiTest] = useState(null);
  const runAiTest = async () => {
    setAiTest("loading");
    try { const txt = await callClaude({ model: "claude-sonnet-4-6", max_tokens: 12, system: "한 단어로만 답해.", messages: [{ role: "user", content: "ok 라고만 답해" }] }); setAiTest({ ok: !!txt, text: (txt || "").slice(0, 40) }); }
    catch (e) { setAiTest({ ok: false, msg: backendError(e) }); }
  };
  const setCodeQty = (code, n) => { const iv = promos[code]; if (!iv) return; const used = iv.usedCount || 0; const maxUses = Math.max(used, Math.max(1, Math.round(+n || 1))); persistPromos({ ...promos, [code]: { ...iv, maxUses } }); };
  const setCodeExpiry = (code, ymd) => { const iv = promos[code]; if (!iv || !ymd) return; persistPromos({ ...promos, [code]: { ...iv, expiresAt: new Date(ymd + "T23:59:59").toISOString() } }); };
  const extendCode = (code, d) => { const iv = promos[code]; if (!iv) return; const base = Math.max(Date.now(), new Date(iv.expiresAt).getTime()); persistPromos({ ...promos, [code]: { ...iv, expiresAt: new Date(base + d * 86400000).toISOString() } }); };
  const newCode = () => { const code = genCode(); const expiresAt = new Date(Date.now() + Number(days) * 86400000).toISOString(); const plan = { posting: Math.max(1, +cPosting || 1), cardnews: !!cCard, thumbnail: !!cThumb }; const price = Math.max(0, +cPrice || 0); persistPromos({ ...promos, [code]: { code, expiresAt, maxUses: Math.max(1, +qty || 1), usedCount: 0, plan, price, createdAt: new Date().toISOString() } }); };

  const active = list.filter((u) => u.status === "active");
  const revenue = active.reduce((s, u) => s + userPrice(u), 0);
  const totalCredits = active.reduce((s, u) => s + (isUnlimited(u) ? 0 : creditsOf(u)), 0);
  const comp = {}; active.forEach((u) => { const c = composition(u.plan); comp[c] = (comp[c] || 0) + 1; });
  const tierC = {}; active.forEach((u) => { const t = (u.plan && u.plan.posting) || 0; tierC[t] = (tierC[t] || 0) + 1; });
  const startOf = (p) => { const d = new Date(); d.setHours(0, 0, 0, 0); if (p === "week") { const day = (d.getDay() + 6) % 7; d.setDate(d.getDate() - day); } else if (p === "month") { d.setDate(1); } else if (p === "all") return 0; return d.getTime(); };
  const pStart = startOf(period); const inP = (iso) => iso && new Date(iso).getTime() >= pStart;
  const pLabel = period === "today" ? "오늘" : period === "week" ? "이번 주" : period === "month" ? "이번 달" : "전체";
  const signupsP = list.filter((u) => inP(u.joinedAt)).length;
  const revP = list.reduce((a, u) => a + (u.payment && inP(u.payment.at) ? (u.payment.amount || 0) : 0), 0);
  const revAll = list.reduce((a, u) => a + (u.payment ? (u.payment.amount || 0) : 0), 0);
  const postsTotal = postAgg ? postAgg.length : null;
  const postsP = postAgg ? postAgg.filter((t) => new Date(t).getTime() >= pStart).length : null;

  return (
    <section>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 10 }}>
        <div>
          <div style={{ fontFamily: MONO, fontSize: 10.5, letterSpacing: ".2em", color: C.brassSoft, marginBottom: 3 }}>ADMIN CONSOLE</div>
          <h2 style={{ ...hStyle, marginBottom: 0 }}>회원 현황 · 관리</h2>
        </div>
        <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
          <span style={{ fontSize: 11.5, color: C.sub, display: "inline-flex", alignItems: "center", gap: 5 }}><span style={{ width: 7, height: 7, borderRadius: "50%", background: C.ok, boxShadow: `0 0 8px ${C.ok}` }} />충전형 · 횟수제</span>
          <button style={smallBtn} onClick={onRefresh}>↻ 새로고침</button>
          <button style={smallBtn} onClick={goWrite}>글쓰기로 →</button>
        </div>
      </div>
      <div style={{ display: "flex", gap: 8, margin: "16px 0", flexWrap: "wrap" }}>
        {[["dash", "현황"], ["members", "회원 관리"], ["posts", "전체 글"], ["codes", "프로모션코드"]].map(([id, lbl]) => <button key={id} onClick={() => setTab(id)} style={{ padding: "9px 16px", borderRadius: 9, border: `1px solid ${tab === id ? C.brass : C.line}`, background: tab === id ? C.brass : C.elev, color: tab === id ? "#fff" : C.sub, fontWeight: 700, fontSize: 13.5, cursor: "pointer", fontFamily: FONT }}>{lbl}</button>)}
      </div>

      {(() => {
        const pendUsers = list.filter((u) => u.status === "pending");
        const rechReqs = list.filter((u) => u.recharge && u.recharge.requested);
        if (!pendUsers.length && !rechReqs.length) return null;
        return (
          <div style={{ background: "radial-gradient(500px 200px at 100% 0,rgba(255,77,109,.16),transparent 60%), " + C.elev, border: "1px solid rgba(255,77,109,.4)", borderRadius: 18, padding: 18, marginBottom: 14 }}>
            <div style={{ fontFamily: MONO, fontSize: 11, letterSpacing: ".16em", color: C.brassSoft, marginBottom: 13 }}>🔔 처리할 신청 {pendUsers.length + rechReqs.length}건</div>
            <div style={{ display: "grid", gap: 8 }}>
              {pendUsers.map((u) => (
                <div key={"p" + u.username} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, flexWrap: "wrap", background: C.card, border: `1px solid ${C.line}`, borderRadius: 12, padding: "11px 14px" }}>
                  <div style={{ fontSize: 13.5, color: C.ink2 }}><b style={{ color: C.ink }}>{u.name || u.username}</b> <span style={{ color: C.sub }}>@{u.username}</span> · 가입 신청 · <b style={{ color: C.brassSoft }}>{(u.plan && u.plan.posting) || 0}건</b> {won(userPrice(u))}</div>
                  <span style={{ display: "flex", gap: 6 }}>
                    <button style={{ ...smallBtn, background: ACCENT_GRAD, color: "#fff", borderColor: "transparent", fontWeight: 700 }} onClick={() => upd(u.username, { status: "active", paid: true, credits: (u.plan && u.plan.posting) || 0, recharge: undefined })}>✓ 승인 +{(u.plan && u.plan.posting) || 0}건</button>
                    <button style={{ ...smallBtn, color: C.danger }} onClick={() => { if (confirm(u.username + " 신청을 거절(삭제)할까요?")) del(u.username); }}>거절</button>
                  </span>
                </div>
              ))}
              {rechReqs.map((u) => (
                <div key={"r" + u.username} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, flexWrap: "wrap", background: C.card, border: `1px solid ${C.line}`, borderRadius: 12, padding: "11px 14px" }}>
                  <div style={{ fontSize: 13.5, color: C.ink2 }}><b style={{ color: C.ink }}>{u.name || u.username}</b> <span style={{ color: C.sub }}>@{u.username}</span> · 충전 신청 · <b style={{ color: C.brassSoft }}>{u.recharge.requested}건</b> {won(u.recharge.price)}</div>
                  <span style={{ display: "flex", gap: 6 }}>
                    <button style={{ ...smallBtn, background: ACCENT_GRAD, color: "#fff", borderColor: "transparent", fontWeight: 700 }} onClick={() => upd(u.username, { credits: creditsOf(u) + (u.recharge.requested || 0), recharge: undefined })}>✓ 충전 +{u.recharge.requested}건</button>
                    <button style={{ ...smallBtn, color: C.danger }} onClick={() => upd(u.username, { recharge: undefined })}>취소</button>
                  </span>
                </div>
              ))}
            </div>
          </div>
        );
      })()}

      {tab === "dash" && (
        <div style={{ display: "grid", gap: 12 }}>
          <div style={{ ...card, padding: 14 }}>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
              <span style={{ fontSize: 12.5, fontWeight: 800, color: C.ink }}>서버 상태</span>
              <button style={{ ...smallBtn, padding: "5px 10px" }} onClick={checkHealth}>↻ 점검</button>
            </div>
            {health === "loading" || !health ? <div style={{ fontSize: 12.5, color: C.sub }}>확인 중…</div> : health.neterr ? <div style={{ fontSize: 12.5, color: C.danger }}>● 서버에 연결하지 못했어요. 배포 상태/주소를 확인해 주세요.</div> : (
              <div style={{ display: "flex", gap: 14, flexWrap: "wrap" }}>
                {[["서버", health.ok], ["AI 키", health.hasKey], ["Firestore", health.firestore], ["결제(포트원)", health.portone]].map(([k, v]) => <span key={k} style={{ fontSize: 12.5, color: C.text, display: "inline-flex", alignItems: "center", gap: 5 }}><span style={{ width: 8, height: 8, borderRadius: "50%", background: v ? C.ok : C.danger }} />{k} {v ? "정상" : "미연결"}</span>)}
              </div>
            )}
            <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 11, paddingTop: 11, borderTop: `1px solid ${C.line}`, flexWrap: "wrap" }}>
              <button style={{ ...smallBtn, padding: "6px 11px" }} onClick={runAiTest}>{aiTest === "loading" ? "AI 테스트 중…" : "🤖 AI 응답 테스트"}</button>
              {aiTest && aiTest !== "loading" && (aiTest.ok ? <span style={{ fontSize: 12.5, color: C.ok, display: "inline-flex", alignItems: "center", gap: 5 }}><span style={{ width: 8, height: 8, borderRadius: "50%", background: C.ok }} />AI 정상 응답 ✓</span> : <span style={{ fontSize: 12.5, color: C.danger }}>● AI 응답 실패 — {aiTest.msg || "원인 확인 필요"}</span>)}
              {health && health.model && <span style={{ fontSize: 11.5, color: C.sub, marginLeft: "auto" }}>모델 {health.model}</span>}
            </div>
          </div>
          <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
            <span style={{ fontSize: 12, color: C.sub, marginRight: 2 }}>기간</span>
            {[["today", "오늘"], ["week", "이번 주"], ["month", "이번 달"], ["all", "전체"]].map(([id, lbl]) => <button key={id} onClick={() => setPeriod(id)} style={{ ...smallBtn, padding: "6px 12px", background: period === id ? C.brass : C.elev, color: period === id ? "#fff" : C.sub, borderColor: period === id ? C.brass : C.line }}>{lbl}</button>)}
            <button style={{ ...smallBtn, padding: "6px 10px", marginLeft: "auto" }} onClick={loadPostStats}>{aggLoading ? "집계 중…" : "↻ 포스팅 집계"}</button>
          </div>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
            <div style={{ ...card, padding: 18 }}><div style={{ fontSize: 12.5, color: C.sub }}>{pLabel} 가입</div><div style={{ fontSize: 26, fontWeight: 900, color: C.ink }}>{signupsP}<span style={{ fontSize: 13, color: C.sub, fontWeight: 600 }}>명</span></div><div style={{ fontSize: 12, color: C.sub, marginTop: 2 }}>누적 {list.length}명 · 이용중 {active.length}</div></div>
            <div style={{ ...card, padding: 18, background: "radial-gradient(280px 130px at 100% 0,rgba(124,92,255,.14),transparent 60%), " + C.elev }}><div style={{ fontSize: 12, color: C.brassSoft, fontFamily: MONO, letterSpacing: ".06em" }}>{pLabel} 수입</div><div style={{ fontSize: 26, fontWeight: 900, color: C.ink, marginTop: 4 }}>{won(revP)}</div><div style={{ fontSize: 12, color: C.sub, marginTop: 2 }}>누적 결제 {won(revAll)}</div></div>
            <div style={{ ...card, padding: 18 }}><div style={{ fontSize: 12.5, color: C.sub }}>{pLabel} 작성 포스팅</div><div style={{ fontSize: 26, fontWeight: 900, color: C.ink }}>{postsP == null ? "—" : postsP}<span style={{ fontSize: 13, color: C.sub, fontWeight: 600 }}>건</span></div><div style={{ fontSize: 12, color: C.sub, marginTop: 2 }}>{postsTotal == null ? (aggLoading ? "집계 중…" : "집계 전") : `누적 ${postsTotal}건`}</div></div>
            <div style={{ ...card, padding: 18, background: "radial-gradient(280px 130px at 100% 0,rgba(255,77,109,.16),transparent 60%), " + C.elev }}><div style={{ fontSize: 12, color: C.brassSoft, fontFamily: MONO, letterSpacing: ".06em" }}>잔여 크레딧 합계</div><div style={{ fontSize: 26, fontWeight: 900, color: C.ink, marginTop: 4 }}>{totalCredits}<span style={{ fontSize: 13, fontWeight: 600, color: C.sub }}>건</span></div><div style={{ fontSize: 12, color: C.sub, marginTop: 2 }}>이용중 회원 미사용 크레딧</div></div>
          </div>
          <div style={{ fontSize: 11.5, color: C.sub, lineHeight: 1.5 }}>· 수입은 포트원 온라인 결제 기준입니다(관리자 수동 입금확인은 제외). · 포스팅 집계는 회원 글 저장 시각 기준이며 "↻ 포스팅 집계"로 갱신돼요.</div>
          <div style={{ ...card, padding: 18 }}>
            <div style={{ fontSize: 12.5, fontWeight: 800, color: C.ink, marginBottom: 10 }}>구성별 (이용중)</div>
            {["포스팅만", "포스팅+썸네일", "포스팅+카드뉴스", "풀패키지"].map((k) => <div key={k} style={{ display: "flex", justifyContent: "space-between", fontSize: 13.5, padding: "5px 0" }}><span style={{ color: C.text }}>{k}</span><b style={{ color: C.ink }}>{comp[k] || 0}명</b></div>)}
          </div>
          <div style={{ ...card, padding: 18 }}>
            <div style={{ fontSize: 12.5, fontWeight: 800, color: C.ink, marginBottom: 10 }}>최근 패키지별 (이용중)</div>
            {[10, 20, 30].map((t) => <div key={t} style={{ display: "flex", justifyContent: "space-between", fontSize: 13.5, padding: "5px 0" }}><span>{t}건 패키지</span><b style={{ color: C.ink }}>{tierC[t] || 0}명</b></div>)}
          </div>
        </div>
      )}

      {tab === "members" && (() => {
        const kw = q.trim().toLowerCase();
        const filtered = list.filter((u) => {
          if (mFilter === "pending" && u.status !== "pending") return false;
          if (mFilter === "active" && u.status !== "active") return false;
          if (mFilter === "recharge" && !(u.recharge && u.recharge.requested)) return false;
          if (!kw) return true;
          return [u.name, u.username, u.academy].some((x) => (x || "").toLowerCase().includes(kw));
        });
        return (
        <div style={{ display: "grid", gap: 10 }}>
          <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap", marginBottom: 2 }}>
            <input style={{ ...inputStyle, flex: "1 1 200px", padding: "10px 14px" }} value={q} onChange={(e) => setQ(e.target.value)} placeholder="🔍 이름·아이디·학원명 검색" />
            {[["all", `전체 ${list.length}`], ["active", "이용중"], ["pending", "대기"], ["recharge", "충전신청"]].map(([id, lbl]) => <button key={id} onClick={() => setMFilter(id)} style={{ ...smallBtn, padding: "8px 12px", background: mFilter === id ? C.brass : C.elev, color: mFilter === id ? "#fff" : C.sub, borderColor: mFilter === id ? C.brass : C.line }}>{lbl}</button>)}
          </div>
          {filtered.length === 0 && <div style={{ color: C.sub, fontSize: 13.5, textAlign: "center", padding: 24 }}>{list.length === 0 ? "아직 가입한 회원이 없어요." : "조건에 맞는 회원이 없어요."}</div>}
          {filtered.map((u) => (
            <div key={u.username} style={{ ...card, padding: 16 }}>
              <div style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
                <div>
                  <div style={{ fontWeight: 800, color: C.ink }}>{u.name || u.username} <span style={{ fontWeight: 500, color: C.sub, fontSize: 13 }}>@{u.username}</span></div>
                  <div style={{ fontSize: 12.5, color: C.sub, marginTop: 3 }}>{u.academy || "학원명 미입력"} · 가입 {fmtDate(u.joinedAt)} · 코드 {u.code}</div>
                </div>
                <div style={{ display: "flex", gap: 6, alignItems: "flex-start", flexWrap: "wrap" }}>
                  <span style={badge(u.status === "active" ? C.ok : u.status === "pending" ? C.brass : u.status === "withdrawn" ? C.sub : C.danger)}>{u.status === "active" ? "이용중" : u.status === "pending" ? "대기" : u.status === "withdrawn" ? "탈퇴" : "정지"}</span>
                  <span style={badge(u.paid ? C.ok : C.sub)}>{u.paid ? "입금확인" : "미입금"}</span>
                </div>
              </div>
              <div style={{ marginTop: 12, borderTop: `1px solid ${C.line}`, paddingTop: 12 }}>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8, marginBottom: 8 }}>
                  <span style={{ fontSize: 12, color: C.sub }}>구성 · <b style={{ color: C.ink }}>{composition(u.plan)}</b></span>
                  <span style={{ fontSize: 13, fontWeight: 800, color: C.ink }}>최근 결제가 {won(userPrice(u))}</span>
                </div>
                <div style={{ display: "flex", gap: 6, marginBottom: 8, flexWrap: "wrap" }}>
                  {[10, 20, 30].map((t) => <button key={t} style={{ ...smallBtn, padding: "5px 10px", background: (u.plan && u.plan.posting) === t ? C.brass : C.elev, color: (u.plan && u.plan.posting) === t ? "#fff" : C.sub, borderColor: (u.plan && u.plan.posting) === t ? C.brass : C.line }} onClick={() => upd(u.username, { plan: { ...(u.plan || {}), posting: t } })}>{t}건</button>)}
                  {[["cardnews", "카드뉴스"], ["thumbnail", "썸네일"]].map(([k, lbl]) => <button key={k} style={{ ...smallBtn, padding: "5px 10px", background: u.plan && u.plan[k] ? "rgba(255,77,94,0.16)" : C.elev, borderColor: u.plan && u.plan[k] ? C.brass : C.line }} onClick={() => upd(u.username, { plan: { ...(u.plan || {}), [k]: !(u.plan && u.plan[k]) } })}>{u.plan && u.plan[k] ? "✓ " : ""}{lbl}</button>)}
                </div>
                {isUnlimited(u) ? (
                  <div style={{ fontSize: 12, color: C.sub }}>크레딧 <b style={{ color: C.ink }}>무제한</b> (테스트/관리 계정)</div>
                ) : (
                  <div style={{ fontSize: 12, color: C.sub, display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
                    <span>잔여 크레딧 <b style={{ color: creditsOf(u) > 0 ? C.ink : C.danger }}>{creditsOf(u)}건</b></span>
                    <span style={{ color: C.line }}>·</span>
                    <span style={{ fontSize: 11 }}>충전</span>
                    {[10, 20, 30].map((n) => <button key={n} style={{ ...smallBtn, padding: "3px 8px" }} onClick={() => upd(u.username, { credits: creditsOf(u) + n })}>+{n}</button>)}
                    <button style={{ ...smallBtn, padding: "3px 8px" }} onClick={() => { const v = prompt(`${u.username} — 더할(또는 뺄) 크레딧 수:`, "10"); if (v !== null && !isNaN(+v)) upd(u.username, { credits: Math.max(0, creditsOf(u) + Math.round(+v)) }); }}>직접</button>
                    <button style={{ ...smallBtn, padding: "3px 8px", color: C.danger }} onClick={() => { if (confirm(`${u.username} 크레딧을 0으로 만들까요?`)) upd(u.username, { credits: 0 }); }}>0</button>
                  </div>
                )}
                <div style={{ fontSize: 11.5, color: C.sub, marginTop: 7, lineHeight: 1.6 }}>누적 — 포스팅 {(u.stats && u.stats.posts) || 0} · 제목 {(u.stats && u.stats.titles) || 0} · 카드 {(u.stats && u.stats.cards) || 0} · 썸네일 {(u.stats && u.stats.thumbs) || 0}<br />종류 — 일상 {(u.stats && u.stats.cat && u.stats.cat.daily) || 0} / 학생 {(u.stats && u.stats.cat && u.stats.cat.student) || 0} / 입시 {(u.stats && u.stats.cat && u.stats.cat.exam) || 0} / 기타 {(u.stats && u.stats.cat && u.stats.cat.etc) || 0} · 최근 {u.stats && u.stats.lastUsed ? fmtDate(u.stats.lastUsed) : "-"}</div>
                {u.recharge && u.recharge.requested && (
                  <div style={{ marginTop: 9, background: "rgba(255,77,94,0.10)", border: `1px solid ${C.brass}`, borderRadius: 10, padding: "9px 11px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, flexWrap: "wrap" }}>
                    <span style={{ fontSize: 12, color: C.ink, fontWeight: 700 }}>🔔 충전 신청 · {u.recharge.requested}건 ({won(u.recharge.price)})</span>
                    <span style={{ display: "flex", gap: 6 }}>
                      <button style={{ ...smallBtn, background: ACCENT_GRAD, color: "#fff", borderColor: "transparent", padding: "5px 10px" }} onClick={() => upd(u.username, { credits: creditsOf(u) + (u.recharge.requested || 0), recharge: undefined })}>입금확인 +{u.recharge.requested}건</button>
                      <button style={{ ...smallBtn, padding: "5px 10px", color: C.danger }} onClick={() => upd(u.username, { recharge: undefined })}>취소</button>
                    </span>
                  </div>
                )}
              </div>
              <div style={{ display: "flex", gap: 6, marginTop: 10, flexWrap: "wrap" }}>
                <button style={smallBtn} onClick={() => upd(u.username, { paid: !u.paid })}>{u.paid ? "입금취소" : "입금확인"}</button>
                <button style={smallBtn} onClick={() => openMemberPosts(u)}>글 보기</button>
                {u.status !== "active" ? <button style={{ ...smallBtn, background: ACCENT_GRAD, color: "#fff", borderColor: "transparent" }} onClick={() => upd(u.username, { status: "active", paid: true, credits: (u.plan && u.plan.posting) || 0 })}>승인 + {(u.plan && u.plan.posting) || 0}건 충전</button> : <button style={smallBtn} onClick={() => upd(u.username, { status: "disabled" })}>정지</button>}
                <button style={{ ...smallBtn, color: C.danger, borderColor: "rgba(164,69,46,0.4)" }} onClick={() => { if (confirm(`${u.username} 삭제할까요?`)) del(u.username); }}>삭제</button>
              </div>
            </div>
          ))}
        </div>
        );
      })()}

      {tab === "posts" && (
        <div>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
            <div style={{ fontSize: 13.5, color: C.sub }}>전 회원 작성글 {allPosts ? <b style={{ color: C.ink }}>{allPosts.length}건</b> : ""}<span style={{ fontSize: 12 }}> · 제목을 누르면 내용을 볼 수 있어요</span></div>
            <button style={smallBtn} onClick={loadAllPosts}>{allLoading ? "불러오는 중…" : "↻ 새로고침"}</button>
          </div>
          {allLoading && <div style={{ color: C.sub, textAlign: "center", padding: 30 }}>불러오는 중…</div>}
          {!allLoading && allPosts && allPosts.length === 0 && <div style={{ color: C.sub, textAlign: "center", padding: 30 }}>아직 작성된 글이 없어요.</div>}
          {!allLoading && !openPost && allPosts && allPosts.length > 0 && (
            <div style={{ display: "grid", gap: 8 }}>
              {allPosts.map((p, i) => (
                <button key={p._username + (p.id || i)} onClick={() => setOpenPost(p)} style={{ ...card, textAlign: "left", padding: "14px 16px", cursor: "pointer", fontFamily: FONT, display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
                  <div style={{ minWidth: 0, flex: "1 1 auto" }}>
                    <div style={{ fontWeight: 700, color: C.ink, fontSize: 14.5, lineHeight: 1.4 }}>{p.title || "(제목 없음)"}</div>
                    <div style={{ fontSize: 12, color: C.sub, marginTop: 4 }}><b style={{ color: C.ink2 }}>{p._name}</b> <span>@{p._username}</span>{p.category ? " · " + ((CATEGORIES.find((c) => c.id === p.category) || {}).name || p.category) : ""}{p.createdAt ? " · " + fmtDate(p.createdAt) : ""}</div>
                  </div>
                  <span style={{ fontFamily: MONO, fontSize: 12, color: C.brassSoft, fontWeight: 700, whiteSpace: "nowrap" }}>열기 →</span>
                </button>
              ))}
            </div>
          )}
          {openPost && (
            <div style={{ ...card, padding: 22 }}>
              <button style={{ ...smallBtn, marginBottom: 14 }} onClick={() => setOpenPost(null)}>← 목록으로</button>
              <div style={{ fontSize: 12, color: C.sub, marginBottom: 6 }}><b style={{ color: C.ink }}>{openPost._name}</b> @{openPost._username}{openPost.category ? " · " + ((CATEGORIES.find((c) => c.id === openPost.category) || {}).name || openPost.category) : ""}{openPost.createdAt ? " · " + fmtDate(openPost.createdAt) : ""}</div>
              <div style={{ fontWeight: 800, color: C.ink, fontSize: 18, marginBottom: 14, lineHeight: 1.35 }}>{openPost.title}</div>
              <div style={{ whiteSpace: "pre-wrap", fontSize: 14, lineHeight: 1.85, color: C.text }}>{openPost.post || ""}</div>
              {Array.isArray(openPost.hashtags) && openPost.hashtags.length > 0 && <div style={{ fontSize: 12.5, color: C.brass, marginTop: 16 }}>{openPost.hashtags.map((h) => (h[0] === "#" ? h : "#" + h)).join(" ")}</div>}
            </div>
          )}
        </div>
      )}

      {tab === "codes" && (
        <div>
          <div style={{ ...card, padding: 16, marginBottom: 14 }}>
            <div style={{ fontSize: 13.5, fontWeight: 700, color: C.ink, marginBottom: 10 }}>새 프로모션코드 발급</div>
            <div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginBottom: 12 }}>
              <label style={{ fontSize: 12, color: C.sub }}>유효기간(일)<input type="number" min={1} value={days} onChange={(e) => setDays(e.target.value)} style={{ ...inputStyle, width: 100, marginTop: 4 }} /></label>
              <label style={{ fontSize: 12, color: C.sub }}>수량(명)<input type="number" min={1} value={qty} onChange={(e) => setQty(e.target.value)} style={{ ...inputStyle, width: 100, marginTop: 4 }} /></label>
            </div>
            <div style={{ fontSize: 12.5, color: C.sub, marginBottom: 8 }}>이 코드로 가입 시 제공되는 플랜 (건수·옵션·금액이 자동 적용)</div>
            <div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-end" }}>
              <label style={{ fontSize: 12, color: C.sub }}>충전 건수<input type="number" min={1} value={cPosting} onChange={(e) => setCPosting(e.target.value)} style={{ ...inputStyle, width: 110, marginTop: 4 }} /></label>
              <label style={{ fontSize: 12, color: C.sub }}>금액(원)<input type="number" min={0} value={cPrice} onChange={(e) => setCPrice(e.target.value)} style={{ ...inputStyle, width: 130, marginTop: 4 }} /></label>
              <div style={{ display: "flex", gap: 7 }}>
                {[["card", "카드뉴스", cCard, setCCard], ["thumb", "썸네일", cThumb, setCThumb]].map(([k, lbl, val, set]) => <button key={k} type="button" onClick={() => set(!val)} style={{ ...smallBtn, padding: "10px 14px", background: val ? "rgba(255,77,94,0.16)" : C.elev, borderColor: val ? C.brass : C.line, color: val ? C.ink : C.sub }}>{val ? "✓ " : ""}{lbl}</button>)}
              </div>
            </div>
            <button style={{ ...primaryBtn, marginTop: 12 }} onClick={newCode}>코드 생성</button>
          </div>
          <div style={{ display: "grid", gap: 8 }}>
            {promoList.length === 0 && <div style={{ color: C.sub, fontSize: 13.5, textAlign: "center", padding: 20 }}>아직 발급한 코드가 없어요.</div>}
            {promoList.map((iv) => {
              const expired = isExpired(iv.expiresAt); const soldout = (iv.usedCount || 0) >= iv.maxUses;
              const status = expired ? "만료" : soldout ? "소진" : "사용가능"; const col = expired ? C.danger : soldout ? C.sub : C.ok;
              return (
                <div key={iv.code} style={{ ...card, padding: "13px 16px" }}>
                  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
                    <div><div style={{ fontFamily: "monospace", fontSize: 15, fontWeight: 700, color: C.ink, letterSpacing: "0.04em" }}>{iv.code}</div><div style={{ fontSize: 12, color: C.sub, marginTop: 3 }}>만료 {fmtDate(iv.expiresAt)} · 사용 {(iv.usedCount || 0)}/{iv.maxUses}명</div></div>
                    <div style={{ display: "flex", gap: 6, alignItems: "center" }}><span style={badge(col)}>{status}</span>{!expired && !soldout && <CopyBtn text={iv.code} />}<button style={{ ...smallBtn, color: editCode === iv.code ? "#fff" : C.ink, background: editCode === iv.code ? C.brass : C.elev }} onClick={() => setEditCode(editCode === iv.code ? null : iv.code)}>수정</button><button style={{ ...smallBtn, color: C.danger, borderColor: "rgba(179,38,30,0.4)" }} onClick={() => delCode(iv.code)}>삭제</button></div>
                  </div>
                  <div style={{ fontSize: 11.5, color: C.sub, marginTop: 8, lineHeight: 1.5 }}>제공 · {iv.plan ? iv.plan.posting : "-"}건 충전{iv.plan && iv.plan.cardnews ? " + 카드뉴스" : ""}{iv.plan && iv.plan.thumbnail ? " + 썸네일" : ""} · {won(iv.price || 0)}</div>
                  {editCode === iv.code && (
                    <div style={{ marginTop: 12, paddingTop: 12, borderTop: `1px dashed ${C.line}`, display: "grid", gap: 11 }}>
                      <div style={{ display: "flex", gap: 9, alignItems: "center", flexWrap: "wrap" }}>
                        <span style={{ fontSize: 12.5, color: C.sub, width: 38 }}>수량</span>
                        <input type="number" min={iv.usedCount || 0} value={iv.maxUses} onChange={(e) => setCodeQty(iv.code, e.target.value)} style={{ ...inputStyle, width: 90, marginBottom: 0 }} />
                        <span style={{ fontSize: 12, color: C.sub }}>이미 {(iv.usedCount || 0)}명 사용 (그 아래로는 못 줄여요)</span>
                      </div>
                      <div style={{ display: "flex", gap: 9, alignItems: "center", flexWrap: "wrap" }}>
                        <span style={{ fontSize: 12.5, color: C.sub, width: 38 }}>만료</span>
                        <input type="date" value={(() => { try { return new Date(iv.expiresAt).toISOString().slice(0, 10); } catch { return ""; } })()} onChange={(e) => setCodeExpiry(iv.code, e.target.value)} style={{ ...inputStyle, width: 160, marginBottom: 0 }} />
                        <button style={smallBtn} onClick={() => extendCode(iv.code, 7)}>+7일</button>
                        <button style={smallBtn} onClick={() => extendCode(iv.code, 30)}>+30일</button>
                      </div>
                      <div style={{ fontSize: 11.5, color: C.ok }}>변경하면 바로 저장돼요.</div>
                    </div>
                  )}
                </div>
              );
            })}
          </div>
        </div>
      )}

      {viewPosts && (
        <div onClick={() => setViewPosts(null)} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.5)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center", padding: 16 }}>
          <div onClick={(e) => e.stopPropagation()} style={{ background: C.card, borderRadius: 16, maxWidth: 580, width: "100%", maxHeight: "85vh", overflow: "auto", padding: 18 }}>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
              <div style={{ fontWeight: 800, color: C.ink }}>{viewPosts.name}님의 글 {memberPosts ? `(${memberPosts.length})` : ""}</div>
              <button style={smallBtn} onClick={() => setViewPosts(null)}>닫기</button>
            </div>
            {postsLoading && <div style={{ color: C.sub, fontSize: 13, padding: 24, textAlign: "center" }}>불러오는 중…</div>}
            {!postsLoading && memberPosts && memberPosts.length === 0 && <div style={{ color: C.sub, fontSize: 13, padding: 24, textAlign: "center" }}>저장된 글이 없어요.</div>}
            {!postsLoading && !openPost && memberPosts && memberPosts.length > 0 && (
              <div style={{ display: "grid", gap: 8 }}>{memberPosts.map((p, i) => (
                <button key={p.id || i} onClick={() => setOpenPost(p)} style={{ textAlign: "left", border: `1px solid ${C.line}`, borderRadius: 10, padding: "11px 13px", background: C.card, cursor: "pointer", fontFamily: FONT }}>
                  <div style={{ fontWeight: 700, color: C.ink, fontSize: 14 }}>{p.title || "(제목 없음)"}</div>
                  <div style={{ fontSize: 11.5, color: C.sub, marginTop: 3 }}>{(CATEGORIES.find((c) => c.id === p.category) || {}).name || p.category || ""}{p.createdAt ? " · " + fmtDate(p.createdAt) : ""}</div>
                </button>
              ))}</div>
            )}
            {openPost && (
              <div>
                <button style={{ ...smallBtn, marginBottom: 12 }} onClick={() => setOpenPost(null)}>← 목록</button>
                <div style={{ fontWeight: 800, color: C.ink, fontSize: 15, marginBottom: 10 }}>{openPost.title}</div>
                <div style={{ whiteSpace: "pre-wrap", fontSize: 13.5, lineHeight: 1.75, color: C.text }}>{openPost.post || ""}</div>
                {Array.isArray(openPost.hashtags) && openPost.hashtags.length > 0 && <div style={{ fontSize: 12.5, color: C.brass, marginTop: 12 }}>{openPost.hashtags.map((h) => (h[0] === "#" ? h : "#" + h)).join(" ")}</div>}
              </div>
            )}
          </div>
        </div>
      )}
    </section>
  );
}
function Writer({ guest, profile: initialProfile, onSaveProfile, plan, credits, recharge, onRequestRecharge, stats = {}, username, onPosted, onTrack, onWithdraw }) {
  const POSTS_KEY = username ? "inskyblog:posts:" + username : null;
  const [posts, setPosts] = useState([]);
  useEffect(() => { if (!POSTS_KEY) { setPosts([]); return; } (async () => { setPosts((await dbGet(POSTS_KEY)) || []); })(); }, [POSTS_KEY]);
  const [viewing, setViewing] = useState(null);
  const [editMode, setEditMode] = useState(false);
  const [edit, setEdit] = useState({ title: "", post: "" });
  function persistPosts(arr) { setPosts(arr); if (POSTS_KEY) dbSet(POSTS_KEY, arr); }
  function savePost(res, cat) {
    if (!POSTS_KEY) return;
    const item = { id: "p" + Date.now(), title: res.optimizedTitle || "(제목 없음)", category: cat, post: res.post || "", photos: res.photos || [], hashtags: res.hashtags || [], osmu: res.osmu || null, scores: res.scores || null, createdAt: new Date().toISOString() };
    persistPosts([item, ...posts]);
  }
  function deletePost(id) { persistPosts(posts.filter((p) => p.id !== id)); }
  function openPost(p) { setViewing(p); setEdit({ title: p.title, post: p.post }); setEditMode(false); setStep("view"); }
  function saveEdit() { const arr = posts.map((p) => (p.id === viewing.id ? { ...p, title: edit.title, post: edit.post } : p)); persistPosts(arr); setViewing({ ...viewing, title: edit.title, post: edit.post }); setEditMode(false); }
  const [step, setStep] = useState(() => (initialProfile && initialProfile.academy && initialProfile.region ? "category" : "setup"));
  const [profile, setProfile] = useState({ director: "", academy: "", region: "", keywords: "", tone: "", styleSamples: "", styleProfile: "", ...initialProfile });
  const [styleLoading, setStyleLoading] = useState(false);
  const [category, setCategory] = useState(null);
  const [input, setInput] = useState({ title: "", brief: "" });
  const [titles, setTitles] = useState([]); const [titleLoading, setTitleLoading] = useState(false);
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState(null);
  const [error, setError] = useState("");
  const [showThumb, setShowThumb] = useState(false); const [showCards, setShowCards] = useState(false);
  const [inspect, setInspect] = useState({ title: "", body: "" });
  const [showWithdraw, setShowWithdraw] = useState(false);
  const [wPass, setWPass] = useState(""); const [wDel, setWDel] = useState(false); const [wErr, setWErr] = useState(""); const [wBusy, setWBusy] = useState(false);
  async function confirmWithdraw() { setWErr(""); setWBusy(true); const e = await onWithdraw(wPass, wDel); setWBusy(false); if (e) setWErr(e); }
  const [inspectRes, setInspectRes] = useState(null); const [inspectLoading, setInspectLoading] = useState(false);
  async function runInspect() {
    if (guest) return guestBlock();
    if (!inspect.body.trim()) return setError("검사할 본문을 붙여넣어 주세요.");
    setInspectLoading(true); setInspectRes(null); setError("");
    try {
      const txt = await callClaude({ model: "claude-sonnet-4-6", max_tokens: 2500, system: buildInspectSystem(), messages: [{ role: "user", content: buildInspectUser(inspect.title, inspect.body) }] });
      let r = extractJSON(txt); if (!(r && r.scores)) r = salvageInspect(txt);
      if (r && r.scores) setInspectRes(r); else setError("검사 결과를 읽지 못했어요. 다시 시도해 주세요.");
    } catch (e) { setError(backendError(e)); } finally { setInspectLoading(false); }
  }
  const unlimited = credits === Infinity || (plan && plan.posting === 99999);
  const creditsLeft = unlimited ? Infinity : (Number(credits) || 0);
  const bank = (window.INSKY_CONFIG && window.INSKY_CONFIG.bank) || {};
  const setP = (k) => (e) => setProfile({ ...profile, [k]: e.target.value });
  const profileReady = profile.academy.trim() && profile.region.trim();
  const catName = CATEGORIES.find((c) => c.id === category)?.name;
  const guestBlock = () => { setError("둘러보기 모드예요. 실제 작성은 로그인 후 이용할 수 있어요."); };

  async function analyzeStyle() {
    if (guest) return guestBlock();
    if (!(profile.styleSamples || "").trim()) return;
    setStyleLoading(true); setError("");
    try { const txt = await callClaude({ model: "claude-sonnet-4-6", max_tokens: 800, system: buildStyleSystem(), messages: [{ role: "user", content: buildStyleUser(profile.styleSamples) }] }); const np = { ...profile, styleProfile: (txt || "").trim() }; setProfile(np); onSaveProfile(np); }
    catch (e) { setError(backendError(e)); } finally { setStyleLoading(false); }
  }
  async function genTitles() {
    if (guest) return guestBlock();
    setTitleLoading(true); setTitles([]); setError("");
    try { const txt = await callClaude({ model: "claude-sonnet-4-6", max_tokens: 1000, system: buildTitleSystem(), messages: [{ role: "user", content: buildTitleUser(profile, catName, input.title) }] }); setTitles(extractArray(txt) || []); onTrack && onTrack("titles"); }
    catch (e) { setError(backendError(e)); } finally { setTitleLoading(false); }
  }
  async function generate() {
    if (guest) return guestBlock();
    if (!unlimited && creditsLeft <= 0) return setError("크레딧을 모두 사용했어요. 대시보드로 돌아가 '크레딧 충전'에서 신청하고 계좌이체하면, 관리자 확인 후 다시 이용할 수 있어요.");
    setLoading(true); setError(""); setResult(null); setShowThumb(false); setShowCards(false);
    try {
      const body = { model: "claude-sonnet-4-6", max_tokens: 8000, system: buildSystem(category, profile.styleProfile), messages: [{ role: "user", content: buildUser(profile, input) }] };
      if (category === "exam") body.tools = [{ type: "web_search_20250305", name: "web_search", max_uses: 3 }];
      let txt;
      try { txt = await callClaude(body, { bill: true }); }
      catch (e) { if (category === "exam" && e.code === "API" && (e.status === 400 || e.status === 404)) { delete body.tools; txt = await callClaude(body, { bill: true }); } else throw e; }
      const r = extractJSON(txt) || { optimizedTitle: input.title, post: txt || "응답을 받지 못했어요.", photos: [], hashtags: [], legalNote: "", osmu: null };
      if (r && r.post) r.post = cleanPost(r.post);
      setResult(r);
      setStep("result"); onPosted && onPosted(category); savePost(r, category);
    } catch (e) { setError(backendError(e)); } finally { setLoading(false); }
  }

  return (
    <div>
      {showWithdraw && onWithdraw && (
        <div onClick={() => !wBusy && setShowWithdraw(false)} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.5)", zIndex: 300, display: "flex", alignItems: "center", justifyContent: "center", padding: 16 }}>
          <div onClick={(e) => e.stopPropagation()} style={{ background: C.card, borderRadius: 16, maxWidth: 420, width: "100%", padding: 22 }}>
            <div style={{ fontSize: 17, fontWeight: 800, color: C.ink, marginBottom: 8 }}>회원 탈퇴</div>
            <div style={{ fontSize: 13.5, color: C.text, lineHeight: 1.65, marginBottom: 14 }}>탈퇴하면 이 계정으로 다시 로그인할 수 없어요. 환불은 환불정책에 따라 별도로 처리돼요. 계속하려면 비밀번호를 입력해 주세요.</div>
            <input type="password" value={wPass} onChange={(e) => setWPass(e.target.value)} placeholder="비밀번호" style={{ ...inputStyle, marginBottom: 12 }} />
            <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13, color: C.text, marginBottom: 14, cursor: "pointer" }}><input type="checkbox" checked={wDel} onChange={(e) => setWDel(e.target.checked)} />내 보관함의 글도 모두 삭제할게요</label>
            {wErr && <div style={{ color: C.danger, fontSize: 13, marginBottom: 12 }}>{wErr}</div>}
            <div style={{ display: "flex", gap: 8 }}>
              <button onClick={() => setShowWithdraw(false)} disabled={wBusy} style={{ ...smallBtn, flex: 1, padding: "12px 0" }}>취소</button>
              <button onClick={confirmWithdraw} disabled={wBusy || !wPass} style={{ flex: 1, padding: "12px 0", borderRadius: 10, border: "none", background: C.danger, color: "#fff", fontWeight: 700, fontSize: 14, cursor: "pointer", fontFamily: FONT, opacity: wBusy || !wPass ? 0.6 : 1 }}>{wBusy ? "처리 중…" : "탈퇴하기"}</button>
            </div>
          </div>
        </div>
      )}
      {guest && <div style={{ background: "rgba(255,77,94,0.16)", border: `1px solid ${C.brass}`, borderRadius: 12, padding: "12px 15px", marginBottom: 18, fontSize: 13, color: C.ink2 }}>둘러보기 모드입니다. 화면은 자유롭게 둘러볼 수 있고, 실제 글 생성은 로그인 후 이용할 수 있어요.</div>}

      {step === "setup" && (
        <section>
          <h2 style={hStyle}>먼저 학원 정보를 한 번만 알려주세요</h2>
          <p style={pStyle}>한 번만 적어두면 글 쓸 때마다 자동으로 반영돼요. 다음부턴 안 물어봐요!</p>
          <GuideBox icon="😊" title="딱 두 칸만 채우면 바로 시작할 수 있어요">
            <b style={{ color: C.text }}>학원명</b>과 <b style={{ color: C.text }}>지역</b>만 필수예요. 나머지는 비워두셔도 글은 잘 나와요.<br />
            <b style={{ color: C.text }}>노출 키워드</b>는 <b style={{ color: C.brassSoft }}>네이버에서 검색되고 싶은 단어</b>(예: 덕풍동수학학원)를 적으면, 그 단어가 글에 잘 녹아들게 써드려요.
          </GuideBox>
          <div style={card}>
            <Field label="원장 이름" hint="선택 · 글에 자연스럽게 들어가요"><input style={inputStyle} value={profile.director} onChange={setP("director")} placeholder="예: 진민하" /></Field>
            <Field label="학원명" hint="필수"><input style={inputStyle} value={profile.academy} onChange={setP("academy")} placeholder="정식 등록명 그대로 (예: 인스카이수학학원)" /></Field>
            <Field label="지역" hint="필수"><input style={inputStyle} value={profile.region} onChange={setP("region")} placeholder="예: 경기 하남시 덕풍동" /></Field>
            <Field label="노출 키워드" hint="선택 · 쉼표로 구분"><input style={inputStyle} value={profile.keywords} onChange={setP("keywords")} placeholder="예: 덕풍동수학학원, 미사수학학원" /></Field>
            <Field label="학원 톤·특징" hint="선택 · 몰라도 됨"><textarea style={{ ...inputStyle, minHeight: 70, resize: "vertical" }} value={profile.tone} onChange={setP("tone")} placeholder="예: 소수정예 · 개념 위주 · 아이 눈높이로 반복 설명" /></Field>
            <div style={{ borderTop: `1px solid ${C.line}`, paddingTop: 14, marginBottom: 14 }}>
              <div style={{ fontSize: 13, fontWeight: 700, color: C.ink, marginBottom: 4 }}>문체 학습 <span style={{ fontWeight: 500, color: C.sub }}>· 개인화 (선택)</span></div>
              <div style={{ fontSize: 12, color: C.sub, marginBottom: 8, lineHeight: 1.5 }}>예전에 쓰신 글 3~4개를 붙여넣으면(--- 로 구분) 그 문체를 학습해, 이 계정에만 반영해 드려요.</div>
              <textarea style={{ ...inputStyle, minHeight: 100, resize: "vertical" }} value={profile.styleSamples || ""} onChange={setP("styleSamples")} placeholder={"예전 포스팅 본문\n---\n또 다른 포스팅\n---\n세 번째 포스팅"} />
              <button onClick={analyzeStyle} disabled={styleLoading || !(profile.styleSamples || "").trim()} style={{ ...smallBtn, marginTop: 8, padding: "9px 15px", background: "rgba(177,75,255,.16)", borderColor: C.violet, color: C.brassSoft, fontWeight: 700, opacity: styleLoading || !(profile.styleSamples || "").trim() ? 0.5 : 1 }}>{styleLoading ? "분석 중…" : "문체 분석하기"}</button>
              {profile.styleProfile && <div style={{ marginTop: 10, background: "rgba(255,77,94,0.10)", border: `1px solid ${C.brassSoft}`, borderRadius: 10, padding: "11px 13px" }}><div style={{ fontSize: 11.5, fontWeight: 700, color: C.brass, marginBottom: 4 }}>학습된 문체 ✓ (이 계정에만 적용)</div><div style={{ fontSize: 12.5, color: C.text, lineHeight: 1.55 }}>{profile.styleProfile}</div></div>}
            </div>
            {error && <div style={{ color: C.danger, fontSize: 13, marginBottom: 10 }}>{error}</div>}
            <button onClick={() => { if (profileReady) { onSaveProfile(profile); setStep("category"); } }} disabled={!profileReady} style={{ ...primaryBtn, opacity: profileReady ? 1 : 0.45 }}>저장하고 다음</button>
            {!profileReady && <div style={{ fontSize: 12.5, color: C.sub, marginTop: 10 }}>학원명과 지역은 꼭 넣어주세요.</div>}
          </div>
        </section>
      )}

      {step === "category" && (
        <section>
          <div className="a-hero">
            <div className="a-hero-l">
              <div className="a-kicker"><span className="kicker-dot" />STUDIO · 콘텐츠 제작실</div>
              <h2>원장님, 오늘은<br />어떤 글을 만들어 볼까요?</h2>
              <p className="a-desc">주제 한 줄이면 네이버 최적화 원고·카드뉴스·썸네일까지. 한곳에서 만들고 관리하세요.</p>
              <div className="a-hero-actions">
                <button style={heroBtn} onClick={() => setStep("setup")}>학원 정보 수정</button>
                {username && <button style={heroBtn} onClick={() => setStep("library")}>📁 내 글 보관함 ({posts.length})</button>}
              </div>
            </div>
            <div className="a-usage">
              <div className="a-usage-h">이용 현황</div>
              {[["남은 크레딧", unlimited ? "무제한" : creditsLeft + "건", unlimited || creditsLeft > 0], ["카드뉴스", plan && plan.cardnews ? "사용 가능" : "미포함", plan && plan.cardnews], ["썸네일", plan && plan.thumbnail ? "사용 가능" : "미포함", plan && plan.thumbnail]].map(([k, v, okk], i) => (
                <div className="a-usage-row" key={i}><span className="k">{k}</span><span className={"chip " + (okk ? "on" : "off")}>{v}</span></div>
              ))}
            </div>
          </div>

          {!unlimited && creditsLeft > 0 && (
            <div style={{ display: "flex", alignItems: "center", gap: 13, background: "linear-gradient(135deg,rgba(255,77,109,.12),rgba(124,92,255,.10))", border: "1px solid rgba(177,75,255,.3)", borderRadius: 16, padding: "14px 18px", margin: "16px 0 0", flexWrap: "wrap" }}>
              <span style={{ fontSize: 24 }}>🎁</span>
              <div style={{ flex: "1 1 240px", fontSize: 13.5, color: C.ink2, lineHeight: 1.6 }}>
                <b style={{ color: C.ink }}>크레딧 {creditsLeft}건</b>으로 지금 바로 한 편 만들어 보세요. 제목만 정하면 2,000자 글이 뚝딱 — <b style={{ color: C.brassSoft }}>마음에 드시면 그때 충전</b>하시면 돼요 😊
              </div>
            </div>
          )}
          <div className="stat-grid">
            {[["남은 크레딧", unlimited ? "무제한" : creditsLeft, true], ["누적 포스팅", stats.posts || 0, false], ["카드뉴스", stats.cards || 0, false], ["썸네일", stats.thumbs || 0, false]].map(([k, v, hl], i) => (
              <div className={"stat-card" + (hl ? " hl" : "")} key={i}>
                <div className="stat-k">{k}</div>
                <div className="stat-v">{v}{typeof v === "number" ? <small>건</small> : ""}</div>
              </div>
            ))}
          </div>

          {username && !unlimited && onRequestRecharge && (
            <div style={{ background: C.card, border: `1px solid ${creditsLeft <= 0 ? C.brass : C.line}`, borderRadius: 18, padding: 20, margin: "4px 0 2px", boxShadow: creditsLeft <= 0 ? "0 10px 30px rgba(255,77,109,.14)" : "none" }}>
              {recharge ? (
                <div>
                  <div style={{ fontSize: 14, fontWeight: 800, color: C.ink, marginBottom: 6 }}>충전 신청됨 · {recharge.requested}건 <span style={{ color: C.brass }}>{won(recharge.price)}</span> <span style={{ fontWeight: 600, color: C.sub, fontSize: 12 }}>· 입금 확인 대기 중</span></div>
                  <div style={{ fontSize: 12.5, color: C.sub, lineHeight: 1.7 }}>아래 계좌로 입금하시면 확인 후 충전해 드려요.<br /><b style={{ color: C.ink, fontSize: 14 }}>{bank.bankName || ""} {bank.account || ""}</b>{bank.holder ? ` (예금주 ${bank.holder})` : ""}<br />입금자명을 가입한 이름/아이디와 같게 해주세요.</div>
                  <button style={{ ...smallBtn, marginTop: 11, color: C.danger, borderColor: "rgba(255,92,108,0.4)" }} onClick={() => onRequestRecharge(null)}>신청 취소</button>
                </div>
              ) : (
                <div>
                  <div style={{ fontSize: 14, fontWeight: 800, color: creditsLeft <= 0 ? C.brass : C.ink, marginBottom: 3 }}>💳 크레딧 충전{creditsLeft <= 0 ? " · 크레딧을 모두 사용했어요" : ""}</div>
                  <div style={{ fontSize: 12.5, color: C.sub, marginBottom: 12 }}>패키지를 고르면 계좌이체 안내가 나와요. 입금 후 원장님(관리자)이 확인하면 크레딧이 충전됩니다.</div>
                  <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
                    {[10, 20, 30].map((t) => (
                      <button key={t} onClick={() => onRequestRecharge(t)} style={{ flex: "1 1 92px", padding: "12px 10px", borderRadius: 12, border: `1px solid ${C.line}`, background: C.elev, cursor: "pointer", fontFamily: FONT, textAlign: "center" }}>
                        <div style={{ fontSize: 15, fontWeight: 800, color: C.ink }}>{t}건</div>
                        <div style={{ fontSize: 12.5, color: C.brass, fontWeight: 700, marginTop: 2 }}>{won(TIERS[t])}</div>
                      </button>
                    ))}
                  </div>
                </div>
              )}
            </div>
          )}

          <div className="sec-label">새 글 만들기 <small>· 종류를 골라 시작하세요</small></div>
          <div className="cat-grid">
            {CATEGORIES.map((c) => (
              <button key={c.id} className="cat-card" onClick={() => { setCategory(c.id); setTitles([]); setStep("compose"); }}>
                <div className="cat-ic">{c.icon}</div>
                <div className="cat-name">{c.name}{c.id === "exam" && <span style={badge(C.brass)}>웹검색</span>}</div>
                <div className="cat-desc">{c.desc}</div>
                <div className="cat-go">만들기 <span className="arr">→</span></div>
              </button>
            ))}
          </div>

          <div className="sec-label">블로그 점검 <small>· 기본 제공</small></div>
          <button className="inspect-card" onClick={() => { setInspectRes(null); setError(""); setStep("inspect"); }}>
            <div className="inspect-ic">🔎</div>
            <div style={{ flex: 1 }}><div style={{ fontSize: 16.5, fontWeight: 800 }}>블로그 포스트 검사하기</div><div style={{ fontSize: 13, color: C.sub, marginTop: 3, lineHeight: 1.5 }}>내가 쓴 글을 붙여넣으면 점수와 고칠 점을 짚어드려요.</div></div>
            <div style={{ fontFamily: MONO, fontSize: 12.5, fontWeight: 700, color: C.brassSoft }}>검사 →</div>
          </button>
          {username && onWithdraw && <div style={{ textAlign: "center", marginTop: 30 }}><button onClick={() => setShowWithdraw(true)} style={{ background: "none", border: "none", color: C.sub, fontSize: 12.5, textDecoration: "underline", cursor: "pointer", fontFamily: FONT }}>회원 탈퇴</button></div>}
        </section>
      )}

      {step === "inspect" && (
        <section>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", flexWrap: "wrap" }}><h2 style={hStyle}>🔎 블로그 포스트 검사</h2><button style={smallBtn} onClick={() => setStep("category")}>← 대시보드</button></div>
          <p style={pStyle}>내가 쓴(또는 쓸) 블로그 글을 붙여넣으면, 우리 기준으로 점수와 고칠 점을 짚어드려요. 이용 건수와 무관한 기본 기능이에요.</p>
          <div style={card}>
            <Field label="제목"><input style={inputStyle} value={inspect.title} onChange={(e) => setInspect({ ...inspect, title: e.target.value })} placeholder="블로그 글 제목" /></Field>
            <Field label="본문 붙여넣기"><textarea style={{ ...inputStyle, minHeight: 200, resize: "vertical" }} value={inspect.body} onChange={(e) => setInspect({ ...inspect, body: e.target.value })} placeholder="블로그 본문 전체를 그대로 붙여넣어 주세요." /></Field>
            {error && <div style={{ color: C.danger, fontSize: 13, marginBottom: 12 }}>{error}</div>}
            <button onClick={runInspect} disabled={inspectLoading || !inspect.body.trim()} style={{ ...primaryBtn, opacity: inspectLoading || !inspect.body.trim() ? 0.5 : 1 }}>{inspectLoading ? "검사하는 중…" : "검사하기"}</button>
          </div>
          {inspectRes && (
            <div style={{ marginTop: 16 }}>
              <Scores s={inspectRes.scores} />
              {inspectRes.summary && <Panel title="총평"><div style={{ fontSize: 14, lineHeight: 1.7, color: C.text }}>{inspectRes.summary}</div></Panel>}
              {Array.isArray(inspectRes.good) && inspectRes.good.length > 0 && <Panel title="잘된 점"><div style={{ display: "grid", gap: 7 }}>{inspectRes.good.map((g, i) => <div key={i} style={{ fontSize: 13.5, lineHeight: 1.55, color: C.text }}>✅ {g}</div>)}</div></Panel>}
              {Array.isArray(inspectRes.fixes) && inspectRes.fixes.length > 0 && <Panel title="고칠 점 · 우선순위순"><div style={{ display: "grid", gap: 12 }}>{inspectRes.fixes.map((f, i) => (
                <div key={i} style={{ borderLeft: `3px solid ${C.brass}`, paddingLeft: 12 }}>
                  <div style={{ fontSize: 13.5, fontWeight: 800, color: C.ink }}>{i + 1}. {f.where}</div>
                  <div style={{ fontSize: 13, color: C.danger, marginTop: 3, lineHeight: 1.55 }}>문제 — {f.issue}</div>
                  <div style={{ fontSize: 13, color: C.text, marginTop: 3, lineHeight: 1.55 }}>고치기 — {f.how}</div>
                </div>
              ))}</div></Panel>}
            </div>
          )}
        </section>
      )}

      {step === "compose" && (
        <section>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", flexWrap: "wrap" }}><h2 style={hStyle}><span style={{ color: C.brass }}>{catName}</span> · 무엇을 쓸까요</h2><button style={smallBtn} onClick={() => setStep("category")}>← 대시보드</button></div>
          <p style={pStyle}>딱 두 가지만 정하면, 나머지는 AI가 2,000자 글로 완성해요.</p>
          <GuideBox icon="✍️" title="처음이세요? 이렇게만 하시면 돼요">
            <b style={{ color: C.text }}>①</b> <b style={{ color: C.text }}>제목</b>은 몰라도 괜찮아요. 비워두고 <b style={{ color: C.brassSoft }}>🪝 제목 추천</b>을 누르면 AI가 5개 만들어 드려요.<br />
            <b style={{ color: C.text }}>②</b> 아래 <b style={{ color: C.text }}>‘간단한 내용’</b> 칸에 하고 싶은 이야기를 <b style={{ color: C.text }}>말하듯 2~3줄</b>만 적어주세요.<br />
            문장·소제목·사진 들어갈 자리·해시태그까지 전부 AI가 알아서 채워드려요. <b style={{ color: C.brassSoft }}>어렵게 쓰지 않으셔도 돼요!</b>
          </GuideBox>
          <div style={{ fontSize: 12.5, color: (!unlimited && creditsLeft <= 0) ? C.danger : C.sub, marginBottom: 14 }}>남은 크레딧 {unlimited ? "무제한" : creditsLeft + "건"}{!unlimited && creditsLeft <= 0 ? " · 소진 (충전 필요)" : ""}</div>
          <div style={card}>
            <Field label="제목">
              <div style={{ display: "flex", gap: 8 }}>
                <input style={inputStyle} value={input.title} onChange={(e) => setInput({ ...input, title: e.target.value })} placeholder="초안 제목 (없어도 추천 가능)" />
                <button onClick={genTitles} disabled={titleLoading} style={{ ...smallBtn, whiteSpace: "nowrap", padding: "0 14px", background: "rgba(177,75,255,.16)", borderColor: C.violet, color: C.brassSoft, fontWeight: 700 }}>{titleLoading ? "생성중…" : "🪝 제목 추천"}</button>
              </div>
            </Field>
            {titles.length > 0 && <div style={{ display: "grid", gap: 7, margin: "0 0 16px" }}>{titles.map((t, i) => <button key={i} onClick={() => setInput({ ...input, title: t })} style={{ textAlign: "left", border: `1px solid ${input.title === t ? C.brass : C.line}`, background: input.title === t ? "rgba(255,77,94,0.10)" : C.elev, borderRadius: 9, padding: "10px 13px", fontSize: 14, color: C.ink, cursor: "pointer", fontFamily: FONT, lineHeight: 1.45 }}>{input.title === t ? "✓ " : ""}{t}</button>)}<div style={{ fontSize: 12, color: C.sub }}>마음에 드는 제목을 누르면 위 칸에 채워져요.</div></div>}
            {category === "etc" && <Field label="원하는 글 스타일" hint="기타는 스타일을 먼저 정해요"><input style={inputStyle} value={input.style || ""} onChange={(e) => setInput({ ...input, style: e.target.value })} placeholder="예: 정보성으로 깔끔하게 / 따뜻한 감성 / 유머러스하게 / 후기형" /></Field>}
            <Field label="간단한 내용" hint="말하듯 편하게 2~3줄"><textarea style={{ ...inputStyle, minHeight: 130, resize: "vertical" }} value={input.brief} onChange={(e) => setInput({ ...input, brief: e.target.value })} placeholder={"이런 식으로 편하게 적어주세요 👇\n\n· 오늘 중2 학생이 어려워하던 함수 문제를 스스로 풀어서 뿌듯했던 이야기\n· 우리 학원은 아이가 이해될 때까지 반복해서 설명한다는 걸 알리고 싶어요\n\n떠오르는 대로 적으면 AI가 멋진 글로 만들어 드려요!"} /></Field>
            {error && <div style={{ color: C.danger, fontSize: 13, marginBottom: 12 }}>{error}</div>}
            <button onClick={generate} disabled={loading || !input.title.trim() || !input.brief.trim()} style={{ ...primaryBtn, opacity: loading || !input.title.trim() || !input.brief.trim() ? 0.5 : 1 }}>{loading ? "원고 작성 중… (2,000자라 조금 걸려요)" : "원고 만들기"}</button>
            {loading && <div style={{ fontSize: 12.5, color: C.sub, marginTop: 10 }}>{category === "exam" ? "최신 정보를 확인하며 쓰는 중이라 조금 더 걸려요." : "풍부한 분량으로 다듬는 중이에요."}</div>}
          </div>
        </section>
      )}

      {step === "result" && result && (
        <section>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", flexWrap: "wrap", gap: 10 }}><h2 style={{ ...hStyle, marginBottom: 6 }}>완성됐어요</h2><span style={{ fontSize: 12.5, color: C.sub }}>{catName} · 약 {String(result.post || "").replace(/【[^】]*】/g, "").length}자{username ? " · 보관함에 저장됨 ✓" : ""}</span></div>
          {result.legalNote && result.legalNote.trim() && <div style={{ background: "rgba(164,69,46,0.07)", border: `1px solid ${C.danger}`, borderRadius: 12, padding: "13px 16px", marginBottom: 16, fontSize: 13.5, color: C.danger, lineHeight: 1.6 }}><b>⚠ 법적 체크</b> — {result.legalNote}</div>}
          <div style={{ background: "rgba(255,77,109,.08)", border: `1px solid rgba(255,77,109,.3)`, borderRadius: 12, padding: "13px 16px", marginBottom: 16, fontSize: 13, color: C.ink2, lineHeight: 1.6 }}>💡 <b style={{ color: C.brassSoft }}>게시 전 한 번만 확인하세요.</b> AI는 드라마·뉴스·통계 같은 외부 사실이나 구체적 수치를 정확히 알지 못해요. <b>[확인 필요]</b> 표시가 있거나 외부 내용을 인용했다면 원장님이 사실을 확인하고 채워주세요. (AI가 90% 쓰고, 마지막 사실 확인은 원장님 몫)</div>
          <GuideBox icon="🚀" title="이제 이렇게 올리면 끝이에요">
            <b style={{ color: C.text }}>1.</b> 아래 <b style={{ color: C.text }}>제목 복사</b> 버튼 → 네이버 블로그 새 글에 붙여넣기<br />
            <b style={{ color: C.text }}>2.</b> <b style={{ color: C.text }}>본문 복사</b> 버튼 → 그대로 붙여넣기<br />
            <b style={{ color: C.text }}>3.</b> 본문 속 <b style={{ color: C.brassSoft }}>📷 사진 자리</b> 표시된 곳에 원장님 사진을 넣어주세요<br />
            <b style={{ color: C.text }}>4.</b> 맨 아래 <b style={{ color: C.text }}>해시태그</b>까지 복사해 넣고 발행하면 완성이에요!
          </GuideBox>
          <Panel title="제목" right={<CopyBtn text={result.optimizedTitle || input.title} />}><div style={{ fontSize: 18, fontWeight: 800, color: C.ink, lineHeight: 1.45 }}>{result.optimizedTitle || input.title}</div></Panel>
          <Panel title={<>본문 <span style={{ fontWeight: 500, color: C.sub }}>· 사진 자리 포함</span></>} right={<CopyBtn text={result.post} label="본문 복사" />}><div style={{ fontSize: 15, lineHeight: 1.85, color: C.text }}>{renderPost(result.post)}</div></Panel>
          {Array.isArray(result.photos) && result.photos.length > 0 && <Panel title="사진 목록"><div style={{ display: "grid", gap: 8 }}>{result.photos.map((ph, i) => <div key={i} style={{ fontSize: 13.5, lineHeight: 1.55 }}><b style={{ color: C.brass }}>{ph.label || i + 1}</b> <b>{ph.subject}</b>{ph.reason && <span style={{ color: C.sub }}> — {ph.reason}</span>}</div>)}</div></Panel>}
          {Array.isArray(result.hashtags) && result.hashtags.length > 0 && <Panel title="해시태그" right={<CopyBtn text={result.hashtags.join(" ")} />}><div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>{result.hashtags.map((h, i) => <span key={i} style={{ fontSize: 13, color: C.ink2, background: C.card, border: `1px solid ${C.line}`, borderRadius: 99, padding: "5px 11px" }}>{h}</span>)}</div></Panel>}
          {result.osmu && <div style={{ background: "linear-gradient(135deg,rgba(124,92,255,.06),rgba(226,61,87,.05)), " + C.card, color: C.ink, border: `1px solid ${C.line}`, borderRadius: 14, padding: "16px 18px", marginBottom: 14 }}><div style={{ fontSize: 12.5, fontWeight: 800, letterSpacing: "0.04em", color: C.brassSoft, borderBottom: `1px solid ${C.line}`, paddingBottom: 9, marginBottom: 12 }}>원소스 멀티유즈 추천</div><div style={{ fontSize: 16, fontWeight: 800 }}>{result.osmu.recommended}</div>{result.osmu.reason && <div style={{ fontSize: 13.5, color: C.sub, marginTop: 5, lineHeight: 1.6 }}>{result.osmu.reason}</div>}{Array.isArray(result.osmu.storyboard) && <div style={{ marginTop: 14, display: "grid", gap: 9 }}>{result.osmu.storyboard.map((s, i) => <div key={i} style={{ background: C.paper, border: `1px solid ${C.line}`, borderRadius: 10, padding: "11px 13px" }}><div style={{ fontSize: 12, fontWeight: 700, color: C.brassSoft }}>{String(s.no ?? i + 1).padStart(2, "0")} · {s.scene}</div><div style={{ fontSize: 14, fontWeight: 600, marginTop: 4, lineHeight: 1.5 }}>{s.text}</div>{s.visual && <div style={{ fontSize: 12.5, color: C.sub, marginTop: 4 }}>🎬 {s.visual}</div>}</div>)}</div>}</div>}

          <Scores s={result.scores} />

          {result.osmu && /카드뉴스|둘 다/.test(result.osmu.recommended || "") && (
            <div style={{ marginTop: 8 }}>
              {plan && plan.cardnews ? <><button onClick={() => { if (!showCards) onTrack && onTrack("cards"); setShowCards((v) => !v); }} style={{ ...primaryBtn, background: showCards ? C.elev : ACCENT_GRAD, color: showCards ? C.ink : "#fff" }}>{showCards ? "카드뉴스 닫기" : "🃏 이 글로 카드뉴스 만들기 (10장 이하)"}</button>{showCards && <div style={{ marginTop: 14 }}><CardNews post={result.post} profile={profile} /></div>}</> : <LockNote text="카드뉴스는 현재 요금제에 포함되어 있지 않아요." />}
            </div>
          )}
          <div style={{ marginTop: 10 }}>
            {plan && plan.thumbnail ? <><button onClick={() => { if (!showThumb) onTrack && onTrack("thumbs"); setShowThumb((v) => !v); }} style={{ ...primaryBtn, background: showThumb ? C.elev : ACCENT_GRAD, color: showThumb ? C.ink : "#fff" }}>{showThumb ? "썸네일 제작기 닫기" : "🖼 이 글로 썸네일 만들기"}</button>{showThumb && <div style={{ marginTop: 14 }}><ThumbnailMaker initial={result.thumbnail} fallbackTitle={result.optimizedTitle || input.title} /></div>}</> : <LockNote text="썸네일 제작기는 현재 요금제에 포함되어 있지 않아요." />}
          </div>

          {!unlimited && (
            <div style={{ textAlign: "center", margin: "20px 0 2px", fontSize: 13.5, color: C.sub, lineHeight: 1.6 }}>
              {creditsLeft > 0
                ? <>남은 크레딧 <b style={{ color: C.brassSoft }}>{creditsLeft}건</b> · 이런 글이 매번 나와요. 마음에 드셨다면 계속 만들어 보세요 😊</>
                : <>크레딧을 다 쓰셨어요! 대시보드에서 <b style={{ color: C.brassSoft }}>충전</b>하시면 이어서 만들 수 있어요.</>}
            </div>
          )}
          <div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 14 }}>
            <button onClick={() => { setResult(null); setInput({ title: "", brief: "" }); setTitles([]); setShowThumb(false); setShowCards(false); setStep("category"); }} style={primaryBtn}>🏠 대시보드로</button>
            <button onClick={() => { setResult(null); setStep("compose"); }} style={{ background: "transparent", border: `1px solid ${C.line}`, color: C.ink, borderRadius: 11, padding: "13px 18px", fontSize: 15, fontWeight: 600, cursor: "pointer", fontFamily: FONT, flex: 1, minWidth: 140 }}>같은 주제로 다시</button>
          </div>
        </section>
      )}

      {step === "library" && (
        <section>
          <Ornament>내 글 보관함</Ornament>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
            <span style={{ fontSize: 13.5, color: C.sub }}>저장된 글 {posts.length}개</span>
            <button style={smallBtn} onClick={() => setStep("category")}>← 대시보드</button>
          </div>
          {posts.length === 0 ? (
            <div style={{ ...card, textAlign: "center", color: C.sub, fontSize: 14, padding: 30 }}>아직 저장된 글이 없어요. 글을 만들면 여기 자동으로 쌓여요.</div>
          ) : (
            <div style={{ display: "grid", gap: 10 }}>
              {posts.map((p) => {
                const cn = CATEGORIES.find((c) => c.id === p.category)?.name || p.category;
                const total = p.scores ? ["title", "seo", "aeo", "geo"].reduce((a, k) => a + (Number(p.scores[k]) || 0), 0) : null;
                return (
                  <div key={p.id} style={{ ...card, padding: 16, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
                    <div style={{ minWidth: 0, flex: 1 }}>
                      <div style={{ fontSize: 15, fontWeight: 800, color: C.ink, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.title}</div>
                      <div style={{ fontSize: 12, color: C.sub, marginTop: 3 }}>{cn} · {fmtDate(p.createdAt)}{total != null ? ` · ${total}점` : ""}</div>
                    </div>
                    <div style={{ display: "flex", gap: 6 }}>
                      <button style={{ ...smallBtn, background: ACCENT_GRAD, color: "#fff", borderColor: "transparent" }} onClick={() => openPost(p)}>열기</button>
                      <button style={{ ...smallBtn, color: C.danger, borderColor: "rgba(209,67,67,.4)" }} onClick={() => { if (confirm("이 글을 삭제할까요?")) deletePost(p.id); }}>삭제</button>
                    </div>
                  </div>
                );
              })}
            </div>
          )}
        </section>
      )}

      {step === "view" && viewing && (
        <section>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
            <button style={smallBtn} onClick={() => setStep("library")}>← 보관함</button>
            <div style={{ display: "flex", gap: 6 }}>
              {editMode ? <button style={{ ...smallBtn, background: C.blue, color: "#fff", borderColor: C.blue }} onClick={saveEdit}>저장</button>
                : <button style={smallBtn} onClick={() => { setEdit({ title: viewing.title, post: viewing.post }); setEditMode(true); }}>수정</button>}
              <button style={{ ...smallBtn, color: C.danger, borderColor: "rgba(209,67,67,.4)" }} onClick={() => { if (confirm("이 글을 삭제할까요?")) { deletePost(viewing.id); setStep("library"); } }}>삭제</button>
            </div>
          </div>
          <Panel title="제목" right={!editMode && <CopyBtn text={viewing.title} />}>
            {editMode ? <input style={inputStyle} value={edit.title} onChange={(e) => setEdit({ ...edit, title: e.target.value })} />
              : <div style={{ fontSize: 18, fontWeight: 800, color: C.ink, lineHeight: 1.45 }}>{viewing.title}</div>}
          </Panel>
          <Panel title="본문" right={!editMode && <CopyBtn text={viewing.post} label="본문 복사" />}>
            {editMode ? <textarea style={{ ...inputStyle, minHeight: 320, resize: "vertical", lineHeight: 1.7 }} value={edit.post} onChange={(e) => setEdit({ ...edit, post: e.target.value })} />
              : <div style={{ fontSize: 15, lineHeight: 1.85, color: C.text }}>{renderPost(viewing.post)}</div>}
          </Panel>
          {!editMode && Array.isArray(viewing.hashtags) && viewing.hashtags.length > 0 && (
            <Panel title="해시태그" right={<CopyBtn text={viewing.hashtags.join(" ")} />}>
              <div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>{viewing.hashtags.map((h, i) => <span key={i} style={{ fontSize: 13, color: C.ink2, background: C.card, border: `1px solid ${C.line}`, borderRadius: 99, padding: "5px 11px" }}>{h}</span>)}</div>
            </Panel>
          )}
          {!editMode && viewing.scores && <Scores s={viewing.scores} />}
          {editMode && <div style={{ fontSize: 12.5, color: C.sub, marginTop: 4 }}>본문의 【사진…】 표시는 그대로 두면 사진 자리로 계속 표시돼요.</div>}
        </section>
      )}
    </div>
  );
}

function Scores({ s }) {
  if (!s) return null;
  const title = Math.max(0, Math.min(25, Math.round(Number(s.title) || 0)));
  let search = s.search;
  if (search == null && (s.seo != null || s.aeo != null || s.geo != null)) search = (Number(s.seo) || 0) + (Number(s.aeo) || 0) + (Number(s.geo) || 0);
  search = Math.max(0, Math.min(75, Math.round(Number(search) || 0)));
  const total = title + search; const tcol = total >= 80 ? C.ok : total >= 60 ? C.brass : C.danger;
  const rows = [["제목 키워드", title, 25], ["검색 최적화", search, 75]];
  return (
    <div style={{ background: C.card, border: `1px solid ${C.line}`, borderRadius: 14, padding: "16px 18px", marginBottom: 14 }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", borderBottom: `1px solid ${C.line}`, paddingBottom: 10, marginBottom: 13 }}><span style={{ fontSize: 12.5, fontWeight: 800, letterSpacing: "0.04em", color: C.ink }}>최적화 점수</span><span style={{ fontSize: 22, fontWeight: 900, color: tcol }}>{total}<span style={{ fontSize: 13, color: C.sub, fontWeight: 600 }}> / 100</span></span></div>
      <div style={{ display: "grid", gap: 9 }}>{rows.map(([label, v, max]) => <div key={label} style={{ display: "flex", alignItems: "center", gap: 10 }}><span style={{ width: 64, fontSize: 12.5, fontWeight: 700, color: C.ink }}>{label}</span><div style={{ flex: 1, height: 8, background: "rgba(255,255,255,.08)", borderRadius: 99, overflow: "hidden" }}><div style={{ width: `${(v / max) * 100}%`, height: "100%", background: label === "검색 최적화" ? C.violet : C.brass, borderRadius: 99 }} /></div><span style={{ width: 46, textAlign: "right", fontSize: 12.5, color: C.sub }}>{v}/{max}</span></div>)}</div>
      {s.notes && <div style={{ fontSize: 12.5, color: C.sub, marginTop: 12, lineHeight: 1.55 }}>💡 {s.notes}</div>}
    </div>
  );
}

/* ──────── 썸네일 ──────── */
let _uid = 0; const uid = () => `t${++_uid}`; const clamp01 = (v) => Math.max(0, Math.min(1, v));
const TEMPLATES = { center: [{ xf: 0.5, yf: 0.44, sizef: 0.13 }, { xf: 0.5, yf: 0.62, sizef: 0.055 }], top: [{ xf: 0.5, yf: 0.22, sizef: 0.12 }, { xf: 0.5, yf: 0.8, sizef: 0.055 }], bottom: [{ xf: 0.5, yf: 0.78, sizef: 0.12 }, { xf: 0.5, yf: 0.92, sizef: 0.05 }] };
const TPL_LABEL = { center: "가운데", top: "위·아래", bottom: "아래쪽" };
function buildInitial(tpl, headline, sub, t) { const pos = TEMPLATES[tpl] || TEMPLATES.center; const base = { stroke: !!t.stroke, strokeColor: t.stroke || "#000000", font: t.font, weight: t.weight }; const arr = [{ uid: uid(), text: headline || "제목", color: t.ink, ...base, ...pos[0] }]; if (sub) arr.push({ uid: uid(), text: sub, color: t.accent, ...base, ...pos[1] }); return arr; }

function ThumbnailMaker({ initial, fallbackTitle }) {
  const init = initial || {};
  const tpl = init.template && TEMPLATES[init.template] ? init.template : "center";
  const [theme, setTheme] = useState(STUDIO_THEMES[0]);
  const [bgType, setBgType] = useState("theme");
  const [bgColor, setBgColor] = useState(init.bgColor || "#1A1414");
  const [bgImg, setBgImg] = useState(null);
  const [elements, setElements] = useState(() => buildInitial(tpl, init.headline || (fallbackTitle || "").slice(0, 16), init.sub || "", STUDIO_THEMES[0]));
  const applyTheme = (t) => { setTheme(t); setBgType("theme"); setElements((els) => els.map((e, i) => ({ ...e, color: i === 0 ? t.ink : t.accent, stroke: !!t.stroke, strokeColor: t.stroke || "#000000", font: t.font, weight: t.weight, lineColors: {}, charStyles: {} }))); };
  const [sel, setSel] = useState(elements[0]?.uid || null);
  const [dragOver, setDragOver] = useState(false);
  const [w, setW] = useState(0);
  const boxRef = useRef(null); const drag = useRef(null);
  useEffect(() => { const up = () => boxRef.current && setW(boxRef.current.clientWidth); up(); window.addEventListener("resize", up); return () => window.removeEventListener("resize", up); }, []);
  const selEl = elements.find((e) => e.uid === sel);
  const [selChar, setSelChar] = useState(null);
  useEffect(() => { setSelChar(null); }, [sel]);
  const updSel = (patch) => setElements((els) => els.map((e) => (e.uid === sel ? { ...e, ...patch } : e)));
  const setLineColor = (li, color) => updSel({ lineColors: { ...(selEl.lineColors || {}), [li]: color } });
  const clearLineColor = (li) => { const m = { ...(selEl.lineColors || {}) }; delete m[li]; updSel({ lineColors: m }); };
  const setLineScale = (li, v) => updSel({ lineScales: { ...(selEl.lineScales || {}), [li]: v } });
  const setCharStyle = (k, patch) => updSel({ charStyles: { ...(selEl.charStyles || {}), [k]: { ...((selEl.charStyles || {})[k] || {}), ...patch } } });
  const clearCharStyle = (k) => { const m = { ...(selEl.charStyles || {}) }; delete m[k]; updSel({ charStyles: m }); };
  const addText = () => { const u = uid(); setElements((els) => [...els, { uid: u, text: "새 텍스트", xf: 0.5, yf: 0.5, sizef: 0.07, color: "#FFFFFF", stroke: true, strokeColor: "#000000" }]); setSel(u); };
  const delSel = () => { setElements((els) => els.filter((e) => e.uid !== sel)); setSel(null); };
  const applyTemplate = (name) => setElements((els) => els.map((e, i) => (TEMPLATES[name][i] ? { ...e, ...TEMPLATES[name][i] } : e)));
  function frac(cx, cy) { const r = boxRef.current.getBoundingClientRect(); return { xf: (cx - r.left) / r.width, yf: (cy - r.top) / r.height }; }
  function onElDown(e, el) { e.stopPropagation(); setSel(el.uid); drag.current = { uid: el.uid, mode: "move" }; boxRef.current.setPointerCapture && boxRef.current.setPointerCapture(e.pointerId); }
  function onHandleDown(e, el) { e.stopPropagation(); setSel(el.uid); const r = boxRef.current.getBoundingClientRect(); const cx = r.left + el.xf * r.width, cy = r.top + el.yf * r.height; const startDist = Math.max(10, Math.hypot(e.clientX - cx, e.clientY - cy)); drag.current = { uid: el.uid, mode: "resize", cx, cy, startDist, startSize: el.sizef }; boxRef.current.setPointerCapture && boxRef.current.setPointerCapture(e.pointerId); }
  function onMove(e) { if (!drag.current) return; const d = drag.current; if (d.mode === "move") { const { xf, yf } = frac(e.clientX, e.clientY); setElements((els) => els.map((el) => (el.uid === d.uid ? { ...el, xf: clamp01(xf), yf: clamp01(yf) } : el))); } else { const dist = Math.hypot(e.clientX - d.cx, e.clientY - d.cy); const ns = Math.max(0.03, Math.min(0.6, d.startSize * (dist / d.startDist))); setElements((els) => els.map((el) => (el.uid === d.uid ? { ...el, sizef: ns } : el))); } }
  function onUp() { drag.current = null; }
  function loadFile(file) { if (!file || !file.type.startsWith("image/")) return; const r = new FileReader(); r.onload = () => { setBgImg(r.result); setBgType("image"); }; r.readAsDataURL(file); }
  function download() {
    const S = 1080; const cv = document.createElement("canvas"); cv.width = S; cv.height = S; const ctx = cv.getContext("2d");
    const drawAll = () => { elements.forEach((el) => drawRichText(ctx, el, S)); cv.toBlob((blob) => { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "thumbnail.png"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }); };
    if (bgType === "theme") { fillThemeBg(ctx, theme, S); drawThumbAccent(ctx, theme, S); drawAll(); }
    else if (bgType === "image" && bgImg) { const img = new Image(); img.onload = () => { const ar = img.width / img.height; let dw = S, dh = S, dx = 0, dy = 0; if (ar > 1) { dh = S; dw = S * ar; dx = (S - dw) / 2; } else { dw = S; dh = S / ar; dy = (S - dh) / 2; } ctx.drawImage(img, dx, dy, dw, dh); drawAll(); }; img.src = bgImg; }
    else { ctx.fillStyle = bgColor; ctx.fillRect(0, 0, S, S); drawAll(); }
  }
  const swatch = (c, active, onClick) => <button key={c} onClick={onClick} style={{ width: 26, height: 26, borderRadius: 7, background: c, border: active ? `2px solid ${C.ink}` : `1px solid ${C.line}`, cursor: "pointer", boxShadow: c === "#FFFFFF" ? "inset 0 0 0 1px #ddd" : "none" }} />;

  return (
    <div style={{ background: C.card, border: `1px solid ${C.line}`, borderRadius: 14, padding: 16 }}>
      <div style={{ fontSize: 13, fontWeight: 800, color: C.ink, marginBottom: 4 }}>썸네일 제작기</div>
      <div style={{ fontSize: 12, color: C.sub, marginBottom: 12 }}>텍스트를 끌어 옮기고, 모서리 점을 바깥으로 끌면 커지고 안으로 끌면 작아져요. 아래 슬라이더와 −/+ 로도 조절돼요. 배경은 색 또는 이미지(끌어다 놓기/업로드).</div>
      <div style={{ display: "flex", gap: 7, marginBottom: 12, flexWrap: "wrap" }}><span style={{ fontSize: 12, color: C.sub, alignSelf: "center" }}>템플릿</span>{Object.keys(TEMPLATES).map((k) => <button key={k} onClick={() => applyTemplate(k)} style={{ ...smallBtn, padding: "6px 12px" }}>{TPL_LABEL[k]}</button>)}</div>
      <div ref={boxRef} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp} onPointerDown={() => setSel(null)} onDrop={(e) => { e.preventDefault(); setDragOver(false); loadFile(e.dataTransfer.files[0]); }} onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} style={{ position: "relative", width: "100%", aspectRatio: "1 / 1", borderRadius: 12, overflow: "hidden", touchAction: "none", userSelect: "none", background: bgType === "theme" ? themeCss(theme) : (bgType === "image" && bgImg ? `#111 center/cover no-repeat url(${bgImg})` : bgColor), border: dragOver ? `2px dashed ${C.brass}` : `1px solid ${C.line}` }}>
        {bgType === "theme" && theme.frame === "gold" && <><div style={{ position: "absolute", inset: "5%", border: `1px solid ${theme.accent}`, opacity: .6, pointerEvents: "none", borderRadius: 2 }} /><div style={{ position: "absolute", right: "-8%", bottom: "-8%", width: "40%", height: "40%", borderRadius: "50%", background: `radial-gradient(circle,${theme.accent}2b,transparent 68%)`, pointerEvents: "none" }} /></>}
        {bgType === "theme" && theme.frame === "slash" && <div style={{ position: "absolute", left: "-20%", bottom: "-16%", width: "75%", height: "34%", background: theme.accent, opacity: .15, transform: "rotate(-16deg)", pointerEvents: "none" }} />}
        {bgType === "theme" && theme.frame === "blob" && <div style={{ position: "absolute", right: "-12%", top: "-14%", width: "46%", height: "46%", borderRadius: "50%", background: `radial-gradient(circle,${theme.accent}4d,transparent 66%)`, pointerEvents: "none" }} />}
        {elements.map((el) => { const fs = el.sizef * (w || 320); const isSel = el.uid === sel; const lc = el.lineColors || {}, cs = el.charStyles || {}, ls = el.lineScales || {}; return <div key={el.uid} onPointerDown={(e) => onElDown(e, el)} style={{ position: "absolute", left: `${el.xf * 100}%`, top: `${el.yf * 100}%`, transform: "translate(-50%,-50%)", maxWidth: "92%", textAlign: "center", cursor: "move", fontFamily: el.font || FONT, fontWeight: el.weight || 900, fontSize: fs, lineHeight: 1.18, whiteSpace: "pre-wrap", wordBreak: "keep-all", WebkitTextStroke: el.stroke ? `${Math.max(1, fs * 0.055)}px ${el.strokeColor}` : undefined, paintOrder: "stroke fill", outline: isSel ? `1.5px dashed ${C.brass}` : "none", outlineOffset: 4, padding: 2 }}>{(el.text || "").split("\n").map((ln, li) => <div key={li} style={{ color: lc[li] || el.color }}>{[...ln].length === 0 ? "\u200b" : [...ln].map((ch, ci) => { const st = cs[li + ":" + ci] || {}; const sc = st.scale || ls[li]; return <span key={ci} style={{ color: st.color || undefined, fontSize: sc ? sc + "em" : undefined }}>{ch}</span>; })}</div>)}{isSel && <span onPointerDown={(e) => onHandleDown(e, el)} style={{ position: "absolute", right: -11, bottom: -11, width: 20, height: 20, borderRadius: 99, background: C.brass, border: "2px solid #fff", cursor: "nwse-resize" }} />}</div>; })}
      </div>
      <div style={{ marginTop: 14 }}>
        <div style={{ display: "flex", gap: 7, marginBottom: 10 }}>{[["theme", "테마"], ["color", "배경 색상"], ["image", "배경 이미지"]].map(([id, lbl]) => <button key={id} onClick={() => setBgType(id)} style={{ ...smallBtn, padding: "7px 13px", background: bgType === id ? C.brass : C.elev, color: bgType === id ? "#fff" : C.sub, borderColor: bgType === id ? C.brass : C.line }}>{lbl}</button>)}</div>
        {bgType === "theme" ? <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>{STUDIO_THEMES.map((t) => <button key={t.id} onClick={() => applyTheme(t)} style={{ flex: "1 1 120px", padding: "10px 12px", borderRadius: 10, border: `1.5px solid ${theme.id === t.id ? C.brass : C.line}`, background: C.card, cursor: "pointer", fontFamily: FONT, textAlign: "left" }}><span style={{ display: "inline-block", width: 34, height: 20, borderRadius: 5, verticalAlign: "middle", marginRight: 8, background: themeCss(t), boxShadow: `inset 0 0 0 1.5px ${t.accent}` }} /><b style={{ fontSize: 12.5, color: C.ink }}>{t.name}</b></button>)}</div>
          : bgType === "color" ? <div style={{ display: "flex", gap: 7, alignItems: "center", flexWrap: "wrap" }}>{BG_SWATCHES.map((c) => swatch(c, bgColor === c, () => setBgColor(c)))}<input type="color" value={bgColor} onChange={(e) => setBgColor(e.target.value)} style={{ width: 30, height: 30, border: "none", background: "none", cursor: "pointer" }} /></div>
          : <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}><label style={{ ...smallBtn, cursor: "pointer" }}>이미지 업로드<input type="file" accept="image/*" onChange={(e) => loadFile(e.target.files[0])} style={{ display: "none" }} /></label><span style={{ fontSize: 12, color: C.sub }}>또는 미리보기에 끌어다 놓기</span>{bgImg && <button style={{ ...smallBtn, color: C.danger }} onClick={() => { setBgImg(null); setBgType("color"); }}>이미지 제거</button>}</div>}
      </div>
      <div style={{ marginTop: 16, borderTop: `1px solid ${C.line}`, paddingTop: 14 }}>
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}><span style={{ fontSize: 12.5, fontWeight: 700, color: C.ink }}>{selEl ? "선택한 텍스트" : "텍스트를 선택하세요"}</span><button style={smallBtn} onClick={addText}>+ 텍스트 추가</button></div>
        {selEl && (
          <div style={{ display: "grid", gap: 11 }}>
            <textarea value={selEl.text} onChange={(e) => updSel({ text: e.target.value })} style={{ ...inputStyle, minHeight: 46, resize: "vertical" }} placeholder="줄바꿈도 됩니다" />
            <div style={{ display: "flex", gap: 7, alignItems: "center", flexWrap: "wrap" }}><span style={{ fontSize: 12, color: C.sub, width: 40 }}>글자색</span>{TEXT_SWATCHES.map((c) => swatch(c, selEl.color === c, () => updSel({ color: c })))}<input type="color" value={selEl.color} onChange={(e) => updSel({ color: e.target.value })} style={{ width: 30, height: 30, border: "none", background: "none", cursor: "pointer" }} /></div>
            <div style={{ display: "flex", gap: 7, alignItems: "center" }}><span style={{ fontSize: 12, color: C.sub, width: 40 }}>크기</span><button onClick={() => updSel({ sizef: Math.max(0.03, +(selEl.sizef - 0.01).toFixed(3)) })} style={{ width: 30, height: 30, borderRadius: 8, border: `1px solid ${C.line}`, background: C.card, color: C.ink, fontSize: 18, fontWeight: 700, cursor: "pointer", lineHeight: 1, flex: "none" }}>−</button><input type="range" min={3} max={60} value={Math.round(selEl.sizef * 100)} onChange={(e) => updSel({ sizef: Number(e.target.value) / 100 })} style={{ flex: 1 }} /><button onClick={() => updSel({ sizef: Math.min(0.6, +(selEl.sizef + 0.01).toFixed(3)) })} style={{ width: 30, height: 30, borderRadius: 8, border: `1px solid ${C.line}`, background: C.card, color: C.ink, fontSize: 18, fontWeight: 700, cursor: "pointer", lineHeight: 1, flex: "none" }}>+</button><span style={{ fontSize: 11.5, color: C.sub, width: 26, textAlign: "right" }}>{Math.round(selEl.sizef * 100)}</span></div>
            <div style={{ display: "flex", gap: 9, alignItems: "center", flexWrap: "wrap" }}><span style={{ fontSize: 12, color: C.sub, width: 40 }}>외곽선</span><button style={{ ...smallBtn, background: selEl.stroke ? C.brass : C.elev, color: selEl.stroke ? "#fff" : C.sub, borderColor: selEl.stroke ? C.brass : C.line }} onClick={() => updSel({ stroke: !selEl.stroke })}>{selEl.stroke ? "켜짐" : "꺼짐"}</button>{selEl.stroke && <>{swatch("#000000", selEl.strokeColor === "#000000", () => updSel({ strokeColor: "#000000" }))}{swatch("#FFFFFF", selEl.strokeColor === "#FFFFFF", () => updSel({ strokeColor: "#FFFFFF" }))}</>}<button style={{ ...smallBtn, color: C.danger, borderColor: "rgba(179,38,30,0.4)", marginLeft: "auto" }} onClick={delSel}>이 텍스트 삭제</button></div>

            {(selEl.text || "").split("\n").length > 1 && (
              <div style={{ borderTop: `1px dashed ${C.line}`, paddingTop: 11 }}>
                <div style={{ fontSize: 12, fontWeight: 700, color: C.ink, marginBottom: 8 }}>줄별 색·크기</div>
                <div style={{ display: "grid", gap: 9 }}>{(selEl.text || "").split("\n").map((ln, li) => { const lsv = (selEl.lineScales || {})[li] || 1; return (
                  <div key={li} style={{ display: "grid", gap: 5 }}>
                    <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
                      <span style={{ fontSize: 11.5, color: C.sub, width: 50, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{li + 1}줄</span>
                      {TEXT_SWATCHES.slice(0, 8).map((c) => swatch(c, (selEl.lineColors || {})[li] === c, () => setLineColor(li, c)))}
                      <input type="color" value={(selEl.lineColors || {})[li] || selEl.color} onChange={(e) => setLineColor(li, e.target.value)} style={{ width: 26, height: 26, border: "none", background: "none", cursor: "pointer" }} />
                      {(selEl.lineColors || {})[li] && <button style={{ ...smallBtn, padding: "4px 8px" }} onClick={() => clearLineColor(li)}>색기본</button>}
                    </div>
                    <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
                      <span style={{ fontSize: 11, color: C.sub, width: 50 }}>크기</span>
                      <input type="range" min={30} max={250} value={Math.round(lsv * 100)} onChange={(e) => setLineScale(li, Number(e.target.value) / 100)} style={{ flex: 1 }} />
                      <span style={{ fontSize: 11.5, color: C.sub, width: 38, textAlign: "right" }}>{Math.round(lsv * 100)}%</span>
                    </div>
                  </div>
                ); })}</div>
              </div>
            )}

            <div style={{ borderTop: `1px dashed ${C.line}`, paddingTop: 11 }}>
              <div style={{ fontSize: 12, fontWeight: 700, color: C.ink, marginBottom: 8 }}>글자별 색·크기 <span style={{ fontWeight: 500, color: C.sub }}>· 글자를 눌러 선택</span></div>
              <div style={{ display: "grid", gap: 6, marginBottom: selChar != null ? 10 : 0 }}>{(selEl.text || "").split("\n").map((ln, li) => (
                <div key={li} style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>{[...ln].length === 0 ? <span style={{ fontSize: 11, color: C.sub }}>(빈 줄)</span> : [...ln].map((ch, ci) => { const k = li + ":" + ci; const st = (selEl.charStyles || {})[k] || {}; const on = selChar === k; return <button key={ci} onClick={() => setSelChar(on ? null : k)} style={{ minWidth: 28, height: 32, padding: "0 5px", borderRadius: 7, border: `1px solid ${on ? C.brass : C.line}`, background: on ? C.brassSoft : C.elev, color: st.color || C.ink, fontWeight: 800, fontSize: 16, cursor: "pointer", position: "relative" }}>{ch === " " ? "␣" : ch}{st.scale && st.scale !== 1 ? <span style={{ position: "absolute", right: 1, bottom: 0, fontSize: 8, color: C.brass, fontWeight: 700 }}>{Math.round(st.scale * 100)}</span> : null}</button>; })}</div>
              ))}</div>
              {selChar != null && (() => { const st = (selEl.charStyles || {})[selChar] || {}; const bs = { width: 28, height: 28, borderRadius: 7, border: `1px solid ${C.line}`, background: C.card, color: C.ink, fontSize: 16, fontWeight: 700, cursor: "pointer", lineHeight: 1, flex: "none" }; return (
                <div style={{ background: C.paper, border: `1px solid ${C.line}`, borderRadius: 10, padding: "10px 11px", display: "grid", gap: 9 }}>
                  <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}><span style={{ fontSize: 11.5, color: C.sub, width: 30 }}>색</span>{TEXT_SWATCHES.map((c) => swatch(c, st.color === c, () => setCharStyle(selChar, { color: c })))}<input type="color" value={st.color || selEl.color} onChange={(e) => setCharStyle(selChar, { color: e.target.value })} style={{ width: 26, height: 26, border: "none", background: "none", cursor: "pointer" }} /></div>
                  <div style={{ display: "flex", gap: 6, alignItems: "center" }}><span style={{ fontSize: 11.5, color: C.sub, width: 30 }}>크기</span><button onClick={() => setCharStyle(selChar, { scale: Math.max(0.3, +(((st.scale || 1) - 0.1)).toFixed(2)) })} style={bs}>−</button><span style={{ fontSize: 12.5, color: C.ink, width: 46, textAlign: "center" }}>{Math.round((st.scale || 1) * 100)}%</span><button onClick={() => setCharStyle(selChar, { scale: Math.min(3, +(((st.scale || 1) + 0.1)).toFixed(2)) })} style={bs}>+</button><button style={{ ...smallBtn, marginLeft: "auto", padding: "4px 9px" }} onClick={() => { clearCharStyle(selChar); setSelChar(null); }}>초기화</button></div>
                </div>
              ); })()}
            </div>
          </div>
        )}
      </div>
      <button style={{ ...primaryBtn, marginTop: 16 }} onClick={download}>PNG로 저장</button>
    </div>
  );
}

/* ──────── 카드뉴스 (멀티페이지 편집기) ──────── */
const CN_SIZE = 1080, CN_MARGIN = 60;
const cnGaps = () => { const sides = ["top", "right", "bottom", "left"]; const n = Math.floor(Math.random() * 2) + 1; const g = []; for (let i = 0; i < n; i++) g.push({ side: sides[Math.floor(Math.random() * 4)], start: Math.random() * 0.5 + 0.2, length: Math.random() * 0.15 + 0.1 }); return g; };
function cnBorderSegs(gaps) {
  const S = CN_SIZE, m = CN_MARGIN; const defs = { top: [m, m, S - m, m], right: [S - m, m, S - m, S - m], bottom: [S - m, S - m, m, S - m], left: [m, S - m, m, m] }; const segs = [];
  ["top", "right", "bottom", "left"].forEach((side) => { const [x1, y1, x2, y2] = defs[side]; const g = gaps.find((q) => q.side === side); if (!g) { segs.push([x1, y1, x2, y2]); return; } const len = Math.hypot(x2 - x1, y2 - y1); const dx = (x2 - x1) / len, dy = (y2 - y1) / len; const gs = g.start * len, ge = gs + g.length * len; segs.push([x1, y1, x1 + dx * gs, y1 + dy * gs]); segs.push([x1 + dx * ge, y1 + dy * ge, x2, y2]); });
  return segs;
}
const cnFont = (f, bold) => f === "gmarket" ? `${bold ? "700" : "400"} {S}px 'GmarketSansBold','Gmarket Sans',sans-serif` : `${bold ? "900" : "700"} {S}px ${FONT}`;
function CardNews({ post, profile }) {
  const [pages, setPages] = useState(null); const [cur, setCur] = useState(0); const [loading, setLoading] = useState(true); const [err, setErr] = useState("");
  const [font, setFont] = useState("gmarket"); const [zipping, setZipping] = useState(false);
  const [theme, setTheme] = useState(STUDIO_THEMES[0]);
  const applyCardTheme = (t) => { setTheme(t); setPages((ps) => (ps || []).map((p) => ({ ...p, lines: p.lines.map((l, i) => ({ ...l, color: i === 0 ? t.ink : t.accent })) }))); };
  const [pw, setPw] = useState(320); const wrapRef = useRef(null); const drag = useRef(null); const dragTab = useRef(null);
  useEffect(() => { const up = () => { if (wrapRef.current) setPw(wrapRef.current.clientWidth); }; up(); window.addEventListener("resize", up); return () => window.removeEventListener("resize", up); }, [pages, cur]);
  useEffect(() => { (async () => { try { const txt = await callClaude({ model: "claude-sonnet-4-6", max_tokens: 3000, system: buildCardSystem(), messages: [{ role: "user", content: buildCardUser(profile, post) }] }); const arr = extractArrJSON(txt); if (arr && arr.length) { setPages(arr.slice(0, 10).map((c) => seedPage(c.title || "", c.body || ""))); } else setErr("카드 생성에 실패했어요. 닫았다가 다시 열어 주세요."); } catch (e) { setErr(backendError(e)); } finally { setLoading(false); } })(); }, []);
  function seedPage(title, body) { const lines = []; if (title) lines.push({ id: "l" + Math.random(), text: title, fontSize: 92, color: theme.ink, align: "left", x: 110, y: body ? 380 : 520 }); if (body) lines.push({ id: "l" + Math.random(), text: body, fontSize: 46, color: theme.accent, align: "left", x: 110, y: 600 }); return { id: "p" + Math.random(), bgImg: null, gaps: cnGaps(), lines }; }
  const page = pages && pages[cur];
  const setPage = (patch) => setPages((ps) => ps.map((p, i) => (i === cur ? { ...p, ...patch } : p)));
  const setLine = (idx, patch) => setPages((ps) => ps.map((p, i) => (i === cur ? { ...p, lines: p.lines.map((l, j) => (j === idx ? { ...l, ...patch } : l)) } : p)));
  const addLine = () => setPage({ lines: [...page.lines, { id: "l" + Math.random(), text: "새 문구", fontSize: 56, color: "#ffffff", align: "center", x: 540, y: 540 }] });
  const delLine = (idx) => setPage({ lines: page.lines.filter((_, j) => j !== idx) });
  const addPage = () => { setPages((ps) => [...ps, seedPage("새 페이지", "")]); setCur(pages.length); };
  const copyPage = () => { setPages((ps) => { const c = ps[cur]; const np = { id: "p" + Math.random(), bgImg: c.bgImg, gaps: cnGaps(), lines: c.lines.map((l) => ({ ...l, id: "l" + Math.random() })) }; const out = [...ps]; out.splice(cur + 1, 0, np); return out; }); setCur(cur + 1); };
  const delPage = (idx) => { if (pages.length === 1) return; setPages((ps) => ps.filter((_, i) => i !== idx)); setCur((c) => (c >= idx && c > 0 ? c - 1 : c)); };
  const reorder = (from, to) => { setPages((ps) => { const out = [...ps]; const [m] = out.splice(from, 1); out.splice(to, 0, m); return out; }); setCur(to); };
  function loadBg(file) { if (!file || !file.type.startsWith("image/")) return; const r = new FileReader(); r.onload = () => setPage({ bgImg: r.result }); r.readAsDataURL(file); }
  const scale = pw / CN_SIZE;
  function lineDown(e, idx) { e.stopPropagation(); const r = wrapRef.current.getBoundingClientRect(); const lx = (e.clientX - r.left) / scale, ly = (e.clientY - r.top) / scale; const l = page.lines[idx]; drag.current = { idx, ox: l.x - lx, oy: l.y - ly }; wrapRef.current.setPointerCapture && wrapRef.current.setPointerCapture(e.pointerId); }
  function move(e) { if (!drag.current) return; const r = wrapRef.current.getBoundingClientRect(); const lx = (e.clientX - r.left) / scale, ly = (e.clientY - r.top) / scale; const d = drag.current; setLine(d.idx, { x: Math.round(lx + d.ox), y: Math.round(ly + d.oy) }); }
  function up() { drag.current = null; }
  const alignTX = (a) => (a === "left" ? "0%" : a === "right" ? "-100%" : "-50%");
  async function renderCanvas(pg) {
    const S = CN_SIZE; const cv = document.createElement("canvas"); cv.width = S; cv.height = S; const ctx = cv.getContext("2d");
    if (pg.bgImg) { await new Promise((res) => { const img = new Image(); img.onload = () => { const sc = Math.max(S / img.width, S / img.height); ctx.drawImage(img, S / 2 - (img.width * sc) / 2, S / 2 - (img.height * sc) / 2, img.width * sc, img.height * sc); res(); }; img.onerror = res; img.src = pg.bgImg; }); const grd = ctx.createLinearGradient(0, 0, 0, S); if (theme.dark) { grd.addColorStop(0, "rgba(0,0,0,.3)"); grd.addColorStop(.5, "rgba(0,0,0,.5)"); grd.addColorStop(1, "rgba(0,0,0,.85)"); } else { grd.addColorStop(0, "rgba(247,240,230,.4)"); grd.addColorStop(1, "rgba(247,240,230,.82)"); } ctx.fillStyle = grd; ctx.fillRect(0, 0, S, S); } else { fillThemeBg(ctx, theme, S); }
    ctx.strokeStyle = theme.accent; ctx.lineWidth = 12; ctx.lineCap = "square"; cnBorderSegs(pg.gaps).forEach(([x1, y1, x2, y2]) => { ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); });
    ctx.textBaseline = "middle";
    for (const l of pg.lines) { if (!l.text.trim()) continue; ctx.font = cnFont(font, true).replace("{S}", l.fontSize); ctx.fillStyle = l.color; ctx.textAlign = l.align; ctx.shadowColor = theme.dark ? "rgba(0,0,0,.8)" : "rgba(0,0,0,.16)"; ctx.shadowBlur = 15; ctx.shadowOffsetX = theme.dark ? 4 : 2; ctx.shadowOffsetY = theme.dark ? 4 : 2; const tls = l.text.split("\n"); const lh = l.fontSize * 1.2; const sy = l.y - ((tls.length - 1) * lh) / 2; tls.forEach((t, i) => ctx.fillText(t, l.x, sy + i * lh)); ctx.shadowColor = "transparent"; }
    return cv;
  }
  async function dlOne() { try { await document.fonts.ready; } catch {} const cv = await renderCanvas(page); cv.toBlob((b) => { const u = URL.createObjectURL(b); const a = document.createElement("a"); a.href = u; a.download = `카드뉴스_${cur + 1}.png`; a.click(); setTimeout(() => URL.revokeObjectURL(u), 1000); }); }
  async function dlZip() {
    if (!window.JSZip) { alert("압축 모듈을 불러오지 못했어요. 새로고침 후 다시 시도해 주세요."); return; }
    setZipping(true); try { await document.fonts.ready; } catch {}
    try { const zip = new window.JSZip(); for (let i = 0; i < pages.length; i++) { const cv = await renderCanvas(pages[i]); const data = cv.toDataURL("image/png").split(",")[1]; zip.file(`카드뉴스_${String(i + 1).padStart(2, "0")}.png`, data, { base64: true }); } const blob = await zip.generateAsync({ type: "blob" }); const u = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = u; a.download = "카드뉴스_전체.zip"; a.click(); setTimeout(() => URL.revokeObjectURL(u), 1500); } catch (e) { alert("ZIP 저장 중 오류가 발생했어요."); } finally { setZipping(false); }
  }
  if (loading) return <div style={{ background: C.card, border: `1px solid ${C.line}`, borderRadius: 14, padding: 22, textAlign: "center", color: C.sub, fontSize: 14 }}>카드뉴스 만드는 중…</div>;
  if (err) return <div style={{ background: C.card, border: `1px solid ${C.line}`, borderRadius: 14, padding: 18, color: C.danger, fontSize: 13.5 }}>{err}</div>;
  const famPreview = font === "gmarket" ? "'GmarketSansBold','Gmarket Sans',sans-serif" : FONT;
  return (
    <div style={{ background: C.card, border: `1px solid ${C.line}`, borderRadius: 14, padding: 16 }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8, marginBottom: 10 }}>
        <div style={{ fontSize: 13, fontWeight: 800, color: C.ink }}>카드뉴스 · {pages.length}장 ({cur + 1}번째)</div>
        <div style={{ display: "flex", gap: 6 }}>
          <button style={{ ...smallBtn, background: font === "gmarket" ? C.brass : C.elev, color: font === "gmarket" ? "#fff" : C.sub, borderColor: font === "gmarket" ? C.brass : C.line }} onClick={() => setFont("gmarket")}>Gmarket</button>
          <button style={{ ...smallBtn, background: font === "pretendard" ? C.brass : C.elev, color: font === "pretendard" ? "#fff" : C.sub, borderColor: font === "pretendard" ? C.brass : C.line }} onClick={() => setFont("pretendard")}>기본</button>
        </div>
      </div>

      <div style={{ display: "flex", gap: 7, marginBottom: 12, flexWrap: "wrap", alignItems: "center" }}>
        <span style={{ fontSize: 12, color: C.sub }}>테마</span>
        {STUDIO_THEMES.map((t) => <button key={t.id} onClick={() => applyCardTheme(t)} style={{ ...smallBtn, padding: "6px 11px", display: "inline-flex", alignItems: "center", gap: 6, borderColor: theme.id === t.id ? C.brass : C.line, background: theme.id === t.id ? "rgba(255,77,94,0.12)" : C.card }}><span style={{ width: 22, height: 14, borderRadius: 4, background: themeCss(t), boxShadow: `inset 0 0 0 1.5px ${t.accent}` }} />{t.name}</button>)}
      </div>

      {/* 페이지 썸네일 (드래그로 순서 변경) */}
      <div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 10, flexWrap: "wrap" }}>
        <button style={{ ...smallBtn, padding: "5px 10px" }} onClick={addPage}>+ 페이지</button>
        <button style={{ ...smallBtn, padding: "5px 10px" }} onClick={copyPage}>현재 복사</button>
        <span style={{ fontSize: 11, color: C.sub }}>썸네일을 끌어 순서 변경</span>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(5,1fr)", gap: 6, marginBottom: 14 }}>
        {pages.map((p, i) => (
          <div key={p.id} draggable onDragStart={() => (dragTab.current = i)} onDragOver={(e) => e.preventDefault()} onDrop={() => { if (dragTab.current != null && dragTab.current !== i) reorder(dragTab.current, i); dragTab.current = null; }} onClick={() => setCur(i)} style={{ position: "relative", aspectRatio: "1/1", borderRadius: 8, overflow: "hidden", cursor: "pointer", border: i === cur ? `2px solid ${C.brass}` : `1px solid ${C.line}`, background: p.bgImg ? `#222 center/cover no-repeat url(${p.bgImg})` : themeCss(theme) }}>
            {p.bgImg && <div style={{ position: "absolute", inset: 0, background: theme.dark ? "linear-gradient(to bottom,rgba(0,0,0,.2),rgba(0,0,0,.55) 60%,rgba(0,0,0,.8))" : "linear-gradient(to bottom,rgba(247,240,230,.2),rgba(247,240,230,.7))" }} />}
            <div style={{ position: "absolute", inset: 0 }}>{p.lines.filter((l) => l.text.trim()).slice(0, 3).map((l, k) => <div key={k} style={{ position: "absolute", left: `${(l.x / CN_SIZE) * 100}%`, top: `${(l.y / CN_SIZE) * 100}%`, transform: `translate(${alignTX(l.align)},-50%)`, color: l.color, fontSize: Math.max(5, (l.fontSize / CN_SIZE) * (pw / 5)), fontFamily: famPreview, fontWeight: 700, whiteSpace: "pre", lineHeight: 1.15, textShadow: theme.dark ? "1px 1px 2px rgba(0,0,0,.8)" : "1px 1px 2px rgba(0,0,0,.15)" }}>{l.text}</div>)}</div>
            <div style={{ position: "absolute", top: 2, left: 3, fontSize: 9, fontWeight: 800, color: "#fff", background: "rgba(0,0,0,.55)", borderRadius: 3, padding: "0 4px" }}>{i + 1}</div>
            {pages.length > 1 && <button onClick={(e) => { e.stopPropagation(); delPage(i); }} style={{ position: "absolute", top: 1, right: 1, width: 16, height: 16, borderRadius: 4, border: "none", background: "rgba(179,38,30,.9)", color: "#fff", fontSize: 11, lineHeight: 1, cursor: "pointer" }}>×</button>}
          </div>
        ))}
      </div>

      {/* 미리보기 (문구 드래그 이동) */}
      <div ref={wrapRef} onPointerMove={move} onPointerUp={up} onPointerCancel={up} onDrop={(e) => { e.preventDefault(); loadBg(e.dataTransfer.files[0]); }} onDragOver={(e) => e.preventDefault()} style={{ position: "relative", width: "100%", aspectRatio: "1/1", borderRadius: 12, overflow: "hidden", background: page.bgImg ? `#222 center/cover no-repeat url(${page.bgImg})` : themeCss(theme), touchAction: "none", userSelect: "none" }}>
        {page.bgImg && <div style={{ position: "absolute", inset: 0, background: theme.dark ? "linear-gradient(to bottom,rgba(0,0,0,.3),rgba(0,0,0,.5) 50%,rgba(0,0,0,.85))" : "linear-gradient(to bottom,rgba(247,240,230,.4),rgba(247,240,230,.82))" }} />}
        <svg viewBox={`0 0 ${CN_SIZE} ${CN_SIZE}`} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }}>{cnBorderSegs(page.gaps).map((s, i) => <line key={i} x1={s[0]} y1={s[1]} x2={s[2]} y2={s[3]} stroke={theme.accent} strokeWidth="12" strokeLinecap="square" />)}</svg>
        {page.lines.map((l, idx) => l.text.trim() ? <div key={l.id} onPointerDown={(e) => lineDown(e, idx)} style={{ position: "absolute", left: l.x * scale, top: l.y * scale, transform: `translate(${alignTX(l.align)},-50%)`, fontFamily: famPreview, fontWeight: 800, fontSize: l.fontSize * scale, color: l.color, textAlign: l.align, lineHeight: 1.2, whiteSpace: "pre", cursor: "grab", textShadow: theme.dark ? "3px 3px 10px rgba(0,0,0,.8)" : "1px 1px 5px rgba(0,0,0,.18)", padding: 4 }}>{l.text}</div> : null)}
      </div>

      {/* 현재 페이지 배경 */}
      <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap", marginTop: 12 }}>
        <label style={{ ...smallBtn, cursor: "pointer" }}>배경 이미지<input type="file" accept="image/*" onChange={(e) => loadBg(e.target.files[0])} style={{ display: "none" }} /></label>
        <span style={{ fontSize: 11.5, color: C.sub }}>또는 미리보기에 끌어다 놓기</span>
        {page.bgImg && <button style={{ ...smallBtn, color: C.danger }} onClick={() => setPage({ bgImg: null })}>배경 제거</button>}
      </div>

      {/* 행 편집 */}
      <div style={{ marginTop: 14, borderTop: `1px solid ${C.line}`, paddingTop: 12, display: "grid", gap: 10 }}>
        <div style={{ fontSize: 12.5, fontWeight: 800, color: C.ink }}>문구 ({page.lines.length})</div>
        {page.lines.map((l, idx) => (
          <div key={l.id} style={{ border: `1px solid ${C.line}`, borderRadius: 10, padding: 10, display: "grid", gap: 8 }}>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
              <span style={{ fontSize: 11.5, fontWeight: 700, color: C.brass }}>{idx + 1}행</span>
              <div style={{ display: "flex", gap: 4 }}>
                {["left", "center", "right"].map((a) => <button key={a} onClick={() => setLine(idx, { align: a })} style={{ ...smallBtn, padding: "4px 9px", background: l.align === a ? C.brass : C.elev, color: l.align === a ? "#fff" : C.sub, borderColor: l.align === a ? C.brass : C.line }}>{a === "left" ? "좌" : a === "center" ? "중" : "우"}</button>)}
                <button onClick={() => delLine(idx)} style={{ ...smallBtn, padding: "4px 9px", color: C.danger, borderColor: "rgba(179,38,30,.4)" }}>삭제</button>
              </div>
            </div>
            <textarea value={l.text} onChange={(e) => setLine(idx, { text: e.target.value })} style={{ ...inputStyle, minHeight: 42, resize: "vertical", fontWeight: 700 }} placeholder="문구 (줄바꿈 가능)" />
            <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
              <span style={{ fontSize: 11.5, color: C.sub, width: 30 }}>크기</span>
              <input type="range" min={20} max={250} value={l.fontSize} onChange={(e) => setLine(idx, { fontSize: Number(e.target.value) })} style={{ flex: 1 }} />
              <span style={{ fontSize: 11.5, color: C.sub, width: 34, textAlign: "right" }}>{l.fontSize}</span>
              <input type="color" value={l.color} onChange={(e) => setLine(idx, { color: e.target.value })} style={{ width: 28, height: 28, border: "none", background: "none", cursor: "pointer" }} />
            </div>
          </div>
        ))}
        <button style={{ ...smallBtn, padding: "9px 0" }} onClick={addLine}>+ 문구 추가</button>
      </div>

      <div style={{ display: "flex", gap: 8, marginTop: 14, flexWrap: "wrap" }}>
        <button style={{ ...primaryBtn, flex: 1 }} onClick={dlOne}>현재 장 PNG</button>
        <button style={{ ...primaryBtn, flex: 1, opacity: zipping ? 0.6 : 1 }} disabled={zipping} onClick={dlZip}>{zipping ? "압축 중…" : `전체 ZIP (${pages.length})`}</button>
      </div>
    </div>
  );
}

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