const $ = (sel) => document.querySelector(sel); const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); let me = { user: null, isAdmin: false }; let state = null; let editing = false; const SEX = { F: { label: 'Fille', emoji: '👧', cls: 'pink' }, M: { label: 'Garçon', emoji: '👦', cls: 'blue' } }; const fmtDate = (iso, opts = { weekday: 'long', day: 'numeric', month: 'long' }) => new Date(`${iso}T12:00:00`).toLocaleDateString('fr-FR', opts); const fmtShortDate = (iso) => fmtDate(iso, { day: 'numeric', month: 'short' }); const fmtTime = (hhmm) => hhmm.replace(':', 'h'); const fmtWeight = (g) => `${(g / 1000).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 3 })} kg`; const fmtLength = (cm) => `${Number(cm).toLocaleString('fr-FR')} cm`; const fmtPts = (n) => n.toLocaleString('fr-FR', { maximumFractionDigits: 1 }); async function api(method, url, body) { const res = await fetch(url, { method, headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || 'Oups, une erreur est survenue'); return data; } function flash(msg, isError = false) { const el = $('#flash'); el.textContent = msg; el.className = `flash${isError ? ' error' : ''}`; el.hidden = false; clearTimeout(flash.t); flash.t = setTimeout(() => { el.hidden = true; }, 4000); } function avatar(picture, name) { return picture ? `` : `${esc((name || '?')[0].toUpperCase())}`; } const googleIcon = ``; // ---------- Rendu ---------- function renderAccount() { $('#account').innerHTML = me.user ? `
${avatar(me.user.picture, me.user.firstName)}Salut ${esc(me.user.firstName)} !
` : `${googleIcon} Se connecter`; $('#logout')?.addEventListener('click', async () => { await api('POST', '/auth/logout'); location.reload(); }); } function renderHero() { const r = state.result; const title = r ? 'Le bébé de Jerry est arrivé !' : 'Le bébé de Jerry arrive !'; document.title = `${title} 🍼`; let html = `

👶 ${title}

`; if (r) { const s = SEX[r.sex]; html += `

${s.emoji} Bienvenue ${esc(r.first_name)} ! 🎉

${s.label} 📅 ${esc(fmtDate(r.birth_date))} 🕐 ${esc(fmtTime(r.birth_time))} ⚖️ ${esc(fmtWeight(r.weight_g))} 📏 ${esc(fmtLength(r.length_cm))}
`; } else { html += `

Jour, heure, sexe, poids, taille, prénom… À toi de jouer : fais ton pronostic et tente de décrocher la première place !

`; const pills = []; if (state.dueDate) { const days = Math.round((new Date(`${state.dueDate}T00:00:00`) - new Date(new Date().toDateString())) / 86400000); const when = days > 1 ? `J-${days}` : days === 1 ? 'Demain !' : days === 0 ? "C'est aujourd'hui !" : `Terme dépassé de ${-days} j`; pills.push(`🗓️ Terme prévu : ${esc(fmtDate(state.dueDate, { day: 'numeric', month: 'long' }))}`); pills.push(`⏳ ${when}`); } pills.push(`🎯 ${state.count} pronostic${state.count > 1 ? 's' : ''}`); if (state.locked) pills.push(`🔒 Pronostics clôturés`); html += `
${pills.join('')}
`; } $('#hero').innerHTML = html; } function renderPodium() { if (!state.result || !state.predictions.length) return ($('#podium').innerHTML = ''); const medals = ['🥇', '🥈', '🥉']; const top = state.predictions.slice(0, 3); $('#podium').innerHTML = `

🏆 Et le grand gagnant est… ${esc(top[0].display_name)} !

${top.map((p, i) => `
${medals[i]}
${p.picture ? avatar(p.picture, p.display_name) : ''}
${esc(p.display_name)}
${fmtPts(p.score.total)} pts
`).join('')}
`; } const ICONS = { calendar: '', clock: '', }; // Un champ de formulaire : libellé, contrôle et zone d'erreur, tous alignés de la même façon. function field({ id, name, emoji, label, control }) { return `
${control}
`; } const pickerControl = (id, name, kind, value, extra = '') => `
`; const unitControl = (id, name, unit, value, placeholder, mode) => `
`; function predictionForm(p, formId = 'pred-form') { const v = p || {}; const id = (n) => `${formId}-${n}`; return `
${field({ id: id('birth_date'), name: 'birth_date', emoji: '📅', label: 'Jour de naissance', control: pickerControl(id('birth_date'), 'birth_date', 'calendar', isoToFr(v.birth_date)) })} ${field({ id: id('birth_time'), name: 'birth_time', emoji: '🕐', label: 'Heure', control: pickerControl(id('birth_time'), 'birth_time', 'clock', v.birth_time || '') })}
Sexe
${field({ id: id('weight_g'), name: 'weight_g', emoji: '⚖️', label: 'Poids', control: unitControl(id('weight_g'), 'weight_g', 'g', v.weight_g ?? '', 'ex : 3350', 'numeric') })} ${field({ id: id('length_cm'), name: 'length_cm', emoji: '📏', label: 'Taille', control: unitControl(id('length_cm'), 'length_cm', 'cm', v.length_cm != null ? String(v.length_cm).replace('.', ',') : '', 'ex : 50,5', 'decimal') })} ${field({ id: id('first_name'), name: 'first_name', emoji: '✨', label: 'Prénom', control: `
` })}
${p ? '' : ''}
`; } // ---------- Dates et heures au format français ---------- const isoToFr = (iso) => (iso ? iso.split('-').reverse().join('/') : ''); function frToIso(s) { const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(String(s || '').trim()); if (!m) return null; const iso = `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`; const d = new Date(`${iso}T00:00:00Z`); return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === iso ? iso : null; } function normalizeTime(s) { const m = /^(\d{1,2})\s*[:hH.]?\s*(\d{2})?$/.exec(String(s || '').trim()); if (!m) return null; const h = Number(m[1]); const min = Number(m[2] || 0); return h < 24 && min < 60 ? `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}` : null; } // Insère automatiquement les « / » et « : » pendant la saisie, en respectant // les séparateurs tapés par l'utilisateur (« 1/11/2026 », « 9h05 »…). function maskInput(input, groups, sep) { input.addEventListener('input', (e) => { if (e.inputType?.startsWith('delete')) return; const v = input.value; const parts = []; for (let chunk of v.split(/\D+/).filter(Boolean)) { while (parts.length < groups.length - 1 && chunk.length > groups[parts.length]) { parts.push(chunk.slice(0, groups[parts.length])); chunk = chunk.slice(groups[parts.length - 1]); } if (parts.length < groups.length) parts.push(chunk.slice(0, groups[parts.length])); } let out = parts.join(sep); const n = parts.length; if (n && n < groups.length && (/\D$/.test(v) || parts[n - 1].length === groups[n - 1])) out += sep; input.value = out; }); } function enhanceInputs(root = document) { root.querySelectorAll('input.date-fr').forEach((i) => { maskInput(i, [2, 2, 4], '/'); attachPicker(i, renderCalendar); }); root.querySelectorAll('input.time-fr').forEach((i) => { maskInput(i, [2, 2], ':'); i.addEventListener('blur', () => { const t = normalizeTime(i.value); if (t) i.value = t; }); attachPicker(i, renderClock); }); } // ---------- Sélecteurs de date et d'heure (en français) ---------- const MONTHS = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']; const pad2 = (n) => String(n).padStart(2, '0'); const popover = Object.assign(document.createElement('div'), { className: 'picker', hidden: true }); popover.setAttribute('role', 'dialog'); document.body.appendChild(popover); let pickerInput = null; function attachPicker(input, renderFn) { const open = () => openPicker(input, renderFn); input.addEventListener('click', open); input.parentElement.querySelector('.picker-btn').addEventListener('click', (e) => { e.preventDefault(); if (pickerInput === input) closePicker(); else { open(); input.focus(); } }); input.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePicker(); if (e.key === 'Tab') closePicker(); }); input.addEventListener('input', () => { if (pickerInput === input) { popover.calMonth = null; renderFn(input); } }); } function openPicker(input, renderFn) { pickerInput = input; popover.calMonth = null; renderFn(input); popover.hidden = false; placePicker(); } function closePicker() { popover.hidden = true; pickerInput = null; } function placePicker() { if (!pickerInput) return; const r = pickerInput.getBoundingClientRect(); const w = popover.offsetWidth; const left = Math.max(8, Math.min(r.left, document.documentElement.clientWidth - w - 8)); popover.style.left = `${left + scrollX}px`; popover.style.top = `${r.bottom + scrollY + 6}px`; } function pick(value) { pickerInput.value = value; pickerInput.dispatchEvent(new Event('change', { bubbles: true })); pickerInput.focus(); closePicker(); } function renderCalendar(input) { const selected = frToIso(input.value); const todayIso = new Date().toLocaleDateString('sv-SE'); // AAAA-MM-JJ en heure locale if (!popover.calMonth) { const base = selected || state?.dueDate || todayIso; popover.calMonth = { y: Number(base.slice(0, 4)), m: Number(base.slice(5, 7)) - 1 }; } const { y, m } = popover.calMonth; const first = (new Date(y, m, 1).getDay() + 6) % 7; // lundi = 0 const days = new Date(y, m + 1, 0).getDate(); let cells = ''.repeat(first); for (let d = 1; d <= days; d++) { const iso = `${y}-${pad2(m + 1)}-${pad2(d)}`; const cls = [iso === selected && 'sel', iso === todayIso && 'today', iso === state?.dueDate && 'due'].filter(Boolean).join(' '); cells += ``; } popover.innerHTML = `
${MONTHS[m]} ${y}
${['lu', 'ma', 'me', 'je', 've', 'sa', 'di'].map((d) => `${d}`).join('')}
${cells}
${state?.dueDate ? '
terme prévu
' : ''}`; popover.querySelectorAll('[data-nav]').forEach((b) => { b.onclick = () => { const d = new Date(y, m + Number(b.dataset.nav), 1); popover.calMonth = { y: d.getFullYear(), m: d.getMonth() }; renderCalendar(input); }; }); popover.querySelectorAll('[data-iso]').forEach((b) => { b.onclick = () => pick(isoToFr(b.dataset.iso)); }); } function renderClock(input) { const cur = normalizeTime(input.value); const [h, min] = cur ? cur.split(':') : [null, null]; const hours = Array.from({ length: 24 }, (_, i) => pad2(i)); const mins = Array.from({ length: 12 }, (_, i) => pad2(i * 5)); popover.innerHTML = `
Heure
${hours.map((x) => ``).join('')}
Minutes
${mins.map((x) => ``).join('')}
`; popover.querySelectorAll('[data-h]').forEach((b) => { b.onclick = () => { input.value = `${b.dataset.h}:${min || '00'}`; renderClock(input); input.focus(); }; }); popover.querySelectorAll('[data-m]').forEach((b) => { b.onclick = () => pick(`${h || '12'}:${b.dataset.m}`); }); } // Les clics dans le sélecteur ne doivent pas faire perdre le focus au champ popover.addEventListener('mousedown', (e) => e.preventDefault()); document.addEventListener('mousedown', (e) => { if (pickerInput && !popover.contains(e.target) && !pickerInput.parentElement.contains(e.target)) closePicker(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePicker(); }); addEventListener('resize', placePicker); function readForm(form) { const fd = new FormData(form); return { birth_date: frToIso(fd.get('birth_date')), birth_time: normalizeTime(fd.get('birth_time')), sex: fd.get('sex'), weight_g: Number(String(fd.get('weight_g')).replace(/\s/g, '')), length_cm: Number(String(fd.get('length_cm')).replace(/\s/g, '').replace(',', '.')), first_name: String(fd.get('first_name') || '').trim(), }; } // Vérifie les saisies et affiche les erreurs sous les champs concernés. function checkForm(form, data) { const errors = { birth_date: !data.birth_date && 'Date invalide : format jj/mm/aaaa', birth_time: !data.birth_time && 'Heure invalide : format hh:mm', sex: !data.sex && 'Choisis fille ou garçon 😉', weight_g: !(Number.isInteger(data.weight_g) && data.weight_g >= 500 && data.weight_g <= 6500) && 'Entre 500 et 6500 g, sans virgule', length_cm: !(data.length_cm >= 25 && data.length_cm <= 65) && 'Entre 25 et 65 cm', first_name: !data.first_name && 'Propose un prénom', }; let first = null; form.querySelectorAll('.field[data-field]').forEach((f) => { const msg = errors[f.dataset.field]; const p = f.querySelector('.field-error'); f.classList.toggle('invalid', !!msg); p.hidden = !msg; p.textContent = msg || ''; f.querySelector('input')?.setAttribute('aria-invalid', msg ? 'true' : 'false'); if (msg && !first) first = f; }); first?.querySelector('input:not([type=radio])')?.focus(); return !first; } // Efface l'erreur d'un champ dès qu'on le corrige. function clearErrorsOnEdit(form) { const clear = (e) => { const f = e.target.closest('.field.invalid'); if (!f) return; f.classList.remove('invalid'); f.querySelector('.field-error').hidden = true; e.target.removeAttribute('aria-invalid'); }; form.addEventListener('input', clear); form.addEventListener('change', clear); } function predTags(p) { const s = SEX[p.sex]; return `
${s.emoji} ${s.label} 📅 ${esc(fmtShortDate(p.birth_date))} 🕐 ${esc(fmtTime(p.birth_time))} ⚖️ ${esc(fmtWeight(p.weight_g))} 📏 ${esc(fmtLength(p.length_cm))} ✨ ${esc(p.first_name)}
`; } function renderMine() { const el = $('#mine'); if (!me.user) { el.innerHTML = `

Envie de jouer ? 🎲

Connecte-toi avec ton compte Google pour enregistrer ton pronostic.

${googleIcon} Se connecter avec Google
`; return; } const mine = state.predictions.find((p) => p.mine); if (state.locked) { el.innerHTML = `

Mon pronostic

${ mine ? predTags(mine) : '

Tu n\'as pas fait de pronostic… et les jeux sont faits ! 🙈

'}
`; return; } if (mine && !editing) { el.innerHTML = `

Mon pronostic ✅

${predTags(mine)}
Tu peux le modifier jusqu'à la clôture des pronostics.
`; $('#edit').onclick = () => { editing = true; renderMine(); }; $('#delete').onclick = async () => { if (!confirm('Supprimer ton pronostic ?')) return; await run(() => api('DELETE', '/api/predictions/me'), 'Pronostic supprimé'); }; return; } el.innerHTML = `

${mine ? 'Modifier mon pronostic' : 'Mon pronostic 🔮'}

${predictionForm(mine)}
`; enhanceInputs(el); if ($('#pred-form')) clearErrorsOnEdit($('#pred-form')); $('#cancel-edit')?.addEventListener('click', () => { editing = false; renderMine(); }); $('#pred-form').onsubmit = async (e) => { e.preventDefault(); const data = readForm(e.target); if (!checkForm(e.target, data)) return; const ok = await run(() => api('PUT', '/api/predictions/me', data), mine ? 'Pronostic mis à jour 💾' : 'Pronostic enregistré, bonne chance ! 🍀'); if (ok) { editing = false; if (!mine) confetti(); renderAll(); } }; } const TIME_TRENDS = { night: '🌙 plutôt la nuit', morning: '🌅 plutôt le matin', afternoon: '☀️ plutôt l\'après-midi', evening: '🌆 plutôt le soir', any: '🕐 à toute heure', }; const SEX_TRENDS = { F: '👧 plutôt fille', M: '👦 plutôt garçon', split: '👧👦 avis partagés' }; function renderAll() { if (state.result) return renderRanking(); const { count, trends: t, minForTrends } = state; let body; if (!count) { body = `

Personne n'a encore joué. Sois le premier ou la première ! 🚀

`; } else if (!t) { const missing = minForTrends - count; body = `

🎯 ${count} pronostic${count > 1 ? 's' : ''}

Les pronostics restent secrets jusqu'à la naissance 🤫
Les tendances apparaîtront à partir de ${minForTrends} pronostics (encore ${missing}).

`; } else { body = `

🎯 ${count} pronostics

${SEX_TRENDS[t.sex]} 📅 semaine du ${esc(fmtDate(t.week, { day: 'numeric', month: 'long' }))} ${TIME_TRENDS[t.time]} ⚖️ environ ${(t.weight_g / 1000).toLocaleString('fr-FR')} kg 📏 environ ${esc(fmtLength(t.length_cm))}

Tendances arrondies pour garder le suspense : les pronostics restent secrets jusqu'à la naissance 🤫

`; } const admin = me.isAdmin && state.participants?.length ? `

🛠️ Participants (visible par les administrateurs uniquement, sans les pronostics)

${state.participants.map((p) => ` ${esc(p.display_name)} `).join('')}
` : ''; $('#all').innerHTML = `

🔮 Les tendances

${body}${admin}
`; bindDeleteButtons(); } function renderRanking() { const preds = state.predictions; if (!preds.length) return ($('#all').innerHTML = ''); const labels = { date: '📅 Jour', time: '🕐 Heure', sex: '👶 Sexe', weight: '⚖️ Poids', length: '📏 Taille', name: '✨ Prénom' }; $('#all').innerHTML = `

📊 Classement

${preds.map((p) => `
${p.rank <= 3 ? `
${['🥇', '🥈', '🥉'][p.rank - 1]}
` : `
${p.rank}
`}
${esc(p.display_name)}${p.mine ? ' (moi)' : ''}
${predTags(p)}
${fmtPts(p.score.total)}points
${Object.entries(p.score.details) .map(([k, v]) => `${labels[k]} ${fmtPts(v)}/${state.maxPoints[k]}`).join('')}
`).join('')}
`; } function bindDeleteButtons() { document.querySelectorAll('[data-del]').forEach((b) => { b.onclick = async () => { if (!confirm('Supprimer ce pronostic ?')) return; await run(() => api('DELETE', `/api/admin/predictions/${encodeURIComponent(b.dataset.del)}`), 'Pronostic supprimé'); }; }); } function renderRules() { const m = state.maxPoints; $('#rules').innerHTML = `

🧮 Comment sont comptés les points ?

📅 Jour — ${m.date} pts
−3 pts par jour d'écart
🕐 Heure — ${m.time} pts
Dégressif, 0 pt à 6 h d'écart
👶 Sexe — ${m.sex} pts
Tout ou rien !
⚖️ Poids — ${m.weight} pts
−1,5 pt par 100 g d'écart
📏 Taille — ${m.length} pts
Dégressif, 0 pt à 8 cm d'écart
✨ Prénom — ${m.name} pts
Exact = ${m.name} pts, même initiale = 3 pts

Total sur 100. En cas d'égalité, c'est le pronostic enregistré (ou modifié pour la dernière fois) le plus tôt qui l'emporte.

`; } function renderAdmin() { if (!me.isAdmin) return ($('#admin').innerHTML = ''); const r = state.result; $('#admin').innerHTML = `

🛠️ Administration

${field({ id: 'due', name: 'due', emoji: '🗓️', label: 'Date du terme prévu', control: pickerControl('due', 'due', 'calendar', isoToFr(state.dueDate)) })}

👶 Le bébé est né ! Saisir les vraies informations

L'enregistrement clôture automatiquement les pronostics et affiche le classement.

${predictionForm(r, 'admin-result-form')}
${r ? '
' : ''}
`; // Le formulaire partagé affiche "Annuler" en mode édition : inutile ici. $('#result-form #cancel-edit')?.remove(); $('#result-form button[type=submit]').textContent = r ? '💾 Mettre à jour le résultat' : '🎉 Annoncer la naissance'; enhanceInputs($('#admin')); clearErrorsOnEdit($('#admin-result-form')); $('#save-due').onclick = () => { const raw = $('#due').value.trim(); const dueDate = raw ? frToIso(raw) : null; const f = $('#admin [data-field=due]'); f.classList.toggle('invalid', !!raw && !dueDate); f.querySelector('.field-error').hidden = !raw || !!dueDate; f.querySelector('.field-error').textContent = 'Date invalide : format jj/mm/aaaa'; if (raw && !dueDate) return; run(() => api('PUT', '/api/admin/settings', { dueDate }), 'Terme enregistré'); }; $('#lock').onchange = (e) => run(() => api('PUT', '/api/admin/settings', { locked: e.target.checked }), e.target.checked ? 'Pronostics clôturés 🔒' : 'Pronostics rouverts 🔓'); $('#result-form form').onsubmit = async (e) => { e.preventDefault(); const data = readForm(e.target); if (!checkForm(e.target, data)) return; if (!r && !confirm('Annoncer la naissance et calculer le classement ?')) return; if (await run(() => api('PUT', '/api/admin/result', data), 'Résultat enregistré 🎉')) { confetti(); scrollTo({ top: 0, behavior: 'smooth' }); } }; $('#clear-result')?.addEventListener('click', () => { if (confirm('Effacer le résultat ?')) run(() => api('DELETE', '/api/admin/result'), 'Résultat effacé'); }); } function render() { closePicker(); renderAccount(); renderHero(); renderPodium(); renderMine(); renderAdmin(); renderAll(); renderRules(); } async function load() { [me, state] = await Promise.all([api('GET', '/api/me'), api('GET', '/api/state')]); render(); } async function run(fn, successMsg) { try { await fn(); await load(); flash(successMsg); return true; } catch (err) { flash(err.message, true); return false; } } function confetti() { if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; const colors = ['#ff8fb1', '#6cb8ff', '#ffd166', '#5fd3a8', '#9b7bff']; for (let i = 0; i < 120; i++) { const c = document.createElement('div'); c.className = 'confetti'; c.style.left = `${Math.random() * 100}vw`; c.style.background = colors[i % colors.length]; c.style.animationDuration = `${2 + Math.random() * 2.5}s`; c.style.animationDelay = `${Math.random() * 0.6}s`; document.body.appendChild(c); setTimeout(() => c.remove(), 5500); } } if (new URLSearchParams(location.search).get('erreur') === 'connexion') { history.replaceState(null, '', '/'); setTimeout(() => flash('La connexion Google a échoué, réessaie 🙏', true)); } load().then(() => { if (state.result && !sessionStorage.getItem('party')) { sessionStorage.setItem('party', 1); confetti(); } }) .catch((err) => flash(err.message, true));