naissance-jerry/public/app.js

634 lines
29 KiB
JavaScript
Raw Normal View History

2026-10-14 21:18:47 +02:00
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
? `<img class="avatar" src="${esc(picture)}" alt="" referrerpolicy="no-referrer">`
: `<span class="avatar">${esc((name || '?')[0].toUpperCase())}</span>`;
}
const googleIcon = `<svg viewBox="0 0 48 48" aria-hidden="true"><path fill="#FFC107" d="M43.6 20.1H42V20H24v8h11.3C33.7 32.7 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.6-.4-3.9z"/><path fill="#FF3D00" d="m6.3 14.7 6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"/><path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.3-11.3-7.9l-6.5 5C9.5 39.6 16.2 44 24 44z"/><path fill="#1976D2" d="M43.6 20.1H42V20H24v8h11.3c-.8 2.2-2.2 4.2-4.1 5.6l6.2 5.2C37 39.2 44 34 44 24c0-1.3-.1-2.6-.4-3.9z"/></svg>`;
// ---------- Rendu ----------
function renderAccount() {
$('#account').innerHTML = me.user
? `<div class="user-chip">${avatar(me.user.picture, me.user.firstName)}<span class="hello">Salut ${esc(me.user.firstName)} !</span>
<button class="btn ghost small" id="logout">Déconnexion</button></div>`
: `<a class="btn google small" href="/auth/google">${googleIcon} Se connecter</a>`;
$('#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 = `<h1><span class="wiggle">👶</span> ${title}</h1>`;
if (r) {
const s = SEX[r.sex];
html += `
<div class="announce">
<p class="big">${s.emoji} Bienvenue ${esc(r.first_name)} ! 🎉</p>
<div class="facts">
<span class="pill ${s.cls}">${s.label}</span>
<span class="pill yellow">📅 ${esc(fmtDate(r.birth_date))}</span>
<span class="pill mint">🕐 ${esc(fmtTime(r.birth_time))}</span>
<span class="pill pink">⚖️ ${esc(fmtWeight(r.weight_g))}</span>
<span class="pill blue">📏 ${esc(fmtLength(r.length_cm))}</span>
</div>
</div>`;
} else {
html += `<p>Jour, heure, sexe, poids, taille, prénom… À toi de jouer : fais ton pronostic et tente de décrocher la première place !</p>`;
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(`<span class="pill yellow">🗓️ Terme prévu : ${esc(fmtDate(state.dueDate, { day: 'numeric', month: 'long' }))}</span>`);
pills.push(`<span class="pill pink">⏳ ${when}</span>`);
}
pills.push(`<span class="pill blue">🎯 ${state.count} pronostic${state.count > 1 ? 's' : ''}</span>`);
if (state.locked) pills.push(`<span class="pill mint">🔒 Pronostics clôturés</span>`);
html += `<div class="countdown">${pills.join('')}</div>`;
}
$('#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 = `
<div class="card">
<h2 style="text-align:center">🏆 Et le grand gagnant est… ${esc(top[0].display_name)} !</h2>
<div class="podium">
${top.map((p, i) => `
<div class="step p${i + 1}">
<div class="medal">${medals[i]}</div>
${p.picture ? avatar(p.picture, p.display_name) : ''}
<div class="name">${esc(p.display_name)}</div>
<div>${fmtPts(p.score.total)} pts</div>
</div>`).join('')}
</div>
</div>`;
}
const ICONS = {
calendar: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15.5" rx="3"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>',
clock: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',
};
// 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 `
<div class="field" data-field="${name}">
<label class="field-label" for="${id}"><span aria-hidden="true">${emoji}</span> ${label}</label>
${control}
<p class="field-error" id="${id}-err" hidden></p>
</div>`;
}
const pickerControl = (id, name, kind, value, extra = '') => `
<div class="control has-btn">
<input type="text" id="${id}" name="${name}" class="${kind === 'calendar' ? 'date-fr' : 'time-fr'}" inputmode="numeric"
maxlength="${kind === 'calendar' ? 10 : 5}" placeholder="${kind === 'calendar' ? 'jj/mm/aaaa' : 'hh:mm'}" autocomplete="off"
aria-describedby="${id}-err" value="${esc(value)}" ${extra}>
<button type="button" class="picker-btn" tabindex="-1" aria-label="${kind === 'calendar' ? 'Ouvrir le calendrier' : "Choisir l'heure"}">${ICONS[kind]}</button>
</div>`;
const unitControl = (id, name, unit, value, placeholder, mode) => `
<div class="control has-suffix">
<input type="text" id="${id}" name="${name}" inputmode="${mode}" autocomplete="off" placeholder="${placeholder}"
aria-describedby="${id}-err" value="${esc(value)}">
<span class="suffix" aria-hidden="true">${unit}</span>
</div>`;
function predictionForm(p, formId = 'pred-form') {
const v = p || {};
const id = (n) => `${formId}-${n}`;
return `
<form id="${formId}" novalidate>
<div class="form-grid">
${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 || '') })}
<div class="field" data-field="sex">
<span class="field-label" id="${id('sex')}-lbl"><span aria-hidden="true">👶</span> Sexe</span>
<div class="sex-choice" role="radiogroup" aria-labelledby="${id('sex')}-lbl">
<input type="radio" name="sex" id="${id('sex-f')}" value="F" ${v.sex === 'F' ? 'checked' : ''}><label for="${id('sex-f')}">👧 Fille</label>
<input type="radio" name="sex" id="${id('sex-m')}" value="M" ${v.sex === 'M' ? 'checked' : ''}><label for="${id('sex-m')}">👦 Garçon</label>
</div>
<p class="field-error" hidden></p>
</div>
${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: `<div class="control"><input type="text" id="${id('first_name')}" name="first_name" maxlength="40" autocomplete="off"
placeholder="Ton idée de prénom" aria-describedby="${id('first_name')}-err" value="${esc(v.first_name)}"></div>` })}
</div>
<div class="actions">
<button class="btn" type="submit">${p ? '💾 Enregistrer' : '🎯 Valider mon pronostic'}</button>
${p ? '<button class="btn ghost" type="button" id="cancel-edit">Annuler</button>' : ''}
</div>
</form>`;
}
// ---------- 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 = '<span></span>'.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 += `<button type="button" class="${cls}" data-iso="${iso}"${iso === state?.dueDate ? ' title="Terme prévu"' : ''}>${d}</button>`;
}
popover.innerHTML = `
<div class="picker-head">
<button type="button" data-nav="-1" aria-label="Mois précédent">‹</button>
<span>${MONTHS[m]} ${y}</span>
<button type="button" data-nav="1" aria-label="Mois suivant">›</button>
</div>
<div class="cal-grid cal-dow">${['lu', 'ma', 'me', 'je', 've', 'sa', 'di'].map((d) => `<span>${d}</span>`).join('')}</div>
<div class="cal-grid">${cells}</div>
${state?.dueDate ? '<div class="picker-legend"><i></i> terme prévu</div>' : ''}`;
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 = `
<div class="picker-head"><span>Heure</span></div>
<div class="clock-grid hours">${hours.map((x) => `<button type="button" data-h="${x}" class="${x === h ? 'sel' : ''}">${x}h</button>`).join('')}</div>
<div class="picker-head"><span>Minutes</span></div>
<div class="clock-grid">${mins.map((x) => `<button type="button" data-m="${x}" class="${x === min ? 'sel' : ''}">${x}</button>`).join('')}</div>`;
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 `<div class="tags">
<span>${s.emoji} <b>${s.label}</b></span>
<span>📅 <b>${esc(fmtShortDate(p.birth_date))}</b></span>
<span>🕐 <b>${esc(fmtTime(p.birth_time))}</b></span>
<span>⚖️ <b>${esc(fmtWeight(p.weight_g))}</b></span>
<span>📏 <b>${esc(fmtLength(p.length_cm))}</b></span>
<span>✨ <b>${esc(p.first_name)}</b></span>
</div>`;
}
function renderMine() {
const el = $('#mine');
if (!me.user) {
el.innerHTML = `<div class="card" style="text-align:center">
<h2>Envie de jouer ? 🎲</h2>
<p class="hint">Connecte-toi avec ton compte Google pour enregistrer ton pronostic.</p>
<a class="btn google" href="/auth/google">${googleIcon} Se connecter avec Google</a>
</div>`;
return;
}
const mine = state.predictions.find((p) => p.mine);
if (state.locked) {
el.innerHTML = `<div class="card"><h2>Mon pronostic</h2>${
mine ? predTags(mine) : '<p class="hint">Tu n\'as pas fait de pronostic… et les jeux sont faits ! 🙈</p>'}</div>`;
return;
}
if (mine && !editing) {
el.innerHTML = `<div class="card"><h2>Mon pronostic ✅</h2>${predTags(mine)}
<div class="actions">
<button class="btn" id="edit">✏️ Modifier</button>
<button class="btn ghost" id="delete">Supprimer</button>
<span class="hint">Tu peux le modifier jusqu'à la clôture des pronostics.</span>
</div></div>`;
$('#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 = `<div class="card"><h2>${mine ? 'Modifier mon pronostic' : 'Mon pronostic 🔮'}</h2>${predictionForm(mine)}</div>`;
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 = `<p class="hint">Personne n'a encore joué. Sois le premier ou la première ! 🚀</p>`;
} else if (!t) {
const missing = minForTrends - count;
body = `
<p class="big-count">🎯 ${count} pronostic${count > 1 ? 's' : ''}</p>
<p class="hint">Les pronostics restent secrets jusqu'à la naissance 🤫<br>
Les tendances apparaîtront à partir de ${minForTrends} pronostics (encore ${missing}).</p>
<div class="progress"><div style="width:${Math.round((count / minForTrends) * 100)}%"></div></div>`;
} else {
body = `
<p class="big-count">🎯 ${count} pronostics</p>
<div class="stats">
<span class="pill ${t.sex === 'F' ? 'pink' : t.sex === 'M' ? 'blue' : 'yellow'}">${SEX_TRENDS[t.sex]}</span>
<span class="pill yellow">📅 semaine du ${esc(fmtDate(t.week, { day: 'numeric', month: 'long' }))}</span>
<span class="pill mint">${TIME_TRENDS[t.time]}</span>
<span class="pill pink">⚖️ environ ${(t.weight_g / 1000).toLocaleString('fr-FR')} kg</span>
<span class="pill blue">📏 environ ${esc(fmtLength(t.length_cm))}</span>
</div>
<p class="hint">Tendances arrondies pour garder le suspense : les pronostics restent secrets jusqu'à la naissance 🤫</p>`;
}
const admin = me.isAdmin && state.participants?.length ? `
<hr>
<h3>🛠️ Participants <small class="hint">(visible par les administrateurs uniquement, sans les pronostics)</small></h3>
<div class="participants">
${state.participants.map((p) => `
<span class="participant">${esc(p.display_name)}
<button class="icon-del" data-del="${esc(p.user_id)}" title="Supprimer ce pronostic" aria-label="Supprimer le pronostic de ${esc(p.display_name)}">✕</button>
</span>`).join('')}
</div>` : '';
$('#all').innerHTML = `<div class="card"><h2>🔮 Les tendances</h2>${body}${admin}</div>`;
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 = `<div class="card">
<h2>📊 Classement</h2>
<div class="preds">
${preds.map((p) => `
<div class="pred ${p.mine ? 'mine' : ''}">
${p.rank <= 3 ? `<div class="rank medal">${['🥇', '🥈', '🥉'][p.rank - 1]}</div>` : `<div class="rank">${p.rank}</div>`}
<div>
<div class="who">${esc(p.display_name)}${p.mine ? ' <small>(moi)</small>' : ''}</div>
${predTags(p)}
</div>
<div class="score">${fmtPts(p.score.total)}<small>points</small></div>
<div class="breakdown">${Object.entries(p.score.details)
.map(([k, v]) => `<span>${labels[k]} ${fmtPts(v)}/${state.maxPoints[k]}</span>`).join('')}</div>
</div>`).join('')}
</div></div>`;
}
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 = `<div class="card">
<h2>🧮 Comment sont comptés les points ?</h2>
<div class="rules">
<div class="rule"><b>📅 Jour — ${m.date} pts</b><br>−3 pts par jour d'écart</div>
<div class="rule"><b>🕐 Heure — ${m.time} pts</b><br>Dégressif, 0 pt à 6 h d'écart</div>
<div class="rule"><b>👶 Sexe — ${m.sex} pts</b><br>Tout ou rien !</div>
<div class="rule"><b>⚖️ Poids — ${m.weight} pts</b><br>−1,5 pt par 100 g d'écart</div>
<div class="rule"><b>📏 Taille — ${m.length} pts</b><br>Dégressif, 0 pt à 8 cm d'écart</div>
<div class="rule"><b>✨ Prénom — ${m.name} pts</b><br>Exact = ${m.name} pts, même initiale = 3 pts</div>
</div>
<p class="hint">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.</p>
</div>`;
}
function renderAdmin() {
if (!me.isAdmin) return ($('#admin').innerHTML = '');
const r = state.result;
$('#admin').innerHTML = `<div class="card admin-card">
<h2>🛠️ Administration</h2>
<div class="admin-row">
<div class="due-field">${field({ id: 'due', name: 'due', emoji: '🗓️', label: 'Date du terme prévu',
control: pickerControl('due', 'due', 'calendar', isoToFr(state.dueDate)) })}</div>
<button class="btn ghost" id="save-due">Enregistrer le terme</button>
<label class="switch"><input type="checkbox" id="lock" ${state.locked ? 'checked' : ''} ${r ? 'disabled' : ''}> 🔒 Clôturer les pronostics</label>
</div>
<hr>
<h3>👶 Le bébé est né ! Saisir les vraies informations</h3>
<p class="hint">L'enregistrement clôture automatiquement les pronostics et affiche le classement.</p>
<div id="result-form">${predictionForm(r, 'admin-result-form')}</div>
${r ? '<div class="actions"><button class="btn danger small" id="clear-result">Effacer le résultat (rouvre le jeu)</button></div>' : ''}
</div>`;
// 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));