naissance-jerry/public/app.js
2026-10-14 21:18:47 +02:00

633 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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));