La modale de bienvenue (position: fixed, inset: 0) recouvre tout l'écran y compris l'en-tête où vivait le seul lien vers la galerie : au premier chargement (cas le plus fréquent, un seul passage par invité et par événement), le lien était visuellement présent mais totalement inatteignable au clic. Ajout d'un lien dupliqué dans la carte de la modale elle-même. Au passage, "mur" remplacé par "fête" partout (jugé pas assez fun) pour rester cohérent avec le texte déjà utilisé sur l'écran de succès. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
696 lines
24 KiB
JavaScript
696 lines
24 KiB
JavaScript
// 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 <span> 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/<slug> 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();
|