633 lines
29 KiB
JavaScript
633 lines
29 KiB
JavaScript
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
|
||
? `<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));
|