// screens.jsx — All screens for La Roue Fleur de Sel // Each screen accepts { width, height, mobile, onNavigate, prize, spinning, started } // and renders the contents to fit the artboard. Footer is shared. // Lien direct pour laisser un avis Google sur la fiche du restaurant (lien de partage Google). const GOOGLE_REVIEW_URL = 'https://g.page/r/CUhEiqjErhTuEBE/review'; // Helpers pour l'horodatage / numero de participation affiches en bas des ecrans Win et Lose. // Permet au serveur de verifier la date du gain (anti-screenshot recycle) et de tracer dans l'admin. const MONTHS_FR = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']; function formatPlayedAt(ms) { const d = new Date(ms); const dd = d.getDate(); const mm = MONTHS_FR[d.getMonth()]; const yy = d.getFullYear(); const hh = String(d.getHours()).padStart(2, '0'); const mn = String(d.getMinutes()).padStart(2, '0'); return `Joué le ${dd} ${mm} ${yy} à ${hh}h${mn}`; } function getParticipationNo() { const n = window.FDSBackend?.getStats?.()?.participations || 1; return `FDS-${String(n).padStart(4, '0')}`; } const COLORS = { // Palette alignee sur l'identite visuelle du restaurant client (fleurdesel-lgc.com) bg: '#FAF6F0', // background du site (HSL 38 15% 97%) surface: '#FFFFFF', ink: '#2F231A', // foreground (HSL 25 30% 15%) marron tres fonce ink2: '#6B5A48', // muted-foreground (HSL 25 15% 45%) gold: '#C9954A', // accent dore conserve pour le visuel casino/bistrot goldDark: '#5C4422', // primary du site (HSL 35 45% 25%) warm-brown cream: '#F5EDD9', // cream du site (HSL 42 40% 95%) border: '#E0D7C5', // border (HSL 35 15% 88%) // Conserves pour les illustrations du jeu (icones secteurs roue) peach: '#F5C9B5', sky: '#C5D6DC', sage: '#B8C8B0', greyBlue: '#9CABB7', }; // ─────────────── Shared bits ─────────────── function CityCrest({ size = 28, color = '#3a3a3a' }) { // Original geometric crest placeholder (NOT a real municipal logo) return ( {/* tiny crown */} {/* shield */} {/* shield interior cross + dots */} ); } function CityWordmark({ scale = 1, color = '#3a3a3a' }) { return (
La
Garenne—
Colombes
); } // Icone couverts (fourchette + couteau) facon "carte de restaurant". function MenuIcon({ size = 15, color = 'currentColor' }) { return ( ); } // Banniere de marque affichee en haut de toute l'app (live). // Reprend le wordmark du site fleurdesel-lgc.com : "FLEUR DE SEL" en Futura, // couleur primary (brun) et majuscules (cf. .font-futura.text-primary.uppercase). function TopBanner() { const futura = '"Futura", "Century Gothic", "Avenir Next", sans-serif'; return (
Fleur de Sel
{/* Le menu (carte du resto) — onglet externe, style secondaire (contour). */} Le menu {/* Jouer — ramène au jeu depuis n'importe quel écran (retour facile après un détour par l'avis Google, la carte, le règlement…). Style primaire. */}
); } function Footer({ mobile, onNavigate }) { const linkStyle = { textDecoration: 'underline', textUnderlineOffset: 2, color: COLORS.ink2, background: 'none', border: 'none', padding: 0, font: 'inherit', cursor: 'pointer', }; const go = (path) => (e) => { e.preventDefault(); if (onNavigate) onNavigate(path); }; return (
Restaurant Fleur de Sel · La Garenne-Colombes · · Mentions légales · Données personnelles
Jeu sans obligation d'achat. Une participation par personne et par jour.
); } function GoldButton({ children, onClick, fullWidth, disabled, size = 'md', style = {} }) { const sizes = { sm: { fs: 18, px: 28, py: 12, r: 10 }, md: { fs: 24, px: 40, py: 16, r: 12 }, lg: { fs: 28, px: 48, py: 20, r: 12 }, }[size]; const [hover, setHover] = React.useState(false); return ( ); } // ─────────────── Animations ─────────────── const fadeUpKF = ` @keyframes fdsFadeUp { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } } @keyframes fdsFadeDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } @keyframes fdsScaleBounce { 0% { opacity: 0; transform: scale(0.6); } 60% { transform: scale(1.08); opacity: 1; } 100% { transform: scale(1); } } @keyframes fdsConfettiFall { 0% { transform: translateY(-20px) rotate(0deg); opacity: 1; } 100% { transform: translateY(110vh) rotate(720deg); opacity: 0.85; } } @keyframes fdsFadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fdsKenBurns { 0% { transform: scale(1.08) translateY(0); } 50% { transform: scale(1.14) translateY(-8px); } 100% { transform: scale(1.08) translateY(0); } } @keyframes fdsCityFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } } @keyframes fdsTitleRise { 0% { opacity: 0; transform: translateY(24px) rotate(-1.5deg); letter-spacing: 0.04em; } 100% { opacity: 1; transform: translateY(0) rotate(0deg); letter-spacing: 0.005em; } } @keyframes fdsPillPop { 0% { opacity: 0; transform: translateY(8px) scale(0.92); } 60% { opacity: 1; transform: translateY(0) scale(1.04); } 100% { transform: translateY(0) scale(1); } } @keyframes fdsShimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } @keyframes fdsArrowNudge { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(5px); } } @keyframes fdsCloudDrift { 0% { transform: translateX(-10px); } 100% { transform: translateX(10px); } } @keyframes fdsWheelWobble { 0% { transform: rotate(-19deg); } 50% { transform: rotate(2deg); } 100% { transform: rotate(-19deg); } } @keyframes fdsComicPop { 0%, 100% { transform: scale(0.78); } 30% { transform: scale(1.18); } 60% { transform: scale(0.92); } } @keyframes fdsCabaretBlink { 0%, 100% { filter: drop-shadow(0 0 4px #FFE070) drop-shadow(0 0 10px #FFD23F); } 50% { filter: drop-shadow(0 0 8px #FFFFFF) drop-shadow(0 0 18px #FFB800); } } @keyframes fdsCabaretSparkle { 0%, 100% { opacity: 0; transform: scale(0.6) rotate(0deg); } 30% { opacity: 1; transform: scale(1.2) rotate(120deg); } 60% { opacity: 0.7; transform: scale(0.9) rotate(220deg); } } .fds-fadeup { animation: fdsFadeUp 600ms ease-out both; } @keyframes fdsScreenEnter { from { opacity: 0; transform: translateY(8px) scale(0.985); filter: blur(3px); } to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); } } /* Cabaret yellow shimmering text — wheel hero + subtitles */ @keyframes fdsCabaretFlicker { 0%, 100% { filter: drop-shadow(0 0 4px #FFE070) drop-shadow(0 0 10px #FFD23F); } 50% { filter: drop-shadow(0 0 8px #FFFFFF) drop-shadow(0 0 18px #FFB800); } } .fds-cabaret-text { font-family: 'Lobster', cursive; color: transparent; background: linear-gradient( to bottom, #FFF6B0 0%, #FFD23F 35%, #FFB800 60%, #C9954A 100% ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; animation: fdsCabaretFlicker 1.4s ease-in-out infinite; text-shadow: 0 1px 0 rgba(0,0,0,0.4); letter-spacing: 0.01em; display: inline-block; } /* Casino Art-Déco gold-metallic title — used on the Jeu Concours card */ .fds-casino-title { font-family: 'Cinzel', 'Playfair Display', serif !important; font-weight: 700 !important; text-transform: uppercase; letter-spacing: 0.18em !important; font-size: 22px !important; line-height: 1.1; color: transparent !important; background: linear-gradient( to bottom, #f5e27a 0%, #c8960c 22%, #ffd700 48%, #c8960c 72%, #8a6500 100% ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; filter: drop-shadow(0 1px 2px rgba(0,0,0,0.5)) drop-shadow(0 0 0.5px rgba(255,215,0,0.4)); text-shadow: none; display: inline-block; } `; function injectKF() { if (document.getElementById('fds-kf')) return; const s = document.createElement('style'); s.id = 'fds-kf'; s.textContent = fadeUpKF; document.head.appendChild(s); } // ─────────────── Confetti ─────────────── function Confetti() { const pieces = React.useMemo(() => { const arr = []; const colors = [COLORS.gold, COLORS.peach, COLORS.sage, COLORS.sky, COLORS.cream]; for (let i = 0; i < 60; i++) { arr.push({ id: i, left: Math.random() * 100, delay: Math.random() * 4, duration: 4 + Math.random() * 3, size: 6 + Math.random() * 8, color: colors[i % colors.length], rot: Math.random() * 360, }); } return arr; }, []); return (
{pieces.map(p => (
))}
); } // ─────────────── Screen Shell ─────────────── // Back arrow — 44×44 round button, top-left, returns to home/landing. function BackArrow({ onClick, mobile = true }) { if (!onClick) return null; return ( ); } function ScreenShell({ width, height, mobile, children, contentStyle = {}, onNavigate, onBack }) { React.useEffect(() => { injectKF(); }, []); // En mode design-canvas (IOSDevice avec dimensions fixes), on respecte width/height. // En mode live (Phone passe-plat), on prend 100% du parent. const inDesign = typeof window !== 'undefined' && (window.location.hash === '#design' || window.location.hash === '#canvas'); return (
{children}
); } // Top mini-bar (used on form & errors): tiny clocher + La Garenne wordmark function TopBar({ mobile }) { return (
{/* tiny clocher */}
); } // ─────────────── CtaCard — bloc CTA standardisé (titre / baseline / bouton) ─────────────── function CtaCard({ eyebrow, title, baseline, ctaLabel, onClick, accent, ctaPrimary, animDelay = 0, footnote, theme }) { const [hover, setHover] = React.useState(false); const isCabaret = theme === 'cabaret'; const isReviews = theme === 'reviews'; // White-on-dark only for cabaret (dark bg). Reviews uses dark ink on light bg. const onDarkText = isCabaret; const cardStyle = isCabaret ? { backgroundImage: 'linear-gradient(rgba(0,0,0,0.55), rgba(0,0,0,0.78)), url(assets/casino-gambling-1.gif)', backgroundSize: 'cover', backgroundPosition: 'center center', border: '4px solid #C9954A', boxShadow: hover ? '0 8px 28px rgba(0,0,0,0.45), 0 0 18px rgba(255,215,0,0.55), inset 0 0 0 1px #FFE88A, inset 0 0 14px rgba(0,0,0,0.45)' : '0 4px 18px rgba(0,0,0,0.35), 0 0 10px rgba(255,215,0,0.35), inset 0 0 0 1px #FFE88A, inset 0 0 14px rgba(0,0,0,0.45)', } : isReviews ? { // Plain white background at 50% opacity, no image. backgroundColor: 'rgba(255,255,255,0.5)', border: `1px solid ${COLORS.border}`, cursor: onClick ? 'pointer' : 'default', boxShadow: hover ? '0 8px 22px rgba(45,53,64,0.10)' : '0 3px 12px rgba(45,53,64,0.05)', } : { background: COLORS.surface, border: `1px solid ${COLORS.border}`, boxShadow: hover ? '0 8px 22px rgba(45,53,64,0.10)' : '0 3px 12px rgba(45,53,64,0.05)', }; // If there's no CTA label, the whole card itself becomes the click target. const cardOnClick = !ctaLabel ? onClick : undefined; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); cardOnClick(); } } : undefined} style={{ display: 'flex', flexDirection: 'column', gap: 10, textAlign: 'center', alignItems: 'center', padding: '18px 18px 16px', borderRadius: 14, transition: 'box-shadow 200ms ease, transform 200ms ease', transform: hover ? 'translateY(-2px)' : 'translateY(0)', animation: `fdsFadeUp 600ms ease-out ${animDelay}ms both`, minHeight: 200, height: '100%', width: '100%', boxSizing: 'border-box', ...cardStyle, }} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} >
{eyebrow}
{title && (
{title}
)} {baseline && (

{baseline}

)} {ctaLabel && } {footnote && (

{footnote}

)}
); } // Petit logo Google (G multicolore). function GoogleG({ size = 16 }) { return ( ); } // Etoile pleine doree. function Star({ size = 18, color = '#FBB400' }) { return ( ); } // Card "Avis Google" moderne : verre depoli + degrade doux, halo dore, // chip Google, 5 etoiles, social proof, bouton pill. Toute la card est cliquable. function ReviewCard({ onClick, animDelay = 0 }) { const [hover, setHover] = React.useState(false); const open = (e) => { if (e) e.stopPropagation(); if (onClick) onClick(); }; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } }} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{ position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 13, padding: '24px 20px', borderRadius: 20, width: '100%', height: '100%', minHeight: 200, boxSizing: 'border-box', background: 'linear-gradient(155deg, #FFFFFF 0%, #FBF5E9 100%)', border: '1px solid rgba(201,149,74,0.28)', boxShadow: hover ? '0 16px 36px rgba(92,68,34,0.16)' : '0 6px 18px rgba(92,68,34,0.08)', transform: hover ? 'translateY(-3px)' : 'translateY(0)', transition: 'transform 240ms cubic-bezier(0.16,1,0.3,1), box-shadow 240ms ease', animation: `fdsFadeUp 600ms ease-out ${animDelay}ms both`, cursor: 'pointer', }} > {/* halo dore decoratif */}
{/* chip Google */}
Avis Google
{/* etoiles + social proof */}
{[0, 1, 2, 3, 4].map(i => )}
Vous avez passé un bon moment ? Votre avis est notre plus belle récompense.
{/* bouton pill moderne */}
); } // ─────────────── 1) LANDING ─────────────── function LandingScreen({ width, height, mobile, onStart, onNavigate, prizes }) { // Build prize pills from backend prizes (3 lots) + the catch-all désolés count. const p = prizes || (window.FDSBackend ? window.FDSBackend.getPrizes() : null); const PRIZES = p ? [ { label: p.entree.label, count: String(p.entree.stock), kind: 'plate' }, { label: p.cafe.label, count: String(p.cafe.stock), kind: 'mug' }, { label: p.digestif.label, count: String(p.digestif.stock), kind: 'glass' }, { label: 'Désolés', count: '∞', kind: 'cross' }, ] : [ { label: 'Repas pour 2', count: '1', kind: 'plate' }, { label: 'Café', count: '100', kind: 'mug' }, { label: 'Digestif', count: '100', kind: 'glass' }, { label: 'Désolés', count: '∞', kind: 'cross' }, ]; return (
{/* Cabaret/bistrot backdrop -- meme image que la WheelScreen, voile clair pour le landing */}
{/* hero illustration cabaret with ken-burns subtle */}
{/* voile creme pour le haut (lisibilite titre + cards), s'estompe vers le bas pour reveler le cabaret */}
{/* Top-aligned content — title 50px from the top */}
{/* (lien "Voir le menu" retire : deja present dans le header TopBanner) */} {/* DEUX CARDS + titre intercale entre les deux (cards a hauteur egale via 1fr) */}
{ window.open(GOOGLE_REVIEW_URL, '_blank', 'noopener,noreferrer'); window.dispatchEvent(new CustomEvent('fds:review-clicked')); }} />

Tentez votre chance

Jeu Concours} baseline="Un dîner pour deux, un café, un digestif. La fortune est entre vos mains — tournez la roue." ctaLabel="Tente ta chance" accent={COLORS.goldDark} ctaPrimary onClick={onStart} animDelay={1100} />
); } // ─────────────── 2) FORMULAIRE ─────────────── function FormScreen({ width, height, mobile, onSubmit, onNavigate }) { const [name, setName] = React.useState(''); const [email, setEmail] = React.useState(''); const [rgpd, setRgpd] = React.useState(false); const [touched, setTouched] = React.useState({ name: false, email: false }); const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); const valid = name.trim().length >= 2 && emailValid && rgpd; const inputBase = { width: '100%', boxSizing: 'border-box', padding: mobile ? '14px 14px' : '16px 16px', border: `1px solid ${COLORS.border}`, borderRadius: 8, fontFamily: 'Inter, sans-serif', fontSize: mobile ? 15 : 16, color: COLORS.ink, background: COLORS.surface, outline: 'none', transition: 'border-color 160ms ease', }; return (

Avant de faire tourner la roue…

Juste votre prénom et votre email. Promis, on n'en fait pas tout un plat.

setName(e.target.value)} onBlur={() => setTouched(t => ({ ...t, name: true }))} placeholder="Camille" style={{ ...inputBase, borderColor: name && touched.name && name.trim().length < 2 ? '#C97C57' : COLORS.border, }} onFocus={(e) => e.currentTarget.style.borderColor = COLORS.gold} />
setEmail(e.target.value)} onBlur={() => setTouched(t => ({ ...t, email: true }))} placeholder="vous@exemple.com" style={{ ...inputBase, borderColor: email && touched.email && !emailValid ? '#C97C57' : COLORS.border, }} onFocus={(e) => e.currentTarget.style.borderColor = COLORS.gold} />
Pour identifier votre participation. Aucun message commercial ne vous sera envoyé.
valid && onSubmit({ name, email })}> C'est parti !
); } // ─────────────── 3) WHEEL ─────────────── // Static fallback layout (also used as the visual template for sector colors). // Index 0 starts at top (-90°) and rotates clockwise. // Losing sectors all show the same "GAME OVER" sign. const LOSE_ICONS = ['game-over', 'game-over', 'game-over', 'game-over']; const WHEEL_FALLBACK = [ { id: 'cafe', label: '', color: COLORS.gold, icon: 'mug', win: 'cafe' }, { id: 'lose1', label: '', color: COLORS.cream, icon: 'game-over', win: null }, { id: 'dig', label: '', color: COLORS.peach, icon: 'glass', win: 'digestif' }, { id: 'lose2', label: '', color: COLORS.sky, icon: 'game-over', win: null }, { id: 'cafe2', label: '', color: COLORS.gold, icon: 'mug', win: 'cafe' }, { id: 'lose3', label: '', color: COLORS.cream, icon: 'game-over', win: null }, { id: 'entree', label: '', color: COLORS.peach, icon: 'plate', win: 'entree' }, { id: 'lose4', label: '', color: COLORS.sky, icon: 'game-over', win: null }, ]; // Win sectors use coloured PNG icons (named after the prize key). // cafe → cafe.png (latte cup) // digestif → drink.png (lemonade glass) // entree → diner.png (fork + knife with heart) const WIN_SLOT_PALETTE = [ { color: COLORS.gold, iconByKey: { cafe: 'cafe', digestif: 'drink', entree: 'diner' } }, { color: COLORS.peach, iconByKey: { cafe: 'cafe', digestif: 'drink', entree: 'diner' } }, { color: COLORS.gold, iconByKey: { cafe: 'cafe', digestif: 'drink', entree: 'diner' } }, { color: COLORS.peach, iconByKey: { cafe: 'cafe', digestif: 'drink', entree: 'diner' } }, ]; const LOSE_SLOT_COLORS = [COLORS.cream, COLORS.sky, COLORS.cream, COLORS.sky]; // Build the 8-sector array from current prize labels + wheelMapping. function buildWheelSectors(prizes, mapping) { if (!prizes || !mapping) return WHEEL_FALLBACK; const winIdx = [0, 2, 4, 6]; const loseIdx = [1, 3, 5, 7]; const sectors = new Array(8); // Short label for the wheel face (e.g. "Café", not "Café offert") const shortLabel = (full) => { if (!full) return ''; // take the first word (or first two if very short) const first = full.split(/\s+/)[0]; return first.length <= 3 && full.split(/\s+/).length > 1 ? full.split(/\s+/).slice(0, 2).join(' ') : first; }; winIdx.forEach((idx, i) => { const key = mapping[idx]; sectors[idx] = { id: `win-${idx}`, label: '', color: WIN_SLOT_PALETTE[i].color, icon: WIN_SLOT_PALETTE[i].iconByKey[key] || 'mug', win: key, }; }); loseIdx.forEach((idx, i) => { sectors[idx] = { id: `lose-${idx}`, label: '', color: LOSE_SLOT_COLORS[i], icon: LOSE_ICONS[i], win: null, }; }); return sectors; } // Default export still named WHEEL_SECTORS for backward compat with app.jsx; // it lazily reads from backend on each access. const WHEEL_SECTORS = new Proxy([], { get(_, prop) { const prizes = window.FDSBackend?.getPrizes(); const mapping = window.FDSBackend?.getWheelMapping(); const arr = buildWheelSectors(prizes, mapping); if (prop === 'length') return arr.length; if (prop === 'map' || prop === 'forEach' || prop === 'filter' || prop === 'find' || prop === 'findIndex' || prop === 'slice' || prop === Symbol.iterator) { return arr[prop].bind(arr); } return arr[prop]; } }); // Single static roulette.png centerpiece — clickable to spin. function WheelCenterpiece({ onClick, mobile, disabled }) { const size = mobile ? 110 : 132; return ( ); } function SectorIcon({ kind, size = 22, color }) { const s = size; if (kind === 'mug') { return ; } if (kind === 'glass') { return ; } if (kind === 'plate') { return ; } // cross return ; } function WheelSVG({ size, rotation, spinning }) { const r = size / 2; const cx = r, cy = r; const n = WHEEL_SECTORS.length; const slice = (2 * Math.PI) / n; // Build path for each sector const paths = WHEEL_SECTORS.map((sec, i) => { const a0 = -Math.PI / 2 + i * slice; const a1 = a0 + slice; const x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0); const x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1); const d = `M${cx},${cy} L${x0},${y0} A${r},${r} 0 0 1 ${x1},${y1} Z`; // label position const am = (a0 + a1) / 2; const lr = r * 0.66; const lx = cx + lr * Math.cos(am); const ly = cy + lr * Math.sin(am); const angleDeg = (am * 180) / Math.PI + 90; return { d, sec, lx, ly, angleDeg, i }; }); return ( {/* Shimmering gold gradient — animated by translating the stops. */} {/* Sparkle stroke — thin warm gold outline behind text for legibility. */} {/* outer ring */} {paths.map(({ d, sec, lx, ly, angleDeg, i }) => { // Faces (round-clipped portrait) — kept for back-compat, unused for now const isFace = sec.icon === 'lolo' || sec.icon === 'manu'; // Flat PNG icons (no round clip) for prize/lose sectors const isPngIcon = sec.icon === 'cafe' || sec.icon === 'drink' || sec.icon === 'diner' || sec.icon === 'game-over'; // For face sectors, push label *under* the avatar; for prize sectors keep // the original layout (icon above, label below). const iconYOffset = isFace ? -32 : -28; const labelY = isFace ? 32 : 6; return ( {isFace ? ( ) : isPngIcon ? ( ) : ( )} {/* Sector label — only rendered if non-empty */} {sec.label ? ( {sec.label} ) : null} ); })} {/* inner ring decoration */} ); } function WheelScreen({ width, height, mobile, spinState, onSpin, soundOn, onToggleSound, onNavigate, sectors, onBack }) { // spinState: { phase: 'idle'|'spinning'|'done', rotation: number } const wheelMax = mobile ? Math.min(width - 40, height * 0.55) : Math.min(height * 0.7, 460); // Re-render when admin edits prizes / regenerates the mapping. const [, force] = React.useReducer(x => x + 1, 0); React.useEffect(() => { if (!window.FDSBackend) return; return window.FDSBackend.subscribe(() => force()); }, []); const subtitle = spinState.phase === 'spinning' ? Et la roue tourne… : Cliquez sur le bouton au centre pour lancer la roue.; return ( {/* sound toggle top right -- pill blanc semi-transparent pour rester visible sur fond cabaret sombre */}
{/* Bloc titre+subtitle pose sur un voile noir doux pour assurer le contraste par-dessus le fond cabaret rouge (les ampoules dorees creaient des zones claires qui faisaient blanchir le titre gradient gold + le subtitle). */}

À vous de jouer !

{subtitle}
{/* pointer */}
{/* Animated centerpiece — replaces the old "GO !" button */} {spinState.phase !== 'spinning' && ( )}
8 secteurs · 4 lots possibles · 4 cases perdantes
); } // ─────────────── 4) WIN ─────────────── const PRIZE_COPY = { cafe: { name: 'Un café offert', body: "Présentez cet écran à votre serveur pour profiter de votre café offert. Et bonne fin de repas !", }, digestif: { name: 'Un digestif offert', body: "Présentez cet écran à votre serveur pour profiter de votre digestif. Le moment idéal pour prolonger un peu le plaisir.", }, entree: { name: 'Une entrée offerte la prochaine fois', body: "Demandez votre bon au serveur, conservez-le précieusement. Il vous attendra à votre prochaine visite — au plaisir de vous revoir !", }, }; function WinScreen({ width, height, mobile, prize = 'cafe', onClose, onNavigate, prizes, onBack }) { const copy = PRIZE_COPY[prize]; const p = prizes || (window.FDSBackend ? window.FDSBackend.getPrizes() : null); const dynamicName = p?.[prize]?.label || copy.name; // Horodatage + n° de participation captures une fois au mount (anti-screenshot recycle + tracabilite serveur). const [playedAt] = React.useState(() => Date.now()); const [partNo] = React.useState(() => getParticipationNo()); return (

Félicitations !

Vous remportez
{dynamicName}

{copy.body}

{/* Lolo video — réaction du chef */}

Lolo est tellement content que vous ayez gagné !

Merci, à très vite !
{/* SECTION AVIS GOOGLE -- totalement separee, optionnelle, sans contrepartie. Conformite : "Riwil rewards spinning the wheel - not the review itself." Le lot a deja ete attribue. L'avis est une demarche volontaire. */}
⭐ Pour aller plus loin
Vous avez aimé votre expérience ?
Un avis Google nous fait toujours énormément plaisir et nous aide à rester ouverts. C'est totalement facultatif — votre lot vous est attribué quoi qu'il arrive.
window.dispatchEvent(new CustomEvent('fds:review-clicked'))} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8, marginTop: 4, padding: '12px 22px', borderRadius: 10, background: '#FFFFFF', color: COLORS.ink, border: `1.5px solid ${COLORS.gold}`, fontFamily: 'Inter, sans-serif', fontSize: 14, fontWeight: 600, textDecoration: 'none', cursor: 'pointer', boxShadow: '0 2px 8px rgba(45,53,64,0.05)', transition: 'transform 160ms ease, box-shadow 160ms ease', }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 14px rgba(45,53,64,0.10)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(45,53,64,0.05)'; }} > Laisser un avis Google
{formatPlayedAt(playedAt)} · Participation n° {partNo}
Jeu sans obligation d'achat. Le lot attribué est indépendant et inconditionnel : il vous est offert que vous laissiez un avis ou non, et quelle que soit la note de cet avis.
); } // ─────────────── 5) LOSE ─────────────── function LoseScreen({ width, height, mobile, onClose, onNavigate, onBack }) { // Capture l'horodatage et le n° une seule fois au mount du screen pour qu'ils restent // stables meme si le user laisse l'ecran ouvert plusieurs minutes. const [playedAt] = React.useState(() => Date.now()); const [partNo] = React.useState(() => getParticipationNo()); return (
{/* Désolé GIF */}
Désolé

Désolé… perdu !

Mais ne regardez pas le serveur ou la serveuse de travers — il (ou elle) n'y est vraiment pour rien.

La fortune tourne, comme dit l'autre. À votre prochaine visite, vous aurez à nouveau votre chance.

À la prochaine !
{formatPlayedAt(playedAt)} · Participation n° {partNo}
); } // ─────────────── ERROR Screens ─────────────── function ErrorOrnament({ kind }) { // tiny geometric ornament — never an SVG illustration return (
{kind === 'A' && ( )} {kind === 'B' && ( )} {kind === 'C' && ( )}
); } // ─────────────── 4 BIS) SECOND CHANCE QUIZ ─────────────── // Shown after a lose so the player has a chance to re-spin if they answer right. const SECOND_CHANCE_QUIZ = [ { image: 'assets/lolo_ntm.png', question: 'De quel film est tirée cette image ?', options: [ { label: "L'Amour", correct: false }, { label: 'La Haine', correct: true }, ], }, { image: 'assets/manu_banane.png', question: 'Notre chef a pris la place de qui ?', options: [ { label: 'Johnny Hallyday', correct: false }, { label: 'Eddie Mitchell', correct: true }, ], }, { image: 'assets/loloBond.png', question: 'De quel film est tirée cette affiche ?', options: [ { label: 'LoloBond', correct: true }, { label: 'JohnnyBond', correct: false }, ], }, ]; function SecondChanceScreen({ width, height, mobile, quizIdx = 0, onCorrect, onWrong, onNavigate, onBack }) { const q = SECOND_CHANCE_QUIZ[quizIdx] || SECOND_CHANCE_QUIZ[0]; const [picked, setPicked] = React.useState(null); const choose = (idx) => { if (picked !== null) return; setPicked(idx); const isCorrect = q.options[idx].correct; setTimeout(() => { if (isCorrect) onCorrect && onCorrect(); else onWrong && onWrong(); }, 1100); }; return (
Seconde chance

Réponds bien et la roue
repart pour un tour

{q.question}

{q.options.map((opt, i) => { const isPicked = picked === i; const showResult = picked !== null; const isCorrect = opt.correct; let bg = COLORS.surface, fg = COLORS.ink, border = `1.5px solid ${COLORS.goldDark}`; if (showResult) { if (isCorrect) { bg = '#2a8a4a'; fg = '#fff'; border = '1.5px solid #2a8a4a'; } else if (isPicked) { bg = '#C04848'; fg = '#fff'; border = '1.5px solid #C04848'; } else { bg = '#F1ECE0'; fg = COLORS.ink2; border = `1.5px solid ${COLORS.border}`; } } return ( ); })}
); } function ErrorScreen({ width, height, mobile, kind = 'A', onAction, cooldownMs = 0, onNavigate, onBack }) { const [remaining, setRemaining] = React.useState(cooldownMs); React.useEffect(() => { if (kind !== 'A' || cooldownMs <= 0) return; setRemaining(cooldownMs); const start = Date.now(); const id = setInterval(() => { const r = Math.max(0, cooldownMs - (Date.now() - start)); setRemaining(r); if (r <= 0) clearInterval(id); }, 250); return () => clearInterval(id); }, [kind, cooldownMs]); const fmt = (ms) => { const s = Math.ceil(ms / 1000); if (s < 60) return `${s}s`; const m = Math.floor(s / 60), ss = s % 60; return `${m}m ${ss.toString().padStart(2, '0')}s`; }; const data = { A: { title: 'Du calme, du calme…', body: "Vous avez déjà tenté votre chance. Repassez-nous voir demain — on garde un secteur de la roue rien que pour vous.", cta: 'À tout à l\'heure !', micro: cooldownMs > 0 ? (remaining > 0 ? `Prochaine tentative dans ${fmt(remaining)}` : 'Vous pouvez rejouer maintenant') : 'Votre prochaine tentative possible : demain à partir de midi', titleSize: 40, }, B: { title: 'Vous arrivez juste après la dernière danse…', body: "Tous les lots du mois ont trouvé preneur — un grand merci à vous tous qui jouez ! La roue se remettra à tourner dès le 1er du mois prochain.", cta: 'Je reviendrai !', micro: 'Prochaine remise en jeu : 1er mai 2026', titleSize: 40, }, C: { title: 'Aïe, un grain de sel dans l\'engrenage…', body: "Quelque chose s'est cassé de notre côté. Réessayez dans un instant, ou prévenez votre serveur.", cta: 'Réessayer', micro: '', titleSize: 36, }, }[kind]; return (

{data.title}

{data.body}

{data.cta} {data.micro &&
{data.micro}
}
); } // ─────────────── 6) RÈGLEMENT ─────────────── function ReglementScreen({ width, height, mobile, onBack, onNavigate, prizes }) { const p = prizes || (window.FDSBackend ? window.FDSBackend.getPrizes() : null); const lots = p ? [ { key: 'cafe', ...p.cafe }, { key: 'digestif', ...p.digestif }, { key: 'entree', ...p.entree }, ] : []; return (

Règlement du jeu

La Roue Fleur de Sel

Le présent jeu est organisé par le restaurant Fleur de Sel, La Garenne-Colombes, sans obligation d'achat.

Une participation par personne et par jour. Chaque appareil ne peut tenter sa chance qu'une fois toutes les 24 heures.

Les lots mis en jeu pour la période en cours sont les suivants :

    {lots.map(l => (
  • {l.label} — {l.stock} lot{l.stock > 1 ? 's' : ''} disponible{l.stock > 1 ? 's' : ''}.
  • ))}

Les lots sont attribués dans la limite des stocks disponibles, par tirage aléatoire à chaque tour de roue.

Le résultat de chaque tour est déterminé par un algorithme aléatoire pondéré selon le stock restant. La répartition des lots sur les secteurs de la roue est elle-même tirée au sort en début de période et à chaque réinitialisation.

Les lots sont à retirer sur place auprès du personnel du restaurant, sur présentation de l'écran de gain.

Le tirage de la roue est totalement indépendant et inconditionnel. Le lot gagné est attribué au participant quelle que soit la note ou le contenu de l'avis Google laissé, et même si aucun avis n'est laissé.

La possibilité de déposer un avis sur Google, proposée à l'issue du jeu, constitue une démarche volontaire et facultative du participant. Aucune contrepartie n'est attachée au dépôt d'un avis, ni à la note attribuée à celui-ci.

Cette mécanique respecte les Conditions d'utilisation de Google relatives aux avis (interdiction d'inciter à laisser des avis positifs en échange d'une contrepartie) ainsi que la position de la CNIL et de la DGCCRF sur les loteries promotionnelles sans obligation d'achat.

Aucune donnée personnelle n'est collectée à des fins commerciales. L'identifiant d'appareil (empreinte locale du navigateur) est conservé en localStorage uniquement pour limiter une participation par appareil et par jour. Aucune donnée n'est transmise à un tiers.

); } function Section({ title, children }) { return (

{title}

{children}
); } Object.assign(window, { COLORS, LandingScreen, FormScreen, WheelScreen, WinScreen, LoseScreen, ErrorScreen, ReglementScreen, SecondChanceScreen, SECOND_CHANCE_QUIZ, WHEEL_SECTORS, buildWheelSectors, });