/* ============================================================================
* 서명전에 — 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 });