// app.jsx — Mobile-first prototype with backend, audio, hash-routing, admin auth. const { useState, useEffect, useReducer, useRef } = React; const M_W = 402; const M_H = 874 - 44 - 34; const SPIN_MS = 4200; // Admin auth is handled SERVER-SIDE by Caddy Basic Auth on the /admin/ path // (bcrypt hash in the Caddyfile). No password lives in this bundle anymore. // The admin UI is served from /admin/index.html which sets window.FDS_ADMIN_PAGE. // ─────────────── Hash router ─────────────── function useHashRoute() { const parse = () => (window.location.hash || '#').slice(1) || 'home'; const [route, setRoute] = useState(parse()); useEffect(() => { const onHash = () => setRoute(parse()); window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []); const navigate = (path) => { if (path === 'home') window.location.hash = ''; else window.location.hash = path; }; return [route, navigate]; } // Subscribe to backend so screens re-render when admin changes labels/stock. function useBackendState() { const [, force] = useReducer(x => x + 1, 0); useEffect(() => { if (!window.FDSBackend) return; return window.FDSBackend.subscribe(() => force()); }, []); return window.FDSBackend?.getStats() || null; } function ProtoFlow({ forcedOutcome = null, onNavigate }) { const [screen, setScreen] = useState('landing'); const [prize, setPrize] = useState('cafe'); const [spin, setSpin] = useState({ phase: 'idle', rotation: 0 }); const [soundOn, setSoundOnState] = useState(false); const [errKind, setErrKind] = useState(null); const [cooldownMs, setCooldownMs] = useState(0); // Second-chance state const [quizIdx, setQuizIdx] = useState(0); const secondChanceUsedRef = useRef(false); const autoSpinPendingRef = useRef(false); const stats = useBackendState(); const prizes = stats?.prizes; const mapping = stats?.wheelMapping; const setSoundOn = (v) => { const next = typeof v === 'function' ? v(soundOn) : v; setSoundOnState(next); window.FDSAudio?.setEnabled(next); }; const handleSpin = async () => { if (spin.phase !== 'idle') return; let result; if (forcedOutcome) { result = { ok: true, outcome: forcedOutcome === 'lose' ? 'lose' : 'win', prize: forcedOutcome === 'lose' ? null : forcedOutcome }; } else { // Tirage autoritaire côté serveur (asynchrone). result = await window.FDSBackend.participate(); } if (!result.ok) { if (result.reason === 'cooldown') { setCooldownMs(result.meta?.remainingMs || 0); setErrKind('A'); } else if (result.reason === 'sold_out') { setErrKind('B'); } else { setErrKind('C'); } setScreen('error'); return; } // Build current sectors from backend mapping & pick a target index. const sectors = window.buildWheelSectors(prizes, mapping); let targetIdx; if (result.outcome === 'lose') { const losers = sectors.map((s, i) => s.win === null ? i : -1).filter(i => i >= 0); targetIdx = losers[Math.floor(Math.random() * losers.length)]; } else { const matches = sectors.map((s, i) => s.win === result.prize ? i : -1).filter(i => i >= 0); targetIdx = matches[Math.floor(Math.random() * matches.length)] ?? 0; } const sliceDeg = 360 / sectors.length; const sectorCenterAt0 = -90 + targetIdx * sliceDeg + sliceDeg / 2; const baseR = (-90 - sectorCenterAt0) % 360; const turns = 5; const finalRotation = spin.rotation + (turns * 360) + ((baseR + 360) % 360 - (spin.rotation % 360)); window.FDSAudio?.scheduleSpinTicks({ totalMs: SPIN_MS, count: 38 }); setSpin({ phase: 'spinning', rotation: finalRotation }); setTimeout(() => { if (result.outcome === 'win') { setPrize(result.prize); setScreen('win'); setTimeout(() => window.FDSAudio?.winJingle(), 300); } else if (!secondChanceUsedRef.current) { // First lose of the round → offer the second-chance quiz instead of "Perdu" setQuizIdx(Math.floor(Math.random() * window.SECOND_CHANCE_QUIZ.length)); setScreen('second-chance'); } else { // Already used second chance — go straight to lose setScreen('lose'); setTimeout(() => window.FDSAudio?.loseThud(), 200); } setSpin(s => ({ ...s, phase: 'done' })); }, SPIN_MS + 100); }; const handleSecondChanceCorrect = () => { secondChanceUsedRef.current = true; autoSpinPendingRef.current = true; setSpin({ phase: 'idle', rotation: 0 }); setScreen('wheel'); // Auto-respin is triggered by the useEffect below once the wheel re-renders // with phase='idle' (avoids stale closures). }; const handleSecondChanceWrong = () => { secondChanceUsedRef.current = true; setScreen('lose'); setTimeout(() => window.FDSAudio?.loseThud(), 200); }; const reset = () => { setSpin({ phase: 'idle', rotation: 0 }); setErrKind(null); secondChanceUsedRef.current = false; autoSpinPendingRef.current = false; setScreen('landing'); }; // Auto-trigger a fresh spin after the player won the second-chance quiz. useEffect(() => { if (screen === 'wheel' && spin.phase === 'idle' && autoSpinPendingRef.current) { autoSpinPendingRef.current = false; const t = setTimeout(() => handleSpin(), 600); return () => clearTimeout(t); } }, [screen, spin.phase]); // eslint-disable-line // Bouton "Jouer" du bandeau (TopBanner) → amène DIRECTEMENT à la roue depuis // n'importe quel écran (y compris depuis l'accueil), pour une action visible. useEffect(() => { const h = () => { secondChanceUsedRef.current = false; autoSpinPendingRef.current = false; setErrKind(null); setSpin({ phase: 'idle', rotation: 0 }); setScreen('wheel'); }; window.addEventListener('fds:goto-game', h); return () => window.removeEventListener('fds:goto-game', h); }, []); // eslint-disable-line const w = M_W, h = M_H; const nav = onNavigate || (() => {}); // Build the current screen element (one place, one return -> wraps with transition) let screenEl = null; let transitionKey = screen; if (screen === 'error' && errKind) { transitionKey = `error-${errKind}`; screenEl = ; } else if (screen === 'landing') { screenEl = { // Daily lock check before entering the wheel — if same device already played today, // route directly to the "Du calme du calme" screen. if (window.FDSBackend?.hasPlayedToday?.()) { setCooldownMs(window.FDSBackend.msUntilParisMidnight()); setErrKind('A'); setScreen('error'); return; } setScreen('wheel'); }} onNavigate={nav} />; } else if (screen === 'wheel') { screenEl = setSoundOn(v => !v)} onNavigate={nav} onBack={reset} />; } else if (screen === 'win') { screenEl = ; } else if (screen === 'second-chance') { screenEl = ; } else if (screen === 'lose') { screenEl = ; } return {screenEl}; } function StaticScreen({ kind, prize }) { const noop = () => {}; const w = M_W, h = M_H; if (kind === 'landing') return ; if (kind === 'wheel-idle') return ; if (kind === 'wheel-spin') return ; if (kind === 'win') return ; if (kind === 'lose') return ; if (kind === 'errA') return ; if (kind === 'errB') return ; if (kind === 'errC') return ; if (kind === 'reglement') return ; return null; } // Bandeau contextuel : dès qu'un lien "avis Google" est cliqué (il s'ouvre dans // un autre onglet), on affiche ici un rappel très visible pour revenir jouer. function ReviewReturnBanner() { const [show, setShow] = useState(false); useEffect(() => { const on = () => setShow(true); window.addEventListener('fds:review-clicked', on); return () => window.removeEventListener('fds:review-clicked', on); }, []); if (!show) return null; const backToGame = () => { setShow(false); if (window.location.hash) window.location.hash = ''; window.dispatchEvent(new CustomEvent('fds:goto-game')); }; return (
Merci pour votre avis 🙏
L'avis s'ouvre dans un autre onglet — revenez quand vous voulez.
); } function Phone({ children }) { // En production (route #home / pas de hash) : full screen, pas de mockup iOS. // La frame iPhone est utilisee uniquement pour le mode design-canvas (#design / #canvas). const inDesign = typeof window !== 'undefined' && (window.location.hash === '#design' || window.location.hash === '#canvas'); if (inDesign) { return {children}; } // Mode live: plein ecran, mobile-first, pas de cadre. // width: 100% (pas 100vw) sinon la scrollbar verticale fait deborder a droite // et casse le centrage par margin: 0 auto. // La banniere de marque (TopBanner) coiffe toute l'app ; les ecrans sont // rendus dans un conteneur position:relative pour que leurs elements en // position:absolute se calent SOUS la banniere, pas par-dessus. return (
{children}
{/* Bandeau "Revenir jouer" — apparaît après un clic sur l'avis Google (qui s'ouvre dans un autre onglet), pour un retour au jeu évident. */} {/* Entrée gestionnaire discrète — mène à la page admin protégée côté serveur (Basic Auth). */}
); } // Wrapper qui applique une transition d'entree a chaque changement d'ecran. // Le `key` force React a demonter/remonter l'enfant, declenchant l'animation CSS. function ScreenTransition({ screenKey, children }) { return (
{children}
); } // ─────────────── Admin dashboard with editable lots ─────────────── function AdminPanel({ onLogout, onNavigate }) { const stats = useBackendState() || window.FDSBackend.getStats(); const { prizes, participations, wins, losses, lastPlay, cooldownMs, deviceId, stockTotal } = stats; const cooldownRemaining = lastPlay ? Math.max(0, cooldownMs - (Date.now() - lastPlay)) : 0; // Local edit state for each lot's label so the input stays smooth. const [drafts, setDrafts] = useState(() => ({ cafe: prizes.cafe.label, digestif: prizes.digestif.label, entree: prizes.entree.label, })); // sync when backend state changes externally useEffect(() => { setDrafts(d => ({ cafe: d.cafe === undefined ? prizes.cafe.label : (d._dirtyCafe ? d.cafe : prizes.cafe.label), digestif: d._dirtyDig ? d.digestif : prizes.digestif.label, entree: d._dirtyEnt ? d.entree : prizes.entree.label, })); // simple: just keep drafts in sync on first mount, then user edits override }, []); // eslint-disable-line const card = { background: '#fff', border: '1px solid #E8DCCC', borderRadius: 10, padding: 14, }; const num = { fontFamily: '"Lobster", cursive', fontSize: 24, color: '#2D3540', lineHeight: 1 }; const lab = { fontSize: 9, color: '#6B7480', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 6 }; const btn = (bg) => ({ background: bg, color: '#fff', border: 'none', padding: '10px 12px', borderRadius: 8, fontSize: 12, fontWeight: 500, cursor: 'pointer', flex: 1, }); const inputStyle = { width: '100%', boxSizing: 'border-box', padding: '8px 10px', border: '1px solid #E8DCCC', borderRadius: 6, fontSize: 13, fontFamily: 'inherit', color: '#2D3540', background: '#fff', outline: 'none', }; const stepBtn = { width: 26, height: 26, borderRadius: 6, border: '1px solid #E8DCCC', background: '#fff', cursor: 'pointer', fontSize: 14, fontWeight: 600, color: '#2D3540', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, }; const setLabel = (key, value) => { setDrafts(d => ({ ...d, [key]: value })); }; const commitLabel = (key) => { const v = drafts[key].trim(); if (v) window.FDSBackend.setPrize(key, { label: v }); else setDrafts(d => ({ ...d, [key]: prizes[key].label })); }; const adjustStock = (key, delta) => { const next = Math.max(0, (prizes[key].stock || 0) + delta); window.FDSBackend.setPrize(key, { stock: next }); }; const setStockDirect = (key, raw) => { const n = parseInt(raw, 10); window.FDSBackend.setPrize(key, { stock: isNaN(n) ? 0 : Math.max(0, n) }); }; const lots = [ { key: 'cafe', short: 'Lot 1' }, { key: 'digestif', short: 'Lot 2' }, { key: 'entree', short: 'Lot 3' }, ]; const doFullReset = async () => { if (!confirm('Réinitialiser : remettre les stocks par défaut, vider les compteurs, et tirer un nouveau plan de roue aléatoire ?')) return; await window.FDSBackend.resetAll(); // refresh local drafts const fresh = window.FDSBackend.getPrizes(); setDrafts({ cafe: fresh.cafe.label, digestif: fresh.digestif.label, entree: fresh.entree.label }); }; // Snapshot at entry, used to compute the diff shown before exit. const initialSnapshot = React.useRef(null); useEffect(() => { if (!initialSnapshot.current) { const s = window.FDSBackend.getStats(); initialSnapshot.current = { prizes: JSON.parse(JSON.stringify(s.prizes)), wheelMapping: JSON.parse(JSON.stringify(s.wheelMapping)), whitelisted: s.whitelisted, }; } }, []); // eslint-disable-line // Modal state: null | { changes: [...] } const [exitState, setExitState] = useState(null); const handleExit = () => { // 1) Force-commit any focused label input by blurring it // (triggers onBlur → commitLabel for label inputs, no-op for stock). const a = document.activeElement; if (a && a.tagName === 'INPUT') a.blur(); // 2) Tick to let backend state flush, then compute diff. setTimeout(() => { const initial = initialSnapshot.current || { prizes, wheelMapping: stats.wheelMapping, whitelisted: stats.whitelisted }; const fresh = window.FDSBackend.getStats(); const changes = []; ['cafe', 'digestif', 'entree'].forEach((k) => { const b = initial.prizes[k]; const a = fresh.prizes[k]; if (!b || !a) return; if (b.label !== a.label) changes.push(`Libellé ${k} : « ${b.label} » → « ${a.label} »`); if (b.stock !== a.stock) { const d = a.stock - b.stock; changes.push(`Stock ${a.label} : ${b.stock} → ${a.stock} (${d > 0 ? '+' : ''}${d})`); } }); if (JSON.stringify(initial.wheelMapping) !== JSON.stringify(fresh.wheelMapping)) { changes.push('Plan de la roue redistribué'); } if (initial.whitelisted !== fresh.whitelisted) { changes.push(fresh.whitelisted ? 'Mode test (whitelist) activé' : 'Mode test (whitelist) désactivé'); } setExitState({ changes }); }, 60); }; const confirmExit = () => onNavigate('home'); const cancelExit = () => setExitState(null); return (

Admin

Device {deviceId.slice(0, 8)}…
{participations}
Tentatives
{wins}
Gains
{losses}
Pertes
Lots — {stockTotal} en stock
{lots.map(({ key, short }) => (
{short} clé : {key}
Libellé
setLabel(key, e.target.value)} onBlur={() => commitLabel(key)} onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }} style={inputStyle} placeholder="Nom du lot" />
Stock
setStockDirect(key, e.target.value)} style={{ ...inputStyle, width: 64, textAlign: 'center', fontVariantNumeric: 'tabular-nums', padding: '6px 4px', }} />
))}
Mode test (whitelist)
{stats.whitelisted ? 'Actif' : 'Inactif'}
Bypass le cooldown 30s + le verrou daily Paris. Permet de jouer sans limite depuis cet appareil pour les tests.
{stats.whitelist?.length > 0 && (
{stats.whitelist.length} appareil(s) whitelisté(s) · le tien : {deviceId.slice(0, 8)}…
)}
Anti-rejeu
Cooldown {Math.round(cooldownMs / 1000)}s par appareil. {cooldownRemaining > 0 ? Reste {Math.ceil(cooldownRemaining / 1000)}s : Prêt à jouer ✓}
Plan de la roue
Mapping aléatoire actuel des 4 secteurs gagnants :
{[0,2,4,6].map(i => ( S{i+1} → {prizes[stats.wheelMapping[i]]?.label || stats.wheelMapping[i]} ))}
Toute modification du libellé est immédiatement reflétée dans le règlement, l'écran 1 et sur la roue. Le reset complet remet les stocks par défaut et retire au sort un nouveau plan de roue.
{exitState && (
e.stopPropagation()} style={{ width: '100%', maxWidth: 340, background: '#FAF7F0', borderRadius: 14, padding: 20, boxShadow: '0 20px 50px rgba(0,0,0,0.25)', border: '1px solid #E8DCCC', }}>
{exitState.changes.length === 0 ? 'Aucune modification' : 'Récapitulatif'}
{exitState.changes.length === 0 ? 'Tu n\'as rien changé pendant cette session. Tout est déjà à jour.' : <> {exitState.changes.length} modification(s) enregistrée(s) en local pendant cette session : }
{exitState.changes.length > 0 && (
    {exitState.changes.map((c, i) => (
  • {c}
  • ))}
)}
Toutes les données sont persistées dans le localStorage du navigateur (clé fds:state).
)}
); } // AdminApp — access is gated SERVER-SIDE by Caddy Basic Auth on /admin/, so no // client-side password is needed. "Logout" just leaves the protected area (the // browser drops Basic Auth creds on tab close). function AdminApp({ onNavigate }) { return ( { window.location.href = '/'; }} /> ); } // ─────────────── Reglement live wrapper ─────────────── function ReglementLive({ onNavigate }) { const stats = useBackendState() || window.FDSBackend.getStats(); return onNavigate('home')} onNavigate={onNavigate} />; } const ART_W = 402; const ART_H = 874; // ─────────────── App with hash routing ─────────────── function App() { const [route, navigate] = useHashRoute(); // Admin page (/admin/index.html sets this flag). Access is protected // server-side by Caddy Basic Auth — there is no public #admin hash route. if (typeof window !== 'undefined' && window.FDS_ADMIN_PAGE) { return ; } // Single-screen routes (full-frame, what a real client sees). if (route === 'reglement') { return ; } // Default (#home or no hash): the actual game — landing → wheel → win/lose. // This is what a customer sees when they land on the site. if (route !== 'design' && route !== 'canvas') { return ; } // #design or #canvas: designer view with all artboards (used during dev/review). return ( ); } ReactDOM.createRoot(document.getElementById('root')).render();