// Page upload invité — photobooth-qr // // Aucune dépendance framework. Une seule lib tierce chargée systématiquement // (browser-image-compression, décision actée dans docs/decisions.md), plus // heic2any chargée UNIQUEMENT si un fichier HEIC/HEIF est détecté (lazy load, // pour ne pas alourdir la page pour les invités qui n'en ont pas besoin). // // Contrat backend (schéma `photobooth`, privé, jamais exposé via PostgREST — // cf. supabase/migrations/20260916090000_create_photobooth_schema.sql) : // - RPC "get_public_event(p_slug text)" (POST .../rpc/get_public_event) : // { id, nom, statut, expire_at } (ou null) SANS exposer admin_token. // - RPC "insert_public_photo(p_event_id, p_url_storage, p_nom_invite, // p_message)" (POST .../rpc/insert_public_photo) : seul chemin d'écriture, // revalide elle-même que l'event accepte encore les uploads. import imageCompression from "../vendor/browser-image-compression.mjs"; const MAX_INPUT_BYTES = 15 * 1024 * 1024; // 15 Mo avant compression (cahier des charges) const MAX_OUTPUT_BYTES = 6 * 1024 * 1024; // garde-fou après compression (cible ~2-3 Mo) const COMPRESSION_OPTIONS = { maxSizeMB: 2, maxWidthOrHeight: 1920, useWebWorker: true, fileType: "image/jpeg", initialQuality: 0.8, preserveExif: false, // on ne conserve pas les métadonnées (GPS, etc.) — minimisation RGPD }; // --------------------------------------------------------------------------- // Éléments DOM // --------------------------------------------------------------------------- const eventNameEl = document.getElementById("event-name"); const loadingBlock = document.getElementById("status-loading"); const errorBlock = document.getElementById("status-error"); const errorMessageEl = document.getElementById("status-error-message"); const retryBtn = document.getElementById("retry-btn"); const successBlock = document.getElementById("success-block"); const addAnotherBtn = document.getElementById("add-another-btn"); const form = document.getElementById("upload-form"); const photoInput = document.getElementById("photo-input"); const photoInputGallery = document.getElementById("photo-input-gallery"); const fileChosenLabel = document.getElementById("file-chosen-label"); const previewImg = document.getElementById("preview"); const nomInput = document.getElementById("nom-input"); const messageInput = document.getElementById("message-input"); const progressText = document.getElementById("progress-text"); const submitBtn = document.getElementById("submit-btn"); const submitBtnLabel = document.getElementById("submit-btn-label"); const welcomeModal = document.getElementById("welcome-modal"); const welcomeModalTitle = document.getElementById("welcome-modal-title"); const welcomeModalDismiss = document.getElementById("welcome-modal-dismiss"); const wallLink = document.getElementById("wall-link"); const wallLinkSuccess = document.getElementById("wall-link-success"); const wallLinkModal = document.getElementById("wall-link-modal"); const legalLink = document.getElementById("legal-link"); // --------------------------------------------------------------------------- // État // --------------------------------------------------------------------------- const state = { event: null, selectedFile: null, compressedBlob: null, isSubmitting: false, lastRetryAction: null, }; // --------------------------------------------------------------------------- // Erreurs applicatives : un code technique -> un message clair pour l'invité // --------------------------------------------------------------------------- const ERROR_MESSAGES = { "config-missing": "Cette page n'est pas configurée correctement. Préviens l'organisateur de l'événement.", "event-not-found": "Cet événement n'existe pas, ou le lien utilisé est incorrect. Vérifie le QR code ou le lien.", "event-closed": "Cet événement n'accepte plus de photos (il est clos). Merci quand même d'avoir essayé !", "guest-limit-reached": "Dis donc, on ne t'arrête plus ! Tu as atteint le nombre de photos autorisé pour cet événement. Merci d'avoir aussi bien joué le jeu !", "file-missing": "Choisis d'abord une photo avant d'envoyer.", "file-not-image": "Ce fichier n'est pas une photo. Choisis une image (JPEG, PNG, HEIC…).", "file-too-large": "Cette photo est trop lourde (plus de 15 Mo). Essaie avec une autre photo.", "heic-load-failed": "Impossible de préparer la conversion de cette photo iPhone (HEIC). Vérifie ta connexion et réessaie, ou choisis une autre photo.", "heic-convert-failed": "Impossible de lire cette photo au format HEIC. Essaie de changer le format des photos dans les réglages de ton iPhone (Réglages > Appareil photo > Formats > « Le plus compatible »), ou choisis une autre photo.", "compression-failed": "La préparation de la photo a échoué. Réessaie, ou choisis une autre photo.", "compressed-too-large": "Cette photo reste trop lourde même après compression. Essaie une autre photo.", network: "La connexion a été coupée pendant l'envoi. Vérifie ta connexion internet et réessaie.", "storage-upload-failed": "L'envoi de la photo a échoué. Réessaie dans un instant.", "db-insert-failed": "Ta photo a bien été transmise, mais une erreur est survenue à la dernière étape. Réessaie, ou préviens l'organisateur si ça persiste.", "server-overloaded": "Le service est momentanément surchargé. Réessaie dans quelques minutes.", unknown: "Une erreur inattendue est survenue. Réessaie, ou reviens un peu plus tard.", }; // Codes pour lesquels on propose un bouton "Réessayer" (échecs transitoires, // pas la peine pour un fichier invalide ou un événement clos). const RETRYABLE_CODES = new Set([ "network", "storage-upload-failed", "db-insert-failed", "server-overloaded", "compression-failed", "heic-load-failed", "heic-convert-failed", ]); function appError(code, cause) { const err = new Error(code); err.code = code; err.cause = cause; return err; } function normalizeError(err) { if (err && err.code && ERROR_MESSAGES[err.code]) return err; if (err instanceof TypeError) return appError("network", err); // fetch() qui échoue = coupure réseau return appError("unknown", err); } // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- function getConfig() { const cfg = window.__PHOTOBOOTH_CONFIG__; if (!cfg || !cfg.SUPABASE_URL || !cfg.SUPABASE_ANON_KEY || !cfg.SUPABASE_STORAGE_BUCKET) { throw appError("config-missing"); } return cfg; } // --------------------------------------------------------------------------- // Utilitaires // --------------------------------------------------------------------------- function generateId() { if (window.crypto && typeof window.crypto.randomUUID === "function") { return window.crypto.randomUUID(); } // Repli simple pour de très vieux navigateurs (pas un usage cryptographique ici) return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } function getSlugFromPath() { const match = window.location.pathname.match(/\/e\/([^/]+)\/?$/); return match ? decodeURIComponent(match[1]) : null; } // --------------------------------------------------------------------------- // Identité invité (anonyme) + compteur local — pour faire respecter // max_photos_per_guest. guest_id est généré une fois par appareil/navigateur // (localStorage), envoyé au serveur qui fait foi pour la limite réelle ; // le compteur local sert uniquement à bloquer l'UI de façon proactive // (éviter de faire recompresser+uploader une photo pour se la faire // refuser à la toute dernière étape). Contournable (autre appareil, // navigation privée, storage vidé) — ce n'est pas une garantie forte, // juste un frein raisonnable pour un usage V1. const GUEST_ID_STORAGE_KEY = "photobooth_guest_id"; function getGuestId() { try { let id = window.localStorage.getItem(GUEST_ID_STORAGE_KEY); if (!id) { id = generateId(); window.localStorage.setItem(GUEST_ID_STORAGE_KEY, id); } return id; } catch { // localStorage indisponible (navigation privée stricte, etc.) : on // régénère un id éphémère, la limite sera simplement moins efficace // pour cet invité plutôt que de bloquer tout envoi. return generateId(); } } function guestCountStorageKey(slug) { return `photobooth_guest_count_${slug}`; } function getLocalGuestPhotoCount(slug) { try { return Number(window.localStorage.getItem(guestCountStorageKey(slug))) || 0; } catch { return 0; } } function incrementLocalGuestPhotoCount(slug) { try { const next = getLocalGuestPhotoCount(slug) + 1; window.localStorage.setItem(guestCountStorageKey(slug), String(next)); } catch { // Pas grave : au pire l'invité pourra tenter un envoi de plus que prévu // et se le fera refuser côté serveur (source de vérité réelle). } } // --------------------------------------------------------------------------- // Mot de bienvenue + RGPD — affiché une fois par événement (localStorage), // plutôt qu'en bloc permanent dans le formulaire (décision Kévin 2026-09-16). // Reste ré-ouvrable à tout moment via le lien "Infos sur tes données". // --------------------------------------------------------------------------- function welcomeSeenStorageKey(slug) { return `photobooth_welcome_seen_${slug}`; } function hasSeenWelcome(slug) { try { return window.localStorage.getItem(welcomeSeenStorageKey(slug)) === "1"; } catch { return false; } } function markWelcomeSeen(slug) { try { window.localStorage.setItem(welcomeSeenStorageKey(slug), "1"); } catch { // Pas grave : la modale se réaffichera simplement à la prochaine visite, // ce n'est pas un risque de conformité (juste redondant pour l'invité). } } function openWelcomeModal(eventName) { welcomeModalTitle.textContent = eventName ? `Bienvenue à ${eventName} !` : "Bienvenue !"; welcomeModal.hidden = false; } function closeWelcomeModal() { welcomeModal.hidden = true; } welcomeModalDismiss.addEventListener("click", () => { const slug = getSlugFromPath(); if (slug) markWelcomeSeen(slug); closeWelcomeModal(); }); legalLink.addEventListener("click", () => { openWelcomeModal(state.event ? state.event.nom : null); }); function isHeicFile(file) { const type = (file.type || "").toLowerCase(); if (type === "image/heic" || type === "image/heif") return true; return /\.hei[cf]$/i.test(file.name || ""); } function isAcceptableImageFile(file) { if (isHeicFile(file)) return true; if (file.type && file.type.startsWith("image/")) return true; // Sur certains navigateurs Android, un fichier HEIC peut arriver avec un // type MIME vide ou générique : on se rabat sur l'extension. return /\.(jpe?g|png|gif|webp|bmp)$/i.test(file.name || ""); } // --------------------------------------------------------------------------- // Affichage des états // --------------------------------------------------------------------------- function showLoading() { loadingBlock.hidden = false; errorBlock.hidden = true; successBlock.hidden = true; form.hidden = true; } function showForm(event) { eventNameEl.textContent = event.nom || "Ajouter une photo"; loadingBlock.hidden = true; errorBlock.hidden = true; successBlock.hidden = true; form.hidden = false; const slug = getSlugFromPath(); if (slug && !hasSeenWelcome(slug)) { openWelcomeModal(event.nom); } } function showError(rawErr, retryAction) { const err = normalizeError(rawErr); loadingBlock.hidden = true; successBlock.hidden = true; errorBlock.hidden = false; errorMessageEl.textContent = ERROR_MESSAGES[err.code] || ERROR_MESSAGES.unknown; // Traitement visuel dédié (chaleureux, pas rouge "erreur") pour la limite // de photos atteinte : ce n'est pas un échec pour l'invité, juste une // limite après participation. Pur toggle de classe CSS, cf. style.css // (.state-block--limit) — aucun changement de logique ici. errorBlock.classList.toggle("state-block--limit", err.code === "guest-limit-reached"); state.lastRetryAction = retryAction || null; const canRetry = Boolean(retryAction) && RETRYABLE_CODES.has(err.code); retryBtn.hidden = !canRetry; // Erreurs bloquantes de chargement d'événement : on masque le formulaire. // Erreurs liées à une tentative d'envoi : le formulaire reste visible. if (["event-not-found", "event-closed", "config-missing", "guest-limit-reached"].includes(err.code)) { form.hidden = true; } if (err.cause) { // Pas de jargon technique affiché à l'invité, mais on garde une trace // en console pour le débogage. console.error(`[upload:${err.code}]`, err.cause); } } function resetPhotoFields() { state.selectedFile = null; state.compressedBlob = null; fileChosenLabel.textContent = "Aucun fichier choisi"; previewImg.hidden = true; if (previewObjectUrl) { URL.revokeObjectURL(previewObjectUrl); previewObjectUrl = null; } photoInput.value = ""; photoInputGallery.value = ""; nomInput.value = ""; messageInput.value = ""; } function showSuccess() { loadingBlock.hidden = true; errorBlock.hidden = true; form.hidden = true; successBlock.hidden = false; // Remise à zéro immédiate (pas seulement au clic sur "Ajouter une autre // photo") : si l'invité revient sur le formulaire par un autre biais, il // ne doit jamais retrouver la photo/le message déjà envoyés pré-remplis. resetPhotoFields(); } function setProgress(text) { progressText.textContent = text || ""; } function setSubmitting(isSubmitting) { state.isSubmitting = isSubmitting; submitBtn.disabled = isSubmitting; // On cible le du libellé (et non submitBtn.textContent) pour ne pas // effacer l'icône SVG du bouton à chaque bascule — changement purement // visuel, la logique de soumission elle-même est inchangée. submitBtnLabel.textContent = isSubmitting ? "Envoi en cours…" : "Envoyer ma photo"; } // --------------------------------------------------------------------------- // Chargement de l'événement (vérifie existence + statut actif + non expiré) // --------------------------------------------------------------------------- async function fetchEventPublicInfo(slug) { const { SUPABASE_URL, SUPABASE_ANON_KEY } = getConfig(); let res; try { res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/get_public_event`, { method: "POST", headers: { "Content-Type": "application/json", apikey: SUPABASE_ANON_KEY, Authorization: `Bearer ${SUPABASE_ANON_KEY}`, }, body: JSON.stringify({ p_slug: slug }), }); } catch (err) { throw appError("network", err); } if (res.status === 404) throw appError("event-not-found"); if (res.status >= 500) throw appError("server-overloaded"); if (!res.ok) throw appError("unknown", await safeReadText(res)); const data = await res.json().catch(() => null); const event = Array.isArray(data) ? data[0] : data; if (!event) throw appError("event-not-found"); return event; } async function safeReadText(res) { try { return await res.text(); } catch { return null; } } function isEventActive(event) { if (!event) return false; if (event.statut !== "actif") return false; if (event.expire_at && new Date(event.expire_at).getTime() <= Date.now()) return false; return true; } async function initEventCheck() { showLoading(); const slug = getSlugFromPath(); if (!slug) { showError(appError("event-not-found")); return; } // Lien vers la galerie posé dès que le slug est connu, sans attendre la // réponse de get_public_event : /galerie/ fait sa propre // vérification (event inconnu/clos), pas besoin de la dupliquer ici. const wallHref = `/galerie/${encodeURIComponent(slug)}`; wallLink.href = wallHref; wallLinkSuccess.href = wallHref; wallLinkModal.href = wallHref; try { const event = await fetchEventPublicInfo(slug); if (!isEventActive(event)) throw appError("event-closed"); if ( event.max_photos_per_guest != null && getLocalGuestPhotoCount(slug) >= event.max_photos_per_guest ) { throw appError("guest-limit-reached"); } state.event = event; showForm(event); } catch (err) { showError(err, () => initEventCheck()); } } // --------------------------------------------------------------------------- // Conversion HEIC (lazy load) + compression // --------------------------------------------------------------------------- let heic2anyLoadPromise = null; function loadHeic2any() { if (window.heic2any) return Promise.resolve(); if (heic2anyLoadPromise) return heic2anyLoadPromise; heic2anyLoadPromise = new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = new URL("../vendor/heic2any.min.js", import.meta.url).href; script.onload = () => resolve(); script.onerror = () => { heic2anyLoadPromise = null; reject(appError("heic-load-failed")); }; document.head.appendChild(script); }); return heic2anyLoadPromise; } async function convertHeicToJpeg(file) { await loadHeic2any(); let resultBlob; try { resultBlob = await window.heic2any({ blob: file, toType: "image/jpeg", quality: 0.9 }); } catch (err) { throw appError("heic-convert-failed", err); } const blob = Array.isArray(resultBlob) ? resultBlob[0] : resultBlob; return new File([blob], file.name.replace(/\.hei[cf]$/i, ".jpg"), { type: "image/jpeg" }); } async function normalizeAndCompress(file) { let workingFile = file; if (isHeicFile(file)) { setProgress("Conversion de la photo…"); workingFile = await convertHeicToJpeg(file); } setProgress("Compression de la photo…"); let compressed; try { compressed = await imageCompression(workingFile, COMPRESSION_OPTIONS); } catch (err) { throw appError("compression-failed", err); } if (compressed.size > MAX_OUTPUT_BYTES) { throw appError("compressed-too-large"); } return compressed; } // --------------------------------------------------------------------------- // Envoi vers Supabase (Storage + table photos) // --------------------------------------------------------------------------- async function uploadToStorage(eventId, blob) { const { SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_STORAGE_BUCKET } = getConfig(); const path = `${eventId}/${generateId()}.jpg`; let res; try { res = await fetch( `${SUPABASE_URL}/storage/v1/object/${encodeURIComponent(SUPABASE_STORAGE_BUCKET)}/${path}`, { method: "POST", headers: { apikey: SUPABASE_ANON_KEY, Authorization: `Bearer ${SUPABASE_ANON_KEY}`, "Content-Type": blob.type || "image/jpeg", "x-upsert": "false", }, body: blob, } ); } catch (err) { throw appError("network", err); } if (res.status >= 500) throw appError("server-overloaded", await safeReadText(res)); if (!res.ok) throw appError("storage-upload-failed", await safeReadText(res)); return path; } async function insertPhotoRow(eventId, path, nom, message) { const { SUPABASE_URL, SUPABASE_ANON_KEY } = getConfig(); // photobooth.photos n'est jamais exposée directement (schéma privé, cf. // supabase/migrations/20260916090000_create_photobooth_schema.sql) : le // seul chemin d'écriture est la RPC public.insert_public_photo, qui // revalide elle-même que l'event accepte encore les uploads. let res; try { res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/insert_public_photo`, { method: "POST", headers: { apikey: SUPABASE_ANON_KEY, Authorization: `Bearer ${SUPABASE_ANON_KEY}`, "Content-Type": "application/json", Prefer: "return=minimal", }, body: JSON.stringify({ p_event_id: eventId, p_url_storage: path, p_nom_invite: nom || null, p_message: message || null, p_guest_id: getGuestId(), }), }); } catch (err) { throw appError("network", err); } if (res.status >= 500) throw appError("server-overloaded", await safeReadText(res)); if (!res.ok) { const bodyText = await safeReadText(res); // La RPC renvoie un message métier explicite (ex. "guest_photo_limit_reached") // dans le corps PostgREST -- on le distingue d'une erreur technique // générique pour afficher le bon message (et ne pas proposer "réessayer"). if (bodyText && bodyText.includes("guest_photo_limit_reached")) { throw appError("guest-limit-reached", bodyText); } throw appError("db-insert-failed", bodyText); } } // --------------------------------------------------------------------------- // Formulaire // --------------------------------------------------------------------------- let previewObjectUrl = null; // Deux inputs (caméra directe / galerie) partagent la même logique de // sélection — état centralisé dans state.selectedFile plutôt que de lire // photoInput.files[0] un peu partout, pour rester correct quel que soit // l'input utilisé. function handleFileSelected(file) { // Nouveau fichier choisi -> on invalide toute compression déjà faite. state.compressedBlob = null; state.selectedFile = file || null; if (previewObjectUrl) { URL.revokeObjectURL(previewObjectUrl); previewObjectUrl = null; } if (file && file.type && file.type.startsWith("image/")) { previewObjectUrl = URL.createObjectURL(file); previewImg.src = previewObjectUrl; previewImg.hidden = false; previewImg.onerror = () => { previewImg.hidden = true; }; } else { // Pas d'aperçu fiable pour les HEIC sur la plupart des navigateurs. previewImg.hidden = true; } fileChosenLabel.textContent = file ? file.name : "Aucun fichier choisi"; } photoInput.addEventListener("change", () => { handleFileSelected(photoInput.files[0] || null); // Un seul fichier actif à la fois : on vide l'autre input pour éviter // toute ambiguïté si l'utilisateur change de méthode après coup. photoInputGallery.value = ""; }); photoInputGallery.addEventListener("change", () => { handleFileSelected(photoInputGallery.files[0] || null); photoInput.value = ""; }); async function submitPhoto() { if (state.isSubmitting) return; if (!state.event) { showError(appError("event-closed")); return; } const file = state.selectedFile; if (!file) return showError(appError("file-missing")); if (!isAcceptableImageFile(file)) return showError(appError("file-not-image")); if (file.size > MAX_INPUT_BYTES) return showError(appError("file-too-large")); setSubmitting(true); try { let blob = state.compressedBlob; if (!blob || state.selectedFile !== file) { blob = await normalizeAndCompress(file); state.compressedBlob = blob; state.selectedFile = file; } setProgress("Envoi de la photo…"); const path = await uploadToStorage(state.event.id, blob); setProgress("Dernière étape…"); await insertPhotoRow(state.event.id, path, nomInput.value.trim(), messageInput.value.trim()); const slug = getSlugFromPath(); if (slug) incrementLocalGuestPhotoCount(slug); setProgress(""); showSuccess(); } catch (err) { setProgress(""); showError(err, () => submitPhoto()); } finally { setSubmitting(false); } } form.addEventListener("submit", (evt) => { evt.preventDefault(); // Garde-fou en plus de state.isSubmitting (qui protège déjà submitPhoto // elle-même) : si jamais l'événement "submit" se déclenche plusieurs fois // très rapidement (double tap mobile), on ignore tout appel supplémentaire // tant qu'un envoi est en cours, sans dépendre uniquement de l'état du // bouton désactivé. if (state.isSubmitting) return; submitPhoto(); }); retryBtn.addEventListener("click", () => { if (state.lastRetryAction) state.lastRetryAction(); }); addAnotherBtn.addEventListener("click", () => { form.reset(); resetPhotoFields(); successBlock.hidden = true; // Revérifie la limite par invité : le dernier envoi peut avoir atteint // exactement le max, auquel cas on ne rouvre pas le formulaire. const slug = getSlugFromPath(); const limit = state.event ? state.event.max_photos_per_guest : null; if (slug && limit != null && getLocalGuestPhotoCount(slug) >= limit) { showError(appError("guest-limit-reached")); return; } form.hidden = false; }); // --------------------------------------------------------------------------- // Démarrage // --------------------------------------------------------------------------- initEventCheck();