// 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 (
);
}
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. */}
{/* 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) */}
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}
/>
);
}
// ─────────────── 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 (
);
}
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 */}
En direct 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.
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é… 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.
);
}
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 (
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 (