leechard / static /js /app.js
nenae18's picture
Deploy SalonView AI (brand single-source)
7d8cf5c verified
Raw
History Blame Contribute Delete
32.2 kB
// 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(() => "<i></i>").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 <video src>, so the bytes are
// fetched here with the code and played from a blob URL.
let rvVideoPollTimer = null;
async function fetchOperatorVideoBlob(taskId) {
const r = await fetch(`${API}/video-file/${encodeURIComponent(taskId)}`,
{ headers: { "X-Access-Code": state.accessCode || "" } });
if (!r.ok) throw new Error(String(r.status));
return r.blob();
}
async function loadRecentVideo(taskId) {
try {
const blob = await fetchOperatorVideoBlob(taskId);
if (!recentViewItem || recentViewItem.task_id !== taskId) return false;
if (recentViewItem._videoObjUrl) URL.revokeObjectURL(recentViewItem._videoObjUrl);
const url = URL.createObjectURL(blob);
recentViewItem._videoObjUrl = url; // reused by μ €μž₯ without a second fetch
$("#rv-video").src = url;
$("#rv-tabs").hidden = false;
return true;
} catch (e) { return false; }
}
function openRecentView(it) {
recentViewItem = it;
if (rvVideoPollTimer) { clearTimeout(rvVideoPollTimer); rvVideoPollTimer = null; }
const imgUrl = sameOriginPath(it.image_url) || recentImageUrl(it.token);
$("#rv-img").src = imgUrl;
prefetchMedia(imgUrl); // warm for μ €μž₯ (share-sheet must fire within the tap window)
$("#rv-video").removeAttribute("src");
$("#rv-tabs").hidden = true;
const hasVideo = !!(it.has_video && it.task_id);
// make-video button: shown only when video is enabled, we have a task_id, and none exists yet
const mk = $("#rv-make-video"), st = $("#rv-video-status");
st.hidden = true; st.textContent = "";
mk.disabled = false; mk.textContent = "✨ 이 μ‚¬μ§„μœΌλ‘œ 홍보 μ˜μƒ λ§Œλ“€κΈ°";
mk.hidden = !(CFG.video_enabled && it.task_id && !hasVideo);
rvShow("photo");
$("#recent-link").textContent = recentShareUrl(it.token);
showScreen("recent-view");
if (hasVideo) loadRecentVideo(it.task_id); // fetch + play the existing promo clip
}
function rvShow(which) {
const photo = which === "photo";
$("#rv-photo-wrap").hidden = !photo;
$("#rv-video-wrap").hidden = photo;
$("#rv-tab-photo").classList.toggle("active", photo);
$("#rv-tab-video").classList.toggle("active", !photo);
const v = $("#rv-video");
if (photo) { try { v.pause(); } catch (e) { /* not loaded */ } }
else { try { v.play(); } catch (e) { /* needs a tap */ } }
$("#rv-save").textContent = photo ? "πŸ“₯ 사진 μ €μž₯" : "πŸ“₯ μ˜μƒ μ €μž₯";
}
// --- make a promo video from THIS saved photo (owner action; paid fal call, explicit) ---
$("#rv-make-video").addEventListener("click", startRecentVideo);
async function startRecentVideo() {
const it = recentViewItem;
if (!it || !it.task_id) return;
const tid = it.task_id;
const mk = $("#rv-make-video"), st = $("#rv-video-status");
mk.disabled = true;
st.hidden = false;
st.textContent = "홍보 μ˜μƒμ„ λ§Œλ“€κ³  μžˆμ–΄μš”β€¦ λΉ λ₯΄λ©΄ 30초, 보톡 1λΆ„ μ •λ„μ˜ˆμš”";
const data = new FormData();
data.append("access_code", state.accessCode || "");
try {
const r = await api(`${API}/video/${encodeURIComponent(tid)}`, { method: "POST", body: data });
if (!recentViewItem || recentViewItem.task_id !== tid) return;
if (r.video_status === "SUCCEEDED") { recentVideoReady(tid); return; }
if (r.video_status === "FAILED") { rvVideoFailed(); return; }
pollRecentVideo(tid);
} catch (e) {
if (!recentViewItem || recentViewItem.task_id !== tid) return;
rvVideoFailed("μ˜μƒ 생성을 μ‹œμž‘ν•˜μ§€ λͺ»ν–ˆμ–΄μš”. λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”.");
}
}
async function pollRecentVideo(tid) {
try {
const r = await fetch(`${API}/video/${encodeURIComponent(tid)}`,
{ headers: { "X-Access-Code": state.accessCode || "" } });
if (!r.ok) throw new Error(String(r.status));
const j = await r.json();
if (!recentViewItem || recentViewItem.task_id !== tid) return; // moved away: stop
if (j.video_status === "SUCCEEDED") { recentVideoReady(tid); return; }
if (j.video_status === "FAILED") { rvVideoFailed(); return; }
} catch (e) { /* transient poll error -> keep trying */ }
rvVideoPollTimer = setTimeout(() => pollRecentVideo(tid), 5000);
}
async function recentVideoReady(tid) {
const mk = $("#rv-make-video"), st = $("#rv-video-status");
const ok = await loadRecentVideo(tid);
if (recentViewItem && recentViewItem.task_id === tid) recentViewItem.has_video = true;
mk.hidden = true;
st.hidden = false;
st.textContent = ok
? "홍보 μ˜μƒμ΄ μ™„μ„±λμ–΄μš”! 'μ˜μƒ' νƒ­μ—μ„œ ν™•μΈν•˜κ³  μ €μž₯ν•˜μ„Έμš”."
: "μ˜μƒμ΄ μ™„μ„±λμ–΄μš”. 'μ˜μƒ' νƒ­μ—μ„œ ν™•μΈν•˜μ„Έμš”.";
if (ok) rvShow("video");
showToast("홍보 μ˜μƒμ΄ μ™„μ„±λμ–΄μš”!");
}
function rvVideoFailed(msg) {
const mk = $("#rv-make-video"), st = $("#rv-video-status");
mk.disabled = false;
st.hidden = false;
st.textContent = msg || "μ˜μƒ 생성에 μ‹€νŒ¨ν–ˆμ–΄μš”. λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”.";
}
// Save a photo/video to the phone's GALLERY. Web Share API (navigator.share with a File)
// gives the native sheet with "사진에 μ €μž₯ / Save to Photos" on both iOS and Android β€” the
// only reliable way to reach the gallery from a web page (a plain <a download> lands in
// Downloads on Android and does nothing useful on iOS). Falls back to a download when the
// browser has no file-share support.
const _mediaCache = {}; // url -> Promise<Blob>; pre-fetched so the save tap can call
function prefetchMedia(url) { // navigator.share within the user-activation window
if (url && !_mediaCache[url]) {
_mediaCache[url] = fetch(url).then((r) => { if (!r.ok) throw 0; return r.blob(); })
.catch(() => { delete _mediaCache[url]; return null; });
}
return _mediaCache[url];
}
async function saveMediaToGallery(url, filename, label) {
let blob;
try { blob = await (prefetchMedia(url) || fetch(url).then((r) => r.blob())); if (!blob) throw 0; }
catch (e) { showToast("λΆˆλŸ¬μ˜€μ§€ λͺ»ν–ˆμ–΄μš”. μ—°κ²° 확인 ν›„ λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”."); return; }
const file = new File([blob], filename, { type: blob.type || "application/octet-stream" });
if (navigator.canShare && navigator.canShare({ files: [file] })) {
try { await navigator.share({ files: [file], title: BRAND.wordmark }); return; }
catch (e) { if (e && e.name === "AbortError") return; /* cancelled */ }
}
const objUrl = URL.createObjectURL(blob);
const a = document.createElement("a"); a.href = objUrl; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(objUrl), 5000);
showToast(`${label} μ €μž₯을 μ‹œμž‘ν–ˆμ–΄μš”. λ‹€μš΄λ‘œλ“œλ₯Ό 확인해 μ£Όμ„Έμš”.`);
}
$("#rv-save").addEventListener("click", () => {
if (!recentViewItem) return;
const showingVideo = !$("#rv-video-wrap").hidden;
if (showingVideo && recentViewItem._videoObjUrl) {
// the promo clip was already fetched with the access code into a blob β€” save that
saveMediaToGallery(recentViewItem._videoObjUrl, `${BRAND.slug}_${recentViewItem.token}.mp4`, "μ˜μƒ");
} else {
saveMediaToGallery(sameOriginPath(recentViewItem.image_url) || recentImageUrl(recentViewItem.token),
`${BRAND.slug}_${recentViewItem.token}.png`, "사진");
}
});
$("#rv-tab-photo").addEventListener("click", () => rvShow("photo"));
$("#rv-tab-video").addEventListener("click", () => rvShow("video"));
$("#btn-recent").addEventListener("click", openRecent);
$("#recent-refresh").addEventListener("click", loadHistory);
$("#recent-copy").addEventListener("click", async () => {
try { await navigator.clipboard.writeText($("#recent-link").textContent || ""); showToast("링크λ₯Ό λ³΅μ‚¬ν–ˆμ–΄μš”."); }
catch { showToast("λ³΅μ‚¬ν•˜μ§€ λͺ»ν–ˆμ–΄μš”."); }
});
// --- access gate (only when the deployment sets an access code) ---
$("#access-go").addEventListener("click", submitAccessCode);
$("#access-input").addEventListener("keydown", (e) => { if (e.key === "Enter") submitAccessCode(); });
async function submitAccessCode() {
const code = $("#access-input").value.trim();
if (!code) return;
const data = new FormData();
data.append("code", code);
try {
await api(`${API}/access`, { method: "POST", body: data });
state.accessCode = code;
$("#access-error").hidden = true;
showScreen("landing");
} catch (e) {
$("#access-error").hidden = false;
}
}
// --- boot ---
// Honor reduced-motion: the gold waves are SVG SMIL (not CSS) and the loading notices
// are a JS interval, so the CSS reduced-motion query can't reach them β€” gate here.
const REDUCE_MOTION = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
if (REDUCE_MOTION) {
const sv = document.querySelector(".bg-waves svg");
if (sv && sv.pauseAnimations) sv.pauseAnimations();
}
buildStepbar();
const sharePath = location.pathname.match(/^\/result\/([^/]+)$/);
if (sharePath) {
// customer link: IMMEDIATELY hide the operator UI (landing ships active in the HTML)
// so a slow network never exposes operator screens to the customer, then load.
showScreen("share", {});
loadSharedResult(sharePath[1]); // customer share view never needs the access code
} else {
boot();
}
async function boot() {
// The free-tier server sleeps when idle; the first launch of the day can hit it mid
// wake-up. Retry quietly (up to ~90s) instead of silently landing on a broken app β€”
// the salon's first customer just sees "κΉ¨μš°λŠ” 쀑" once and it comes up by itself.
for (let attempt = 0; attempt < 30; attempt++) {
try {
const cfg = await api(`${API}/config`);
CFG = Object.assign(CFG, cfg);
// only steer the UI if the operator hasn't already started working β€” a late
// config success must NEVER yank them out of consent/capture/loading mid-flow
if (currentScreen === null || currentScreen === "landing") {
showScreen(cfg.access_required ? "access" : "landing");
}
return;
} catch (e) {
if (attempt === 0) showToast("μ„œλ²„λ₯Ό κΉ¨μš°λŠ” μ€‘μ΄μ—μš”β€¦ μž μ‹œλ§Œ κΈ°λ‹€λ € μ£Όμ„Έμš”. (μ΅œλŒ€ 1~2λΆ„)");
await new Promise((r) => setTimeout(r, 3000));
}
}
if (currentScreen === null) showScreen("landing"); // give up gracefully
}