// 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"
/>
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.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();