/* ============================================================================ * 서명전에 — Web App Controller * 모바일 우선. 900px 이상에서 사이드바 + 센터 컬럼. * 기능: 랜딩/로그인/가입 + 앱 쉘(홈/기록/도움말/계정) + 분석 · 결과 · 원문 뷰어 * ========================================================================== */ const { useState: useStateW, useEffect: useEffectW, useMemo: useMemoW, useRef: useRefW } = React; /* ---------------------------------------------------------------------------- * 분석 응답(ContractAnalysisResponse)을 기존 UI(ResultScreen)의 목업 data 구조로 * 변환. window.SCENARIOS와 동일한 shape을 돌려준다. * -------------------------------------------------------------------------- */ /* 백엔드 _expand_missing_reason이 붙이는 공통 prefix/suffix를 걷어내 원래 사유만 남긴다. */ const MISSING_REASON_PREFIX_RE = /^이 항목은 계약서에 없거나 너무 약하게 적혀 있어서 나중에 다툼이 생기기 쉽습니다\.?\s*/; const MISSING_REASON_SUFFIX_RE = /\s*특히\s+(?:보증금[^.]*?실제 돈과 직결되는 문제가 생길 수 있습니다|임금[^.]*?실제 불이익으로 이어질 수 있습니다|대금[^.]*?(?:분쟁이 커질 수 있습니다|서로 다르게 해석할 위험이 있습니다))\.?$/; function trimMissingReason(reason) { if (!reason) return ""; let r = reason.trim().replace(MISSING_REASON_PREFIX_RE, ""); r = r.replace(MISSING_REASON_SUFFIX_RE, "").trim(); return r; } /* 이슈 카드 미리보기로 쓸 짧은 한 줄. 첫 문장 위주. 회귀 ("[[K]]별도 협의 없이[[/K]]" 가 카드에 raw 로 렌더됨): 하이라이트 마커 ([[K]]...[[/K]] 와 **...**) 를 먼저 벗긴 뒤 slice. 카드에선 하이라이트 없이 평문이 보이고, 하이라이트는 IssueDetail 시트에서만 살아남는다. */ function _stripMarkers(s) { return String(s || "") .replace(/\[\[K\]\]/g, "") .replace(/\[\[\/K\]\]/g, "") .replace(/\*\*([^*]+)\*\*/g, "$1"); } function issuePreview(iss) { const src = _stripMarkers((iss.explanation || iss.why_it_matters || iss.reason || "").trim()); if (!src) return ""; const firstSentence = src.split(/(?<=[.。])\s+/)[0] || src; return firstSentence.length > 110 ? firstSentence.slice(0, 108) + "…" : firstSentence; } /* key_facts 표시 순서 — 사용자가 자연스럽게 읽는 흐름: "보증금이 얼마? → 계약금/중도금/잔금 어떻게? → 월세는? → 어디? → 언제?" 백엔드/LLM 출력 순서가 들쭉날쭉이라 프런트에서 정렬해 일관된 경험 제공. */ const KEY_FACT_ORDER = [ "보증금/전세금", "보증금", "전세금", "계약금", "중도금", "잔금", "월 차임", "월세", "월 임대료", "임대료", "관리비", "소재지", "임대부분", "임대 부분", "면적", "계약기간", "계약 기간", "계약 시작일", "계약 종료일", ]; function sortKeyFacts(kfs) { const arr = Array.isArray(kfs) ? [...kfs] : []; return arr.sort((a, b) => { const ai = KEY_FACT_ORDER.indexOf(a?.label); const bi = KEY_FACT_ORDER.indexOf(b?.label); if (ai === -1 && bi === -1) return 0; if (ai === -1) return 1; // 알려지지 않은 라벨은 뒤로 if (bi === -1) return -1; return ai - bi; }); } /* IssueDetail 시트가 기대하는 목업 키(reason/quote/suggestion)를 호환 노출. suggestion 에는 **해요체 안내** (recommendation + negotiation_tip) 만 합쳐 step 분할이 자연스럽게 되도록 한다. ⚠️ suggested_replacement 는 의도적으로 제외 — 이 필드는 RiskPattern 에 하드 코딩된 **평서 어미 모범조항** ("임대인은 ... 지급한다" 같은) 이라 "이렇게 고쳐 달라고 말해 봐요" 리스트에 섞여 들어가면 톤이 튐. 같은 시나리오 회귀가 여러 번 보고됨. 대신 IssueDetail 쪽에서 suggested_replacement 전용 "바로 넣어볼 문구" 카드로 별도 렌더 — 사용자가 복붙용으로 쓸 수 있게 보존. */ /* evidence_quote 가 "1. … 2. … 3. …" 처럼 여러 번호 조항을 걸치면 issue title 의 한글 키워드와 가장 매칭되는 조항만 남기고 나머지 trim. 백엔드에도 동일 로직(_trim_evidence_to_relevant_clause)이 있지만, **저장된 history (옛 데이터) 또는 캐시된 분석** 은 backend 가 trim 하지 않은 상태로 응답이 묶여있을 수 있어 frontend 에서 한 번 더 보호. 회귀: "결로·곰팡이·누수" issue 인데 quote 가 3번+4번 다 담고 [[K]] 마커가 4번에 잘못 찍혀 사용자가 "quote 와 issue 가 어긋남" 으로 인지하던 케이스. */ function _trimQuoteToRelevantClause(quote, title) { if (!quote || !title) return quote; const parts = String(quote).split(/(?=\d+\.\s)/); if (parts.length <= 1) return quote; const titleWords = new Set((title.match(/[가-힣]{2,}/g) || [])); if (titleWords.size === 0) return quote; let bestPart = null; let bestScore = -1; for (const p of parts) { const ps = p.trim(); if (!ps) continue; const partWords = new Set((ps.match(/[가-힣]{2,}/g) || [])); let score = 0; for (const w of titleWords) if (partWords.has(w)) score += 1; if (score > bestScore) { bestScore = score; bestPart = ps; } } // 매칭 0점이면 (어떤 part 도 title 키워드 없음) 잘못 자르는 위험 회피 → 원본 유지 if (bestScore <= 0 || !bestPart) return quote; return bestPart; } function withIssueCompat(iss, idx) { const reason = iss.explanation || iss.why_it_matters || iss.reason || ""; const rawQuote = iss.evidence_quote || iss.quote || null; const quote = _trimQuoteToRelevantClause(rawQuote, iss.title || ""); const suggestionParts = [iss.recommendation, iss.negotiation_tip] .map((s) => (s || "").trim()) .filter(Boolean); const suggestion = suggestionParts.length ? suggestionParts.join(" ") : iss.suggestion || ""; return { id: iss.id || `srv-i${idx}`, ...iss, reason, quote, suggestion, preview: issuePreview({ ...iss, reason }), }; } /* ─── 프론트엔드 관리비 safety net (사이클 1, 2026-04) ───────────────────── * 백엔드에서 관리비가 LLM 환상값(예: "1만원")으로 들어오는 회귀가 반복적으로 * 보고되어 (서버 재시작/캐시/이전 분석 history 등 다양한 원인), 프론트엔드 * 에서도 한 번 더 검증한다. extracted_text 안에서 관리비 anchor 직후 120자 * 안 가장 큰 만원/원 단위 금액을 뽑아서, 백엔드가 보낸 관리비 값과 다르면 * 본문 추출값을 채택한다 (본문이 항상 정답). * * 경쟁 라벨 (청소비/주차비/공과금/사용료 등) 직전이면 그 금액은 다른 항목 * 소속이라 제외 — 백엔드 _COMPETING_LABEL_RE 와 동일 set. * 본문에서도 못 찾으면 백엔드 값 그대로 둠 (override 안 함). * ──────────────────────────────────────────────────────────────────────── */ /* ──────────────────────────────────────────────────────────────────── * 관리비 추출 — backend `_extract_management_fee_v2` 와 동일 알고리즘 * (2026-04-24 사용자 요청 "처음부터 다시 설계"). * * 4-stage priority: * 1000 — 표 구조 ("관리비 | 12만원" / "관리비\t12만원") * 800 — 콜론 라벨 ("관리비: 12만원") * 500 — 자유 forward ("관리비는 월 12만원으로") * 200 — 자유 reverse ("12만원의 관리비") * * 모든 stage 공통: * • sentence boundary 안에서만 (마침표 / \n / 다음 번호 항목) * • 경쟁 라벨 (청소비/주차비/위약금/배상금/공과금/…) 직전 금액 제외 * • 부정 표현 ("없음/0원/해당 없음") 즉시 "없음" 반환 * * Backend 가 정답을 보내는 게 1차이고, 이 frontend safety net 은 backend * 추출이 실패한 경우/네트워크 문제로 LLM 환각만 도착한 경우의 last line of * defense. backend 와 같은 결과를 내야 일관성 확보. * ──────────────────────────────────────────────────────────────────── */ const _MGMT_LABEL_RE_SRC = "(?:관\\s*리\\s*비\\s*용|관\\s*리\\s*비|관\\s*리\\s*료)"; const _MGMT_FRONT_COMPETING_RE = /(?:청\s*소\s*비|주\s*차\s*비|사\s*용\s*료|공\s*과\s*금|잡\s*비|세\s*탁\s*비|인\s*터\s*넷|TV\s*수신료?|보\s*험\s*료|식\s*대|수\s*도\s*료|전\s*기\s*료|가\s*스\s*료|관\s*리\s*위\s*탁|수\s*리\s*비|배\s*상\s*금?|위\s*약\s*금|벌\s*금|손\s*해\s*배\s*상)\s*[::()]?\s*$/; const _MGMT_NEGATIVE_RE = /(?:없\s*(?:음|다|어요)|0\s*원|영\s*원\s*정?|해당\s*없\s*음?|미\s*적\s*용|미\s*설\s*정|별\s*도\s*협\s*의|미\s*정)/; function _trimToSentence(t) { let cut = t.length; const mNext = t.match(/\n\s*\d+\s*[\.\)]/); if (mNext && mNext.index < cut) cut = mNext.index; const mDot = t.match(/[.。](?:\s|$)/); if (mDot && mDot.index + mDot[0].length < cut) cut = mDot.index + mDot[0].length; const mNl = t.indexOf("\n"); if (mNl >= 0 && mNl < cut) cut = mNl; return t.slice(0, cut); } function _hasMgmtCompetingBefore(s, pos, window) { window = window || 12; const before = s.slice(Math.max(0, pos - window), pos); return _MGMT_FRONT_COMPETING_RE.test(before); } /* segment 안의 첫 금액을 찾되 경쟁 라벨 직전이면 skip. 없으면 null. */ function _firstMgmtAmount(segment) { // ₩ / \\ 표기 우선 검사 — 더 명시적 const wonRe = /([₩\\])\s*([\d,\.]{2,15})/g; const hanRe = /금\s*([영공일이삼사오육칠팔구십백천만억\s]{2,12}\s*원\s*정?)/g; const numRe = /(\d[\d,]{0,12})\s*(만\s*원|원|만원)/g; // 모든 매치를 모아서 등장 순서로 첫 비-경쟁 금액 반환 const allMatches = []; let m; while ((m = wonRe.exec(segment)) !== null) { allMatches.push({ start: m.index, label: m[1] + m[2].trim() }); } while ((m = hanRe.exec(segment)) !== null) { allMatches.push({ start: m.index, label: "금 " + m[1].trim() }); } while ((m = numRe.exec(segment)) !== null) { allMatches.push({ start: m.index, label: m[1].trim() + m[2].replace(/\s/g, "") }); } allMatches.sort((a, b) => a.start - b.start); for (const x of allMatches) { if (_hasMgmtCompetingBefore(segment, x.start)) continue; return x.label; } return null; } function _extractMgmtFromText(text) { if (!text) return null; const candidates = []; // {priority, pos, value} // === Stage 1: 표 구조 (priority 1000) === const tableRe = new RegExp(_MGMT_LABEL_RE_SRC + "\\s*[|│┃\\t]\\s*([^\\n|│┃\\t]{1,40})", "g"); let m; while ((m = tableRe.exec(text)) !== null) { const cell = m[1].trim(); if (_MGMT_NEGATIVE_RE.test(cell)) { candidates.push({ priority: 1000, pos: m.index, value: "없음" }); continue; } const amt = _firstMgmtAmount(cell); if (amt) candidates.push({ priority: 1000, pos: m.index, value: amt }); } // === Stage 2: 콜론 라벨 (priority 800) === const colonRe = new RegExp(_MGMT_LABEL_RE_SRC + "\\s*[::]\\s*", "g"); while ((m = colonRe.exec(text)) !== null) { const tail = _trimToSentence(text.slice(m.index + m[0].length, m.index + m[0].length + 80)); if (_MGMT_NEGATIVE_RE.test(tail.slice(0, 30))) { candidates.push({ priority: 800, pos: m.index, value: "없음" }); continue; } const amt = _firstMgmtAmount(tail); if (amt) candidates.push({ priority: 800, pos: m.index, value: amt }); } // === Stage 3: 자유 forward (priority 500) === const fwdRe = new RegExp(_MGMT_LABEL_RE_SRC + "(?:는|은|을|를|이|가|로|과|와)?\\s*", "g"); const seen = new Set(); while ((m = fwdRe.exec(text)) !== null) { if (seen.has(m.index)) continue; seen.add(m.index); const tail = _trimToSentence(text.slice(m.index + m[0].length, m.index + m[0].length + 100)); if (_MGMT_NEGATIVE_RE.test(tail.slice(0, 25))) { candidates.push({ priority: 500, pos: m.index, value: "없음" }); continue; } const amt = _firstMgmtAmount(tail); if (amt) candidates.push({ priority: 500, pos: m.index, value: amt }); } // === Stage 4: 자유 reverse (priority 200) === const revRe = new RegExp( "(\\d[\\d,]{0,12})\\s*(만\\s*원|원|만원)\\s*(?:을|를|의|으로|이|\\s)*\\s*" + _MGMT_LABEL_RE_SRC, "g" ); while ((m = revRe.exec(text)) !== null) { if (_hasMgmtCompetingBefore(text, m.index)) continue; candidates.push({ priority: 200, pos: m.index, value: m[1].trim() + m[2].replace(/\s/g, ""), }); } if (candidates.length === 0) return null; candidates.sort((a, b) => (b.priority - a.priority) || (a.pos - b.pos)); return candidates[0].value; } /* 월세/차임 safety net — 관리비와 동일 철학. LLM 이 "보증금" 값을 "월세" field 에 잘못 복사하는 회귀 다수 보고됨 (예: 월세 2,000만원으로 표시되는데 실제 계약서엔 85만원). 본문의 "차임/월세/월 임대료" 라벨 직후 금액을 직접 추출해서 backend 값과 다르면 override. 본문에서 못 찾으면 backend 값 유지. */ /* anchor regex — ⚠️ 콜론(:) 을 **필수** 로 한다. 이전엔 [::]? 로 optional 이라, 계약서 본문의 prose mention ("임차보증금 및 차임을 아래와 같이 지불한다") 까지 anchor 로 잡혀서 tail 이 다음 줄의 보증금 ₩20,000,000 으로 흘러들어가 월세=2,000만원 오인 추출 회귀가 있었음. field 표기 ("차임 : 금 850,000원") 만 잡도록 콜론 강제 + "금 " 프리픽스 optional. (?:\s*\([^)]{1,10}\))? 로 "차임(월세)" 같은 alias 괄호 허용. */ const _RENT_ANCHOR_RE = /(?:차\s*임|월\s*세|월\s*임대료|월\s*임\s*차료)(?:\s*\([^)]{1,10}\))?\s*[::]\s*(?:금\s*)?/g; function _extractRentFromText(text) { if (!text) return null; let bestVal = -1; let bestLabel = null; let m; // 명시적 "월세 없음" 표현 — 차임 anchor 뒤 50자 안에 "영원정/₩0/해당 없음/없음" // 이 나오면 확실히 없음. 이게 없으면 아래 금액 추출 로직으로. // 전세 계약서에서 차임이 0 으로 표기되는 정상 케이스 — "확인 필요" 가 아니라 "없음" 이 맞음. const NONE_PAT = /(?:영\s*원\s*정|[₩\\]\s*0\b|금\s*0\s*원|해당\s*없음|없음)/; _RENT_ANCHOR_RE.lastIndex = 0; let foundNone = false; while ((m = _RENT_ANCHOR_RE.exec(text)) !== null) { const tail = text.slice(m.index + m[0].length, m.index + m[0].length + 80); if (NONE_PAT.test(tail)) { foundNone = true; } // ₩ 표기 우선 const wonM = tail.match(/[₩\\]\s*([\d,]{3,15})/); if (wonM) { const v = parseInt(wonM[1].replace(/,/g, ""), 10); if (!isNaN(v) && v >= 1000 && v > bestVal) { bestVal = v; bestLabel = "₩" + wonM[1]; } } // "금 X원정" / "X만원" 형태 const numM = tail.match(/(\d[\d,]{0,12})\s*(만\s*원|원)/); if (numM) { const numStr = numM[1].replace(/,/g, ""); const unit = numM[2]; let v = parseInt(numStr, 10); if (!isNaN(v)) { if (unit.indexOf("만") >= 0) v *= 10000; if (v > bestVal) { bestVal = v; bestLabel = numM[1].trim() + unit.replace(/\s/g, ""); } } } } // 유효 금액이 안 잡혔고 명시적 none 패턴은 잡혔으면 "없음" 반환. // 전세 계약서의 "차임: 금 영원정 (₩0)" 같은 정상 케이스 커버. if (!bestLabel && foundNone) { return "없음"; } return bestLabel; } /* 값에서 숫자만 뽑아 정규화 (₩, 콤마, 만원 변환) — 두 KeyFact 가 같은 금액 인지 비교할 때 사용. */ function _normRentNumeric(val) { if (!val) return 0; const s = String(val).trim(); let best = 0; // ₩ 표기 const wonRe = /[₩\\]\s*([\d,]{3,15})/g; let m; while ((m = wonRe.exec(s)) !== null) { const v = parseInt(m[1].replace(/,/g, ""), 10); if (!isNaN(v) && v > best) best = v; } // X만원 / X원 const numRe = /(\d[\d,]{0,12})\s*(만\s*원|원)/g; while ((m = numRe.exec(s)) !== null) { let v = parseInt(m[1].replace(/,/g, ""), 10); if (isNaN(v)) continue; if (m[2].indexOf("만") >= 0) v *= 10000; if (v > best) best = v; } return best; } function _overrideRentFromText(keyFacts, extractedText) { if (!Array.isArray(keyFacts)) return keyFacts; const isRent = (lbl) => { const l = (lbl || "").trim(); return l === "월세" || l === "차임" || l === "월 차임" || l === "월 임대료" || l === "임대료"; }; const isDeposit = (lbl) => { const l = (lbl || "").trim(); return l === "보증금/전세금" || l === "보증금" || l === "전세금"; }; /* 1) 본문에서 직접 추출 시도 — 성공하면 무조건 그걸로 교체. */ if (extractedText) { const fromText = _extractRentFromText(extractedText); if (fromText) { try { console.log("[월세 safety net] text→", fromText); } catch (_) {} const cleaned = keyFacts.filter((kf) => !isRent(kf?.label)); cleaned.push({ label: "월세", value: fromText, meta: "" }); return cleaned; } } /* 2) 본문 추출 실패 — LLM 값 검증. 월세 == 보증금 같은 명백한 LLM hallucination 패턴을 잡아낸다. 실제 임대차에서 월세가 보증금과 같은 액수일 일은 없음 (월 1,500만원 월세는 비현실적). 이런 케이스는 LLM 이 보증금 값을 잘못 복사한 것 으로 보고 "확인 필요" 로 교체. */ const rentEntry = keyFacts.find((kf) => isRent(kf?.label)); if (!rentEntry) return keyFacts; // 월세 자체가 없으면 그대로 const rentVal = _normRentNumeric(rentEntry.value); if (rentVal <= 0) return keyFacts; // 숫자 없는 값 ("없음" 등) 은 그대로 const depositEntry = keyFacts.find((kf) => isDeposit(kf?.label)); if (depositEntry) { const depVal = _normRentNumeric(depositEntry.value); if (depVal > 0 && rentVal === depVal) { try { console.log("[월세 safety net] LLM 월세=보증금 동일→ hallucination, 확인 필요"); } catch (_) {} const cleaned = keyFacts.filter((kf) => !isRent(kf?.label)); cleaned.push({ label: "월세", value: "확인 필요", meta: "" }); return cleaned; } } /* 3) 추가 sanity: 월세가 비현실적으로 큰 경우 (500만원 이상) 도 의심. 실주거 월세는 200만원 이하가 대부분. 500만원 넘으면 보증금/잔금 계열 수치를 LLM 이 잘못 가져왔을 가능성이 큼. */ if (rentVal >= 5000000) { try { console.log("[월세 safety net] 비현실적으로 큰 월세→ 확인 필요:", rentVal); } catch (_) {} const cleaned = keyFacts.filter((kf) => !isRent(kf?.label)); cleaned.push({ label: "월세", value: "확인 필요", meta: "" }); return cleaned; } return keyFacts; } function _overrideMgmtFromText(keyFacts, extractedText) { if (!Array.isArray(keyFacts)) return keyFacts; const isMgmt = (lbl) => { const l = (lbl || "").trim(); return l.indexOf("관리비") >= 0 || l.indexOf("관리료") >= 0 || l.indexOf("관리 비용") >= 0 || l.indexOf("관리비용") >= 0; }; /* 1) 본문에서 직접 추출 시도. 성공하면 무조건 그걸로 교체. */ if (extractedText) { const fromText = _extractMgmtFromText(extractedText); if (fromText) { // 디버깅용 마커 — devtools 에서 safety net 이 작동했는지 확인 가능 try { console.log("[관리비 safety net] text→", fromText); } catch (_) {} const cleaned = keyFacts.filter((kf) => !isMgmt(kf?.label)); cleaned.push({ label: "관리비", value: fromText, meta: "" }); return cleaned; } } /* 2) 본문 추출 실패 케이스 — LLM 값의 숫자 토큰이 실제 본문에 등장하는지 verify. 없으면 LLM hallucination 으로 보고 "확인 필요" 로 교체. 특히 "1만원" 같이 본문에 절대 없는 환상값을 잡아내기 위함. */ if (!extractedText) return keyFacts; // 본문 자체가 없으면 override 불가 const mgmtEntries = keyFacts.filter((kf) => isMgmt(kf?.label)); if (mgmtEntries.length === 0) return keyFacts; // 관리비 자체가 없으면 그대로 const verifiable = (val) => { if (!val) return true; // 빈 값은 수정 안 함 const v = String(val).trim(); if (["없음", "확인 필요", "확인필요", "미정", "해당없음", "해당 없음", "별도 협의"].indexOf(v) >= 0) { return true; // placeholder 는 OK } // 값 안의 숫자+단위 토큰 추출 (예: "1만원" → "1만원" 자체를 본문에서 찾기) const tokens = []; const tokRe = /(\d[\d,]{0,12})\s*(만\s*원|원|만원)/g; let tm; while ((tm = tokRe.exec(v)) !== null) { const raw = tm[0].replace(/\s+/g, ""); tokens.push(raw); } if (tokens.length === 0) return true; // 숫자 토큰 없으면 수정 안 함 // 본문에서 관리비 anchor 주변 ±100자 안에 tokens 중 하나라도 있는지 확인 const anchorRe = /관\s*리\s*비(?:용)?|관\s*리\s*료/g; anchorRe.lastIndex = 0; let am; while ((am = anchorRe.exec(extractedText)) !== null) { const win = extractedText .slice(Math.max(0, am.index - 100), am.index + 220) .replace(/\s+/g, ""); for (const tk of tokens) { if (win.indexOf(tk) >= 0) return true; } } return false; // 본문 어디에서도 이 숫자를 찾을 수 없음 → hallucination }; const allOk = mgmtEntries.every((kf) => verifiable(kf.value)); if (!allOk) { try { console.log("[관리비 safety net] unverifiable→ 확인 필요"); } catch (_) {} const cleaned = keyFacts.filter((kf) => !isMgmt(kf?.label)); cleaned.push({ label: "관리비", value: "확인 필요", meta: "" }); return cleaned; } return keyFacts; } function toResultData(analysis) { if (!analysis) return null; const issues = (analysis.issues || []).map((iss, i) => withIssueCompat(iss, i)); const counts = issues.reduce((acc, iss) => { acc[iss.severity] = (acc[iss.severity] || 0) + 1; return acc; }, { high: 0, medium: 0, low: 0 }); const legalCounts = issues.reduce((acc, iss) => { const k = iss.legal_status || "negotiable"; acc[k] = (acc[k] || 0) + 1; return acc; }, { void: 0, unfair: 0, negotiable: 0 }); const tone = counts.high > 0 ? "alert" : counts.medium > 0 ? "warn" : "safe"; const verdictTitle = analysis.summary ? analysis.summary.trim() : (counts.high > 0 ? "위험한 조항이 보여요." : counts.medium > 0 ? "주의할 조항이 있어요." : "크게 걸리는 부분은 없어요."); /* 빠진 조항(missing_items) 은 importance("중요" > "권장") 순으로 정렬해서 사용자가 위에서부터 읽어도 가장 우선순위 높은 항목을 먼저 보게 한다. 원본 LLM 출력 순서가 들쭉날쭉이라 같은 importance 안에서는 원래 순서 보존. */ const MISSING_IMPORTANCE_RANK = { "중요": 0, "필수": 0, "권장": 1, "선택": 2 }; const cleanedMissing = (analysis.missing_items || []) .map((m, i) => ({ ...m, reason: trimMissingReason(m.reason || ""), _idx: i, // stable sort 폴백 })) .sort((a, b) => { const ar = MISSING_IMPORTANCE_RANK[a.importance] ?? 1; const br = MISSING_IMPORTANCE_RANK[b.importance] ?? 1; if (ar !== br) return ar - br; return a._idx - b._idx; }) .map(({ _idx, ...rest }) => rest); return { contract_type: analysis.contract_type, contract_type_label: analysis.contract_type_label, verdict_tone: tone, verdict_title: verdictTitle, risk_totals: counts, legal_totals: legalCounts, summary_meta: { pages: analysis.page_count || 1, char_count: analysis.char_count || 0, }, issues, key_facts: sortKeyFacts(_overrideMgmtFromText( _overrideRentFromText( (analysis.key_facts || []).map((kf) => ({ label: kf.label, value: kf.value, meta: "" })), analysis.extracted_text || analysis.text_preview || "" ), analysis.extracted_text || analysis.text_preview || "" )), /* 백엔드 numeric_audits — "임대차보호법 2년 보장권" 같은 자동 안내 항목. Result 화면에서 별도 섹션으로 노출. */ numeric_audits: (analysis.numeric_audits || []).map((na) => ({ check: na.check || "", status: na.status || "warning", detail: na.detail || "", severity: na.severity || "medium", })), redflags: [], // 서버 엔드포인트 없음 — 빈 배열로 섹션 숨김 missing_items: cleanedMissing, revision_suggestions: analysis.revision_suggestions || [], recommended_questions: analysis.recommended_questions || [], next_steps: analysis.next_steps || [], warnings: analysis.warnings || [], disclaimer: analysis.disclaimer || "", text_preview: analysis.text_preview || "", extracted_text: analysis.extracted_text || analysis.text_preview || "", is_real: true, }; } window.toResultData = toResultData; /* ---------------------------------------------------------------------------- * AnalysisHistoryItem → 홈/기록 탭의 '최근 분석' 카드 shape으로 변환. * 기존 mock: { id, type, title, sev, high, when } 를 그대로 유지하되 * analysis(원본 ContractAnalysisResponse)를 함께 담아 상세 진입 시 재사용한다. * -------------------------------------------------------------------------- */ function historyItemType(item) { const ct = item?.result?.contract_type || "lease"; if (ct === "employment" || ct === "freelancer") return "labor"; const hay = `${item.filename || ""} ${item.result?.contract_type_label || ""} ${item.summary || ""}`.toLowerCase(); if (hay.includes("월세") || hay.includes("wolsae")) return "wolsae"; return "jeonse"; } function historyItemHighCount(item) { const issues = item?.result?.issues || []; return issues.filter((i) => i.severity === "high").length; } /* 카드 라벨·색은 **순수 legal_status 분류** 로만 결정. 이전엔 numericMismatch (잔금일≠인도일, 위약금 연환산 등 숫자 audit mismatch) 를 voidCnt 에 합쳐서 라벨이 "법 위반 N건" + 썸네일 alert(빨강) 로 올라갔지만, 숫자 불일치는 법 위반이 아니라 "확인 필요" 수준이라 카테고리가 잘못 섞임. 회귀 사례: "불공정 1건" 이슈 + 잔금일 mismatch 1건 → 라벨 "법 위반 2건" 으로 왜곡되고 썸네일 빨강. 홈 목록 전체가 빨강으로 보이는 원인. 새 우선순위 (Result hero 의 heroKind 와 일치): ① void > 0 → 법 위반 (alert 빨강) ② unfair > 0 → 불공정 (warn 주황) ③ negotiable > 0 → 협상 대상 (safe 초록) ④ numericMismatch > 0 → 확인할 항목 (warn 주황, 빨강 아님) ⑤ high severity 폴백 → 위험 (alert) ⑥ medium severity 폴백 → 주의 (warn) ⑦ missing items → 체크해야 할 항목 (warn) ⑧ 모두 0 → 이슈 없음 (safe) */ function _historyLegalCounts(item) { const issues = item?.result?.issues || []; return { voidCnt: issues.filter((i) => i.legal_status === "void").length, unfairCnt: issues.filter((i) => i.legal_status === "unfair").length, negotiableCnt: issues.filter((i) => i.legal_status === "negotiable").length, numericMismatch: (item?.result?.numeric_audits || []) .filter((na) => (na?.status || "").toLowerCase() === "mismatch").length, high: issues.filter((i) => i.severity === "high").length, med: issues.filter((i) => i.severity === "medium").length, missing: (item?.result?.missing_items || []).length, }; } function historyItemKind(item) { const c = _historyLegalCounts(item); if (c.voidCnt > 0) return { label: "법 위반", count: c.voidCnt }; if (c.unfairCnt > 0) return { label: "불공정", count: c.unfairCnt }; if (c.negotiableCnt > 0) return { label: "협상 대상", count: c.negotiableCnt }; if (c.numericMismatch > 0) return { label: "확인할 항목", count: c.numericMismatch }; if (c.high > 0) return { label: "위험", count: c.high }; if (c.med > 0) return { label: "주의", count: c.med }; if (c.missing > 0) return { label: "참고하면 좋은 항목", count: c.missing }; return { label: null, count: 0 }; } function historyItemSev(item) { const c = _historyLegalCounts(item); if (c.voidCnt > 0) return "alert"; if (c.unfairCnt > 0) return "warn"; if (c.negotiableCnt > 0) return "safe"; if (c.numericMismatch > 0) return "warn"; // 숫자 불일치는 주황 (빨강 아님) // legal 분류가 모두 0 인 heuristic-only 케이스 — severity 폴백 return c.high > 0 ? "alert" : c.med > 0 ? "warn" : "safe"; } function formatHistoryWhen(iso, { relative = false } = {}) { if (!iso) return ""; try { const d = new Date(iso); if (isNaN(d.getTime())) return ""; const now = new Date(); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const startOfYesterday = new Date(startOfToday.getTime() - 24 * 3600 * 1000); if (relative && d >= startOfToday) { const h = d.getHours(); const m = d.getMinutes(); const ap = h < 12 ? "오전" : "오후"; const hh = ((h + 11) % 12) + 1; const mm = m.toString().padStart(2, "0"); return `오늘 ${ap} ${hh}:${mm}`; } if (relative && d >= startOfYesterday) { return "어제"; } return `${d.getMonth() + 1}월 ${d.getDate()}일`; } catch (_) { return ""; } } /* 홈 카드의 제목은 파일명을 그대로 쓰면 "KakaoTalk_Photo_2026-04-15-11-05-19.jpeg" * 같이 의미 없는 꼬리가 길게 붙거나 "r5_jeonse_redflag_pack" 같이 영문 픽스처 * 이름이 그대로 노출돼 한국 사용자에게 어색하다. 다음 규칙으로 다듬는다: * - KakaoTalk_Photo_YYYY-MM-DD-HH-MM-SS.ext → "카톡 사진 (M월 D일)" * - Screenshot_YYYY-MM-DD... → "스크린샷 (M월 D일)" * - IMG_xxxx.* → "사진 IMG_xxxx" * - 한글이 한 글자도 없는 영문/숫자 fixture 이름은 키워드로 한글 라벨 매핑 * (jeonse → 전세 계약서, oneroom → 원룸 전세 계약서, wolsae → 월세 계약서, …) * - 나머지는 확장자 제거. 24자 넘으면 앞부분 + … */ function prettifyFilename(raw) { const name = (raw || "").trim(); if (!name) return "계약서"; // 확장자 분리 (마지막 점만) const dot = name.lastIndexOf("."); const stem = dot > 0 ? name.slice(0, dot) : name; // 카카오톡 자동 파일명 const kakao = stem.match(/^KakaoTalk(?:_Photo|_Moim|_\w+)?_(\d{4})-(\d{2})-(\d{2})/i); if (kakao) { const [, , mm, dd] = kakao; return `카톡 사진 (${parseInt(mm, 10)}월 ${parseInt(dd, 10)}일)`; } // iOS / Android 스크린샷 const shot = stem.match(/^(?:Screenshot|스크린샷|화면\s*캡처)[_\- ]?(\d{4})[-_]?(\d{2})[-_]?(\d{2})/i); if (shot) { const [, , mm, dd] = shot; return `스크린샷 (${parseInt(mm, 10)}월 ${parseInt(dd, 10)}일)`; } // IMG_1234 if (/^IMG_\d+$/i.test(stem)) return `사진 ${stem}`; /* 영문 fixture 이름 한글화 — r5_jeonse_redflag_pack, r1_oneroom_v2 같은 테스트 * 픽스처가 운영 데이터에 섞여 들어와도 한국어 화면 톤이 깨지지 않게 한다. */ const hasHangul = /[\uAC00-\uD7A3]/.test(stem); if (!hasHangul) { const lower = stem.toLowerCase(); /* 키워드 우선순위: 더 구체적인 라벨 먼저. oneroom → 원룸, jeonse → 전세, wolsae → 월세, employment/labor → 근로 */ if (/oneroom|원룸|studio/.test(lower)) return "원룸 전세 계약서"; if (/tworoom|투룸|two[\-_ ]?room/.test(lower)) return "투룸 계약서"; if (/wolsae|월세|monthly[\-_ ]?lease/.test(lower)) return "월세 계약서"; if (/jeonse|전세|key[\-_ ]?money/.test(lower)) return "전세 계약서"; if (/employ|labor|근로|worker/.test(lower)) return "근로 계약서"; if (/freelance|프리랜서/.test(lower)) return "프리랜서 계약서"; if (/lease|rental|rent|임대/.test(lower)) return "임대차 계약서"; if (/contract|agreement|계약/.test(lower)) return "계약서"; /* 그래도 매칭이 없으면 — 영문 그대로보단 일반 라벨이 낫다 */ return "계약서"; } // 기본: 확장자 뗀 stem. 너무 길면 앞 22자 + … if (stem.length <= 24) return stem; return `${stem.slice(0, 22)}…`; } /* 카드 제목 결정 — 파일명이 generic("계약서") 이거나 비어있으면 분석 결과에서 더 구체적인 라벨("월세 계약서", "전세 계약서", "근로계약서") 추론. backend 의 contract_type 이 "lease" 한 가지라 전세/월세 구분이 안 됨 → summary/key_facts/파일명 키워드로 프런트에서 분리. */ const _GENERIC_TITLE_RE = /^(계약서|lease|rental|contract|agreement|file|document|문서|.{0,2})$/i; function deriveContractKindLabel(item) { const ct = item?.result?.contract_type; if (ct === "employment") return "근로계약서"; if (ct === "freelancer") return "프리랜서 계약서"; if (ct === "general") return "일반 계약서"; // lease — summary/key_facts/filename 키워드로 월세/전세 분리 const summary = item?.result?.summary || ""; const keyFacts = (item?.result?.key_facts || []).map((kf) => `${kf?.label || ""}:${kf?.value || ""}`).join(" "); const filename = item?.filename || ""; const hay = `${summary} ${keyFacts} ${filename}`.toLowerCase(); if (hay.includes("월세") || hay.includes("월 차임") || hay.includes("월차임") || hay.includes("monthly")) return "월세 계약서"; if (hay.includes("전세")) return "전세 계약서"; return "임대차 계약서"; } function smartTitle(item) { const filename = prettifyFilename(item.filename || ""); if (!filename || _GENERIC_TITLE_RE.test(filename)) { return deriveContractKindLabel(item); } return filename; } function historyToRecent(item, opts) { const kind = historyItemKind(item); return { id: item.history_id || item.local_id || `h-${Math.random().toString(36).slice(2, 8)}`, type: historyItemType(item), title: smartTitle(item), /* 카드 라벨용 — legal_status 우선순위 ("불공정 1건" 등). high 만 보던 기존 r.high 보다 정확. Home/HistoryAccount 카드에서 사용. */ kindLabel: kind.label, kindCount: kind.count, // 홈 위젯의 '파일명당 최신 1개' 접기 로직이 raw 파일명 기준으로 돌도록 // 원본 파일명을 함께 실어둔다. prettifyFilename 은 '카톡 사진 (4월 21일)' // 처럼 서로 다른 파일을 같은 라벨로 접을 수 있어 접기 기준으로는 부적절. filename: item.filename || "", sev: historyItemSev(item), high: historyItemHighCount(item), when: formatHistoryWhen(item.created_at, opts), analysis: item.result || null, history_id: item.history_id || null, // 로컬 전용 기록은 history_id 가 없음. 삭제 시 로컬 캐시에서 빼낼 수 있도록 // local_id 도 별도 필드로 보존한다 (편집 모드에서 사용). local_id: item.local_id || null, }; } function historyToRecents(items, opts = {}) { return (items || []).map((it) => historyToRecent(it, opts)); } window.historyToRecents = historyToRecents; /* -------------------- dataUri / 이미지 → File 변환 -------------------- * 토스 카메라/앨범 API 는 { id, dataUri, mimeType? } 형태로 응답한다. * /analyze 는 multipart File 을 기대하므로 중간 변환 함수가 필요하다. * - base64: true 로 요청했으면 dataUri 는 "iVBOR..." (raw base64) 이므로 * mimeType 접두사를 붙여 Blob 으로 디코딩한다. * - base64: false 로 요청했으면 dataUri 는 file:// 또는 data: URL. Blob 변환은 * 이 경로에서는 지원하지 않는다 (프레임워크가 실제 파일 경로를 돌려주기 때문). */ function dataUriToFile(dataUri, fileName) { try { if (!dataUri) return null; // Full data URL const m = /^data:([^;]+);base64,(.+)$/.exec(dataUri); let mime = "image/jpeg"; let b64; if (m) { mime = m[1]; b64 = m[2]; } else if (/^[A-Za-z0-9+/=\s]+$/.test(dataUri.slice(0, 200))) { // Raw base64 (no prefix) b64 = dataUri.replace(/\s/g, ""); } else { // 실제 파일 경로(file://, content://)가 반환된 경우 → 블롭 변환 불가 return null; } const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return new File([bytes], fileName || suggestCameraFilename({ mimeType: mime }), { type: mime }); } catch (err) { console.warn("[dataUriToFile] failed:", err); return null; } } function suggestCameraFilename(image) { const mime = image?.mimeType || "image/jpeg"; const ext = mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg"; const ts = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14); return `계약서_${ts}.${ext}`; } window.dataUriToFile = dataUriToFile; /* -------------------- Landing -------------------- */ function Landing({ onLogin, onSignup, onEnterApp }) { return (
AI가 먼저 읽어보는 계약서

서명 전에,
계약서 위험을 먼저 봐요.

전세·월세·근로 계약서를 올리면 AI가 위험한 조항을 찾고, 법적 근거와 함께 쉬운 말로 풀어드려요.

개인정보 암호화 분석 끝나면 파일은 바로 지워요 가입 즉시 1회 무료
AI 분석 결과

계약서를 넘겨주세요. 제가 먼저 훑어볼게요.

AI가 조항 하나하나를 법령과 판례에 비춰 위험도를 매기고, 고치면 좋은 포인트까지 정리해 드려요.

위험 조항을 자동으로 찾아요

갱신청구권 포기, 보증금 반환 거부, 수선 의무 떠넘기기 같은 6개 큰 항목에서 100여 개 규칙을 살펴봐요.

원문과 바로 비교해서 볼 수 있어요

조항 카드를 누르면 원본 계약서의 해당 위치로 바로 넘어가요. 어떤 문장이 문제인지 한눈에 보여요.

법 근거와 바꿀 말까지 알려드려요

주택임대차보호법이나 근로기준법 같은 법 조문을 보여주고, 상대에게 어떻게 바꿔 달라고 할지 문구까지 제안해 드려요.

); } /* -------------------- Auth -------------------- */ /* 앱인토스 미니앱 정책 6.1: 외부 소셜 로그인(카카오/구글 등) 미지원. 이메일 로그인만 렌더하고, 향후 토스 로그인 SDK 연동은 별도 슬롯으로 추가한다. `emailAuthOnly` prop은 하위호환용으로 남겨 두되 값과 무관하게 소셜 버튼은 제거한다. */ function Auth({ mode, onSwitch, onSubmit, onClose, errorMessage, busy, emailAuthOnly }) { void emailAuthOnly; const [email, setEmail] = useStateW(""); const [pw, setPw] = useStateW(""); const isSignup = mode === "signup"; return (

{isSignup ? "무료로 시작하기" : "다시 만나 반가워요"}

{isSignup ? "가입하면 바로 1회 무료 분석 크레딧을 드려요." : "이메일로 로그인해 주세요."}

{errorMessage && (
{errorMessage}
)}
{ e.preventDefault(); if (!busy) onSubmit({ email, pw }); }}>
setEmail(e.target.value)} disabled={busy} autoComplete="email" required />
setPw(e.target.value)} disabled={busy} autoComplete={isSignup ? "new-password" : "current-password"} minLength={isSignup ? 8 : undefined} required />
{/* 앱인토스 미니앱은 토스 로그인만 허용. 외부 OAuth(카카오/구글) 버튼은 정책 6.1에 따라 제거됨. 미니앱 토스 로그인 SDK가 붙으면 이 자리에 단일 "토스로 계속하기" 버튼을 추가한다. */}
{isSignup ? ( <>이미 계정이 있어요? ) : ( <>처음이에요? )}
); } /* -------------------- Tweaks -------------------- */ function WebTweaks({ open, onClose, scenario, onScenario, theme, onTheme, onJump }) { /* 앱인토스 비게임 정책: 프로덕션 번들에는 노출 금지. 로컬·preview·staging 또는 명시적 window.__DEV_TWEAKS__ 플래그가 있을 때만 렌더한다. */ const _allowed = (() => { if (typeof window === "undefined") return false; const host = (window.location?.hostname || "").toLowerCase(); if (!host) return false; if (host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0") return true; if (host.endsWith(".local")) return true; if (host.includes("preview") || host.includes("staging") || host.includes("vercel.app")) return true; return !!(window).__DEV_TWEAKS__; })(); if (!_allowed) return null; if (!open) return null; return (
Tweaks
계약서 종류
{[["jeonse","전세"],["wolsae","월세"],["labor","근로"]].map(([id, label]) => { const active = scenario === id; return ( ); })}
바로가기
{[ ["landing","랜딩"], ["login","로그인"], ["signup","가입"], ["home","홈"], ["history","기록"], ["result","결과"], ["preview","원문"], ["account","계정"], ["help","도움말"], ].map(([id, label]) => ( ))}
{/* 앱인토스 비게임 정책: 라이트 모드 전용 → 테마 선택 섹션 제거 */}
); } /* -------------------- Main Controller -------------------- */ function WebApp() { const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "scenario": "jeonse", "theme": "light", "route": "landing", "credits": 1, "sidebarCollapsed": false }/*EDITMODE-END*/; const [route, setRoute] = useStateW(TWEAK_DEFAULTS.route); // landing | login | signup | app const [tab, setTab] = useStateW("home"); const [stack, setStack] = useStateW([]); // ["capture"|"picker"|"result"|"preview"] const [scenario, setScenario] = useStateW(TWEAK_DEFAULTS.scenario); /* 앱인토스 비게임 정책(디자인): 라이트 모드 전용. theme 상태를 "light"로 고정. 기존 setTheme 호출부 호환을 위해 no-op 함수로 대체한다. */ const theme = "light"; const setTheme = () => {}; const [credits, setCredits] = useStateW(TWEAK_DEFAULTS.credits); const [sidebarCollapsed, setSidebarCollapsed] = useStateW(!!TWEAK_DEFAULTS.sidebarCollapsed); const [analyzing, setAnalyzing] = useStateW(false); /* 파일 업로드 확인 modal — 사용자가 파일 선택 직후 "[서명전에] 알림" 스타일의 우리만의 다이얼로그를 띄운다. native window.confirm 은 "127.0.0.1 says:" 같은 origin label 을 강제로 보여주는 한계가 있어 UX/브랜드 측면에서 부적합. 이 modal 은 self-contained inline JSX 로 렌더해서 외부 컴포넌트 의존성 (ConfirmDialog) 표시 회귀 위험 회피. 단일 파일(pendingFile) 과 여러 장 이미지(pendingFiles) 두 상태를 두어 — 단일은 기존 /analyze 플로우, 다수는 /extract-multi → /analyze-text. */ const [pendingFile, setPendingFile] = useStateW(null); const [pendingFiles, setPendingFiles] = useStateW(null); // File[] — 여러 장 이미지 모드 const [issueSheet, setIssueSheet] = useStateW(null); const [billingSheet, setBillingSheet] = useStateW(false); const [uploadSheet, setUploadSheet] = useStateW(false); const [focusIssueId, setFocusIssueId] = useStateW(null); const [tweaksOpen, setTweaksOpen] = useStateW(false); /* G-8: 토스 익명 hash 모드 첫 진입 안내 다이얼로그. * null = 아직 storage 체크 전 (다이얼로그 안 띄움 — 깜빡임 방지) * true = 처음 들어온 사용자 → 안내 띄움 * false = 이미 봤거나 닫음 → 다시 안 띄움 * "베타 데이터가 비워졌고, 이제는 토스 로그인으로 자동 인증된다" 한 화면 정리. */ const [tossIntroOpen, setTossIntroOpen] = useStateW(null); // useToast — 분석 결과를 못 불러올 때 사용자에게 사일런트 폴백 대신 알림. const _toast = (typeof window !== "undefined" && typeof window.useToast === "function") ? window.useToast() : null; /* 앱인토스 비게임 정책: 개발자 도구(WebTweaks)는 프로덕션 번들에 노출 금지. 로컬·preview·staging 호스트에서만 활성화하고, 그 외 환경에서는 메시지 브리지가 `__activate_edit_mode`를 보내도 패널이 떠선 안 된다. */ const _tweaksAllowed = (() => { if (typeof window === "undefined") return false; const host = (window.location?.hostname || "").toLowerCase(); if (!host) return false; if (host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0") return true; if (host.endsWith(".local")) return true; if (host.includes("preview") || host.includes("staging") || host.includes("vercel.app")) return true; // 명시적 dev 플래그 (window.__DEV_TWEAKS__ = true) 가 있을 때만 노출 return !!(window).__DEV_TWEAKS__; })(); /* 실제 분석 결과 (없으면 scenario 목업으로 폴백) */ const [analysis, setAnalysis] = useStateW(null); const [analysisError, setAnalysisError] = useStateW(null); const [analysisPending, setAnalysisPending] = useStateW(false); /* 숨김 파일 input — 액션시트에서 '파일 선택' 누르면 여기로 위임 */ const fileInputRef = useRefW(null); /* 실제 Supabase 인증 + 계정 상태 (auth.jsx에서 제공) */ const auth = (typeof useAuth === "function") ? useAuth() : null; /* 인증 모드. * toss-anonymous : 앱인토스 표준 — 랜딩/로그인 화면 자체가 없어요. 부트스트랩이 * 끝나면 바로 app 쉘로 진입. 사용자 입장에선 "로그인" 개념이 * 존재하지 않음. * supabase-jwt : 레거시 — 기존 랜딩 → 로그인/가입 → app 흐름 유지. 마이그레이션 * 유예 기간만 사용. */ const isTossMode = (auth?.authMode || "toss-anonymous") === "toss-anonymous"; /* 로그인 성공 시 자동으로 앱 쉘로 이동. 단, 랜딩/로그인/가입 화면에서만 점프 — 이미 app 안이면 그대로 유지. toss-anonymous 모드에서는 부트스트랩이 끝나는 순간 자동으로 app 으로 이동. */ useEffectW(() => { if (!auth) return; if (auth.ready && auth.session && (route === "landing" || route === "login" || route === "signup")) { setRoute("app"); } }, [auth?.ready, auth?.session, route]); /* 로그아웃 시 랜딩으로 — supabase-jwt 모드 한정. toss-anonymous 모드는 signOut 이 새 hash 로 즉시 재부트스트랩하므로 route 유지. */ useEffectW(() => { if (!auth) return; if (isTossMode) return; if (auth.ready && !auth.session && route === "app") { setRoute("landing"); setStack([]); setTab("home"); } }, [auth?.ready, auth?.session, isTossMode]); /* G-8: 토스 hash 모드 첫 진입 안내 다이얼로그 게이팅. * 베타 기간(이메일 가입) 사용자들은 마이그레이션으로 데이터가 비워졌다는 걸 * 명확히 알아야 하고, 신규 사용자는 "별도 가입 없이 자동 인증" 컨셉을 한 번은 * 안내받아야 동선이 안 흔들린다. 둘 다 동일한 한 화면으로 처리. * * 타이밍: 부트스트랩이 완전히 끝나고(=auth.ready + auth.session) 토스 모드일 때만. * 중복 노출 방지: TossAdapter.storage 의 "toss_intro_seen_v1" 키로 영구 기록. * Storage 비활성 환경(시크릿 모드 등)이면 isUsable() 이 false 일 수 있으므로 * 그 경우엔 안내를 한 번 보여주고 끝(getItem 이 null 을 돌려주므로 자연스레 표시). */ useEffectW(() => { if (!isTossMode) return; if (!auth?.ready || !auth?.session) return; if (tossIntroOpen !== null) return; // 이미 한 번 결정됨 let cancelled = false; (async () => { try { const seen = await (window.TossAdapter && window.TossAdapter.storage ? window.TossAdapter.storage.getItem("toss_intro_seen_v1") : Promise.resolve(null)); if (cancelled) return; // null/undefined/"" 모두 미열람으로 간주. setTossIntroOpen(seen ? false : true); } catch (_) { if (cancelled) return; // 스토리지 오류는 무시하고 안내만 한 번 띄움 — 닫으면 메모리에서 false 로 바뀜. setTossIntroOpen(true); } })(); return () => { cancelled = true; }; }, [isTossMode, auth?.ready, auth?.session, tossIntroOpen]); /* G-8: 안내 다이얼로그 닫기 — storage 에 영구 기록. 실패해도 메모리 상태는 false 로. */ const closeTossIntro = () => { setTossIntroOpen(false); try { if (window.TossAdapter && window.TossAdapter.storage) { // setItem 은 Promise — await 안 해도 OK. 실패는 console 만. window.TossAdapter.storage.setItem("toss_intro_seen_v1", "1").catch(() => {}); } } catch (_) {} }; /* 서버 크레딧 동기화. - unlimited 계정: UI에서는 '무제한' 배지로 표시 (숫자 대신 ∞) - 일반 계정: free_uses_remaining + credits 합산 */ const isUnlimited = !!auth?.account?.unlimited; const resolvedCredits = (() => { if (!auth || !auth.account) return credits; const a = auth.account; return (a.free_uses_remaining || 0) + (a.credits || 0); })(); /* 최근 분석. * 홈 위젯은 "파일명당 최신 1개" 로 접어 같은 계약서를 여러 번 분석해도 슬롯을 * 낭비하지 않는다. (사용자 입장에선 "이 파일" 한 줄만 보이면 충분.) * 전체보기(기록 탭) 는 의도대로 모든 분석을 다 보여준다. * 홈도 상대시간(`relative: true`) 으로 "오늘 오전 HH:MM" 을 찍어서 오늘 * 분석이 여러 건이어도 '4월 21일' 로 모두 똑같이 찍히는 일은 없게 한다. * auth?.history 는 이미 refreshHistory 에서 created_at 내림차순이라 * 여기서는 "처음 보이는 파일명만 남기기" 한 번이면 최신본이 유지된다. */ const historyRecentsHome = useMemoW(() => { const recents = historyToRecents(auth?.history || [], { relative: true }); const seen = new Set(); const uniq = []; for (const r of recents) { // raw 파일명 기준 접기. 파일명 없는 경우(로컬 임시 항목) 는 접지 않는다. const key = (r.filename || "").trim().toLowerCase(); if (key && seen.has(key)) continue; if (key) seen.add(key); uniq.push(r); if (uniq.length >= 3) break; } return uniq; }, [auth?.history]); const historyRecentsAll = useMemoW( () => historyToRecents(auth?.history || [], { relative: true }), [auth?.history] ); const hasSession = !!auth?.session; /* theme: 라이트 모드 강제 고정 (앱인토스 비게임 체크리스트 준수) */ useEffectW(() => { document.documentElement.setAttribute("data-theme", "light"); }, []); /* Tweaks bridge — 개발 환경에서만 활성화 (프로덕션 번들은 postMessage 무시) */ useEffectW(() => { if (!_tweaksAllowed) return; const onMsg = (ev) => { const m = ev.data; if (!m || typeof m !== "object") return; if (m.type === "__activate_edit_mode") setTweaksOpen(true); if (m.type === "__deactivate_edit_mode") setTweaksOpen(false); }; window.addEventListener("message", onMsg); window.parent.postMessage({ type: "__edit_mode_available" }, "*"); return () => window.removeEventListener("message", onMsg); }, []); const persist = (patch) => window.parent.postMessage({ type: "__edit_mode_set_keys", edits: patch }, "*"); /* Actions */ const toLanding = () => setRoute("landing"); const toLogin = () => setRoute("login"); const toSignup = () => setRoute("signup"); const toApp = () => { setRoute("app"); persist({ route: "app" }); }; /* 실제 로그인/가입 핸들러 — auth.jsx의 Supabase 클라이언트를 경유. 앱인토스 미니앱 정책 6.1: 외부 OAuth(provider) 경로는 제거. 토스 로그인 SDK가 연동되면 별도 핸들러로 분기 추가 예정이며, 그때까지는 provider 페이로드를 무시해서 어떤 소셜 경로도 열리지 않도록 한다. */ const onAuthSubmit = async (payload) => { if (payload?.provider) { // 정책상 허용되지 않는 경로 — 조용히 무시 (버튼 자체가 제거됐으므로 도달 불가) return; } const { email, pw } = payload || {}; if (!email || !pw) return; if (!auth || !auth.ready) { alert("서버 설정을 불러오는 중이에요. 잠시 뒤 다시 시도해 주세요."); return; } if (auth.configError) { alert(`지금은 로그인할 수 없어요: ${auth.configError}`); return; } const fn = route === "signup" ? auth.signUp : auth.signIn; const result = await fn(email, pw); if (result?.ok) { // 가입 시 이메일 확인이 필요한 경우 session이 null일 수 있음 — onAuthStateChange가 처리 // 로그인 성공 시에는 useEffect가 감지해 route를 app으로 전환 if (route === "signup" && !result.data?.session) { alert("가입 확인 메일을 보냈어요. 메일함에서 확인 링크를 눌러 주세요."); } } // 실패 시 auth.authError가 자동으로 채워짐 → Auth 컴포넌트에서 렌더 }; /* "새 계약서 분석하기" / FAB 핸들러. uploadSheet 의 open 가드가 result/preview/capture/picker/paste 화면에선 sheet 를 강제로 hidden 으로 만들기 때문에, 이런 화면에 있을 때 사용자가 사이드바 CTA 나 FAB 를 눌러도 아무 반응이 없는 회귀가 있었음. 해결: 시트 열기 전에 stack 을 비워 home 으로 돌아간 뒤 setUploadSheet(true). issueSheet / billingSheet 같은 다른 sheet 도 함께 닫음. */ const onFab = () => { setStack([]); setIssueSheet(null); setBillingSheet(false); setTab("home"); // 다음 tick 에 시트 열기 (위의 state 들이 먼저 반영되도록) setUploadSheet(true); }; /* 업로드 소스 분기. 실제 파일 선택은 숨김 input으로, 카메라는 데모 플로우로. kind === "paste" 는 /analyze-text 엔드포인트 경로 — PasteScreen 으로 진입. */ const onPickSource = (kind) => { setUploadSheet(false); if (kind === "camera") { setStack(["capture"]); } else if (kind === "upload") { openFilePicker(); } else if (kind === "paste") { if (!auth?.session) { // toss-anonymous 모드에서는 부트스트랩이 끝났다는 게 곧 세션이 있다는 뜻 — // 이 분기는 부트스트랩 실패 시에만 도달. 로그인 화면 대신 안내만 띄우고 멈춤. alert(isTossMode ? "사용자 인증이 아직 끝나지 않았어요. 잠시 뒤 다시 시도해 주세요." : "먼저 로그인해야 계약서를 분석할 수 있어요."); if (!isTossMode) setRoute("login"); return; } setStack(["paste"]); } else { setStack(["picker"]); } }; const openFilePicker = () => { if (!auth?.session) { alert(isTossMode ? "사용자 인증이 아직 끝나지 않았어요. 잠시 뒤 다시 시도해 주세요." : "먼저 로그인해야 계약서를 올릴 수 있어요."); if (!isTossMode) setRoute("login"); return; } if (fileInputRef.current) { fileInputRef.current.value = ""; fileInputRef.current.click(); } }; /* 실제 /analyze 호출 — 파일을 multipart로 올리고 ContractAnalysisResponse를 받는다. */ const runAnalyze = async (file) => { if (!file) return; if (typeof window.apiFetch !== "function") { alert("연결이 아직 준비되지 않았어요. 새로고침하고 다시 시도해 주세요."); return; } setStack([]); setAnalysis(null); setAnalysisError(null); setAnalysisPending(true); setAnalyzing(true); const form = new FormData(); form.append("file", file); form.append("contract_type", "auto"); try { const res = await window.apiFetch("/analyze", { method: "POST", body: form }); if (!res.ok) { let detail = `분석이 끝나지 못했어요 (HTTP ${res.status})`; try { const j = await res.json(); if (j?.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); } catch (_) { /* 본문 없음 */ } if (res.status === 401) detail = "로그인이 풀렸어요. 다시 로그인해 주세요."; else if (res.status === 402) detail = "무료 크레딧을 다 썼어요. 크레딧을 충전하고 다시 시도해 주세요."; throw new Error(detail); } const data = await res.json(); setAnalysis(data); /* 기존 UI 호환을 위해 scenario key 매핑 (아이콘/라벨 fallback용) */ const scenarioKey = data.contract_type === "lease" ? (file.name?.toLowerCase().includes("월세") ? "wolsae" : "jeonse") : data.contract_type === "employment" ? "labor" : data.contract_type === "freelancer" ? "labor" : "jeonse"; setScenario(scenarioKey); setAnalysisPending(false); /* onAnalyzeDone(=AnalyzingScreen이 100%까지 찬 뒤 콜백)이 result로 전환 */ if (auth?.refreshAccount) auth.refreshAccount(); // Supabase에 analysis_history 테이블이 없어도 기록 탭에 반드시 뜨도록 // 로컬 캐시에 먼저 박아 넣고, 그 다음 서버 refresh로 merge 시킨다. if (auth?.rememberAnalysis) auth.rememberAnalysis(data); if (auth?.refreshHistory) auth.refreshHistory(); } catch (err) { console.error("[analyze] failed:", err); setAnalysisError(err?.message || String(err)); setAnalysisPending(false); setAnalyzing(false); alert(`분석하다가 오류가 생겼어요.\n\n${err?.message || String(err)}`); } }; /* 파일 선택 → 즉시 분석 시작이 아니라 **확인 단계** 한 번 거쳐서 "잘못 올렸나?" 사용자가 점검할 기회 제공. native window.confirm 은 "127.0.0.1 says:" origin label 을 강제로 보여주는 한계가 있어, 우리 브랜드 헤더("[서명전에] 알림") 를 가진 커스텀 modal 을 띄운다. modal 자체는 같은 파일 하단 inline JSX 로 직접 렌더 (외부 ConfirmDialog 컴포넌트 의존성 X — 표시 회귀 방지). (붙여넣기/카메라 경로는 사용자가 명시적으로 텍스트/이미지를 만들고 분석 버튼을 누르므로 별도 confirm 불필요.) */ const onFileInputChange = (e) => { const fs = Array.from(e.target.files || []); if (fs.length === 0) return; // 여러 이미지 선택 → 멀티 파일 모드로 분기 (/extract-multi → /analyze-text). // 단일 또는 비이미지 → 기존 단일 /analyze 플로우. const isImage = (f) => /\.(jpg|jpeg|png|webp|heic)$/i.test(f.name || ""); if (fs.length > 1 && fs.every(isImage)) { setPendingFiles(fs.slice(0, 8)); // 서버 제한 8장 } else { setPendingFile(fs[0]); } try { e.target.value = ""; } catch (_) {} }; /* 여러 이미지 업로드 플로우 (사이클 1-B, 2026-04-24). /extract-multi 로 각 이미지 OCR → 결과 text 를 /analyze-text 로 넘겨 한 번에 분석. 단일 이미지 플로우는 기존 runAnalyze 유지. */ const runAnalyzeMulti = async (files) => { if (!files || files.length === 0) return; if (typeof window.apiFetch !== "function") { alert("연결이 아직 준비되지 않았어요. 새로고침하고 다시 시도해 주세요."); return; } setStack([]); setAnalysis(null); setAnalysisError(null); setAnalysisPending(true); setAnalyzing(true); try { const extractForm = new FormData(); files.forEach((f) => extractForm.append("files", f)); extractForm.append("contract_type", "auto"); const extractRes = await window.apiFetch("/extract-multi", { method: "POST", body: extractForm }); if (!extractRes.ok) { let detail = `이미지 여러 장 처리가 실패했어요 (HTTP ${extractRes.status})`; try { const j = await extractRes.json(); if (j?.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); } catch (_) {} if (extractRes.status === 402) detail = "무료 크레딧을 다 썼어요. 크레딧을 충전하고 다시 시도해 주세요."; throw new Error(detail); } const extracted = await extractRes.json(); // /analyze-text 로 본문 분석 const analyzeForm = new FormData(); analyzeForm.append("filename", extracted.filename || `이미지 ${files.length}장`); analyzeForm.append("extension", extracted.extension || ".jpg"); analyzeForm.append("contract_type", "auto"); analyzeForm.append("page_count", String(extracted.page_count || files.length)); analyzeForm.append("extracted_text", extracted.extracted_text || ""); (extracted.warnings || []).forEach((w) => analyzeForm.append("warnings", w)); const res = await window.apiFetch("/analyze-text", { method: "POST", body: analyzeForm }); if (!res.ok) { let detail = `분석이 끝나지 못했어요 (HTTP ${res.status})`; try { const j = await res.json(); if (j?.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); } catch (_) {} throw new Error(detail); } const data = await res.json(); setAnalysis(data); setScenario( data.contract_type === "lease" ? "jeonse" : data.contract_type === "employment" ? "labor" : data.contract_type === "freelancer" ? "labor" : "jeonse" ); setAnalysisPending(false); if (auth?.refreshAccount) auth.refreshAccount(); if (auth?.rememberAnalysis) auth.rememberAnalysis(data); if (auth?.refreshHistory) auth.refreshHistory(); } catch (err) { console.error("[analyze-multi] failed:", err); setAnalysisError(err?.message || String(err)); setAnalysisPending(false); setAnalyzing(false); alert(`분석하다가 오류가 생겼어요.\n\n${err?.message || String(err)}`); } }; /* 붙여 넣은 계약서 본문을 /analyze-text 로 POST. AnalyzeExtractedTextRequest 스펙: * { filename, extension, contract_type, page_count, extracted_text, warnings } * runAnalyze 와 마찬가지로 분석 진행 오버레이 → Result 전환 플로우를 탄다. */ const runAnalyzeText = async (text) => { const body = (text || "").trim(); if (!body) { alert("붙여 넣은 내용이 비어 있어요."); return; } if (typeof window.apiFetch !== "function") { alert("연결이 아직 준비되지 않았어요. 새로고침하고 다시 시도해 주세요."); return; } // YYYYMMDD_HHMM 타임스탬프 파일명 (히스토리 목록에서 식별 가능하도록) const pad = (n) => String(n).padStart(2, "0"); const d = new Date(); const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_` + `${pad(d.getHours())}${pad(d.getMinutes())}`; const filename = `클립보드_붙여넣기_${stamp}.txt`; setStack([]); setAnalysis(null); setAnalysisError(null); setAnalysisPending(true); setAnalyzing(true); try { const res = await window.apiFetch("/analyze-text", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename, extension: ".txt", contract_type: "auto", page_count: 1, extracted_text: body, warnings: ["사용자가 붙여넣은 텍스트예요. 원문과 차이가 있을 수 있어요."], }), }); if (!res.ok) { let detail = `분석이 끝나지 못했어요 (HTTP ${res.status})`; try { const j = await res.json(); if (j?.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); } catch (_) { /* 본문 없음 */ } if (res.status === 401) detail = "로그인이 풀렸어요. 다시 로그인해 주세요."; else if (res.status === 402) detail = "무료 크레딧을 다 썼어요. 크레딧을 충전하고 다시 시도해 주세요."; throw new Error(detail); } const data = await res.json(); setAnalysis(data); const scenarioKey = data.contract_type === "lease" ? "jeonse" : data.contract_type === "employment" ? "labor" : data.contract_type === "freelancer" ? "labor" : "jeonse"; setScenario(scenarioKey); setAnalysisPending(false); if (auth?.refreshAccount) auth.refreshAccount(); if (auth?.rememberAnalysis) auth.rememberAnalysis(data); if (auth?.refreshHistory) auth.refreshHistory(); } catch (err) { console.error("[analyze-text] failed:", err); setAnalysisError(err?.message || String(err)); setAnalysisPending(false); setAnalyzing(false); alert(`분석하다가 오류가 생겼어요.\n\n${err?.message || String(err)}`); } }; /* 카메라 촬영 결과 처리. * - image 가 있으면 (TossAdapter.camera.open 성공): dataUri → File 변환해 * 실제 /analyze 호출. 분석 결과로 Result 스크린 진입. * - image 가 null 이면 (어댑터 미지원/폴백): 기존 목업 플로우. */ const onCaptureShoot = (image) => { setStack([]); setAnalysis(null); setAnalysisError(null); if (image && image.dataUri) { const file = dataUriToFile(image.dataUri, suggestCameraFilename(image)); if (file) { // 실제 분석 경로 (/analyze 호출). runAnalyze 내부에서 analyzing=true 로 전환. runAnalyze(file); return; } } // 목업 경로 (데모) setAnalysisPending(false); setAnalyzing(true); }; /* 앨범/파일 피커 확정. payload 가 객체이면 실제 ImageResponse 로 간주하고 * /analyze 호출, 문자열이면 목업 플로우로 분기. */ const onPickerConfirm = (payload) => { setStack([]); setAnalysis(null); setAnalysisError(null); if (payload && typeof payload === "object" && payload.dataUri) { const file = dataUriToFile(payload.dataUri, suggestCameraFilename(payload)); if (file) { runAnalyze(file); return; } } // 목업 경로 (문자열 id 또는 변환 실패) setAnalysisPending(false); setAnalyzing(true); }; const onAnalyzeDone = () => { setAnalyzing(false); setStack(["result"]); /* 실제 /analyze 성공 시엔 auth.refreshAccount가 크레딧을 갱신. 목업(카메라/picker) 경로에서만 로컬 카운터 감소. */ if (!analysis) setCredits((c) => Math.max(0, c - 1)); }; const onOpenIssue = (iss) => { /* IssueDetail 시트를 열기 전에 다른 BottomSheet/ActionSheet 들을 강제로 닫는다. 이전엔 사용자가 FAB → 업로드 메뉴(uploadSheet) 를 열어두고 결과 화면으로 진입한 뒤 issue 카드를 누르면 두 sheet 가 z-index 상으로 겹쳐서 화면이 깨졌음. 같은 시점에 한 sheet 만 노출되도록 보장. */ setUploadSheet(false); setBillingSheet(false); setIssueSheet(iss); }; /* 화면 stack 이 바뀔 때 (홈 → 결과, 결과 → 미리보기 등) ActionSheet/BottomSheet 도 같이 닫는다. 이전 화면에서 열어둔 시트가 다음 화면에 leak 되는 회귀 방지. */ useEffectW(() => { setUploadSheet(false); }, [stack.join("|")]); const goPreviewFromIssue = () => { const iss = issueSheet; setIssueSheet(null); setFocusIssueId(iss?.id || null); setStack((s) => s.includes("preview") ? s : [...s, "preview"]); }; const onOpenPreview = () => { setFocusIssueId(null); setStack((s) => s.includes("preview") ? s : [...s, "preview"]); }; const onBackPreview = () => { setFocusIssueId(null); setStack((s) => s.filter((x) => x !== "preview")); }; const onBackResult = () => { setStack([]); setAnalysis(null); setAnalysisError(null); setFocusIssueId(null); }; const onOpenRecent = (r) => { if (!r) return; /* 서버에 저장된 기록인데 result 가 비어 있는 케이스 방어. * (DB row 의 result_json 이 깨졌거나 마이그레이션 누락 등) * 사일런트 폴백으로 mock 시나리오를 띄우면 "어떤 걸 눌러도 같은 화면" 처럼 * 보여 사용자가 클릭이 안 먹는다고 오해한다. 차라리 토스트로 못 불러왔다고 * 알리고 이동을 막는다. mock 데이터(history_id 가 없고 r1/r3 형태) 는 예외로 * 기존 폴백을 허용한다. */ const isServerRecord = !!(r.history_id || r.local_id); if (isServerRecord && !r.analysis) { console.warn("[onOpenRecent] 분석 결과를 못 불러왔어요:", { id: r.id, history_id: r.history_id }); if (_toast?.openToast) { _toast.openToast({ description: "이 기록은 다시 불러올 수 없어요. 새로 분석해 주세요." }); } return; } setScenario(r.type); persist({ scenario: r.type }); if (r.analysis) { setAnalysis(r.analysis); setAnalysisError(null); setAnalysisPending(false); } else { // mock 폴백 — 데모용 시나리오 화면 setAnalysis(null); } setStack(["result"]); }; /* 기록 한 건 삭제 — HistoryScreen 편집 모드에서 호출. * r 은 historyToRecent 가 만든 모양({ history_id, local_id, ... }). * auth.deleteHistory 가 서버 DELETE + 로컬 캐시 정리 + 스토어 갱신을 모두 처리한다. * 반환: { ok: bool, error? } — HistoryScreen 이 토스트로 결과를 알린다. */ const onDeleteRecent = async (r) => { if (!r) return { ok: false, error: new Error("기록이 없어요") }; if (typeof auth?.deleteHistory !== "function") { return { ok: false, error: new Error("삭제 기능을 쓸 수 없는 환경이에요") }; } return auth.deleteHistory({ history_id: r.history_id || "", local_id: r.local_id || "", }); }; /* 앱인토스 IAP 결제 흐름 (Task #197, 2026-04-24): * 1) TossAdapter.iap.purchase({ productId }) * → Toss SDK 가 결제 UI 표시, 사용자 승인 후 { orderId, receipt, productId } 반환 * → 웹 폴백: 스텁 receipt (백엔드가 검증 실패 → 사용자에게 에러 표시) * 2) POST /payments/iap/confirm { product_id, order_id, receipt, amount } * → 백엔드가 mTLS 로 Toss 서버에 영수증 검증 * → 성공하면 iap_grants 에 멱등 INSERT + profiles.credits 증가 * → 응답에 idempotent=True 가 오면 "이미 처리된 주문" 으로 취급 * * product_id 는 /client-config 에서 받아온 맵 사용. 콘솔 미등록이면 null → * BillingSheet 가 결제 버튼을 비활성화해야 함 (iapProductIds 상태로 전달). */ const onPurchase = async (plan) => { const accessToken = auth?.session?.access_token; if (!accessToken) { return { ok: false, message: "로그인 세션이 만료됐어요. 다시 시도해 주세요." }; } // 1) PurchaseKind → productId 해석 (auth store 가 /client-config 에서 받아온 값) const authState = auth || {}; const iapMap = { single_text: authState.iapProductIdSingleText, single_ocr: authState.iapProductIdSingleOcr, pack: authState.iapProductIdPack, }; const productId = iapMap[plan.kind]; if (!productId) { return { ok: false, message: "이 상품은 아직 결제 준비 중이에요. 잠시 후 다시 시도해 주세요.", }; } try { // 2) IAP 구매 (Toss SDK / 웹 폴백) let purchase = null; try { const adapter = (typeof window !== "undefined" && window.TossAdapter) || null; purchase = await adapter?.iap?.purchase?.({ productId, orderName: plan.title || plan.sub, }); } catch (err) { return { ok: false, message: err?.message || "결제 창을 여는 중에 문제가 생겼어요." }; } if (!purchase || !purchase.orderId || !purchase.receipt) { return { ok: false, message: "결제 정보를 받지 못했어요. 잠시 후 다시 시도해 주세요." }; } // 3) /payments/iap/confirm — 백엔드에서 영수증 검증 + 크레딧 지급 const confRes = await fetch("/payments/iap/confirm", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ product_id: productId, order_id: purchase.orderId, receipt: purchase.receipt, amount: plan.price, }), }); if (!confRes.ok) { const body = await confRes.json().catch(() => ({})); return { ok: false, message: body?.detail || `결제 승인에 실패했어요 (HTTP ${confRes.status})`, }; } const conf = await confRes.json(); // 성공: 최신 계정 스냅샷으로 갱신 await auth?.refreshAccount?.(); return { ok: true, creditsAdded: conf.credits_added || 0, total: (conf.free_uses_remaining || 0) + (conf.credits || 0), idempotent: !!conf.idempotent, // 재시도로 이미 처리된 주문이면 true }; } catch (err) { return { ok: false, message: err?.message || "네트워크 오류로 결제를 마치지 못했어요." }; } }; /* Tweaks jumps */ const onJump = (id) => { if (id === "landing") setRoute("landing"); else if (id === "login") setRoute("login"); else if (id === "signup") setRoute("signup"); else { setRoute("app"); if (id === "result") setStack(["result"]); else if (id === "preview") setStack(["result", "preview"]); else { setStack([]); setTab(id === "home" ? "home" : id); } } }; // Debug nav: expose on window for screenshot tooling useEffectW(() => { window.__webnav = onJump; return () => { delete window.__webnav; }; }); /* ====== toss-anonymous 모드: 랜딩/로그인 자체가 없음 ====== * 부트스트랩 끝나기 전 → 무자극 스플래시 (앱이 멈춰 보이지 않게 페이드 인 정도). * 부트스트랩 실패 → 사용자 안내 화면 (앱 업데이트 / 카테고리 거절 / 일시 오류). * 부트스트랩 성공 → 곧장 app 쉘로 폴 스루. */ if (isTossMode) { if (auth?.tossBootstrapError) { return (
잠시 기다려 주세요
{auth.tossBootstrapError}
); } if (!auth?.ready || !auth?.session) { // 부트스트랩 중 — 짧게 스치는 무자극 화면. 보통 100~300ms 안에 끝남. return (
); } // ready + session 있음 → app 쉘로 폴 스루. } /* ====== supabase-jwt (레거시) 모드 한정 랜딩/로그인 화면 ====== * G-9 검증 끝나면 .env 의 AUTH_MODE 가 toss-anonymous 로 굳어지면서 * 이 두 분기는 영원히 도달하지 않게 됨 → G-10 정리 단계에서 통째 삭제. */ if (!isTossMode && route === "landing") { return ( <> setTweaksOpen(false)} scenario={scenario} onScenario={(s) => { setScenario(s); persist({ scenario: s }); }} theme={theme} onTheme={(t) => { setTheme(t); persist({ theme: t }); }} onJump={onJump} /> ); } if (!isTossMode && (route === "login" || route === "signup")) { return ( <> { if (auth?.clearAuthError) auth.clearAuthError(); setRoute(m); }} onSubmit={onAuthSubmit} onClose={() => { if (auth?.clearAuthError) auth.clearAuthError(); toLanding(); }} errorMessage={auth?.authError} busy={!!auth?.authBusy} emailAuthOnly={true} /> setTweaksOpen(false)} scenario={scenario} onScenario={(s) => { setScenario(s); persist({ scenario: s }); }} theme={theme} onTheme={(t) => { setTheme(t); persist({ theme: t }); }} onJump={onJump} /> ); } /* App shell */ const hideNav = analyzing || stack.includes("capture") || stack.includes("picker") || stack.includes("paste"); return ( <> { setTab(t); setStack([]); }} onFab={onFab} onOpenBilling={() => setBillingSheet(true)} credits={resolvedCredits} unlimited={isUnlimited} hideNav={hideNav} sidebarCollapsed={sidebarCollapsed} onToggleSidebar={() => setSidebarCollapsed((v) => { persist({ sidebarCollapsed: !v }); return !v; })} > <> {tab === "home" && ( setBillingSheet(true)} onSeeAllRecents={() => { setTab("history"); setStack([]); }} recents={historyRecentsHome} historyLoading={!!auth?.historyLoading} hasSession={hasSession} userName={auth?.account?.email ? auth.account.email.split("@")[0] : null} /> )} {tab === "history" && ( )} {tab === "help" && } {tab === "account" && ( {}} /* 라이트 모드 강제 — 토글 비활성화 */ onOpenBilling={() => setBillingSheet(true)} /* 계정 화면의 "도움말과 문의" 행 → 하단 탭 도움말 화면으로 이동. setStack([])로 모달/오버레이 스택을 초기화해서 깔끔하게 전환. */ onOpenHelp={() => { setTab("help"); setStack([]); }} /* toss-anonymous 모드에서 화면 카피("로그아웃하기" → "내 기록 초기화") 분기. */ authMode={auth?.authMode || "toss-anonymous"} userEmail={auth?.account?.email || auth?.user?.email || null} onLogout={() => { if (auth?.session) { // toss-anonymous : signOut() 이 새 hash 로 즉시 재부트스트랩 → route 유지. // supabase-jwt : onAuthStateChange + useEffect 가 route 를 landing 으로. auth.signOut(); } else if (!isTossMode) { setRoute("login"); } // toss-anonymous + 세션 없음 = 부트스트랩 실패 화면이 떠 있음 → no-op. }} /> )} {stack.includes("capture") && ( setStack([])} onShoot={onCaptureShoot} /> )} {stack.includes("picker") && ( setStack([])} onPick={onPickerConfirm} /> )} {stack.includes("paste") && ( setStack([])} onSubmit={(text) => runAnalyzeText(text)} /> )} {stack.includes("result") && !stack.includes("preview") && (
)} {stack.includes("preview") && (
)} {analyzing && (
{ setAnalyzing(false); setAnalysisPending(false); }} onDone={onAnalyzeDone} />
)}
setUploadSheet(false)} title={ /* 큰 경고 배너 + 보조 안내. 홈 화면 배너와 동일한 톤·문구로 통일해서 사용자가 어느 진입점에서든 같은 액션 가이드를 듣게 함: "공인중개사에게 PDF 파일로 받아 올려라". */
💡 공인중개사에게 PDF 파일을 받아 올려주세요
중개사에게 "계약서 PDF로 보내주세요" 라고 요청하시면 훨씬 정확하게 분석해 드릴 수 있어요.
지원 형식: PDF · DOCX · XLSX · 이미지 여러 장
📸 사진은 평평한 곳 · 밝은 조명 · 정면에서 · 페이지 순서대로
} actions={[ { icon: "folder", label: "파일에서 고르기", onClick: () => onPickSource("upload") }, { icon: "photos", label: "이미지 여러 장 고르기", onClick: () => onPickSource("upload") }, { icon: "camera", label: "카메라로 찍기 (데모)", onClick: () => onPickSource("camera") }, { icon: "doc", label: "텍스트 붙여넣기", onClick: () => onPickSource("paste") }, ]} /> {/* 실제 파일 업로드용 숨김 input — multiple 속성으로 여러 이미지 허용. 단일 파일(docx/pdf/xlsx) 을 골라도 기존 플로우 그대로 작동하고, 이미지 여러 장 고르면 /extract-multi 를 거쳐 한 번에 분석. */} setIssueSheet(null)} header={이 조항 자세히 보기} > setBillingSheet(false)} header={크레딧 충전하기} > setBillingSheet(false)} /> setTweaksOpen(false)} scenario={scenario} onScenario={(s) => { setScenario(s); persist({ scenario: s }); }} theme={theme} onTheme={(t) => { setTheme(t); persist({ theme: t }); }} onJump={onJump} /> {/* G-8: 토스 hash 모드 첫 진입 안내. 하단 splash/landing 이 아니라 app 쉘 위에 얹어서 "앱이 비어 있다" 는 인상 없이 메시지만 받고 닫으면 바로 사용 가능. AlertDialog 는 단일 CTA(확인) 만 — 결제·무료권 안내는 본문에서 산문으로 처리. storage 키 toss_intro_seen_v1 로 1회 노출 보장 (위 useEffect 참고). */} {/* 파일 업로드 확인 modal — "[서명전에] 알림" 브랜드 헤더로 나타나는 커스텀 다이얼로그. native confirm 의 origin label ("127.0.0.1 says:") 제거 + 외부 컴포넌트 의존성 없이 inline JSX 로 자체 렌더해서 표시 누락 회귀도 함께 방어. open 가드: pendingFile 이 truthy 일 때만 mount. */} {pendingFile ? (
{ if (e.target === e.currentTarget) setPendingFile(null); }} >
[서명전에] 알림

이 파일을 분석할까요?

파일명: {pendingFile.name}
크기: {(pendingFile.size / 1024).toFixed(1)} KB
{/* 이미지면 정확도 안내 배너 — 원문(docx) / PDF 로 올릴 수 있으면 그게 훨씬 정확하다는 점을 고지. 사진 촬영이 유일한 선택지일 수도 있으니 strict 하게 막지는 않고 부드럽게 안내만. */} {/\.(jpg|jpeg|png|webp|heic)$/i.test(pendingFile.name || "") && (
사진은 글자 인식이 떨어질 수 있어요
중개사에게 "계약서 PDF로 보내주세요" 라고 요청하시는 게 가장 정확해요. 지금은 사진으로 그대로 진행해도 괜찮아요.
)}

다른 파일을 올리려면 [취소] 를 누른 뒤 다시 골라 주세요.

) : null} {/* 다중 파일 (이미지 여러 장) confirm modal — 위 단일 modal 과 거의 동일한 구조지만 파일명을 list 로 보여주고 [분석 시작] 시 runAnalyzeMulti 호출. */} {pendingFiles && pendingFiles.length > 0 ? (
{ if (e.target === e.currentTarget) setPendingFiles(null); }} >
[서명전에] 알림

이미지 {pendingFiles.length}장을 한꺼번에 분석할까요?

{pendingFiles.map((f, i) => (
{i + 1}. {f.name} {(f.size / 1024).toFixed(0)} KB
))}
{/* 이미지는 항상 사진이므로 조건 없이 배너 표시 */}
사진은 글자 인식이 떨어질 수 있어요
원본 파일(DOCX · PDF)이 있다면 그걸 올리는 게 훨씬 정확해요. 사진밖에 없다면 그대로 진행해도 괜찮아요.

순서대로 페이지로 이어붙여 한 번에 분석해요. 다시 고르려면 [취소].

) : null} {typeof window !== "undefined" && window.AlertDialog ? ( 토스로 자동 로그인됐어요 } description={

별도의 회원가입 없이, 토스 계정으로 안전하게 인증됐어요. 처음 한 번은 무료로 계약서를 검토해 볼 수 있어요.

베타 기간에 가입했던 분이라면, 보안 강화 작업으로 이전 분석 기록과 잔여 횟수가 초기화됐어요. 다시 한 번 무료권을 드리니 편하게 시작해 보세요.

} alertButton={ 시작하기 } /> ) : null} ); } ReactDOM.createRoot(document.getElementById("root")).render();