// Premium salon SPA — operator flow (landing→consent→capture→loading→result→qr→done) // + customer share view (/result/{token}) + error/expired states. Wired to /api/v1/photos. // 브랜드명은 서버가 index.html 에 심어주는 window.__BRAND__ 에서만 읽는다 // (정본: app/core/brands.json). 하드코딩 금지 — 매장이 바뀌어도 이 파일은 그대로다. // 폴백은 서버 치환이 실패했을 때만 쓰인다 — brands.json 의 기본 프리셋과 같은 값으로 맞춰 둘 것. const BRAND = window.__BRAND__ || { wordmark: "SalonView AI", slug: "salonview", nameKo: "살롱뷰 AI" }; const API = "/api/v1/photos"; const $ = (s) => document.querySelector(s); const state = { file: null, originalUrl: null, // objectURL of the captured photo (operator before/after only) taskId: null, resultUrl: null, shareUrl: null, deliveryAvailable: true, accessCode: "", gender: "auto", // operator pick on capture screen: "auto" | "woman" | "man" retryTo: "capture", }; let CFG = { access_required: false, video_enabled: true }; // step screens in order (for the top progress bar) const STEPS = ["landing", "consent", "capture", "loading", "result", "qr", "done"]; function buildStepbar() { const bar = $("#stepbar"); bar.innerHTML = STEPS.map(() => "").join(""); } function setStep(name) { const idx = STEPS.indexOf(name); const items = $("#stepbar").children; for (let i = 0; i < items.length; i++) items[i].classList.toggle("on", idx >= 0 && i <= idx); $("#stepbar").style.display = idx >= 0 ? "flex" : "none"; } let currentScreen = null; let firstNav = true; function showScreen(name, opts = {}) { document.querySelectorAll(".screen").forEach((s) => s.classList.toggle("active", s.id === `screen-${name}`)); setStep(name); const screen = document.getElementById(`screen-${name}`); const top = screen && screen.querySelector(".screen-body"); if (top) top.scrollTop = 0; // Move focus to the new screen's heading (or primary action) for keyboard/screen-reader users. if (screen) { // access screen: focus the CODE INPUT so the tablet keyboard opens right away const target = name === "access" ? screen.querySelector("#access-input") : screen.querySelector(".title, .brand-logo, .btn-primary"); if (target) { if (!target.hasAttribute("tabindex") && name !== "access") target.setAttribute("tabindex", "-1"); try { target.focus({ preventScroll: true }); } catch (e) { /* older browsers */ } } } // ANDROID BACK: keep one history entry per screen visit so the hardware/gesture back // navigates to the previous screen instead of CLOSING the fullscreen app (PWA/TWA has // no browser UI, so an empty history = instant exit — reported by the operator). const prev = currentScreen; currentScreen = name; if (!opts.fromPop && name !== prev) { try { const url = location.pathname + location.search; if (firstNav) history.replaceState({ screen: name }, "", url); else history.pushState({ screen: name }, "", url); } catch (e) { /* very old browsers */ } firstNav = false; } } // Screens that hold per-customer data: back must NOT land here after the flow was reset // (the NEXT customer would see broken/previous-customer screens, or an unchecked-consent // capture screen whose upload can only 403). const _BACK_GUARDS = { result: () => !!state.resultUrl, qr: () => !!state.shareUrl, done: () => !!state.resultUrl, loading: () => false, // never re-enter a processing screen via back capture: () => $("#c-ai").checked && $("#c-qr").checked, // consent must still stand "recent-view": () => !!recentViewItem, }; window.addEventListener("popstate", (e) => { // never let back abandon an in-flight beautify: re-arm the entry and stay if (currentScreen === "loading") { try { history.pushState({ screen: "loading" }, "", location.pathname + location.search); } catch (err) { /* noop */ } showToast("보정 중이에요. 잠시만 기다려 주세요."); return; } let target = (e.state && e.state.screen) || null; if (!target) target = (CFG.access_required && !state.accessCode) ? "access" : "landing"; // the entry beneath 'result' is the finished 'loading' — going back from the result // must return to the result-bearing flow, never hide a just-paid result behind landing if (target === "loading") target = state.resultUrl ? "result" : "landing"; const guard = _BACK_GUARDS[target]; if (guard && !guard()) target = "landing"; // leaving the result back to capture = retake intent: clear the OLD photo so the // confirm button can't re-bill the identical file if (target === "capture" && currentScreen === "result") resetCapture(); showScreen(target, { fromPop: true }); // overwrite the consumed entry with what we actually showed, so stale per-customer // entries are progressively scrubbed instead of living in the stack all day try { history.replaceState({ screen: target }, "", location.pathname + location.search); } catch (err) { /* noop */ } }); let toastTimer = null; function showToast(msg) { const t = $("#toast"); t.textContent = msg; t.classList.add("show"); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 2600); } async function api(url, opts) { const res = await fetch(url, opts); if (res.status === 410) { const e = new Error("expired"); e.expired = true; throw e; } if (!res.ok) throw new Error((await res.text()) || `요청 실패: ${res.status}`); return res.json(); } // Result/image URLs may come back absolute with a configured host; reload them from // the page's own origin so they work behind the Cloudflare tunnel (or any host), // not the phone's own machine. function sameOriginPath(u) { if (!u) return u; try { const x = new URL(u, location.origin); return x.pathname + x.search; } catch (e) { return u; } } function showError(title, msg, retryTo) { $("#error-title").textContent = title; $("#error-msg").textContent = msg; state.retryTo = retryTo || "capture"; showScreen("error"); } // --- generic [data-go] navigation --- document.addEventListener("click", (e) => { const b = e.target.closest("[data-go]"); if (!b || b.disabled) return; if (b.dataset.go === "done") $("#done-thumb").src = state.resultUrl || ""; // result -> capture (다시 찍기 / topbar back) = retake: clear the old photo so the // confirm button can't silently re-bill the identical file if (b.dataset.go === "capture" && currentScreen === "result") resetCapture(); showScreen(b.dataset.go); }); // --- consent: enable CTA only when both required boxes are checked --- function syncConsent() { $("#consent-go").disabled = !($("#c-ai").checked && $("#c-qr").checked); } ["c-ai", "c-qr"].forEach((id) => $("#" + id).addEventListener("change", syncConsent)); // --- capture: tap to open camera / gallery --- const input = $("#photo-input"); $("#btn-shoot").addEventListener("click", () => { input.setAttribute("capture", "environment"); input.click(); }); $("#btn-gallery").addEventListener("click", () => { input.removeAttribute("capture"); input.click(); }); $("#btn-retake").addEventListener("click", resetCapture); // operator picks gender (overrides auto-detect so a misread face routes correctly) document.querySelectorAll("#gender-pick .gender-opt").forEach((b) => { b.addEventListener("click", () => { document.querySelectorAll("#gender-pick .gender-opt").forEach((o) => o.classList.remove("active")); b.classList.add("active"); state.gender = b.dataset.gender || "auto"; }); }); function resetGenderPick() { state.gender = "auto"; document.querySelectorAll("#gender-pick .gender-opt").forEach((o) => o.classList.toggle("active", o.dataset.gender === "auto")); } const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; const MAX_MB = 20; input.addEventListener("change", (ev) => { const f = ev.target.files && ev.target.files[0]; if (!f) return; // Validate up front so the operator gets a clear message instead of a server 415/413. // (Empty type is allowed through — some pickers omit it; the server still guards.) if (f.type && !ALLOWED_TYPES.includes(f.type)) { showToast("JPG·PNG·WEBP 사진만 올릴 수 있어요. (아이폰 HEIC는 변환 후 올려 주세요)"); input.value = ""; return; } if (f.size > MAX_MB * 1024 * 1024) { showToast(`사진이 너무 커요 (최대 ${MAX_MB}MB). 더 작은 사진으로 올려 주세요.`); input.value = ""; return; } state.file = f; if (state.originalUrl) URL.revokeObjectURL(state.originalUrl); state.originalUrl = URL.createObjectURL(f); const prev = $("#capture-preview"); prev.src = state.originalUrl; prev.hidden = false; $("#face-guide").style.display = "none"; $("#capture-hint").textContent = "이 사진으로 진행할까요?"; $("#capture-actions").hidden = true; $("#capture-confirm").hidden = false; }); function resetCapture() { state.file = null; $("#capture-preview").hidden = true; $("#face-guide").style.display = ""; $("#capture-hint").textContent = "얼굴이 잘 보이게 정면으로 맞춰 주세요"; $("#capture-actions").hidden = false; $("#capture-confirm").hidden = true; resetGenderPick(); input.value = ""; } // --- upload + processing --- $("#btn-use").addEventListener("click", startProcessing); async function startProcessing() { if (!state.file) { showToast("사진을 먼저 선택해 주세요."); return; } $("#loading-bg").style.backgroundImage = `url(${state.originalUrl})`; showScreen("loading"); startLoadingTimer(); const data = new FormData(); data.append("file", state.file); // 매장 집계용 내부 식별자 — 손님에게 보이지 않고, 기존 사용량 집계가 끊기지 않도록 // 브랜드명이 바뀌어도 그대로 둔다 (표시용 이름은 BRAND.wordmark). data.append("salon_id", "leechard_pro"); data.append("ai_consent", $("#c-ai").checked ? "true" : "false"); data.append("qr_delivery_allowed", $("#c-qr").checked ? "true" : "false"); data.append("marketing_allowed", $("#c-mkt").checked ? "true" : "false"); if (state.gender && state.gender !== "auto") data.append("gender", state.gender); data.append("access_code", state.accessCode || ""); try { const up = await api(`${API}/upload`, { method: "POST", body: data }); state.taskId = up.task_id; statusFailures = 0; await pollStatus(); } catch (err) { stopLoadingTimer(); // tell the operator WHAT actually failed instead of blaming the network for a 403 const msg = String((err && err.message) || ""); if (msg.includes("access code")) { showToast("접속 코드를 다시 입력해 주세요."); state.accessCode = ""; showScreen("access"); } else if (msg.includes("consent")) { showError("동의가 필요해요", "필수 동의 항목을 체크한 뒤 다시 시도해 주세요.", "consent"); } else { showError("업로드에 실패했어요", "네트워크를 확인하고 다시 시도해 주세요.", "capture"); } } } let loadingT0 = 0, loadingTimer = null; function startLoadingTimer() { loadingT0 = Date.now(); const tick = () => { const s = (Date.now() - loadingT0) / 1000; // single opaque AI call: ease toward 92% so the bar moves immediately and never stalls const pct = Math.min(92, Math.round(100 * (1 - Math.exp(-s / 15)))); $("#progress-fill").style.width = pct + "%"; $("#loading-pct").textContent = pct + "%"; $("#loading-time").textContent = s < 30 ? "보통 15초, 길면 1분 정도 걸려요" : `${Math.round(s)}초째 처리 중… (처음 한 장은 더 걸려요)`; }; tick(); loadingTimer = setInterval(tick, 500); startNotices(); } function setProgressComplete() { $("#progress-fill").style.width = "100%"; $("#loading-pct").textContent = "100%"; } function stopLoadingTimer() { if (loadingTimer) { clearInterval(loadingTimer); loadingTimer = null; } stopNotices(); } // rotating notices shown on the loading screen while the result is processed. // Each notice carries EXPLICIT line breaks (rendered via white-space: pre-line) so the // text always sits on 1-2 meaningful lines — never wrapped mid-phrase by the browser. const NOTICES = [ "헤어·의상·배경은 그대로,\n얼굴만 더 멋지게 보정돼요.", "결과는 QR로 손님 휴대폰에\n바로 전달돼요 (7일간 유효)", "AI로 생성·편집된\n가상 이미지예요.", "원본 사진은 서버에\n저장되지 않아요.", "보통 15초, 길면 1분 걸려요.\n잠시만 기다려 주세요.", ]; let noticeTimer = null, noticeIdx = 0; function startNotices() { const el = $("#notice-text"); if (!el) return; noticeIdx = 0; el.textContent = NOTICES[0]; el.style.opacity = "1"; if (noticeTimer) clearInterval(noticeTimer); if (REDUCE_MOTION) return; // reduced-motion: show one static notice, don't rotate noticeTimer = setInterval(() => { noticeIdx = (noticeIdx + 1) % NOTICES.length; el.style.opacity = "0"; setTimeout(() => { el.textContent = NOTICES[noticeIdx]; el.style.opacity = "1"; }, 350); }, 3600); } function stopNotices() { if (noticeTimer) { clearInterval(noticeTimer); noticeTimer = null; } } const FAIL_MSG = { QC_FAILED: ["얼굴이 잘 안 보여요", "밝은 곳에서 얼굴이 정면으로 보이게 다시 찍어 주세요."], AI_FAILED: ["지금 보정이 어려워요", "잠시 후 다시 시도해 주세요."], QA_FAILED: ["결과 검수를 통과하지 못했어요", "다시 한 번 시도해 주세요."], }; let statusFailures = 0; async function pollStatus() { let st; try { st = await api(`${API}/status/${state.taskId}`); statusFailures = 0; } catch (e) { // NEVER declare a PAID in-flight beautify dead on one wifi blip: the fal job keeps // running server-side, so tolerate several consecutive poll failures first statusFailures++; if (statusFailures <= 8) { setTimeout(pollStatus, 1600); return; } stopLoadingTimer(); showError("처리 상태를 확인하지 못했어요", "네트워크가 불안정해요. 결과는 '작업 내역'에 저장되니 잠시 후 확인해 주세요.", "capture"); return; } if (st.status === "SUCCEEDED") { stopLoadingTimer(); setProgressComplete(); state.resultUrl = sameOriginPath(st.result_url); // only offer QR hand-off if the backend confirms delivery is allowed (consent honored) state.deliveryAvailable = st.delivery_available !== false; showResult(); return; } if (st.is_terminal) { stopLoadingTimer(); const [t, m] = FAIL_MSG[st.error_code] || FAIL_MSG[st.status] || ["보정에 실패했어요", "다시 시도해 주세요."]; showError(t, m, "capture"); return; } setTimeout(pollStatus, 1100); } // --- result --- function showResult() { $("#ba-after").src = state.resultUrl; $("#ba-before").src = state.originalUrl || state.resultUrl; const range = $("#ba-range"); const apply = () => $("#ba").style.setProperty("--p", range.value + "%"); range.oninput = apply; range.value = 50; apply(); $("#result-go").disabled = !state.deliveryAvailable; showScreen("result"); addRecent(tokenFromResultUrl(state.resultUrl)); // remember it in the local gallery } // Result screen is PHOTO-ONLY: the customer is handed a photo (fast, no video wait/cost). // Promo video is made later by the owner from '작업 내역' (recent-view). Save the result // photo onto the OPERATOR's device (independent of the server's 7-day retention). $("#btn-save").addEventListener("click", () => { if (state.resultUrl) saveMediaToGallery(state.resultUrl, `${BRAND.slug}_${Date.now()}.png`, "사진"); else showToast("결과가 아직 없어요."); }); $("#result-go").addEventListener("click", loadQr); async function loadQr() { try { const qr = await api(`${API}/qr/${state.taskId}`); $("#qr-img").src = `data:image/png;base64,${qr.qr_png_base64}`; state.shareUrl = qr.share_url || ""; $("#share-link").textContent = state.shareUrl; $("#done-thumb").src = state.resultUrl || ""; showScreen("qr"); } catch (e) { showError("QR 생성에 실패했어요", "결과가 준비되었는지 확인하고 다시 시도해 주세요.", "result"); } } $("#btn-copy").addEventListener("click", async () => { try { await navigator.clipboard.writeText(state.shareUrl || ""); showToast("링크를 복사했어요."); } catch { showToast("복사하지 못했어요."); } }); // --- error retry + done home --- $("#error-retry").addEventListener("click", () => { if (state.retryTo === "capture") resetCapture(); showScreen(state.retryTo || "capture"); }); $("#done-home").addEventListener("click", () => { resetCapture(); state.taskId = null; state.resultUrl = null; state.shareUrl = null; recentViewItem = null; // close the back-guard into the previous customer's viewer too $("#c-ai").checked = $("#c-qr").checked = $("#c-mkt").checked = false; syncConsent(); showScreen("landing"); }); // --- customer share view (/result/{token}) — PHOTO ONLY --- // The customer is never handed a video: video is the salon's promo tool, so it never // appears here (no wait, no cost surprise). The server also returns no video URL for // the customer share path — this is enforced on both ends. let shareData = { token: null, imageUrl: null }; async function loadSharedResult(token) { // A sleeping/waking server or a cell blip must NOT show a paying customer the terminal // "링크가 만료되었습니다" screen — only a real 410 means expired. Retry transient failures. for (let attempt = 0; attempt < 20; attempt++) { try { const share = await api(`${API}/share/${encodeURIComponent(token)}`); shareData = { token, imageUrl: sameOriginPath(share.result_image_url) }; $("#share-img").src = shareData.imageUrl; prefetchMedia(shareData.imageUrl); // warm the blob so 저장 fires within the tap window showScreen("share"); return; } catch (e) { if (e && e.expired) { showScreen("expired"); return; } // true 410 only if (attempt === 0) showToast("불러오는 중이에요… 잠시만 기다려 주세요."); await new Promise((r) => setTimeout(r, 3000)); } } // persistent failure ≠ expired: say so honestly and suggest retrying the link $("#screen-expired .title").textContent = "연결이 원활하지 않아요"; $("#screen-expired .error-msg").textContent = "잠시 후 링크를 다시 열어 주세요."; showScreen("expired"); } $("#share-save").addEventListener("click", () => { if (shareData.imageUrl) { saveMediaToGallery(shareData.imageUrl, `${BRAND.slug}_${shareData.token}.png`, "사진"); } }); // --- work history (operator): SERVER-BACKED so a designer sees every past result on ANY // tablet, after a PWA reinstall, or after a cache wipe (the results live on the persistent // volume). A small localStorage cache is kept only as an OFFLINE fallback for the grid. const RECENT_KEY = "leechard_recent_v1"; const RECENT_MAX = 60; let recentViewItem = null; // { token, image_url, video_url, has_video } function loadRecent() { try { const l = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]"); return Array.isArray(l) ? l : []; } catch { return []; } } function saveRecent(list) { try { localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX))); } catch { /* quota */ } } function tokenFromResultUrl(u) { const m = /\/share\/([^/]+)\/image/.exec(u || ""); return m ? m[1] : null; } function addRecent(token) { // offline cache only; the server /history is the real source if (!token) return; const list = loadRecent().filter((e) => e.token !== token); list.unshift({ token, at: Date.now() }); saveRecent(list); } function recentImageUrl(token) { return `${API}/share/${encodeURIComponent(token)}/image`; } function recentShareUrl(token) { return `${location.origin}/result/${encodeURIComponent(token)}`; } async function openRecent() { recentViewItem = null; // returning to the grid: the back-guard into the viewer closes showScreen("recent"); await loadHistory(); } async function loadHistory() { const grid = $("#recent-grid"), empty = $("#recent-empty"); grid.innerHTML = ""; empty.hidden = true; let items = null; try { const r = await fetch(`${API}/history`, { headers: { "X-Access-Code": state.accessCode || "" } }); if (r.status === 403) { // wrong/lost code is NOT "server down" — send to the gate showToast("접속 코드가 필요해요."); state.accessCode = ""; showScreen("access"); return; } if (!r.ok) throw new Error(String(r.status)); items = await r.json(); } catch (e) { // server unreachable -> fall back to the local cache so the designer still sees thumbnails items = loadRecent().map((e2) => ({ token: e2.token, image_url: recentImageUrl(e2.token), has_video: false })); } empty.hidden = items.length > 0; for (const it of items) renderHistoryCell(grid, empty, it); } function renderHistoryCell(grid, empty, it) { const cell = document.createElement("button"); cell.className = "recent-item"; cell.type = "button"; const img = document.createElement("img"); img.alt = "AI 결과"; img.onerror = () => { cell.classList.add("offline"); cell.title = "연결 후 다시 표시돼요"; }; img.src = sameOriginPath(it.image_url) || recentImageUrl(it.token); cell.appendChild(img); if (it.has_video) { const b = document.createElement("span"); b.className = "recent-badge"; b.textContent = "▶"; cell.appendChild(b); } cell.addEventListener("click", () => openRecentView(it)); grid.appendChild(cell); } // promo video is OWNER-only: served access-code gated and keyed by task_id (never the // customer share path). A custom header can't ride on a