// app.jsx — main state machine, theming, render. const { useState: useS, useEffect: useE } = React; // 상담 접수 백엔드 (biz.anyinsu.com, GnuBoard4 anyinsu_classic). CORS 허용됨. const CONSULT_API = "https://biz.anyinsu.com/anyinsu/api.php"; const THEMES = { navy: { label: "딥 네이비", v: { "--bg":"#03070f","--app-bg":"#0a1a30","--app-bg-2":"#06101f","--surface":"#102844","--surface-2":"#16314f","--surface-hi":"#1c3a5e","--border":"rgba(255,255,255,.09)","--border-hi":"rgba(255,255,255,.16)","--text":"#eef4fb","--text-muted":"#9fb1c9","--text-dim":"#6f86a3","--accent":"#2fe0c0","--accent-ink":"#042018","--accent-soft":"rgba(47,224,192,.12)","--accent-line":"rgba(47,224,192,.35)","--accent-glow":"rgba(47,224,192,.5)" } }, indigo: { label: "인디고", v: { "--bg":"#05060f","--app-bg":"#0e1330","--app-bg-2":"#080a1c","--surface":"#181d3e","--surface-2":"#22284f","--surface-hi":"#2b3360","--border":"rgba(255,255,255,.09)","--border-hi":"rgba(255,255,255,.16)","--text":"#eef1fb","--text-muted":"#a6acce","--text-dim":"#737aa3","--accent":"#5fa8ff","--accent-ink":"#04122b","--accent-soft":"rgba(95,168,255,.14)","--accent-line":"rgba(95,168,255,.4)","--accent-glow":"rgba(95,168,255,.5)" } }, violet: { label: "미드나잇", v: { "--bg":"#06040d","--app-bg":"#15102e","--app-bg-2":"#0c0820","--surface":"#221a44","--surface-2":"#2d2356","--surface-hi":"#382c66","--border":"rgba(255,255,255,.09)","--border-hi":"rgba(255,255,255,.17)","--text":"#f1edfb","--text-muted":"#b6abd4","--text-dim":"#7e72a0","--accent":"#a98bff","--accent-ink":"#190a33","--accent-soft":"rgba(169,139,255,.16)","--accent-line":"rgba(169,139,255,.4)","--accent-glow":"rgba(169,139,255,.5)" } }, light: { label: "클린 라이트", v: { "--bg":"#e7ecf3","--app-bg":"#f6f8fc","--app-bg-2":"#eef2f8","--surface":"#ffffff","--surface-2":"#f1f5fb","--surface-hi":"#e8eef6","--border":"rgba(15,32,60,.10)","--border-hi":"rgba(15,32,60,.2)","--text":"#0f1f38","--text-muted":"#5a6b86","--text-dim":"#94a3b8","--accent":"#0fb89a","--accent-ink":"#ffffff","--accent-soft":"rgba(15,184,154,.10)","--accent-line":"rgba(15,184,154,.4)","--accent-glow":"rgba(15,184,154,.4)" } }, }; const THEME_LABELS = Object.keys(THEMES).map((k) => THEMES[k].label); const LABEL_TO_KEY = Object.fromEntries(Object.keys(THEMES).map((k) => [THEMES[k].label, k])); const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "theme": "딥 네이비", "radius": 18 }/*EDITMODE-END*/; function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const themeKey = LABEL_TO_KEY[t.theme] || "navy"; useE(() => { const root = document.documentElement; const vars = THEMES[themeKey].v; Object.keys(vars).forEach((k) => root.style.setProperty(k, vars[k])); root.style.setProperty("--radius", t.radius + "px"); root.style.setProperty("--radius-sm", Math.max(8, t.radius - 5) + "px"); }, [themeKey, t.radius]); // ---- flow state ---- const [screen, setScreen] = useS("home"); const [overlay, setOverlay] = useS(null); // {title, sub} | null const [name, setName] = useS(""); const [phone, setPhone] = useS(""); const [industry, setIndustry] = useS(""); const [pyeong, setPyeong] = useS(""); const [result, setResult] = useS(null); const [liabChoice, setLiabChoice] = useS(""); const [summary, setSummary] = useS(""); const [consultId, setConsultId] = useS(null); // 접수 후 발급된 레코드 id const [consultToken, setConsultToken] = useS(""); // 사업자등록증 업로드 인증 토큰 const goTop = () => { const b = document.querySelector(".screen-body"); if (b) b.scrollTop = 0; }; // 화면 전환을 브라우저 히스토리에 기록 → 모바일/브라우저 '뒤로가기'가 사이트를 벗어나지 않고 // 직전 단계로 이동하도록 함. (home 진입점에서만 뒤로가기 시 사이트 이탈 허용) const go = (s) => { window.history.pushState({ screen: s }, ""); setScreen(s); requestAnimationFrame(goTop); }; const goBack = () => window.history.back(); useE(() => { // 최초 진입(home)을 히스토리 baseline 으로 기록 window.history.replaceState({ screen: "home" }, ""); const onPop = (e) => { const s = (e.state && e.state.screen) || "home"; setOverlay(null); setScreen(s); requestAnimationFrame(goTop); }; window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); const reset = () => { setName(""); setPhone(""); setIndustry(""); setPyeong(""); setResult(null); setLiabChoice(""); setSummary(""); setConsultId(null); setConsultToken(""); go("home"); }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // [선접수] 보험 선택 화면 진입 시점에 이름·전화로 상담 행을 먼저 생성(status=진행중). // 이후 단계는 같은 행을 Update. (이미 접수된 경우 중복 생성 방지) const startConsult = async (force) => { if (consultId && !force) return; // force=true: 재인증 시 새 접수 행 강제 생성 try { const res = await fetch(CONSULT_API + "?action=start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, phone }), }); const j = await res.json(); if (j && j.ok) { setConsultId(j.id); setConsultToken(j.token); } } catch (e) { /* noop */ } }; // [최종 제출] 선접수 행이 있으면 Update, 없으면(네트워크 실패 등) create 로 fallback. // 네트워크 실패해도 완료 화면은 정상 노출(접수 자체는 담당자 연락으로 진행). const finalizeConsult = async (payload, sm, overlayMsg) => { setSummary(sm); setOverlay(overlayMsg); try { if (consultId) { await fetch(CONSULT_API + "?action=update", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: consultId, token: consultToken, ...payload, summary: sm, step: "done" }), }); } else { const res = await fetch(CONSULT_API + "?action=create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, summary: sm }), }); const j = await res.json(); if (j && j.ok) { setConsultId(j.id); setConsultToken(j.token); } } } catch (e) { /* noop — 완료 화면은 그대로 진행 */ } await sleep(700); setOverlay(null); go("done"); }; // 완료 화면에서 사업자등록증 업로드 (consultId 발급된 경우에만). const uploadBizCert = async (file) => { if (!consultId) return { ok: false, error: "no-id" }; const fd = new FormData(); fd.append("action", "upload"); fd.append("id", consultId); fd.append("token", consultToken); fd.append("file", file); try { const res = await fetch(CONSULT_API, { method: "POST", body: fd }); return await res.json(); } catch (e) { return { ok: false, error: "network" }; } }; const handleVerify = () => { setOverlay({ title: "본인확인을 진행하고 있어요", sub: "외부 인증기관을 통해 본인 여부를 확인합니다." }); setTimeout(() => { setOverlay(null); go("type"); // 뒤로가기 후 재인증 시에도 새 접수 행이 생성되도록 이전 접수 id 초기화 + 강제 생성 setConsultId(null); setConsultToken(""); startConsult(true); }, 1800); }; // 화재보험 안내내용(guide) 빌더 — 산출 결과 + 근거(실제/로컬/null similar 모두 대응) const buildFireGuide = (r) => { const won = (n) => (n || n === 0) ? Number(n).toLocaleString("ko-KR") : "-"; const lines = []; const basisNote = (r.premiumBasis === "similar" && r.similar) ? ` (유사 청약 #${r.similar.id != null ? r.similar.id : "-"} 확인 보험료 그대로 적용)` : " (업종·면적 추정)"; lines.push(`[예상 보험료] 월 ${won(r.monthly)}원 · 연납 ${won(r.yearly)}원${basisNote}`); lines.push(`[산출 조건] ${r.industryLabel || r.industry.name} · ${r.pyeong}평${r.m2 ? `(약 ${Math.round(r.m2)}㎡)` : ""}`); if (r.coverage) lines.push(`[보장 한도] 화재·재산 ${r.coverage.fire} / 배상책임 ${r.coverage.liab}`); if (r.similar && r.similar.real) { // 실제 등록 청약서 기반 근거 — 청약서 ID + 연보험료 + 상세 정보 const s = r.similar; lines.push(`[근거] 유사 계약(실제 등록 청약서 #${s.id != null ? s.id : "-"})`); lines.push(`- 업종: ${s.industryName || "-"}`); const areaTxt = (s.m2 != null && s.m2 !== "") ? `${won(Math.round(s.m2))}㎡${s.pyeong ? ` (약 ${s.pyeong}평)` : ""}` : (s.pyeong ? `${s.pyeong}평` : "-"); lines.push(`- 사업장면적: ${areaTxt}`); lines.push(`- 연 보험료: ${s.yearly != null ? `${won(s.yearly)}원` : "-"}${s.monthly != null ? ` (월 ${won(s.monthly)}원)` : ""}`); if (s.coverage != null && s.coverage !== "") lines.push(`- 총보험가입금액: ${won(s.coverage)}원`); if (s.applyDate) lines.push(`- 청약일: ${s.applyDate}`); } else if (r.similar) { // (폴백) 로컬 추정 유사 계약 — 한 줄 const s = r.similar; const when = s.applyDate ? s.applyDate : (s.months != null ? `${s.months}개월 전 청약` : ""); const price = (s.yearly != null) ? `연 ${won(s.yearly)}원` : (s.monthly != null ? `${won(s.monthly)}원/월` : ""); lines.push(`[근거] 유사 계약 — ${s.region ? s.region + " · " : ""}${s.industryName || ""}${s.pyeong ? " " + s.pyeong + "평" : ""}${price ? ", " + price : ""}${when ? " (" + when + ")" : ""}`); } else { lines.push(`[근거] 유사 계약 데이터 없음`); } return lines.join("\n"); }; const fireIndustry = (r) => r.industryLabel || (r.industry && r.industry.name) || ""; const fireSummary = (r) => `화재보험 · ${fireIndustry(r)} ${r.pyeong}평 설계사 상담`; const fireDetail = (r) => ({ 업종: fireIndustry(r), 평수: `${r.pyeong}평` }); // [2단계] 보험료 산출 결과 시점에 상담내용+안내내용 업데이트 (status=보험료확인) const submitEstimate = async (r) => { if (!consultId) return; try { await fetch(CONSULT_API + "?action=estimate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: consultId, token: consultToken, type: "fire", summary: fireSummary(r), detail: fireDetail(r), guide: buildFireGuide(r) }), }); } catch (e) { /* noop */ } }; const handleFireSubmit = async (customIndustry) => { setOverlay({ title: "AI가 분석하고 있어요", sub: "업종·면적 조건으로 유사 계약을 찾는 중입니다." }); const base = estimateFire(industry, Number(pyeong)); // 예상 보험료(로컬 추정) const ind = INDUSTRIES.find((i) => i.id === industry) || {}; // 기타 업종이면 상세 업종 입력값을 유효 업종명으로 사용(상담내용·안내내용·표시에 반영) base.industryLabel = (industry === "etc" && customIndustry && customIndustry.trim()) ? customIndustry.trim() : (ind.name || (base.industry && base.industry.name) || ""); try { const res = await fetch(CONSULT_API + "?action=similar", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ industryId: industry, industryName: ind.name || "", customIndustry: customIndustry || "", pyeong: Number(pyeong), }), }); const j = await res.json(); base.similar = (j && j.ok && j.result) ? { real: true, id: j.result.id, industryName: j.result.industry, pyeong: j.result.pyeong, m2: j.result.site_area, yearly: j.result.annual_premium, monthly: j.result.annual_premium ? Math.round(j.result.annual_premium / 12) : null, coverage: j.result.total_coverage, applyDate: j.result.apply_date, } : null; } catch (e) { base.similar = null; } // 예상 보험료: 유사 청약건(실제)이 있으면 그 확인 보험료를 보정 없이 그대로 산출 보험료로 제시. // 유사건이 없으면 추정공식 값을 사용. base.premiumBasis = "estimate"; if (base.similar && base.similar.real && base.similar.yearly > 0) { base.yearly = base.similar.yearly; base.monthly = (base.similar.monthly != null) ? base.similar.monthly : Math.round(base.similar.yearly / 12); base.premiumBasis = "similar"; } // 분석 연출 시간 보장(최소 1.2s) await new Promise((r) => setTimeout(r, 1200)); setResult(base); submitEstimate(base); // [2단계] 상담내용/안내내용 + status=보험료확인 setOverlay(null); go("fireResult"); }; // [3단계] 상담 접수(상담신청) — 고객 메모는 cust_memo 로 분리 저장. content/guide 는 2단계(estimate)에서 설정됨. const handleConsult = (memo) => { const r = result; const memoText = memo ? ` · 메모: ${memo}` : ""; const sm = fireSummary(r) + memoText; // done 화면 표시용 finalizeConsult( // 정상 경로: estimate 가 content/guide 설정 → 여기선 cust_memo + status 만. (no-id 폴백 대비 입력값도 함께 전송) { type: "fire", name, phone, cust_memo: memo || "", summary: fireSummary(r), detail: fireDetail(r), guide: buildFireGuide(r) }, sm, { title: "상담 신청을 접수하고 있어요", sub: "잠시만 기다려 주세요." } ); }; const handleLiabSubmit = () => { const opt = LIAB_OPTIONS.find((o) => o.id === liabChoice); const sm = `영업배상책임보험 · ${opt.title}`; finalizeConsult( { type: "liability", name, phone, detail: { 요청유형: opt.title, 설명: opt.desc } }, sm, { title: "산출 의뢰를 접수하고 있어요", sub: "담당자에게 요청을 전달합니다." } ); }; const handleConsultReq = () => { finalizeConsult( { type: "consult", name, phone, detail: {} }, "보험 상담 요청", { title: "상담 요청을 접수하고 있어요", sub: "담당 상담사에게 요청을 전달합니다." } ); }; let view = null; if (screen === "home") view = go("verify")} />; else if (screen === "verify") view = ; else if (screen === "type") view = go(ty === "fire" ? "fireForm" : "liability")} onConsult={handleConsultReq} onBack={goBack} />; else if (screen === "fireForm") view = ; else if (screen === "liability") view = ; else if (screen === "fireResult") view = ; else if (screen === "done") view = ; return (
{view}
{overlay && } setTweak("theme", v)} /> setTweak("radius", v)} />
); } ReactDOM.createRoot(document.getElementById("root")).render();