// screens.jsx — all screen components for the AnyInsu flow. Shared to window.
const { useState, useEffect, useRef } = React;
const LOGO = "https://www.anyinsu.com/wp-content/uploads/2021/03/anyinsu_logo_2-300x138.png";
/* ---------- shared chrome ---------- */
function Brand() {
return (
AI
ANYINSU
);
}
function TopBar({ onBack, hint }) {
return (
{onBack
?
: }
{hint && {hint} }
);
}
const STEP_NAMES = ["본인확인", "보험 선택", "조건 입력", "산출 결과"];
function Stepper({ step }) {
return (
<>
STEP {step}/4 · {STEP_NAMES[step - 1]}
{STEP_NAMES.map((_, i) => (
))}
>
);
}
/* ---------- 0. HOME ---------- */
function FlipDigit({ d }) {
const [cur, setCur] = useState(d);
const [prev, setPrev] = useState(d);
const [roll, setRoll] = useState(false);
const liveRef = useRef(d);
const timerRef = useRef(null);
useEffect(() => {
const target = d;
if (liveRef.current === target) return;
const step = () => {
const from = liveRef.current;
const next = (from + 1) % 10;
liveRef.current = next;
setPrev(from);
setCur(next);
setRoll(true);
timerRef.current = setTimeout(() => {
setRoll(false);
if (next !== target) {
timerRef.current = setTimeout(step, 12);
}
}, 165);
};
clearTimeout(timerRef.current);
step();
return () => clearTimeout(timerRef.current);
}, [d]);
return (
{cur}
{roll ? prev : cur}
{roll &&
{prev}
}
{roll &&
{cur}
}
);
}
function FlipTile({ ch }) {
return (
);
}
function FlipAmount() {
const rand = () => (Math.floor(Math.random() * 5001) + 1000) * 100; // 100,000 ~ 600,000 (100원 단위)
const [amt, setAmt] = useState(rand);
useEffect(() => {
const id = setInterval(() => {
setAmt((p) => { let n; do { n = rand(); } while (n === p); return n; });
}, 3000);
return () => clearInterval(id);
}, []);
const digits = String(amt).padStart(6, "0").split("");
return (
연 보험료
{digits.map((ch, i) => (
{i === 3 && , }
))}
);
}
function Home({ onStart }) {
return (
AI 실시간 보험료 산출
AI 분석으로 나에게 맞는 최적 보험,지금 바로 확인하세요.
회원가입 없음
1분 소요
본인확인 안심
보험료 산출
에니인슈는 믿고 맡기는 안전의 문화를 만들어 갑니다.
);
}
/* ---------- 1. 본인확인 ---------- */
function formatPhone(v) {
const d = v.replace(/\D/g, "").slice(0, 11);
if (d.length < 4) return d;
if (d.length < 8) return d.slice(0, 3) + "-" + d.slice(3);
return d.slice(0, 3) + "-" + d.slice(3, 7) + "-" + d.slice(7);
}
function Verify({ name, setName, phone, setPhone, onVerify, onBack }) {
const digits = phone.replace(/\D/g, "");
const valid = name.trim().length >= 2 && digits.length >= 10;
return (
);
}
/* ---------- 2. 보험 종류 선택 ---------- */
function SelectType({ name, onSelect, onConsult, onBack }) {
return (
{name}님 본인확인 완료
어떤 보험이 필요하세요?
산출을 원하는 보험을 선택해 주세요.
onSelect("fire")}>
화재보험
사업장의 화재·폭발·재산 피해를 보장합니다. 업종·면적만 입력하면 예상 보험료를 바로 확인할 수 있어요.
onSelect("liability")}>
영업배상책임보험
영업 중 타인의 신체·재산에 입힌 손해를 배상합니다. 담당자가 맞춤 상담을 도와드려요.
또는
상담 요청
어떤 보험이 필요한지 모르겠다면, 전문 상담사가 맞춤으로 안내해 드려요.
);
}
/* ---------- 3a. 화재보험 조건 ---------- */
const QUICK_PYEONG = [10, 20, 30, 50];
function FireForm({ industry, setIndustry, pyeong, setPyeong, onSubmit, onBack }) {
const [customIndustry, setCustomIndustry] = useState("");
const valid = industry && Number(pyeong) > 0 && (industry !== "etc" || customIndustry.trim().length > 0);
const m2 = Number(pyeong) > 0 ? (Number(pyeong) * PYEONG_TO_M2).toFixed(1) : null;
return (
화재보험 산출 조건
업종과 사업장 면적을 입력하면 AI가 유사 계약을 분석해 예상 보험료를 산출해요.
업종 선택
{INDUSTRIES.map((ind) => (
setIndustry(ind.id)}>
{ind.name}
))}
{industry === "etc" && (
상세 업종 입력
setCustomIndustry(e.target.value)} />
)}
사업장 면적
setPyeong(e.target.value.replace(/[^0-9]/g, "").slice(0, 4))} />
평
{QUICK_PYEONG.map((p) => (
setPyeong(String(p))}>{p}평
))}
{m2 &&
약 {m2}㎡
}
onSubmit(customIndustry)}>예상 보험료 확인
);
}
/* ---------- 3b. 영업배상 ---------- */
const LIAB_OPTIONS = [
{ id: "docs", icon: "doc", title: "구비서류 안내 요청", desc: "가입에 필요한 서류를 안내해 드려요. 담당자가 확인 후 연락드립니다." },
{ id: "call", icon: "phone", title: "연락 바랍니다", desc: "담당자가 직접 연락드려 사업장에 맞는 보장을 상담해 드립니다." },
];
function LiabilityForm({ choice, setChoice, onSubmit, onBack }) {
return (
영업배상책임보험
어떻게 도와드릴까요?
영업배상은 조건이 다양해 담당자 상담으로 진행돼요. 원하시는 방식을 선택해 주세요.
{LIAB_OPTIONS.map((o) => (
setChoice(o.id)}>
{o.title}
{o.desc}
))}
산출 의뢰 접수하기
);
}
/* ---------- 4a. 화재보험 결과 ---------- */
function FireResult({ result, name, onConsult, onRedo, onBack }) {
const r = result;
const [memo, setMemo] = useState("");
return (
{name}님의 예상 보험료
연 예상 보험료{r.premiumBasis === "similar" ? " · 실제 청약 기준" : ""}
₩ {r.yearly.toLocaleString("ko-KR")}/년
월 환산 {wonPlain(r.monthly)} {r.premiumBasis === "similar" ? "" : " · 연 1.2% 할인 적용"}
{r.premiumBasis === "similar" ? "유사 청약 1건 기준 · 실제 보험료" : "AI 분석 · 유사 계약 기준 예상치"}
{r.similar ? (
<>
유사 계약 1건 비슷한 사업장은 이렇게 가입했어요
업종
{r.similar.industryName}
{r.similar.pyeong ? (
면적
{r.similar.pyeong}평약 {Math.round(r.similar.m2)}㎡
) : null}
{r.similar.coverage ? (
가입금액
{wonPlain(r.similar.coverage)}
) : null}
{r.similar.monthly ? (
실제 가입 보험료
{wonPlain(r.similar.yearly)} /년월 {wonPlain(r.similar.monthly)}
) : null}
{r.similar.applyDate ? (
청약일
{r.similar.applyDate}
) : null}
>
) : (
비슷한 청약 사례를 찾지 못했어요. 담당자가 직접 정확히 산출해 드립니다.
)}
개인정보가 제거된 익명 유사 청약 사례이며, 실제 보험료는 가입 조건·보장 설계에 따라 달라질 수 있어요.
메모 (선택)
onConsult(memo)}>이 조건으로 전문 설계사 상담받기
조건 다시 입력
);
}
/* ---------- 사업자등록증 자동 스캔(크롭) : OpenCV.js + jscanify 지연 로드 ---------- */
let _scannerPromise = null;
function loadScanner() {
if (window.jscanify && window.cv && window.cv.Mat) return Promise.resolve();
if (_scannerPromise) return _scannerPromise;
const loadScript = (src) => new Promise((res, rej) => {
const s = document.createElement("script");
s.src = src; s.async = true; s.onload = res; s.onerror = () => rej(new Error("load:" + src));
document.head.appendChild(s);
});
// emscripten MODULARIZE 빌드: window.cv 가 Promise 이거나 init 후 Mat 보유 → 모두 대응 + 타임아웃
const cvReady = (timeoutMs) => new Promise((resolve, reject) => {
const t0 = Date.now(); let thened = false, fin = false;
const ok = () => { if (!fin) { fin = true; resolve(); } };
const tick = () => {
if (fin) return;
const c = window.cv;
if (c && c.Mat && typeof c.Mat === "function") return ok();
if (c && !thened && typeof c.then === "function") {
thened = true;
c.then((m) => { if (m && m.Mat) window.cv = m; ok(); }, () => {});
}
if (Date.now() - t0 > timeoutMs) { fin = true; return reject(new Error("cv-timeout")); }
setTimeout(tick, 80);
};
tick();
});
_scannerPromise = loadScript("/assets/vendor/opencv.js")
.then(() => cvReady(25000))
.then(() => (window.jscanify ? null : loadScript("/assets/vendor/jscanify.min.js")))
.catch((e) => { _scannerPromise = null; throw e; });
return _scannerPromise;
}
function _dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
function _clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
// 자연좌표 4꼭지 → 원근보정 크롭 캔버스
function warpCorners(imgEl, c) {
const cv = window.cv;
const src = cv.imread(imgEl);
let outW = Math.round(Math.max(_dist(c.tl, c.tr), _dist(c.bl, c.br)));
let outH = Math.round(Math.max(_dist(c.tl, c.bl), _dist(c.tr, c.br)));
const cap = 1600;
if (outW > cap) { outH = Math.round(outH * cap / outW); outW = cap; }
outW = Math.max(80, outW); outH = Math.max(80, outH);
const srcTri = cv.matFromArray(4, 1, cv.CV_32FC2, [c.tl.x, c.tl.y, c.tr.x, c.tr.y, c.br.x, c.br.y, c.bl.x, c.bl.y]);
const dstTri = cv.matFromArray(4, 1, cv.CV_32FC2, [0, 0, outW, 0, outW, outH, 0, outH]);
const M = cv.getPerspectiveTransform(srcTri, dstTri);
const dst = new cv.Mat();
cv.warpPerspective(src, dst, M, new cv.Size(outW, outH), cv.INTER_LINEAR, cv.BORDER_CONSTANT, new cv.Scalar());
const out = document.createElement("canvas"); out.width = outW; out.height = outH;
cv.imshow(out, dst);
src.delete(); dst.delete(); srcTri.delete(); dstTri.delete(); M.delete();
return out;
}
// 자동 문서 외곽 검출 → 자연좌표 4꼭지 (실패 시 null)
function detectCorners(imgEl) {
let mat = null, contour = null;
try {
const scanner = new window.jscanify();
mat = window.cv.imread(imgEl);
contour = scanner.findPaperContour(mat);
if (!contour) return null;
const p = scanner.getCornerPoints(contour);
if (!p || !p.topLeftCorner || !p.bottomRightCorner) return null;
return {
tl: { x: p.topLeftCorner.x, y: p.topLeftCorner.y },
tr: { x: p.topRightCorner.x, y: p.topRightCorner.y },
br: { x: p.bottomRightCorner.x, y: p.bottomRightCorner.y },
bl: { x: p.bottomLeftCorner.x, y: p.bottomLeftCorner.y },
};
} catch (e) { return null; }
finally { if (mat && mat.delete) mat.delete(); if (contour && contour.delete) contour.delete(); }
}
/* ---------- 사업자등록증 크롭 편집기 (촬영 직후) ---------- */
function BizCertCropper({ file, onCropped, onCancel }) {
const [phase, setPhase] = useState("load"); // load | edit | error
const [note, setNote] = useState("스캐너 준비 중… (최초 1회 다운로드)");
const [disp, setDisp] = useState({ w: 0, h: 0 });
const [corners, setCorners] = useState(null);
const [busy, setBusy] = useState(false);
const [scan, setScan] = useState(""); // "" | ok | nodetect | noscanner
const boxRef = useRef(null);
const imgRef = useRef(null);
const urlRef = useRef("");
const scaleRef = useRef(1);
useEffect(() => {
const url = URL.createObjectURL(file); urlRef.current = url;
const img = new Image();
img.onload = async () => {
imgRef.current = img;
const maxW = (boxRef.current && boxRef.current.clientWidth) || 300;
const w = Math.min(maxW, img.naturalWidth);
const sc = w / img.naturalWidth; scaleRef.current = sc;
setDisp({ w: Math.round(w), h: Math.round(img.naturalHeight * sc) });
let auto = false;
try { await loadScanner(); auto = true; }
catch (e) { setScan("noscanner"); }
let nat = auto ? detectCorners(img) : null;
if (auto) setScan(nat ? "ok" : "nodetect");
if (!nat) {
const ix = img.naturalWidth, iy = img.naturalHeight, mx = ix * 0.08, my = iy * 0.08;
nat = { tl: { x: mx, y: my }, tr: { x: ix - mx, y: my }, br: { x: ix - mx, y: iy - my }, bl: { x: mx, y: iy - my } };
}
const m = (p) => ({ x: p.x * sc, y: p.y * sc });
setCorners({ tl: m(nat.tl), tr: m(nat.tr), br: m(nat.br), bl: m(nat.bl) });
setPhase("edit");
};
img.onerror = () => { setNote("이미지를 읽지 못했어요."); setPhase("error"); };
img.src = url;
return () => { if (urlRef.current) URL.revokeObjectURL(urlRef.current); };
}, []);
const startDrag = (key) => (e) => {
e.preventDefault();
const move = (ev) => {
const rect = boxRef.current.getBoundingClientRect();
const px = (ev.touches ? ev.touches[0].clientX : ev.clientX) - rect.left;
const py = (ev.touches ? ev.touches[0].clientY : ev.clientY) - rect.top;
setCorners((c) => ({ ...c, [key]: { x: _clamp(px, 0, disp.w), y: _clamp(py, 0, disp.h) } }));
};
const up = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
window.addEventListener("pointermove", move); window.addEventListener("pointerup", up);
};
const useOriginal = () => { onCropped(file, URL.createObjectURL(file)); };
const apply = () => {
if (busy || !corners || !imgRef.current) return;
setBusy(true);
try {
const sc = scaleRef.current;
const nat = {
tl: { x: corners.tl.x / sc, y: corners.tl.y / sc }, tr: { x: corners.tr.x / sc, y: corners.tr.y / sc },
br: { x: corners.br.x / sc, y: corners.br.y / sc }, bl: { x: corners.bl.x / sc, y: corners.bl.y / sc },
};
const canvas = warpCorners(imgRef.current, nat);
canvas.toBlob((blob) => {
if (!blob) { setBusy(false); return useOriginal(); }
const base = (file.name || "scan").replace(/\.[^.]+$/, "");
const cf = new File([blob], base + "_scan.jpg", { type: "image/jpeg" });
onCropped(cf, URL.createObjectURL(blob));
}, "image/jpeg", 0.92);
} catch (e) { setBusy(false); useOriginal(); }
};
const HANDLE = { position: "absolute", width: 26, height: 26, marginLeft: -13, marginTop: -13, borderRadius: "50%", border: "2.5px solid var(--accent)", background: "rgba(47,224,192,.25)", touchAction: "none", cursor: "grab", boxShadow: "0 1px 4px rgba(0,0,0,.4)" };
const poly = corners ? `${corners.tl.x},${corners.tl.y} ${corners.tr.x},${corners.tr.y} ${corners.br.x},${corners.br.y} ${corners.bl.x},${corners.bl.y}` : "";
return (
사업자등록증 영역 확인
파란 모서리를 드래그해 등록증 네 귀퉁이에 맞춘 뒤 [반듯하게 크롭]을 눌러 주세요.
{phase !== "load" && (
)}
{phase === "edit" && corners && (
)}
{phase === "edit" && corners && ["tl", "tr", "br", "bl"].map((k) => (
))}
{phase === "load" && (
{note}
)}
{phase === "edit" && scan && (
{scan === "ok" ? "✓ 문서를 자동으로 찾았어요. 필요하면 모서리를 조정하세요."
: scan === "nodetect" ? "⚠ 자동 검출 실패 — 네 모서리를 직접 맞춰 주세요."
: "⚠ 자동 검출 모듈 로드 실패 — 모서리를 직접 맞추거나 [원본 사용]."}
)}
{busy ? "처리 중…" : "반듯하게 크롭"}
원본 사용
다시 촬영 / 취소
);
}
/* ---------- 5a. 사업자등록증 업로드 (완료 화면 내) ---------- */
function BizCertUpload({ onUpload }) {
const [file, setFile] = useState(null);
const [preview, setPreview] = useState("");
const [status, setStatus] = useState("idle"); // idle | uploading | done | error
const [err, setErr] = useState("");
const [cropFile, setCropFile] = useState(null); // 크롭 대상 원본 이미지 (있으면 크롭 화면)
const inputRef = useRef(null);
const pick = (e) => {
const f = e.target.files && e.target.files[0];
if (e.target) e.target.value = ""; // 동일 파일 재선택 허용
if (!f) return;
setErr("");
if (f.size > 12 * 1024 * 1024) { setErr("파일 용량은 12MB 이하만 가능해요."); return; }
if (f.type && f.type.indexOf("image/") === 0) {
setCropFile(f); // 이미지 → 자동 크롭 편집기로
} else {
setFile(f); setStatus("idle"); setPreview(""); // PDF 등 → 바로 업로드 단계
}
};
if (cropFile) {
return (
{ setFile(cf); setPreview(url); setStatus("idle"); setCropFile(null); }}
onCancel={() => setCropFile(null)} />
);
}
const submit = async () => {
if (!file || status === "uploading") return;
setStatus("uploading"); setErr("");
const r = await onUpload(file);
if (r && r.ok) { setStatus("done"); }
else { setStatus("error"); setErr("업로드에 실패했어요. 잠시 후 다시 시도해 주세요."); }
};
if (status === "done") {
return (
사업자등록증이 접수되었어요
담당자가 함께 확인 후 상담을 도와드릴게요.
);
}
return (
사업자등록증을 첨부해 주시면 보다 원활한 상담이 가능합니다.
inputRef.current && inputRef.current.click()}>
{file ? "다시 선택 / 촬영" : "촬영 또는 파일 선택"}
{file && (
{preview
?
:
}
{file.name}
)}
{err &&
{err}
}
{status === "uploading" ? "접수 중…" : "사업자 등록증 접수"}
);
}
/* ---------- 5. 접수 완료 ---------- */
function Done({ name, phone, summary, onHome, canUpload, onUpload }) {
return (
상담요청이 접수됐어요
영업일 기준 1일 이내 에 담당 설계사가 연락드릴게요.
신청자 {name}
연락처 {phone}
요청 내용 {summary}
{canUpload &&
}
처음으로
);
}
/* ---------- overlay (verifying / analyzing) ---------- */
function Overlay({ title, sub }) {
return (
);
}
Object.assign(window, { Home, Verify, SelectType, FireForm, LiabilityForm, FireResult, Done, Overlay, LIAB_OPTIONS });