// 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