Ajoute la création d'événement via n8n et refond la DA en cartes papier
- Nouvelle Edge Function create-event + RPC public.create_event : génère slug/admin_token/expire_at et insère l'event, réservée à service_role, appelée par le workflow n8n "Créer un événement" (Form Trigger, publié). - Passe DA "polaroid poussé à fond" : les cartes d'état (chargement/erreur/ succès), la modale de bienvenue et le formulaire passent en papier clair avec ruban washi tape et léger tilt, plutôt qu'en carte sombre sur fond sombre (ombre invisible dans l'ancienne version). Admin resté sobre/sombre mais cartes plus lisibles (liseré coloré, contraste renforcé). - Générateur QR et carton imprimable : lisent maintenant ?slug= et ?baseUrl= pour se pré-remplir depuis le lien renvoyé par create-event. - Correctifs : icône manquante sur "Télécharger" (admin), bug [hidden] anticipé sur l'état vide de la galerie invité.
This commit is contained in:
@@ -5,6 +5,131 @@ la date à laquelle elles ont été prises. Toute nouvelle décision structurant
|
||||
(schéma DB, auth, hébergement, etc.) doit être ajoutée ici après validation
|
||||
par Kévin.
|
||||
|
||||
## 2026-09-18 (six)
|
||||
|
||||
- **Limitation connue (pas un bug à corriger)** : sur /e/OS + navigateur
|
||||
DuckDuckGo, le bouton "Prendre une photo" (`capture="environment"`)
|
||||
ouvre la galerie au lieu de l'appareil photo, et l'inverse pour "Depuis
|
||||
la galerie". Vérifié : le HTML/JS est conforme à la spec (wiring
|
||||
correct), c'est DuckDuckGo Android qui n'honore pas fiablement
|
||||
l'attribut `capture`. Décision : ne rien coder de spécifique pour ce
|
||||
navigateur (combo /e/OS + DuckDuckGo trop marginal côté invités réels,
|
||||
qui arrivent très majoritairement via Safari/Chrome par défaut) — à
|
||||
revérifier par Kévin sur iPhone/Android dès qu'il en a sous la main.
|
||||
- **Événement de test créé en conditions réelles** via le workflow n8n +
|
||||
`create-event` (slug `test-da-verification-kevin`) : premier passage
|
||||
bout-en-bout du pipeline complet (n8n → Edge Function → Telegram),
|
||||
fonctionnel. Confirme aussi que le déploiement de la passe DA
|
||||
"polaroid poussé à fond" (voir entrée précédente) rend bien en
|
||||
conditions réelles sur le domaine de prod, pas seulement en local —
|
||||
modale de bienvenue et état vide de la galerie vérifiés par capture
|
||||
d'écran sur `https://photobooth.assistantaikev.eu`.
|
||||
- **Déployé sur le VPS** (scp direct des 7 fichiers modifiés vers
|
||||
`/home/kevin/photobooth-qr/site/`, pas de rebuild nécessaire — site
|
||||
statique servi par nginx).
|
||||
|
||||
## 2026-09-18 (cinq)
|
||||
|
||||
- **2e itération de la 4e passe DA, plus radicale** : retour Kévin sur la
|
||||
version "rim light + grain" ("franchement c'est nul", captures à l'appui
|
||||
après avoir vérifié moi-même dans Chrome) — l'effet était bien trop
|
||||
subtil, l'app restait un grand vide noir avec une petite carte sombre
|
||||
illisible. Changement structurel plutôt qu'un réglage plus fort du même
|
||||
hack : les cartes "props" côté invité (bloc de chargement/erreur/succès,
|
||||
modale de bienvenue, formulaire prénom+message) passent en **papier
|
||||
clair** (`#FAF6F0`, encre `#12100E`) posé sur la table sombre, avec un
|
||||
ruban "washi tape" en haut de carte et une légère inclinaison
|
||||
(-2.2°/+1.8° selon l'état) — comme un vrai polaroid, plutôt qu'un panneau
|
||||
UI sombre. Une carte claire sur fond quasi noir projette une vraie ombre
|
||||
(`--shadow-paper`), contrairement à une carte sombre sur fond sombre :
|
||||
ça résout le problème à la racine plutôt qu'en le maquillant.
|
||||
- Bug technique corrigé au passage : l'inclinaison ne s'affichait pas du
|
||||
tout au premier essai — une `animation` qui touche `transform`
|
||||
(`fade-rise`) remplace toute la propriété, elle ne fusionne pas avec un
|
||||
`rotate()` posé à côté en statique. Fix : rotation intégrée dans les
|
||||
keyframes (`state-card-in`), avec repli statique sous
|
||||
`prefers-reduced-motion: reduce`.
|
||||
- **Écran admin** (galerie + générateur QR) : reste sombre/sobre, sans
|
||||
tilt ni tape (exception du skill DA conservée), mais `--color-card`
|
||||
légèrement éclairci (`#1E1B18` → `#241F1A`) et liseré coloré plein en
|
||||
haut de carte (plus un fond teinté translucide) pour rester repérable —
|
||||
même diagnostic que côté invité (contraste carte/fond trop faible pour
|
||||
se voir), remède différent (sobre, pas de papier). Le générateur QR
|
||||
(`src/admin/qrcode/`) n'avait quasiment aucune mise en forme avant cette
|
||||
passe (champs nus sur le fond, aucun regroupement visuel, police
|
||||
"General Sans" référencée mais jamais chargée dans ce fichier) : plus
|
||||
gros écart identifié lors de l'audit, corrigé (carte de formulaire,
|
||||
police chargée, contraste champs/carte).
|
||||
- **Vérifié visuellement cette fois** : captures d'écran via Chrome headless
|
||||
local (`google-chrome --headless=new --screenshot=...`) sur les 5 écrans
|
||||
+ la modale de bienvenue forcée visible temporairement pour vérification
|
||||
— pas juste une relecture de CSS. Contrastes WCAG AA recalculés pour les
|
||||
nouvelles combinaisons papier (encre/muted sur `#FAF6F0`) : ≥5:1 sur tous
|
||||
les textes ajoutés.
|
||||
- Pas testé avec un vrai événement (formulaire rempli, grille de photos
|
||||
garnie, écran de succès) faute de slug valide en local — seuls les états
|
||||
vides/erreur ont pu être vérifiés en conditions réelles de rendu.
|
||||
|
||||
## 2026-09-18 (quater)
|
||||
|
||||
- **4e passe DA "Polaroid Néon"** : suite au retour "ça fait vraiment
|
||||
low-cost, il faut me rendre cette application sexy", audit écran par
|
||||
écran (upload, galerie invité, admin galerie, générateur QR, carton
|
||||
imprimable). Diagnostic : ce n'était pas la direction elle-même
|
||||
(conservée intégralement, aucune valeur de palette changée) mais des
|
||||
défauts d'exécution — ombres noires invisibles sur fond quasi noir
|
||||
(`#12100E`), fond en aplat sans texture, pastille de marque non
|
||||
généralisée à tous les écrans, icône manquante sur le lien "Télécharger"
|
||||
admin. Corrigé via : liseré clair ("rim light") sur les cartes/boutons,
|
||||
grain de fond en SVG data URI inline (~350 octets, 0 requête réseau),
|
||||
pastille corail derrière le logo sur tous les écrans, icône ajoutée au
|
||||
lien de téléchargement admin. Bug `[hidden]` masqué par un `display`
|
||||
auteur (déjà rencontré 3 fois sur ce projet) anticipé et corrigé au
|
||||
passage sur `#empty-state` (galerie invité, passé en `display: flex`).
|
||||
Contrastes WCAG AA revérifiés, aucun n'a changé (aucune couleur de texte/
|
||||
fond modifiée). Non vérifié visuellement dans un navigateur avant cette
|
||||
entrée (pas d'accès Chrome dans l'environnement) — à prévisualiser en
|
||||
local avant déploiement.
|
||||
|
||||
## 2026-09-18 (ter)
|
||||
|
||||
- **`create-event` déployée en prod** sur `kevin-lecou-hub` (migration
|
||||
`20260918100000_create_event_rpc.sql` + Edge Function
|
||||
`supabase/functions/create-event/`). Vérifié après coup : grants limités à
|
||||
`service_role` (n'apparaît pas dans les fonctions `SECURITY DEFINER`
|
||||
exécutables par `anon`/`authenticated`, contrairement à
|
||||
`get_public_event`/`insert_public_photo` qui le sont intentionnellement).
|
||||
Un `search_path` manquant sur la fonction interne
|
||||
`photobooth.generate_unique_slug` (signalé par le linter sécurité Supabase
|
||||
juste après déploiement) a été corrigé dans la foulée
|
||||
(`20260918101500_harden_generate_unique_slug_search_path.sql`).
|
||||
- Le workflow n8n `photobooth-qr — Créer un événement` reste **inactif** :
|
||||
à activer par Kévin une fois le DNS `git.assistantaikev.eu` et un premier
|
||||
test réel du formulaire validés.
|
||||
|
||||
## 2026-09-18 (bis)
|
||||
|
||||
- **Création d'événement** : via un **formulaire n8n** (Form Trigger),
|
||||
plutôt qu'un formulaire dans l'appli photobooth-qr ou du SQL manuel. Le
|
||||
workflow insère l'event en base (génère `slug` + `admin_token`) et
|
||||
renvoie les liens invité/admin à Kévin. Choisi pour rester cohérent avec
|
||||
la stack n8n déjà en place et éviter d'ajouter une nouvelle couche
|
||||
d'authentification "admin produit" dans l'appli (aujourd'hui il n'existe
|
||||
que des tokens par événement, pas de compte admin global).
|
||||
- **Pas de personnalisation visuelle par événement** : la DA "Polaroid
|
||||
Néon" reste identique pour tous les événements ; seules les infos
|
||||
textuelles varient par event (nom, date, limite de photos). Pas de
|
||||
marque blanche (palette/logo par client) en V1 — jugé de la
|
||||
sur-ingénierie pour un besoin V2/V3 pas encore confirmé par un client
|
||||
réel.
|
||||
- **Remote Git : Gitea auto-hébergé sur le VPS existant** (plutôt que
|
||||
GitHub). Cohérent avec la préférence d'autonomie de Kévin — tout reste
|
||||
chez lui, pas de dépendance à un service tiers pour le code source. Le
|
||||
VPS héberge déjà n8n (Traefik/SSL en place) ; Gitea tournera comme
|
||||
service Docker supplémentaire derrière le même reverse proxy. Détails
|
||||
d'implémentation (sous-domaine, ressources, sauvegarde) à définir avant
|
||||
déploiement.
|
||||
|
||||
## 2026-09-18
|
||||
|
||||
- **Galerie invité (revirement)** : les invités peuvent désormais voir
|
||||
|
||||
@@ -273,7 +273,10 @@ function renderPhotoGrid() {
|
||||
// à la place (limitation connue, acceptée pour éviter un fetch+blob par
|
||||
// photo qui multiplierait les requêtes réseau).
|
||||
downloadLink.download = "";
|
||||
downloadLink.textContent = "Télécharger";
|
||||
// Icône + texte plutôt qu'un lien texte nu : cohérent avec le langage
|
||||
// iconographique du reste de l'écran (Actualiser, Télécharger tout).
|
||||
downloadLink.innerHTML =
|
||||
'<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 4v11"/><path d="M7 10.5 12 15.5 17 10.5"/><path d="M5 19.5h14"/></svg><span>Télécharger</span>';
|
||||
downloadLink.addEventListener("click", (evt) => {
|
||||
if (isPhotoLinkLikelyExpired(photo)) {
|
||||
evt.preventDefault();
|
||||
|
||||
+65
-15
@@ -45,9 +45,14 @@
|
||||
--color-yellow: #ffd166;
|
||||
--color-mint: #06d6a0;
|
||||
|
||||
/* Exception admin du skill : cartes en #1E1B18. */
|
||||
--color-card: #1e1b18;
|
||||
--color-card-muted: #262220;
|
||||
/* Exception admin du skill : cartes sombres, jamais blanc cassé. Un peu
|
||||
éclairci par rapport à la version initiale (#1E1B18 -> #241F1A) lors de
|
||||
la passe "polaroid poussé à fond" (2026-09-18) : l'écart avec
|
||||
--color-bg restait trop faible pour se voir sans l'aide d'une ombre,
|
||||
qui elle-même ne portait pas sur un fond aussi sombre — repéré en
|
||||
capture d'écran, pas en théorie. */
|
||||
--color-card: #241f1a;
|
||||
--color-card-muted: #2c2621;
|
||||
--color-border-ui: rgba(250, 246, 240, 0.14);
|
||||
--color-border-ui-strong: rgba(250, 246, 240, 0.26);
|
||||
|
||||
@@ -58,13 +63,19 @@
|
||||
--radius-pill: 999px;
|
||||
|
||||
--shadow-soft: 0 10px 24px rgba(0, 0, 0, 0.35);
|
||||
--shadow-card: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||
--shadow-card: 0 16px 32px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
--font-display: "General Sans", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-ui: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
|
||||
/* Rim light : voir le commentaire détaillé dans src/e/style.css. Même
|
||||
nécessité ici (#1E1B18 sur #12100E, écart de luminance faible : une
|
||||
ombre noire classique n'y est presque pas visible). */
|
||||
--inset-rim: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
--inset-rim-btn: inset 0 1px 0 0 rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -78,7 +89,13 @@ html {
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
background: var(--color-bg);
|
||||
/* Grain identique aux écrans invité (voir src/e/style.css) : cohérence
|
||||
visuelle admin/invité, casse l'aplat du fond, sans impact mesurable
|
||||
sur le contraste WCAG déjà vérifié pour ce fichier. */
|
||||
background-color: var(--color-bg);
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxODAiIGhlaWdodD0iMTgwIj48ZmlsdGVyIGlkPSJuIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iMC44NSIgbnVtT2N0YXZlcz0iMiIgc3RpdGNoVGlsZXM9InN0aXRjaCIgcmVzdWx0PSJub2lzZSIvPjxmZUNvbG9yTWF0cml4IGluPSJub2lzZSIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMSAgMCAwIDAgMCAxICAwIDAgMCAwIDEgIDAgMCAwIDAuMDUgMCIvPjwvZmlsdGVyPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbHRlcj0idXJsKCUyM24pIi8+PC9zdmc+");
|
||||
background-repeat: repeat;
|
||||
background-size: 180px 180px;
|
||||
color: var(--color-text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -104,11 +121,19 @@ body {
|
||||
color: var(--color-muted-2);
|
||||
}
|
||||
|
||||
/* Pastille corail derrière la marque, cohérente avec les écrans invité
|
||||
(src/e/style.css, src/galerie/style.css) — un détail de marque, pas une
|
||||
décoration : reste discret, n'entre pas en conflit avec l'exception
|
||||
admin (sobre, data-first). */
|
||||
.brand__mark {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-coral);
|
||||
background: rgba(255, 94, 91, 0.14);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.brand__name {
|
||||
@@ -135,16 +160,31 @@ body {
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
|
||||
/* Liseré haut plein plutôt qu'un fond entièrement teinté (l'ancien
|
||||
.state-block--error en rgba coral 0.14% se distinguait à peine de
|
||||
--color-card une fois les deux sombres) : reste "sobre" (aucune couleur
|
||||
décorative sur toute la surface) tout en rendant la carte repérable. */
|
||||
.state-block {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 1.25rem 1rem;
|
||||
padding: 1.5rem 1rem 1.25rem;
|
||||
border-radius: var(--radius-ui);
|
||||
margin-bottom: 1rem;
|
||||
background: var(--color-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.state-block::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
background: var(--accent-color, var(--color-coral));
|
||||
}
|
||||
|
||||
.state-block--error {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -205,7 +245,7 @@ body {
|
||||
cursor: pointer;
|
||||
background: var(--color-coral);
|
||||
color: var(--color-ink);
|
||||
box-shadow: var(--shadow-card);
|
||||
box-shadow: var(--shadow-card), var(--inset-rim-btn);
|
||||
transition: transform 0.12s var(--ease-standard), background 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
@@ -219,7 +259,7 @@ body {
|
||||
background: var(--color-card);
|
||||
color: var(--color-text);
|
||||
border: 1.5px solid var(--color-border-ui-strong);
|
||||
box-shadow: none;
|
||||
box-shadow: var(--inset-rim);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@@ -245,7 +285,7 @@ body {
|
||||
.btn--secondary:hover:not(:disabled) {
|
||||
background: var(--color-card-muted);
|
||||
border-color: var(--color-coral);
|
||||
box-shadow: none;
|
||||
box-shadow: var(--inset-rim);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +324,7 @@ a:focus-visible {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-card);
|
||||
box-shadow: var(--shadow-card), var(--inset-rim);
|
||||
transition: box-shadow 0.2s var(--ease-standard), transform 0.2s var(--ease-standard);
|
||||
}
|
||||
|
||||
@@ -304,7 +344,7 @@ a:focus-visible {
|
||||
|
||||
@media (hover: hover) {
|
||||
.photo-card:hover {
|
||||
box-shadow: var(--shadow-soft);
|
||||
box-shadow: var(--shadow-soft), var(--inset-rim);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
@@ -374,7 +414,10 @@ a:focus-visible {
|
||||
}
|
||||
|
||||
.photo-card__download {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
text-align: center;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-coral);
|
||||
@@ -382,6 +425,13 @@ a:focus-visible {
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
transition: background 0.15s var(--ease-standard);
|
||||
}
|
||||
|
||||
.photo-card__download .btn-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.photo-card__download:hover {
|
||||
|
||||
@@ -224,10 +224,19 @@
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Pastille corail derrière la marque, même traitement que les écrans
|
||||
numériques (src/e/style.css, src/galerie/style.css,
|
||||
src/admin/gallery/style.css) — adapté en unités mm pour l'impression,
|
||||
opacité un peu plus marquée qu'à l'écran pour rester visible une fois
|
||||
imprimé (l'encre "délave" toujours un peu le rendu). */
|
||||
.card__mark {
|
||||
width: 5mm;
|
||||
height: 5mm;
|
||||
width: 7mm;
|
||||
height: 7mm;
|
||||
padding: 1.2mm;
|
||||
box-sizing: border-box;
|
||||
color: var(--color-coral);
|
||||
background: rgba(255, 94, 91, 0.12);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.card__brand-name {
|
||||
@@ -439,6 +448,21 @@
|
||||
|
||||
baseUrlInput.placeholder = DEFAULT_BASE_URL;
|
||||
|
||||
// Pré-remplissage depuis l'URL (?slug=...&baseUrl=...), même logique
|
||||
// que src/admin/qrcode/index.html — le lien "qrcode" renvoyé par
|
||||
// create-event pointe vers le générateur QR, pas vers ce carton, mais
|
||||
// les paramètres sont repris ici aussi pour un usage direct/futur
|
||||
// (lien partagé à la main, favori, etc.).
|
||||
const initialParams = new URLSearchParams(window.location.search);
|
||||
const initialSlug = initialParams.get('slug');
|
||||
const initialBaseUrl = initialParams.get('baseUrl');
|
||||
if (initialBaseUrl) {
|
||||
baseUrlInput.value = initialBaseUrl;
|
||||
}
|
||||
if (initialSlug) {
|
||||
slugInput.value = initialSlug;
|
||||
}
|
||||
|
||||
function humanizeSlug(slug) {
|
||||
return slug
|
||||
.replace(/[-_]+/g, ' ')
|
||||
|
||||
+105
-11
@@ -6,6 +6,7 @@
|
||||
<title>Générateur de QR code — photobooth-qr (admin)</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#12100E" />
|
||||
<link rel="preload" href="/vendor/fonts/generalsans-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<style>
|
||||
/* Direction artistique "Polaroid Néon" (.claude/skills/photobooth-da/SKILL.md),
|
||||
exception admin : sobre, sombre, data-first. Mêmes tokens que
|
||||
@@ -23,15 +24,25 @@
|
||||
--color-muted: #e8dfd3;
|
||||
--color-muted-2: #a89e90;
|
||||
--color-coral: #ff5e5b;
|
||||
--color-card: #1e1b18;
|
||||
--color-card-muted: #262220;
|
||||
--color-card: #241f1a;
|
||||
--color-card-muted: #2c2621;
|
||||
--color-border-ui: rgba(250, 246, 240, 0.16);
|
||||
--radius-sm: 10px;
|
||||
--radius: 14px;
|
||||
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
/* Carte plus soutenue + ombre large + liseré haut coloré (pas de tilt/
|
||||
tape ici : "écran admin = exception, sobre" du skill DA). Passe
|
||||
"polaroid poussé à fond" (2026-09-18) : avant cette passe, les champs
|
||||
flottaient nus sur le fond sans regroupement visuel — l'écran le plus
|
||||
"low-cost" des cinq lors de la revue. */
|
||||
--shadow-card: 0 16px 32px rgba(0, 0, 0, 0.5);
|
||||
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
/* Rim light : voir src/e/style.css pour le détail (ombre noire
|
||||
classique quasi invisible sur fond quasi noir). */
|
||||
--inset-rim: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
--inset-rim-btn: inset 0 1px 0 0 rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@@ -42,13 +53,29 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Ajoutée dans cette passe : le h1 référençait "General Sans" (comme les
|
||||
autres écrans) sans jamais la charger ici, retombant silencieusement
|
||||
sur la police système. */
|
||||
@font-face {
|
||||
font-family: "General Sans";
|
||||
src: url("/vendor/fonts/generalsans-700.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 1.5rem 1.1rem 3rem;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
/* Grain identique aux autres écrans (voir src/e/style.css) : cohérence
|
||||
visuelle, même sur cet outil interne. */
|
||||
background-color: var(--color-bg);
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxODAiIGhlaWdodD0iMTgwIj48ZmlsdGVyIGlkPSJuIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iMC44NSIgbnVtT2N0YXZlcz0iMiIgc3RpdGNoVGlsZXM9InN0aXRjaCIgcmVzdWx0PSJub2lzZSIvPjxmZUNvbG9yTWF0cml4IGluPSJub2lzZSIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMSAgMCAwIDAgMCAxICAwIDAgMCAwIDEgIDAgMCAwIDAuMDUgMCIvPjwvZmlsdGVyPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbHRlcj0idXJsKCUyM24pIi8+PC9zdmc+");
|
||||
background-repeat: repeat;
|
||||
background-size: 180px 180px;
|
||||
max-width: 480px;
|
||||
margin-inline: auto;
|
||||
line-height: 1.45;
|
||||
@@ -62,10 +89,14 @@
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.brand svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-coral);
|
||||
background: rgba(255, 94, 91, 0.14);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.brand span {
|
||||
font-size: 0.7rem;
|
||||
@@ -74,7 +105,8 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.2rem;
|
||||
font-family: "General Sans", var(--font-sans);
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 0.35rem;
|
||||
@@ -83,7 +115,26 @@
|
||||
color: var(--color-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
/* Carte qui regroupe les 3 champs — avant cette passe ils flottaient nus
|
||||
directement sur le fond, sans le moindre regroupement visuel. */
|
||||
.form-card {
|
||||
position: relative;
|
||||
padding: 1.3rem 1.1rem 0.3rem;
|
||||
border: 1px solid var(--color-border-ui);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.form-card::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
background: var(--color-coral);
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
@@ -92,6 +143,9 @@
|
||||
margin-bottom: 0.35rem;
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
.form-card label:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.75rem;
|
||||
@@ -99,8 +153,11 @@
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--color-border-ui);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-card);
|
||||
/* Distinct de --color-card (fond du .form-card qui les contient
|
||||
désormais) : sinon les champs se fondent dans la carte. */
|
||||
background: var(--color-bg);
|
||||
color: inherit;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
transition: border-color 0.15s var(--ease-standard), box-shadow 0.15s var(--ease-standard);
|
||||
}
|
||||
input:focus,
|
||||
@@ -110,16 +167,32 @@
|
||||
box-shadow: 0 0 0 4px rgba(255, 94, 91, 0.22);
|
||||
}
|
||||
.preview {
|
||||
position: relative;
|
||||
margin-top: 1.5rem;
|
||||
padding: 1.1rem;
|
||||
padding: 1.3rem 1.1rem 1.1rem;
|
||||
border: 1px solid var(--color-border-ui);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.preview::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
background: var(--color-mint);
|
||||
}
|
||||
.preview svg {
|
||||
background: var(--color-paper, #faf6f0);
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
/* Une règle auteur avec `display` passe toujours devant `[hidden] {
|
||||
display: none }` du navigateur (origine auteur > origine user-agent
|
||||
dans la cascade), donc il faut le dire explicitement ici pour que
|
||||
@@ -154,7 +227,7 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-coral);
|
||||
color: var(--color-ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: var(--shadow-sm), var(--inset-rim-btn);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s var(--ease-standard), background 0.15s ease;
|
||||
}
|
||||
@@ -165,7 +238,7 @@
|
||||
background: var(--color-card);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-border-ui);
|
||||
box-shadow: none;
|
||||
box-shadow: var(--inset-rim);
|
||||
}
|
||||
button.secondary:active:not(:disabled) {
|
||||
background: var(--color-card-muted);
|
||||
@@ -212,6 +285,7 @@
|
||||
Ne fait aucun appel à Supabase.
|
||||
</p>
|
||||
|
||||
<div class="form-card">
|
||||
<label for="slug">Slug de l'événement</label>
|
||||
<input id="slug" type="text" placeholder="mariage-julie-marc" autocomplete="off" />
|
||||
|
||||
@@ -225,6 +299,7 @@
|
||||
<option value="Q">Q — élevé (recommandé si affiché en extérieur)</option>
|
||||
<option value="H">H — maximal (support très exposé/manipulé)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="preview" id="preview" hidden>
|
||||
<div id="svgContainer"></div>
|
||||
@@ -262,6 +337,25 @@
|
||||
// voir src/e/config.example.js) puis via DEFAULT_BASE_URL en secours.
|
||||
baseUrlInput.placeholder = DEFAULT_BASE_URL;
|
||||
|
||||
// Pré-remplissage depuis l'URL (?slug=...&baseUrl=...) : le lien
|
||||
// "qrcode" renvoyé par l'Edge Function create-event (cf.
|
||||
// supabase/functions/create-event) et affiché par le workflow n8n de
|
||||
// création d'événement pointe déjà vers cette page avec ces
|
||||
// paramètres — jusqu'ici aucun des deux n'était lu, il fallait tout
|
||||
// ressaisir à la main (slug ET domaine) à chaque fois.
|
||||
const initialParams = new URLSearchParams(window.location.search);
|
||||
const initialSlug = initialParams.get('slug');
|
||||
const initialBaseUrl = initialParams.get('baseUrl');
|
||||
if (initialBaseUrl) {
|
||||
baseUrlInput.value = initialBaseUrl;
|
||||
}
|
||||
if (initialSlug) {
|
||||
slugInput.value = initialSlug;
|
||||
// `refresh` est déclarée plus bas (function declaration, hissée dans
|
||||
// la portée du module) : appelable ici sans réorganiser le fichier.
|
||||
refresh();
|
||||
}
|
||||
|
||||
function currentOptions() {
|
||||
const baseUrl = baseUrlInput.value.trim() || undefined;
|
||||
const errorCorrectionLevel = ecLevelSelect.value;
|
||||
|
||||
+180
-42
@@ -96,6 +96,35 @@
|
||||
--color-success-bg: rgba(6, 214, 160, 0.12);
|
||||
|
||||
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Liseré clair posé en haut des surfaces "carte" (rim light). Sur un fond
|
||||
quasi noir (#12100E), une ombre portée classique (rgba noir) est quasi
|
||||
invisible — elle ne suffit pas à faire "flotter" une carte. Ce filet
|
||||
très ténu (5% blanc) simule un reflet du haut, seule readable façon de
|
||||
donner du relief ici sans dégradé ni glow. Toujours combiné à
|
||||
--shadow-card/--shadow-soft, jamais seul. */
|
||||
--inset-rim: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
--inset-rim-btn: inset 0 1px 0 0 rgba(255, 255, 255, 0.16);
|
||||
|
||||
/* --- Passe "polaroid poussé à fond" (2026-09-18, retour Kévin : "ça fait
|
||||
nul", version rim-light/grain jugée insuffisante) ---
|
||||
Le vrai problème n'était pas un manque de reflet mais l'inverse : des
|
||||
cartes SOMBRES sur fond SOMBRE ne peuvent physiquement pas projeter
|
||||
d'ombre visible, quel que soit le liseré ajouté. Fix structurel : les
|
||||
cartes "props" (bloc d'état plein écran, modale de bienvenue, pense-bête
|
||||
RGPD) passent en PAPIER clair — comme un vrai polaroid/post-it posé sur
|
||||
une table sombre — ce qui rend l'ombre noire enfin visible ET renforce
|
||||
l'univers polaroid au lieu de le cantonner à la grille de photos. */
|
||||
--color-paper: #faf6f0;
|
||||
--color-paper-alt: #fffdf9; /* champs de saisie, légèrement plus clair que la carte qui les contient */
|
||||
--color-paper-ink: #12100e;
|
||||
--color-paper-muted: #6b6153;
|
||||
|
||||
--shadow-paper: 0 18px 34px rgba(0, 0, 0, 0.55), 0 4px 10px rgba(0, 0, 0, 0.4);
|
||||
--shadow-paper-sm: 0 8px 18px rgba(0, 0, 0, 0.45);
|
||||
|
||||
--tilt-1: -2.2deg;
|
||||
--tilt-2: 1.8deg;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -109,7 +138,16 @@ html {
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
background: var(--color-bg);
|
||||
/* Grain fin (SVG feTurbulence inline en data URI, ~350 octets, zéro
|
||||
requête réseau) posé sur le fond quasi noir : casse l'effet
|
||||
"aplat/dégradé PowerPoint" d'un fond uni, cohérent avec la texture
|
||||
film d'un vrai polaroid. Opacité très basse (5%), imperceptible en
|
||||
tant que telle, ne modifie pas le contraste texte/fond calculé
|
||||
(#12100E reste la couleur de référence pour WCAG AA). */
|
||||
background-color: var(--color-bg);
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxODAiIGhlaWdodD0iMTgwIj48ZmlsdGVyIGlkPSJuIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iMC44NSIgbnVtT2N0YXZlcz0iMiIgc3RpdGNoVGlsZXM9InN0aXRjaCIgcmVzdWx0PSJub2lzZSIvPjxmZUNvbG9yTWF0cml4IGluPSJub2lzZSIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMSAgMCAwIDAgMCAxICAwIDAgMCAwIDEgIDAgMCAwIDAuMDUgMCIvPjwvZmlsdGVyPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbHRlcj0idXJsKCUyM24pIi8+PC9zdmc+");
|
||||
background-repeat: repeat;
|
||||
background-size: 180px 180px;
|
||||
color: var(--color-text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -147,11 +185,19 @@ body {
|
||||
color: var(--color-muted-2);
|
||||
}
|
||||
|
||||
/* Pastille corail derrière la marque : même traitement que le header de
|
||||
la modale de bienvenue (voir .welcome-modal .brand__mark plus bas),
|
||||
généralisé ici pour que la marque ait un minimum de poids visuel sur
|
||||
l'écran principal aussi, pas seulement dans la modale. */
|
||||
.brand__mark {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-coral);
|
||||
background: rgba(255, 94, 91, 0.14);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.brand__name {
|
||||
@@ -191,32 +237,76 @@ body {
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
|
||||
/* Carte "papier" : le bloc plein écran (chargement/erreur/succès) est LE
|
||||
contenu de l'écran à ce moment précis — traité comme un polaroid/index
|
||||
card posé sur la table plutôt que comme un panneau UI sombre. Une carte
|
||||
sombre sur fond #12100E ne peut jamais projeter d'ombre visible, d'où le
|
||||
passage en papier clair : l'ombre redevient réelle, et l'univers polaroid
|
||||
ne se limite plus à la grille de photos de src/galerie/. */
|
||||
.state-block {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 1.5rem 1.2rem;
|
||||
border-radius: var(--radius-ui);
|
||||
margin-bottom: 1rem;
|
||||
padding: 1.75rem 1.3rem 1.5rem;
|
||||
margin: 0.75rem 0.35rem 1.5rem;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-paper-ink);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-paper);
|
||||
/* La rotation doit être DANS les keyframes, pas posée en `transform`
|
||||
statique à côté d'une `animation` : une animation qui touche `transform`
|
||||
remplace toute la propriété (elle ne fusionne pas avec une valeur non
|
||||
animée), donc `rotate()` disparaissait dès que fade-rise s'appliquait
|
||||
— bug réel repéré en vérifiant la capture d'écran. */
|
||||
animation: state-card-in 0.5s var(--ease-standard) both;
|
||||
}
|
||||
|
||||
@keyframes state-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) rotate(var(--tilt, var(--tilt-1)));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) rotate(var(--tilt, var(--tilt-1)));
|
||||
}
|
||||
}
|
||||
|
||||
/* Bande "washi tape" en haut de carte, pincée par-dessus le bord — signature
|
||||
scrapbook réutilisée sur les 3 variantes, seule la couleur change. */
|
||||
.state-block::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
left: 50%;
|
||||
width: 74px;
|
||||
height: 24px;
|
||||
transform: translateX(-50%) rotate(-2.5deg);
|
||||
background: var(--tape-color, var(--color-yellow));
|
||||
opacity: 0.88;
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.state-block--loading {
|
||||
color: var(--color-muted);
|
||||
--tilt: var(--tilt-2);
|
||||
--tape-color: var(--color-muted-2);
|
||||
color: var(--color-paper-ink);
|
||||
}
|
||||
|
||||
.state-block--error {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-text);
|
||||
--tilt: -2.6deg;
|
||||
--tape-color: var(--color-coral);
|
||||
}
|
||||
|
||||
.state-block--error.state-block--limit {
|
||||
background: var(--color-limit-bg);
|
||||
--tape-color: var(--color-yellow);
|
||||
}
|
||||
|
||||
.state-block--success {
|
||||
--tilt: var(--tilt-1);
|
||||
--tape-color: var(--color-mint);
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, var(--color-success-bg), transparent 75%);
|
||||
color: var(--color-text);
|
||||
overflow: visible;
|
||||
padding-top: 1.75rem;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.state-block p {
|
||||
@@ -315,28 +405,47 @@ body {
|
||||
|
||||
/* Regroupe prénom + petit mot dans une même carte : second niveau de
|
||||
hiérarchie visuelle (la photo reste l'action principale au-dessus). */
|
||||
/* Carte "papier" comme .state-block (même logique : ombre réelle,
|
||||
cohérence avec l'univers polaroid) — pas de tilt ici, c'est une zone de
|
||||
saisie active, pas un prop posé sur la table. */
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-ui);
|
||||
border-radius: var(--radius-ui);
|
||||
background: var(--color-paper);
|
||||
color: var(--color-paper-ink);
|
||||
border-radius: 8px;
|
||||
padding: 1.1rem;
|
||||
box-shadow: var(--shadow-paper-sm);
|
||||
}
|
||||
|
||||
.field-group label {
|
||||
color: var(--color-paper-ink);
|
||||
}
|
||||
|
||||
.field-group .optional {
|
||||
color: var(--color-paper-muted);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
textarea {
|
||||
font: inherit;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1.5px solid var(--color-border-ui);
|
||||
border: 1.5px solid rgba(18, 16, 14, 0.14);
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg);
|
||||
color: inherit;
|
||||
background: var(--color-paper-alt);
|
||||
color: var(--color-paper-ink);
|
||||
width: 100%;
|
||||
box-shadow: inset 0 1px 3px rgba(18, 16, 14, 0.08);
|
||||
transition: border-color 0.18s var(--ease-standard), box-shadow 0.18s var(--ease-standard);
|
||||
}
|
||||
|
||||
input[type="text"]::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--color-paper-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--color-coral);
|
||||
@@ -384,13 +493,14 @@ textarea:focus {
|
||||
border: none;
|
||||
background: var(--color-coral);
|
||||
color: var(--color-ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: var(--shadow-sm), var(--inset-rim-btn);
|
||||
}
|
||||
|
||||
.btn-source--secondary {
|
||||
border: 1.5px solid var(--color-border-ui-strong);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--inset-rim);
|
||||
}
|
||||
|
||||
.btn-source:active {
|
||||
@@ -456,14 +566,18 @@ button:focus-visible {
|
||||
animation: pop-in 0.35s var(--ease-spring) both;
|
||||
}
|
||||
|
||||
/* Sous-carte "papier" nichée dans la modale (elle-même en papier depuis
|
||||
cette passe) : un ton légèrement plus soutenu que le fond de la modale
|
||||
pour rester lisible comme bloc distinct, sans redevenir sombre. */
|
||||
.legal-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface-muted);
|
||||
border-radius: var(--radius-ui);
|
||||
color: var(--color-paper-muted);
|
||||
background: rgba(18, 16, 14, 0.045);
|
||||
border: 1px solid rgba(18, 16, 14, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -473,7 +587,7 @@ button:focus-visible {
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.1rem;
|
||||
color: var(--color-muted-2);
|
||||
color: var(--color-paper-muted);
|
||||
}
|
||||
|
||||
.legal-link {
|
||||
@@ -535,32 +649,40 @@ button:focus-visible {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Modale en papier : premier écran vu par l'invité (avant même le
|
||||
formulaire), donc le meilleur endroit pour planter l'univers polaroid
|
||||
dès l'ouverture plutôt qu'un panneau sombre générique. */
|
||||
.welcome-modal__card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 88vh;
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-ui) var(--radius-ui) 0 0;
|
||||
padding: 1.5rem 1.25rem calc(1.5rem + env(safe-area-inset-bottom));
|
||||
box-shadow: var(--shadow-soft);
|
||||
background: var(--color-paper);
|
||||
color: var(--color-paper-ink);
|
||||
border-radius: 10px 10px 0 0;
|
||||
padding: 2rem 1.25rem calc(1.5rem + env(safe-area-inset-bottom));
|
||||
box-shadow: var(--shadow-paper);
|
||||
animation: slide-up 0.35s var(--ease-spring) both;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
/* Liseré corail en haut de la carte — même traitement de "chrome" que la
|
||||
galerie admin (src/admin/gallery/style.css), touche de couleur cohérente
|
||||
entre écrans sur un élément de bordure, pas de contenu. */
|
||||
/* Ruban washi tape (au lieu du liseré plat) qui "pince" le haut de la
|
||||
carte, comme .state-block — reprend le même geste graphique dès le
|
||||
tout premier écran vu par l'invité. */
|
||||
.welcome-modal__card::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 4px;
|
||||
flex-shrink: 0;
|
||||
margin: -1.5rem -1.25rem 1.1rem;
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
width: 88px;
|
||||
height: 26px;
|
||||
transform: translateX(-50%) rotate(-2deg);
|
||||
background: var(--color-coral);
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@media (min-width: 520px) {
|
||||
@@ -574,6 +696,10 @@ button:focus-visible {
|
||||
|
||||
.welcome-modal .brand {
|
||||
margin-bottom: 0;
|
||||
/* .brand hérite de --color-muted-2 (pensé pour fond sombre) : sur le
|
||||
papier de la modale ça tombe sous le seuil de contraste lisible pour
|
||||
un petit label en majuscules — corrigé ici. */
|
||||
color: var(--color-paper-muted);
|
||||
}
|
||||
|
||||
.welcome-modal .brand__mark {
|
||||
@@ -591,19 +717,19 @@ button:focus-visible {
|
||||
font-size: 1.6rem;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
color: var(--color-paper-ink);
|
||||
}
|
||||
|
||||
.welcome-modal__text {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
color: var(--color-muted);
|
||||
color: var(--color-paper-muted);
|
||||
}
|
||||
|
||||
#welcome-modal-dismiss {
|
||||
background: var(--color-coral);
|
||||
color: var(--color-ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: var(--shadow-sm), var(--inset-rim-btn);
|
||||
}
|
||||
|
||||
#welcome-modal-dismiss:active {
|
||||
@@ -648,7 +774,7 @@ button {
|
||||
gap: 0.5rem;
|
||||
background: var(--color-coral);
|
||||
color: var(--color-ink);
|
||||
box-shadow: var(--shadow-soft);
|
||||
box-shadow: var(--shadow-soft), var(--inset-rim-btn);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@@ -665,13 +791,16 @@ button {
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
/* Utilisé sur papier (succès + modale de bienvenue) depuis la passe
|
||||
"polaroid poussé à fond" : couleur encre adaptée, plus var(--color-muted)
|
||||
qui était pensée pour du texte clair sur fond sombre. */
|
||||
.btn-secondary-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-muted);
|
||||
color: var(--color-paper-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -705,6 +834,7 @@ button {
|
||||
#retry-btn {
|
||||
background: var(--color-coral);
|
||||
color: var(--color-ink);
|
||||
box-shadow: var(--inset-rim-btn);
|
||||
}
|
||||
|
||||
#retry-btn:active {
|
||||
@@ -852,7 +982,7 @@ button {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.75rem;
|
||||
color: var(--color-text);
|
||||
color: var(--color-paper-ink);
|
||||
margin: 0 0 0.3rem;
|
||||
}
|
||||
|
||||
@@ -864,7 +994,7 @@ button {
|
||||
font-weight: 600;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.3;
|
||||
color: var(--color-muted);
|
||||
color: var(--color-paper-muted);
|
||||
margin: 0 0 1.4rem;
|
||||
}
|
||||
|
||||
@@ -879,6 +1009,7 @@ button {
|
||||
button,
|
||||
.btn-source,
|
||||
.page__header,
|
||||
.state-block,
|
||||
.upload-form,
|
||||
.welcome-modal,
|
||||
.welcome-modal__card,
|
||||
@@ -888,4 +1019,11 @@ button {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* L'inclinaison des cartes papier vit dans les keyframes (voir
|
||||
state-card-in) : sans elle une fois l'animation coupée, on la repose
|
||||
ici en statique pour ne pas perdre le rendu "posé sur la table". */
|
||||
.state-block {
|
||||
transform: rotate(var(--tilt, var(--tilt-1)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,12 @@
|
||||
<button type="button" id="retry-btn" hidden>Réessayer</button>
|
||||
</section>
|
||||
|
||||
<p id="empty-state" class="empty-state" hidden>
|
||||
Aucune photo pour l'instant — sois le·la premier·ère à en déposer une !
|
||||
</p>
|
||||
<div id="empty-state" class="empty-state" hidden>
|
||||
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M12 20.2s-7.5-4.6-7.5-10.1a4.4 4.4 0 0 1 7.5-3.1 4.4 4.4 0 0 1 7.5 3.1c0 5.5-7.5 10.1-7.5 10.1Z"/>
|
||||
</svg>
|
||||
<p>Aucune photo pour l'instant — sois le·la premier·ère à en déposer une !</p>
|
||||
</div>
|
||||
|
||||
<ul id="polaroid-grid" class="polaroid-grid" aria-live="polite" hidden></ul>
|
||||
</main>
|
||||
|
||||
+125
-7
@@ -61,6 +61,20 @@
|
||||
--font-display: "General Sans", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-hand: "Caveat", "Segoe Script", cursive;
|
||||
--font-ui: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
|
||||
/* --- Passe "polaroid poussé à fond" (2026-09-18) : mêmes tokens et même
|
||||
logique que src/e/style.css — les blocs d'état (chargement/erreur/vide)
|
||||
n'avaient ICI aucune carte du tout (texte nu sur fond noir), encore
|
||||
plus "low-cost" que sur l'écran upload. Aligné sur src/e/ pour que les
|
||||
deux écrans invité partagent la même signature visuelle. */
|
||||
--color-paper: #faf6f0;
|
||||
--color-paper-ink: #12100e;
|
||||
--color-paper-muted: #6b6153;
|
||||
|
||||
--shadow-paper: 0 18px 34px rgba(0, 0, 0, 0.55), 0 4px 10px rgba(0, 0, 0, 0.4);
|
||||
|
||||
--tilt-1: -2.2deg;
|
||||
--tilt-2: 1.8deg;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -70,7 +84,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
background: var(--color-bg);
|
||||
/* Même grain que src/e/style.css (cohérence entre écrans), voir le
|
||||
commentaire détaillé là-bas : casse l'aplat du fond quasi noir,
|
||||
opacité trop basse pour affecter le contraste WCAG déjà vérifié. */
|
||||
background-color: var(--color-bg);
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxODAiIGhlaWdodD0iMTgwIj48ZmlsdGVyIGlkPSJuIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iMC44NSIgbnVtT2N0YXZlcz0iMiIgc3RpdGNoVGlsZXM9InN0aXRjaCIgcmVzdWx0PSJub2lzZSIvPjxmZUNvbG9yTWF0cml4IGluPSJub2lzZSIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMSAgMCAwIDAgMCAxICAwIDAgMCAwIDEgIDAgMCAwIDAuMDUgMCIvPjwvZmlsdGVyPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbHRlcj0idXJsKCUyM24pIi8+PC9zdmc+");
|
||||
background-repeat: repeat;
|
||||
background-size: 180px 180px;
|
||||
color: var(--color-text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -96,11 +116,16 @@ body {
|
||||
color: var(--color-muted-2);
|
||||
}
|
||||
|
||||
/* Pastille corail derrière la marque, cohérente avec src/e/style.css. */
|
||||
.brand__mark {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-coral);
|
||||
background: rgba(255, 94, 91, 0.14);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.brand__name {
|
||||
@@ -228,10 +253,53 @@ body {
|
||||
/* États chargement / erreur / vide */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
||||
/* Carte papier, même geste que src/e/style.css : ombre réelle (impossible
|
||||
sur une carte sombre posée sur #12100E), ruban washi tape, léger tilt —
|
||||
traite le message d'état comme un objet posé sur la table plutôt que du
|
||||
texte flottant dans le vide. */
|
||||
.state-block {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 2rem 1.2rem;
|
||||
color: var(--color-muted);
|
||||
padding: 1.75rem 1.3rem 1.5rem;
|
||||
margin: 0.75rem 0.35rem 1.5rem;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-paper-ink);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-paper);
|
||||
animation: state-card-in 0.5s var(--ease-spring) both;
|
||||
}
|
||||
|
||||
@keyframes state-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) rotate(var(--tilt, var(--tilt-1)));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) rotate(var(--tilt, var(--tilt-1)));
|
||||
}
|
||||
}
|
||||
|
||||
.state-block::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
left: 50%;
|
||||
width: 74px;
|
||||
height: 24px;
|
||||
transform: translateX(-50%) rotate(-2.5deg);
|
||||
background: var(--tape-color, var(--color-muted-2));
|
||||
opacity: 0.88;
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.state-block--loading {
|
||||
--tilt: var(--tilt-2);
|
||||
}
|
||||
|
||||
.state-block--error {
|
||||
--tilt: -2.6deg;
|
||||
--tape-color: var(--color-coral);
|
||||
}
|
||||
|
||||
/* Vigilance (bug réel déjà rencontré trois fois en prod, 2026-09-16/18) :
|
||||
@@ -275,19 +343,69 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Même carte papier que .state-block (tilt inverse, tape jaune, pour
|
||||
varier des deux autres états plutôt que de répéter le même angle). */
|
||||
.empty-state {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
text-align: center;
|
||||
padding: 2.5rem 1.2rem;
|
||||
color: var(--color-muted);
|
||||
padding: 2.25rem 1.3rem 2rem;
|
||||
margin: 0.75rem 0.35rem 1.5rem;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-paper-ink);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-paper);
|
||||
transform: rotate(1.6deg);
|
||||
}
|
||||
|
||||
.empty-state::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
left: 50%;
|
||||
width: 74px;
|
||||
height: 24px;
|
||||
transform: translateX(-50%) rotate(2deg);
|
||||
background: var(--color-yellow);
|
||||
opacity: 0.88;
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* #empty-state est maintenant un <div> avec un display personnalisé (flex,
|
||||
pour empiler icône + texte) : sans cette règle explicite, [hidden] du
|
||||
navigateur serait masqué par la déclaration `display` ci-dessus (bug
|
||||
déjà rencontré trois fois sur ce projet, voir .polaroid-grid[hidden]
|
||||
plus haut et les mêmes règles dans src/e/style.css). */
|
||||
.empty-state[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-family: var(--font-hand);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: var(--color-coral);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.polaroid,
|
||||
.polaroid--dropping,
|
||||
.state-block,
|
||||
.spinner {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.state-block {
|
||||
transform: rotate(var(--tilt, var(--tilt-1)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// Edge Function `create-event`
|
||||
//
|
||||
// Contrat :
|
||||
// POST {SUPABASE_URL}/functions/v1/create-event
|
||||
// Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>
|
||||
// Body JSON : { nom: string, date_evenement: "YYYY-MM-DD",
|
||||
// client_nom?: string, max_photos_per_guest?: number }
|
||||
//
|
||||
// Appelée uniquement par le workflow n8n de création d'événement (Form
|
||||
// Trigger -> cet endpoint), jamais depuis le front photobooth-qr : il
|
||||
// n'existe aucun formulaire de création d'event côté appli (cf.
|
||||
// docs/decisions.md, "2026-09-18 (bis)"). Avant cette fonction, la création
|
||||
// d'un event se faisait uniquement en SQL manuel.
|
||||
//
|
||||
// Tourne avec la service_role key (cf. _shared/supabase-admin.ts). Appelle
|
||||
// la RPC `public.create_event` (cf.
|
||||
// supabase/migrations/20260918100000_create_event_rpc.sql), qui génère
|
||||
// slug + admin_token + expire_at côté SQL et insère la ligne
|
||||
// photobooth.events.
|
||||
//
|
||||
// Sécurité : même pattern que expire-events/index.ts — pas d'auth Supabase
|
||||
// standard (le JWT anon suffirait à passer la gateway, ce qui ne protège
|
||||
// rien pour un endpoint qui crée des events), donc comparaison EXPLICITE en
|
||||
// temps constant du Bearer fourni contre la service_role key, pour
|
||||
// s'assurer que seul un appelant serveur-à-serveur de confiance (le
|
||||
// workflow n8n, avec la clé dans son Credentials Store, jamais côté client)
|
||||
// peut créer un événement.
|
||||
//
|
||||
// Pas de CORS (écart assumé par rapport à admin-gallery/public-gallery, qui
|
||||
// importent _shared/cors.ts) : cette fonction n'est appelée qu'en
|
||||
// serveur-à-serveur depuis n8n, jamais depuis un navigateur — un
|
||||
// Access-Control-Allow-Origin n'aurait aucun effet utile ici, et l'ajouter
|
||||
// suggérerait à tort qu'un appel cross-origin navigateur est un usage
|
||||
// prévu.
|
||||
//
|
||||
// ⚠️ Ne pas déployer depuis cet agent (pas de `deploy_edge_function`) :
|
||||
// fichier fourni pour relecture humaine avant déploiement (Kévin applique
|
||||
// lui-même via son accès MCP Supabase direct).
|
||||
import { getSupabaseAdmin } from "../_shared/supabase-admin.ts";
|
||||
import { timingSafeEqual } from "../_shared/timing-safe.ts";
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function extractBearerToken(req: Request): string | null {
|
||||
const header = req.headers.get("authorization") ?? "";
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
type CreateEventBody = {
|
||||
nom?: unknown;
|
||||
date_evenement?: unknown;
|
||||
client_nom?: unknown;
|
||||
max_photos_per_guest?: unknown;
|
||||
};
|
||||
|
||||
type CreateEventRow = {
|
||||
id: string;
|
||||
slug: string;
|
||||
nom: string;
|
||||
date_evenement: string;
|
||||
expire_at: string;
|
||||
admin_token: string;
|
||||
client_nom: string | null;
|
||||
max_photos_per_guest: number | null;
|
||||
};
|
||||
|
||||
// YYYY-MM-DD strict : évite qu'une chaîne ambiguë ("01/10/2026") ou une
|
||||
// date-heure complète soit acceptée silencieusement puis mal interprétée
|
||||
// par la colonne `date` de photobooth.events. Pas de vérification
|
||||
// passé/futur (aucune règle produit ne l'exige à ce jour).
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function isValidDateString(value: string): boolean {
|
||||
if (!DATE_RE.test(value)) return false;
|
||||
const parsed = new Date(`${value}T00:00:00Z`);
|
||||
return !Number.isNaN(parsed.getTime());
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method !== "POST") {
|
||||
return jsonResponse(405, { error: "method_not_allowed" });
|
||||
}
|
||||
|
||||
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||
const bearer = extractBearerToken(req);
|
||||
|
||||
if (!serviceRoleKey || !(await timingSafeEqual(bearer, serviceRoleKey))) {
|
||||
return jsonResponse(401, { error: "unauthorized" });
|
||||
}
|
||||
|
||||
// Même secret que admin-gallery/public-gallery (cf. _shared/cors.ts), réutilisé
|
||||
// ici pour construire les liens, pas pour du CORS.
|
||||
const appBaseUrl = Deno.env.get("APP_BASE_URL");
|
||||
if (!appBaseUrl) {
|
||||
console.error("[create-event] APP_BASE_URL manquante dans l'environnement.");
|
||||
return jsonResponse(500, { error: "internal_error" });
|
||||
}
|
||||
|
||||
let body: CreateEventBody;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonResponse(400, { error: "invalid_json_body" });
|
||||
}
|
||||
|
||||
const nom = typeof body.nom === "string" ? body.nom.trim() : "";
|
||||
if (!nom) {
|
||||
return jsonResponse(400, { error: "nom_required" });
|
||||
}
|
||||
|
||||
const dateEvenement =
|
||||
typeof body.date_evenement === "string" ? body.date_evenement.trim() : "";
|
||||
if (!dateEvenement || !isValidDateString(dateEvenement)) {
|
||||
return jsonResponse(400, { error: "date_evenement_invalid" });
|
||||
}
|
||||
|
||||
let clientNom: string | null = null;
|
||||
if (body.client_nom !== undefined && body.client_nom !== null) {
|
||||
if (typeof body.client_nom !== "string") {
|
||||
return jsonResponse(400, { error: "client_nom_invalid" });
|
||||
}
|
||||
const trimmed = body.client_nom.trim();
|
||||
clientNom = trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
let maxPhotosPerGuest: number | null = null;
|
||||
if (body.max_photos_per_guest !== undefined && body.max_photos_per_guest !== null) {
|
||||
const value = body.max_photos_per_guest;
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
||||
return jsonResponse(400, { error: "max_photos_per_guest_invalid" });
|
||||
}
|
||||
maxPhotosPerGuest = value;
|
||||
}
|
||||
|
||||
const supabaseAdmin = getSupabaseAdmin();
|
||||
|
||||
// public.create_event (service_role uniquement) génère slug + admin_token
|
||||
// + expire_at côté SQL et insère la ligne — cf.
|
||||
// 20260918100000_create_event_rpc.sql. Revalide elle-même nom/date/max
|
||||
// (ne fait pas confiance à cette validation faite en amont, même principe
|
||||
// que insert_public_photo côté RPC anon).
|
||||
const { data: event, error } = await supabaseAdmin
|
||||
.rpc("create_event", {
|
||||
p_nom: nom,
|
||||
p_date_evenement: dateEvenement,
|
||||
p_client_nom: clientNom,
|
||||
p_max_photos_per_guest: maxPhotosPerGuest,
|
||||
})
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error("[create-event] create_event RPC failed", error);
|
||||
return jsonResponse(500, { error: "internal_error" });
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
console.error("[create-event] create_event RPC returned no row");
|
||||
return jsonResponse(500, { error: "internal_error" });
|
||||
}
|
||||
|
||||
const typedEvent = event as CreateEventRow;
|
||||
const cleanBaseUrl = appBaseUrl.trim().replace(/\/+$/, "");
|
||||
|
||||
// 201 (plutôt que 200 comme les autres fonctions, toutes des GET) : seule
|
||||
// fonction du projet qui crée une ressource.
|
||||
return jsonResponse(201, {
|
||||
event: {
|
||||
nom: typedEvent.nom,
|
||||
slug: typedEvent.slug,
|
||||
date_evenement: typedEvent.date_evenement,
|
||||
expire_at: typedEvent.expire_at,
|
||||
client_nom: typedEvent.client_nom,
|
||||
max_photos_per_guest: typedEvent.max_photos_per_guest,
|
||||
},
|
||||
links: {
|
||||
invite: `${cleanBaseUrl}/e/${encodeURIComponent(typedEvent.slug)}`,
|
||||
admin: `${cleanBaseUrl}/admin/${encodeURIComponent(typedEvent.slug)}?token=${encodeURIComponent(
|
||||
typedEvent.admin_token
|
||||
)}`,
|
||||
// src/admin/qrcode/index.html lit désormais `?slug=` ET `?baseUrl=`
|
||||
// pour se pré-remplir entièrement (corrigé le 2026-09-18, cf.
|
||||
// docs/decisions.md — ce lien ne servait à rien tant que la page ne
|
||||
// lisait aucun des deux paramètres).
|
||||
qrcode: `${cleanBaseUrl}/admin/qrcode/?slug=${encodeURIComponent(
|
||||
typedEvent.slug
|
||||
)}&baseUrl=${encodeURIComponent(cleanBaseUrl)}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
-- Migration : fonction `create_event` (public, service_role uniquement)
|
||||
-- Réf. : docs/decisions.md ("2026-09-18 (bis)", création d'événement via un
|
||||
-- formulaire n8n + Edge Function `create-event`), docs/modele-donnees.md
|
||||
-- (table `events`), 20260916090400_create_public_functions.sql (pattern
|
||||
-- des fonctions SECURITY DEFINER réservées à service_role).
|
||||
--
|
||||
-- Avant cette migration, la création d'un event se faisait uniquement en SQL
|
||||
-- manuel (cf. docs/cahier-des-charges.md, livrable non coché "Documentation
|
||||
-- courte : comment créer un nouvel événement manuellement"). Cette fonction
|
||||
-- devient le point d'entrée unique, appelée par la nouvelle Edge Function
|
||||
-- `supabase/functions/create-event/index.ts` (elle-même appelée par un
|
||||
-- workflow n8n serveur-à-serveur, jamais depuis le front).
|
||||
--
|
||||
-- ⚠️ Ne pas appliquer depuis cet agent (Kévin applique lui-même).
|
||||
|
||||
-- unaccent est nécessaire pour dériver un slug lisible à partir de `nom`
|
||||
-- (ex: "Mariage Julie & Marc" -> "mariage-julie-marc"), même logique que
|
||||
-- `sanitizeFilenamePart` côté admin-gallery/index.ts mais en SQL (extension
|
||||
-- contrib standard, pas de dépendance tierce).
|
||||
create extension if not exists unaccent with schema extensions;
|
||||
|
||||
-- =============================================================================
|
||||
-- photobooth.generate_unique_slug — interne (pas un point de contrat public)
|
||||
-- =============================================================================
|
||||
-- Dérive un slug lisible à partir d'un texte libre, et garantit son unicité
|
||||
-- contre photobooth.events.slug en ajoutant un suffixe numérique en cas de
|
||||
-- collision ("mariage-julie-marc", puis "mariage-julie-marc-2", etc.).
|
||||
-- Fonction interne au schéma photobooth : ce n'est pas un point de contrat
|
||||
-- public (comme photobooth.events_set_expire_at()), pas besoin d'être dans
|
||||
-- public ni d'avoir des grants dédiés (appelée uniquement par
|
||||
-- public.create_event, SECURITY DEFINER, propriétaire postgres).
|
||||
create or replace function photobooth.generate_unique_slug(p_source text)
|
||||
returns text
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
v_base_slug text;
|
||||
v_candidate text;
|
||||
v_suffix integer := 1;
|
||||
-- Garde-fou défensif : en pratique la boucle se termine toujours au
|
||||
-- premier ou deuxième tour (collision rarissime), mais on évite une
|
||||
-- boucle infinie en cas de bug/donnée pathologique.
|
||||
v_max_attempts constant integer := 1000;
|
||||
begin
|
||||
v_base_slug := lower(trim(both '-' from
|
||||
regexp_replace(extensions.unaccent(coalesce(p_source, '')), '[^a-zA-Z0-9]+', '-', 'g')
|
||||
));
|
||||
|
||||
if v_base_slug = '' then
|
||||
v_base_slug := 'evenement';
|
||||
end if;
|
||||
|
||||
v_candidate := v_base_slug;
|
||||
|
||||
while exists (select 1 from photobooth.events where slug = v_candidate) loop
|
||||
v_suffix := v_suffix + 1;
|
||||
v_candidate := v_base_slug || '-' || v_suffix;
|
||||
|
||||
if v_suffix > v_max_attempts then
|
||||
raise exception 'slug_generation_failed'
|
||||
using errcode = 'P0001',
|
||||
detail = format('Impossible de générer un slug unique pour "%s" après %s tentatives.', p_source, v_max_attempts);
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
return v_candidate;
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function photobooth.generate_unique_slug(text) is
|
||||
'Dérive un slug lisible et unique (photobooth.events.slug) à partir d''un texte libre (typiquement events.nom) : normalise via unaccent, remplace tout ce qui n''est pas [a-zA-Z0-9] par un tiret, ajoute un suffixe numérique en cas de collision. Fonction interne, appelée uniquement par public.create_event.';
|
||||
|
||||
-- =============================================================================
|
||||
-- create_event — service_role UNIQUEMENT (NOUVEAU)
|
||||
-- =============================================================================
|
||||
-- Point d'entrée unique pour créer un événement. Réservée à service_role :
|
||||
-- appelée par l'Edge Function `create-event`, elle-même appelée uniquement
|
||||
-- par le workflow n8n de création d'événement (jamais depuis le front, pas
|
||||
-- de formulaire de création dans l'appli photobooth-qr, cf. décision
|
||||
-- "2026-09-18 (bis)").
|
||||
--
|
||||
-- Ne calcule NI admin_token NI expire_at explicitement : ces deux valeurs
|
||||
-- sont déjà garanties par la table elle-même (default
|
||||
-- `encode(extensions.gen_random_bytes(32), 'hex')` pour admin_token, trigger
|
||||
-- `trg_events_set_expire_at` pour expire_at, cf.
|
||||
-- 20260916090100_create_events_table.sql) — dupliquer cette logique ici
|
||||
-- créerait deux sources de vérité pour le même comportement.
|
||||
create or replace function public.create_event(
|
||||
p_nom text,
|
||||
p_date_evenement date,
|
||||
p_client_nom text default null,
|
||||
p_max_photos_per_guest integer default null
|
||||
)
|
||||
returns table (
|
||||
id uuid,
|
||||
slug text,
|
||||
nom text,
|
||||
date_evenement date,
|
||||
expire_at timestamptz,
|
||||
admin_token text,
|
||||
client_nom text,
|
||||
max_photos_per_guest integer
|
||||
)
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, photobooth, extensions, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_slug text;
|
||||
begin
|
||||
-- Revalidation défensive côté SQL (l'Edge Function valide déjà ces mêmes
|
||||
-- règles avant l'appel RPC, cf. supabase/functions/create-event/index.ts) :
|
||||
-- même principe que public.insert_public_photo(), qui ne fait pas confiance
|
||||
-- à un appelant "déjà propre".
|
||||
if p_nom is null or trim(p_nom) = '' then
|
||||
raise exception 'nom_required'
|
||||
using errcode = 'P0001',
|
||||
detail = 'Le nom de l''événement est requis.';
|
||||
end if;
|
||||
|
||||
if p_date_evenement is null then
|
||||
raise exception 'date_evenement_required'
|
||||
using errcode = 'P0001',
|
||||
detail = 'La date de l''événement est requise.';
|
||||
end if;
|
||||
|
||||
if p_max_photos_per_guest is not null and p_max_photos_per_guest <= 0 then
|
||||
raise exception 'max_photos_per_guest_invalid'
|
||||
using errcode = 'P0001',
|
||||
detail = 'max_photos_per_guest doit être un entier strictement positif si fourni.';
|
||||
end if;
|
||||
|
||||
v_slug := photobooth.generate_unique_slug(p_nom);
|
||||
|
||||
return query
|
||||
insert into photobooth.events (slug, nom, date_evenement, client_nom, max_photos_per_guest)
|
||||
values (v_slug, p_nom, p_date_evenement, p_client_nom, p_max_photos_per_guest)
|
||||
returning
|
||||
events.id,
|
||||
events.slug,
|
||||
events.nom,
|
||||
events.date_evenement,
|
||||
events.expire_at,
|
||||
events.admin_token,
|
||||
events.client_nom,
|
||||
events.max_photos_per_guest;
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function public.create_event(text, date, text, integer) is
|
||||
'Crée un événement (photobooth.events) : génère un slug unique à partir de `nom`, laisse la table calculer admin_token (default) et expire_at (trigger). Réservée à service_role (Edge Function create-event) : ne JAMAIS accorder EXECUTE à anon/authenticated.';
|
||||
|
||||
revoke all on function public.create_event(text, date, text, integer) from public, anon, authenticated;
|
||||
grant execute on function public.create_event(text, date, text, integer) to service_role;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Durcit photobooth.generate_unique_slug : le linter de sécurité Supabase
|
||||
-- signale un search_path mutable (absent) sur cette fonction, introduite par
|
||||
-- 20260918100000_create_event_rpc.sql. Même remède que le reste du projet
|
||||
-- (public.create_event fixe déjà search_path) : figer explicitement, pour
|
||||
-- empêcher qu'un search_path attaquant (ex: schéma temporaire malveillant)
|
||||
-- ne fasse résoudre unaccent() ou photobooth.events vers autre chose que
|
||||
-- prévu.
|
||||
create or replace function photobooth.generate_unique_slug(p_source text)
|
||||
returns text
|
||||
language plpgsql
|
||||
set search_path = photobooth, extensions, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_base_slug text;
|
||||
v_candidate text;
|
||||
v_suffix integer := 1;
|
||||
v_max_attempts constant integer := 1000;
|
||||
begin
|
||||
v_base_slug := lower(trim(both '-' from
|
||||
regexp_replace(extensions.unaccent(coalesce(p_source, '')), '[^a-zA-Z0-9]+', '-', 'g')
|
||||
));
|
||||
|
||||
if v_base_slug = '' then
|
||||
v_base_slug := 'evenement';
|
||||
end if;
|
||||
|
||||
v_candidate := v_base_slug;
|
||||
|
||||
while exists (select 1 from photobooth.events where slug = v_candidate) loop
|
||||
v_suffix := v_suffix + 1;
|
||||
v_candidate := v_base_slug || '-' || v_suffix;
|
||||
|
||||
if v_suffix > v_max_attempts then
|
||||
raise exception 'slug_generation_failed'
|
||||
using errcode = 'P0001',
|
||||
detail = format('Impossible de générer un slug unique pour "%s" après %s tentatives.', p_source, v_max_attempts);
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
return v_candidate;
|
||||
end;
|
||||
$$;
|
||||
Reference in New Issue
Block a user