/* LuckyDraw.jsx — 福利獎池 (Weekly Draw with LINE redemption) Flow: 1. User types UID (must be exactly 11 digits). 2. UID is matched against the alliance member roster. 3. Only on a successful match does 主公 name display + 搖籤 button enable. 4. Click 搖籤 → cylinder shakes 1.2s → random prize is picked from the pool of (enabled && stock > 0) items. 5. Picked prize: stock auto -1 (persisted to sw_lottery_prizes_v2 → synced to Supabase by data-sync.js). 6. Modal pops with the won prize + Screenshot button (uses html2canvas to download a PNG) + LINE redemption button. 7. One draw per UID per weekly cycle. Cycle rolls Sunday 09:00. */ const LS_PRIZES_KEY = "sw_lottery_prizes_v2"; const LS_CLAIMS_KEY = "sw_welfare_lastdraw_v3"; // { [uid]: { date, prizeId, week } } const LS_WEEK_KEY = "sw_lottery_week_v1"; const MEMBERS_LS_KEY = "sw_alliance_members"; const LINE_INVITE_URL = "https://line.me/ti/g/acGDPGsBvP"; const UID_LENGTH = 11; // === Image resolution (standalone bundle support) === const _R2 = (typeof window !== "undefined" && window.__resources) ? window.__resources : {}; const _imgMap = { "./assets/prize-1-wanshi.webp?v=2": _R2.prize1, "./assets/prize-2-qianshi.webp?v=2": _R2.prize2, "./assets/prize-3-jade60.webp?v=2": _R2.prize3, "./assets/prize-4-jade300.webp?v=2": _R2.prize4, "./assets/prize-5-jade980.webp?v=2": _R2.prize5, "./assets/prize-6-jade1980.webp?v=2": _R2.prize6, "./assets/prize-7-jade3280.webp?v=2": _R2.prize7, "./assets/prize-8-jade6480.webp?v=2": _R2.prize8, }; const _ldResolveImg = (p) => { if (!p) return ""; if (p.startsWith("data:") || p.startsWith("blob:") || p.startsWith("http")) return p; return _imgMap[p] || p; }; // === Time helpers === const todayKey = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; }; const lastSundayNineAm = () => { const now = new Date(); const t = new Date(now); t.setHours(9, 0, 0, 0); const day = now.getDay(); if (day === 0) { if (now.getHours() < 9) t.setDate(t.getDate() - 7); } else { t.setDate(t.getDate() - day); } return t.getTime(); }; const weekKey = () => { const d = new Date(lastSundayNineAm()); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; }; const nextRefreshTime = () => { const now = new Date(); const day = now.getDay(); const target = new Date(now); target.setHours(9, 0, 0, 0); if (day === 0 && now.getHours() < 9) { // already today } else { const daysToSunday = (7 - day) % 7 || 7; target.setDate(now.getDate() + daysToSunday); } return target; }; const formatCountdown = (ms) => { if (ms <= 0) return "即將更新"; const totalSec = Math.floor(ms / 1000); const d = Math.floor(totalSec / 86400); const h = Math.floor((totalSec % 86400) / 3600); const m = Math.floor((totalSec % 3600) / 60); return `${d}日 ${String(h).padStart(2,"0")}時 ${String(m).padStart(2,"0")}分`; }; // === Prize storage === const _migrate = (p) => ({ id: p.id, name: p.name, tier: p.tier || "common", maxStock: Number.isFinite(Number(p.maxStock)) ? Number(p.maxStock) : Number(p.stock) || 0, stock: Number.isFinite(Number(p.stock)) ? Number(p.stock) : Number(p.maxStock) || 0, enabled: p.enabled === undefined ? true : !!p.enabled, img: p.img || "", }); const readPrizes = () => { try { const v = JSON.parse(localStorage.getItem(LS_PRIZES_KEY) || "null"); if (Array.isArray(v) && v.length) return v.map(_migrate).filter((p) => p.id !== "luck"); } catch {} return []; }; const writePrizes = (p) => { try { localStorage.setItem(LS_PRIZES_KEY, JSON.stringify(p)); } catch {} }; // Auto-refill once per weekly boundary (Sun 09:00). const maybeAutoReset = () => { try { const stored = localStorage.getItem(LS_WEEK_KEY); if (stored === weekKey()) return readPrizes(); const prizes = readPrizes().map((p) => ({ ...p, stock: p.maxStock })); writePrizes(prizes); localStorage.setItem(LS_WEEK_KEY, weekKey()); return prizes; } catch { return readPrizes(); } }; // Split "儲值 ×6480" → { base: "儲值", count: "×6480" } const splitName = (full) => { const m = String(full || "").match(/^(.*?)\s*(×\d+|x\d+)?$/); return { base: (m && m[1]) ? m[1].trim() : full, count: (m && m[2]) ? m[2] : "" }; }; // === Members === const readMembers = () => { try { return JSON.parse(localStorage.getItem(MEMBERS_LS_KEY) || '{"members":[]}').members || []; } catch { return []; } }; // === Claims (per-uid weekly limit) === const readClaims = () => { try { return JSON.parse(localStorage.getItem(LS_CLAIMS_KEY) || "{}"); } catch { return {}; } }; const writeClaims = (c) => { try { localStorage.setItem(LS_CLAIMS_KEY, JSON.stringify(c)); } catch {} }; // Pick a random prize from the (enabled && stock > 0) pool. Equal-probability; // stock count is consumed but does NOT bias selection (matches "all real, random"). const pickRandomPrize = (prizes) => { const pool = prizes.filter((p) => p.enabled && p.stock > 0); if (pool.length === 0) return null; return pool[Math.floor(Math.random() * pool.length)]; }; // ─── Component ────────────────────────────────────────────────────────────── const LuckyDraw = () => { const [prizes, setPrizes] = React.useState(() => maybeAutoReset()); const [uid, setUid] = React.useState(""); const [memberName, setMemberName] = React.useState(""); const [phase, setPhase] = React.useState("idle"); // idle | shaking | revealed | locked const [result, setResult] = React.useState(null); const [err, setErr] = React.useState(""); const [myClaim, setMyClaim] = React.useState(null); // { week, prizeId, date } | null const [countdown, setCountdown] = React.useState(formatCountdown(nextRefreshTime() - new Date())); // Sync prize config from storage / other tabs / admin pane React.useEffect(() => { const refresh = () => setPrizes(maybeAutoReset()); const onStorage = (e) => { if (e.key === LS_PRIZES_KEY || e.key === LS_WEEK_KEY || e.key === LS_CLAIMS_KEY) refresh(); }; window.addEventListener("storage", onStorage); const t = setInterval(refresh, 8000); return () => { window.removeEventListener("storage", onStorage); clearInterval(t); }; }, []); // Countdown ticker React.useEffect(() => { const tick = () => setCountdown(formatCountdown(nextRefreshTime() - new Date())); const t = setInterval(tick, 60000); return () => clearInterval(t); }, []); // Only resolve 主公 name when UID is exactly 11 digits AND matches a member. const uidDigits = uid.replace(/\D/g, ""); const uidValid = uidDigits.length === UID_LENGTH; React.useEffect(() => { if (!uidValid) { setMemberName(""); setMyClaim(null); return; } const m = readMembers().find((x) => x.uid === uidDigits); if (!m) { setMemberName(""); setMyClaim(null); return; } setMemberName(m.name || ""); const c = readClaims()[uidDigits]; setMyClaim(c && c.week === weekKey() ? c : null); }, [uidDigits, uidValid]); const canDraw = uidValid && memberName && !myClaim && phase === "idle"; const onDraw = () => { if (!canDraw) return; setErr(""); // Re-pull latest prizes (in case another tab just decremented) const live = maybeAutoReset(); const winnerPreview = pickRandomPrize(live); if (!winnerPreview) { setErr("獎池已抽乾 · 請等待週日補貨。"); return; } setPhase("shaking"); setResult(null); // Shake animation lasts ~1.2s setTimeout(() => { // Re-pick fresh in case state changed during the shake const after = maybeAutoReset(); const winner = pickRandomPrize(after); if (!winner) { setErr("獎池剛剛被抽乾 · 請等待週日補貨。"); setPhase("idle"); return; } // Decrement winner stock and persist const next = after.map((p) => p.id === winner.id ? { ...p, stock: Math.max(0, p.stock - 1) } : p); writePrizes(next); setPrizes(next); // Mark this UID as claimed for the week const claims = readClaims(); const rec = { date: todayKey(), prizeId: winner.id, week: weekKey() }; claims[uidDigits] = rec; writeClaims(claims); setMyClaim(rec); setResult(winner); setPhase("revealed"); }, 1200); }; const onCloseModal = () => setPhase("idle"); // Real screenshot via html2canvas (lazy-loaded on first use) const onScreenshot = async () => { const card = document.getElementById("prize-modal-card"); if (!card) return; if (typeof window.html2canvas !== "function") { try { await new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = "https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"; s.onload = resolve; s.onerror = () => reject(new Error("html2canvas load failed")); document.head.appendChild(s); }); } catch (e) { window.alert("截圖工具載入失敗 · 請檢查網路後重試。"); return; } } try { // brief flash for UX feedback card.classList.add("prize-modal__card--flash"); setTimeout(() => card.classList.remove("prize-modal__card--flash"), 600); const canvas = await window.html2canvas(card, { backgroundColor: null, scale: 2, useCORS: true, allowTaint: true, logging: false, }); const dataUrl = canvas.toDataURL("image/png"); const a = document.createElement("a"); a.href = dataUrl; a.download = `福利獎池-${memberName || uidDigits}-${todayKey()}.png`; document.body.appendChild(a); a.click(); document.body.removeChild(a); } catch (e) { console.error("[LuckyDraw] screenshot failed:", e); window.alert("截圖失敗 · 請手動截圖或重試。"); } }; const visible = prizes.filter((p) => p.enabled); // Determine what to render for the "right" face of the modal frame const resultImg = result ? _ldResolveImg(result.img) : ""; // Member-name display text logic let nameDisplay = "—"; let nameColor = "var(--fg-dim)"; if (uidValid && memberName) { nameDisplay = memberName; nameColor = "var(--paper-100)"; } else if (uidValid && !memberName) { nameDisplay = "UID 未登錄於同盟名冊"; nameColor = "#e07a6a"; } return (