/* ============================================================================ * 서명전에 — iOS Capture & Picker screens * 사진 찍기 / 파일 · 사진 업로드 탭 후 실제 분석 직전에 들어가는 중간 화면. * ========================================================================== */ /* -------------------- 카메라 촬영 -------------------- */ function CaptureScreen({ onBack, onShoot }) { const [flash, setFlash] = useState(false); const [grid, setGrid] = useState(true); const [aligned, setAligned] = useState(false); const [busy, setBusy] = useState(false); // 살짝 움직이는 가이드 효과 useEffect(() => { const t = setTimeout(() => setAligned(true), 800); return () => clearTimeout(t); }, []); /* 셔터. TossAdapter.camera.open 이 있으면 실촬영 → {id, dataUri} 를 onShoot 에 * 넘기고, 없거나 지원하지 않으면 기존 목업 플로우로 빠진다. 어댑터의 웹 폴백은 * input[type=file][capture=environment] 로 시스템 카메라를 연다. */ const shoot = async () => { if (busy) return; setFlash(true); setBusy(true); try { const adapter = (typeof window !== "undefined" && window.TossAdapter) || null; if (adapter?.camera?.open) { try { const image = await adapter.camera.open({ type: "photo", base64: true, maxWidth: 1600, }); // image = { id, dataUri, mimeType? } setTimeout(() => onShoot(image), 180); return; } catch (err) { // 권한 거부 / 사용자 취소 / 미지원 → 목업으로 폴백 if (adapter.TossPermissionError && err instanceof adapter.TossPermissionError) { alert("카메라 권한이 필요해요"); setBusy(false); setFlash(false); return; } console.warn("[capture] adapter.camera.open failed, falling back to demo:", err); } } // 최후 폴백: 기존 시나리오 목업 setTimeout(() => onShoot(null), 180); } finally { // 다음 촬영을 위해 busy 해제 (onShoot 이 stack 을 바꾸므로 대개 unmount 됨) setTimeout(() => { setBusy(false); setFlash(false); }, 200); } }; return (
{/* 상단 바 */}
계약서 촬영
{/* 뷰파인더 */}
{/* 가짜 종이 문서 프리뷰 */}
주택 전세 계약서
{/* 격자 */} {grid && (
)} {/* 문서 자동 감지 프레임 */}
{/* 가이드 토스트 */}
{aligned ? "문서가 잘 보여요. 찍어도 돼요" : "계약서를 프레임 안에 맞춰 주세요"}
{/* 플래시 오버레이 */} {flash &&
}
{/* 하단 컨트롤 */}
테두리를 자동으로 찾고 기울기도 맞춰 드려요
); } /* -------------------- 파일 · 사진 업로드 -------------------- */ function PickerScreen({ onBack, onPick }) { const [tab, setTab] = useState("photos"); // photos | files const [selected, setSelected] = useState(null); // 실제 앨범(TossAdapter.photos.fetchAlbum 결과) — 로딩 성공하면 목업을 덮는다. const [albumPhotos, setAlbumPhotos] = useState(null); // ImageResponse[] | null const [albumError, setAlbumError] = useState(null); const [albumLoading, setAlbumLoading] = useState(false); // 앨범 탭으로 들어왔을 때 1회만 로딩 시도. useEffect(() => { if (tab !== "photos") return; if (albumPhotos !== null) return; // 이미 로딩됨 const adapter = (typeof window !== "undefined" && window.TossAdapter) || null; if (!adapter?.photos?.fetchAlbum) return; // 어댑터 없음 → 목업 유지 // 웹 폴백의 fetchAlbum 은 input[type=file] 을 열기 때문에 즉시 자동 호출하면 // 사용자 제스처가 없어서 막힐 수 있다. 앱인토스 환경에서만 자동 로딩한다. if (!adapter.isInAppsInToss?.()) return; let cancelled = false; setAlbumLoading(true); adapter.photos .fetchAlbum({ base64: true, maxCount: 24, maxWidth: 360 }) .then((images) => { if (cancelled) return; setAlbumPhotos(Array.isArray(images) ? images : []); setAlbumLoading(false); }) .catch((err) => { if (cancelled) return; if (adapter.TossPermissionError && err instanceof adapter.TossPermissionError) { setAlbumError("앨범 접근 권한이 필요해요"); } else { setAlbumError(err?.message || "앨범을 불러오지 못했어요"); } setAlbumLoading(false); }); return () => { cancelled = true; }; }, [tab, albumPhotos]); const photos = [ { id: "p1", kind: "contract", label: "전세계약서", tint: "#6366f1" }, { id: "p2", kind: "receipt", label: "영수증", tint: "#14b8a6" }, { id: "p3", kind: "contract", label: "월세계약_202603", tint: "#f59e0b" }, { id: "p4", kind: "photo", label: "풍경", tint: "#60a5fa" }, { id: "p5", kind: "contract", label: "월세계약서", tint: "#8b5cf6" }, { id: "p6", kind: "photo", label: "음식", tint: "#ef4444" }, { id: "p7", kind: "contract", label: "임대차 수정본", tint: "#0ea5e9" }, { id: "p8", kind: "photo", label: "스크린샷", tint: "#64748b" }, { id: "p9", kind: "contract", label: "전세_v2", tint: "#10b981" }, ]; /* 앨범 로드 성공 시 실제 이미지로 렌더. fetchAlbum 반환 shape: * { id, dataUri, mimeType? } — base64: true 이면 dataUri 는 순수 base64 이므로 * "data:;base64," 접두사를 붙여야 에서 쓸 수 있다. */ const useRealAlbum = Array.isArray(albumPhotos); const realImageSrc = (img) => { if (!img?.dataUri) return ""; if (/^data:|^file:|^content:|^https?:/.test(img.dataUri)) return img.dataUri; const mime = img.mimeType || "image/jpeg"; return `data:${mime};base64,${img.dataUri}`; }; const files = [ { id: "f1", name: "전세계약서_구로동_202604.pdf", size: "1.2MB", when: "오늘", type: "pdf" }, { id: "f2", name: "월세계약서_최종본.pdf", size: "842KB", when: "어제", type: "pdf" }, { id: "f3", name: "월세계약서_원본.pdf", size: "1.8MB", when: "3월 30일", type: "pdf" }, { id: "f4", name: "IMG_5521.HEIC", size: "3.4MB", when: "3월 28일", type: "img" }, { id: "f5", name: "스캔본_20260312.pdf", size: "2.1MB", when: "3월 12일", type: "pdf" }, ]; const canContinue = selected !== null; /* 선택 확정 시 호출. 실제 앨범 항목이 있으면 ImageResponse 객체를, 아니면 id 를 * 반환한다. 호출부(web-app.jsx) 는 객체이면 dataUriToFile 로 File 변환해 실제 * /analyze 를 호출하고, 문자열 id 이면 기존 목업 경로를 탄다. */ const pickPayload = () => { if (useRealAlbum && albumPhotos) { const hit = albumPhotos.find((x) => x.id === selected); if (hit) return hit; } return selected; }; return (
파일 올리기
{/* 세그먼티드 */}
{tab === "photos" ? (
{useRealAlbum ? "앨범에서 고르기" : "최근에 찍은 사진"}
{albumLoading && (
앨범 불러오는 중…
)} {albumError && !albumLoading && (
{albumError}
)} {useRealAlbum && !albumLoading && !albumError ? ( // 실제 앨범 렌더
{albumPhotos.length === 0 ? (
앨범에 사진이 없어요
) : ( albumPhotos.map((img) => ( )) )}
) : ( // 목업 렌더 (웹 환경 / 어댑터 없음 / 에러 시 폴백)
{photos.map((p) => ( ))}
)}
) : (
iCloud Drive
{files.map((f) => ( ))}
)} {/* 하단 액션 */}
); } window.CaptureScreen = CaptureScreen; window.PickerScreen = PickerScreen; Object.assign(window, { CaptureScreen, PickerScreen });