# 📝 Mémo Technique - NEOAD

Ce fichier contient les patterns et conventions techniques du projet.

**Dernière mise à jour:** 13 février 2026, 18:30

---

## 🔴 PROBLÈME ACTIF : Freeze intermittent page campagne

### Symptôme
La page `/dashboard/campaigns/new?edit=XXX` freeze parfois complètement le navigateur (boucle infinie dans les `$effect`).

### Debug en place
Un système de tracking est actif dans `campaigns/new/+page.svelte` :c
```typescript
let effectRunCounts: Record<string, number> = {};
function trackEffect(name: string) {
  // Log les 5 premières exécutions de chaque effet
  // Lève une erreur si > 50 exécutions (stoppe la boucle avant freeze)
}
```

**Si le freeze revient** : Ouvrir la console AVANT de naviguer vers la page. L'erreur `🔴 INFINITE LOOP in effect "XXX"` indiquera quel effet boucle.

### Effets trackés
`urlStep`, `bodyScroll`, `preselectedAd`, `syncStartDate`, `syncEndDate`, `syncToDateRange`, `syncFromDateRange`, `autoSaveDates`, `radiusMemory`, `targetingModeChange`, `autoExpandAccordion`, `urlEditChange`, `initCampaign`, `loadScreens`, `syncBudget`, `syncStore`, `paymentMethod`

### Fixes déjà appliqués (peuvent être insuffisants)
- `untrack()` sur les lectures de valeurs précédentes
- Flags `isInitializingCampaign`, `syncingDates`, `hasAutoExpandedAccordion`
- Conditions de sortie anticipée dans chaque effet

---

## ✅ Résolu : "Retour à 0" du widget Campaign Manager lors de la navigation

### Symptôme
Quand on naviguait entre MediaMap et l'éditeur de campagne, le widget Campaign Manager (layout) affichait brièvement 0€ avant de revenir aux bonnes valeurs.

### Cause racine identifiée
Dans `campaigns/new/+page.svelte`, l'init `$effect` appelait `selectedCampaignStore.set({ budgetUsed: cmp.budget_used || 0 })`. Le serveur retournait `budget_used = 0` (subquery SQL vide), écrasant la valeur correcte (ex: 5340) déjà dans le store venant de la page précédente (MediaMap). ~450ms plus tard, `loadCampaignBookings` chargeait les écrans et le sync `$effect` restaurait la bonne valeur → flash visible 0→5340.

### Fix appliqué
Init `$effect` vérifie maintenant si le store a déjà cette campagne avec `budgetUsed > 0` et que le serveur retourne 0. Dans ce cas, il conserve les valeurs du store au lieu d'écraser avec 0.

```typescript
const existing = get(selectedCampaignStore);
const keepExistingTotals = existing && existing.id === extractedCampaignId && existing.budgetUsed > 0 && serverBudgetUsed === 0;
selectedCampaignStore.set({
  ...
  budgetUsed: keepExistingTotals ? existing.budgetUsed : serverBudgetUsed,
  screensCount: keepExistingTotals ? existing.screensCount : serverScreensCount,
  ...
});
```

### Protections accumulées (toujours en place)
- `isDestroying` flag via `onDestroy()` sur les 2 pages
- `campaigns.length === 0` guard sur MediaMap
- `setUser()` guard contre re-overwrite
- Layout: subscription séparée de `setUser()`
- Debug traces `🔍` retirées du store après résolution

---

## 🆕 Dernières modifications

### Session actuelle (14 février 2026)

1. **Invoices — Montant premier paiement (acompte)** :
   - Section "En attente de paiement" affiche maintenant le montant du 1er paiement (acompte) au lieu du total
   - Nouvelle fonction `getFirstInstallmentAmount(campaign)` qui reproduit la logique de step 4 :
     - `totalTTC = budgetHT * 1.20`
     - Si campagne commence > 30 jours → acompte 10% (`totalTTC * 0.10`)
     - Sinon → premier versement mensuel (`remaining / months`)
   - Affichage "XXX€ TTC" avec sous-texte "1er paiement"
   - Fichier : `dashboard/invoices/+page.svelte`

2. **Invoices — Restructuration layout** :
   - **Stats bar** compacté : `p-4` → `px-3 py-2.5`, icônes `h-5 w-5` → `h-4 w-4`, texte `text-2xl` → `text-lg`, gap `gap-4` → `gap-3`
   - **Tabs + Search sur une seule ligne** : tabs à gauche dans un `flex`, search bar à droite (`max-w-xs`), le tout dans un `flex items-end justify-between` avec `border-b`
   - Search bar réduite : `h-10` → `h-8`, `rounded-lg` → `rounded-md`
   - Fichier : `dashboard/invoices/+page.svelte`

3. **Dashboard — Campagne actuelle améliorée** :
   - **Badge inline** avec le titre : badge statut sur la même ligne que `<h3>` dans un `flex items-center gap-2`, au lieu d'être sur une ligne séparée sous les dates
   - **Bouton "Voir les détails"** : lien changé vers `/dashboard/invoices` (au lieu de `/dashboard/campaigns/{id}`), ajout `shrink-0` pour éviter qu'il soit coupé
   - **Conteneurs flex** : ajout `min-h-0` sur les conteneurs `flex-1` pour un overflow correct
   - Fichier : `dashboard/+page.svelte`

4. **Dashboard — Logo NEOAD** :
   - Logo ajouté en haut à gauche du dashboard (au-dessus du Welcome Banner)
   - Composant `LogoNeoad.svelte` créé (`src/lib/components/LogoNeoad.svelte`) : SVG inline avec `fill="currentColor"` sur le groupe texte "neoad"
   - L'icône N (carré noir + N blanc) reste fixe ; le texte "neoad" s'adapte au thème via `text-foreground`
   - Taille : `h-7 w-auto`
   - Le fichier `static/logo-neoad.svg` a aussi été mis à jour avec `fill="currentColor"` sur le `<g>` texte
   - Fichiers : `dashboard/+page.svelte`, `src/lib/components/LogoNeoad.svelte`, `static/logo-neoad.svg`

### Session précédente (13 février 2026, 18:30)

1. **Page Facturation — refonte tabs + données** :
   - **4 onglets** : "Campagnes payées" | "En attente de paiement" (badge count amber) | "Factures" | "Prochaines factures"
   - `activeTab` : `'campaigns' | 'pending' | 'invoices' | 'upcoming'`
   - **Search bar** conditionnelle : visible uniquement sur les onglets "Campagnes payées" et "Factures"
   - **Onglet "En attente"** : Cards détaillées avec écrans réservés, montant total, bouton "Régler maintenant"
   - **Onglet "Prochaines factures"** : Timeline verticale des paiements à venir avec dots colorés (urgent=amber, passé=gray, futur=primary)
     - Sources : pending_payment campaigns (urgent), active campaigns avec solde restant (échéances mensuelles), factures impayées
     - Total à venir affiché en haut
   - **Fix données** : Query serveur pour pending campaigns utilise maintenant `campaign_booking` pour calculer `booking_count` et `total_booked_price` (au lieu de `screens_count`/`budget_used` qui étaient à 0)
   - Fichiers : `dashboard/invoices/+page.svelte`, `dashboard/invoices/+page.server.ts`

2. **Dialog overlay dark mode** :
   - **Tous les dialogs/modals** : `bg-white/70` → `bg-white/70 dark:bg-black/70` pour un overlay adapté au thème
   - 21 occurrences mises à jour dans 13 fichiers (Dialog.svelte, MediaLibraryModal, toutes les pages dashboard, agency, admin)
   - Pattern standard : `bg-white/70 dark:bg-black/70 backdrop-blur-sm`

2. **Campaign Manager widget (sidebar) — revert layout** :
   - La disposition 3-colonnes (Total/semaines/écrans) a été **annulée**, retour au layout original :
     - Prix total en `text-2xl font-bold tabular-nums` (gros chiffre)
     - Barre de budget (X€ / X€) + progress bar en dessous
     - Flex row `justify-between` avec semaines / écrans en `text-xs`
   - **`tabular-nums`** conservé pour un alignement stable des chiffres animés
   - Fichier : `(dashboard)/+layout@.svelte`

3. **Media library preview — nouveau style** :
   - Overlay passé de `bg-black/70` à `bg-white/70 dark:bg-black/70 backdrop-blur-sm`
   - Ajout `lg:left-[var(--sidebar-width,16rem)]` pour centrer relativement au contenu principal
   - Taille réduite : `max-w-4xl` → `max-w-3xl`, `max-h-[90vh]` → `max-h-[85vh]`
   - Fichier : `dashboard/media/+page.svelte`

4. **Ad preview — filter blur sur le contenu** :
   - Ajout `style={showPlayerModal ? 'filter: blur(5px);' : ''}` sur le wrapper `<div class="space-y-6">` de la page ads
   - Le contenu derrière le player modal est maintenant uniformément flouté (comme campaigns/new)
   - Fichier : `dashboard/ads/+page.svelte`

5. **Step 4 — bouton devis dans le header** :
   - Le bouton "Télécharger le devis (PDF)" en bas de la page a été supprimé
   - Nouveau bouton "Devis PDF" ajouté dans le header de facturation, à gauche du bloc réf/date
   - Style : `border border-border bg-background` avec icône `Download`
   - Fichier : `campaigns/new/+page.svelte`

6. **Autres corrections récentes (session précédente)** :
   - Hover popover MediaMap
   - Réservation dialog 2 étapes (step 3) avec vérification ad
   - Smart week suggestion (`suggestedStartDate`/`suggestedEndDate` dans `weeksInfo`)
   - DatePicker format `DD.MM.YYYY` (au lieu de `YYYY.MM.DD`)
   - `pending_payment` status + section facturation en attente (`dashboard/invoices`)
   - Map tooltip avec dates (`startDate → endDate`)
   - Suppression ligne "Génération publicité ... Inclus" (sauf si prix > 0)

### Patterns UI établis

- **Overlay dialog** : `bg-white/70 dark:bg-black/70 backdrop-blur-sm` (PARTOUT)
- **Centrage dialog dashboard** : `lg:left-[var(--sidebar-width,16rem)]` ou `lg:pl-[var(--sidebar-width,16rem)]`
- **Blur contenu** : `filter: blur(5px)` sur le wrapper principal quand dialog ouvert (pas backdrop-filter)
- **Stacking context** : `view-transition-name: main-content` sur `<main>` crée un stacking context → les `position: fixed` restent sous le main. Solution : portal action + blur via `filter` sur le contenu

### Session précédente (13 février 2026, 11:45)

1. **🔥 CRITIQUE — Fix boucles infinies `$effect` dans `campaigns/new/+page.svelte`** :
   - **Symptôme** : Le navigateur freezait complètement en chargeant la page d'édition de campagne
   - **Cause racine** : Plusieurs `$effect` lisaient ET écrivaient dans les mêmes variables `$state`, créant des boucles de ré-exécution infinies
   - **Pattern problématique** :
     ```svelte
     // ❌ MAUVAIS — crée une boucle infinie
     $effect(() => {
       if (someCondition) {
         myStateVar = newValue; // Écriture qui re-déclenche l'effet
       }
     });
     ```
   - **Solution appliquée** — `untrack()` pour lire sans créer de dépendance :
     ```svelte
     // ✅ BON — utilise untrack pour lire la valeur précédente
     $effect(() => {
       const currentVal = someReactiveVar;
       const prevVal = untrack(() => previousValue);
       if (currentVal !== prevVal) {
         previousValue = currentVal; // Ne re-déclenche pas car untrack
       }
     });
     ```
   - **Effets corrigés** :
     - `selectedPaymentMethod` : Ajout `untrack()` + garde `isInitializingCampaign`
     - `lastUrlStep` / `lastEditId` : Utilisent `untrack()` pour lire la valeur précédente
     - `previousTargetingMode` : Changé de `$state` à variable normale avec `untrack()`
     - Accordéon auto-expand : Ajout flag `hasAutoExpandedAccordion` pour éviter ré-expansion
   - **Règle Svelte 5** : Tout `$effect` qui écrit dans un `$state` doit soit:
     1. Utiliser `untrack()` pour lire la valeur actuelle avant d'écrire
     2. Avoir une condition qui empêche l'écriture si la valeur est déjà correcte
     3. Être protégé par un flag comme `isInitializingCampaign`
   - Fichier : `campaigns/new/+page.svelte`

2. **Fix erreur SurrealDB "EngineDisconnected"** :
   - **Symptôme** : Erreur 500 sporadique sur certaines pages
   - **Solution** : Ajout wrapper `withRetry<T>()` dans `db.ts` qui détecte les déconnexions et force une reconnexion
   - **Fonctions ajoutées** : `withRetry()`, `isDisconnectionError()`, `forceReconnect()`
   - **Utilisation** : `getSessionByToken` et `getMultiSessionData` dans `auth.ts` utilisent maintenant `withRetry`
   - Fichiers : `src/lib/server/db.ts`, `src/lib/server/auth.ts`

3. **Fix "retour à 0€" du widget Campaign Manager** :
   - **Cause racine** : `persistCachedTotals` a un debounce de 1.5s — si l'utilisateur navigue avant, le timer est annulé et la DB garde `cached_budget_used: 0`
   - **Fix 1** : `onDestroy` dans `campaigns/new/+page.svelte` flush maintenant le timer pending avec `persistCachedTotals(... immediate=true)` 
   - **Fix 2** : Sur MediaMap, l'effect store-sync prioritise désormais `localBudgetUsed` (calculé depuis les bookings chargés) quand `campaignBookings.length > 0`, au lieu de toujours préférer `cached_budget_used` de la DB
   - Fichiers : `campaigns/new/+page.svelte` (onDestroy), `map/+page.svelte` (store sync $effect)

4. **Warning "semaine incomplète" dismissable** :
   - Ajout d'un bouton ✕ sur l'alerte amber "Le tarif des écrans est calculé à la semaine..."
   - State `incompleteWeekWarningDismissed = $state(false)` — persiste pour la session
   - Fichier : `campaigns/new/+page.svelte`

5. **Renommage des étapes de campagne** :
   - Étape 1 : Réglages (inchangé)
   - Étape 2 : **Diffusion** (ex "Programme")
   - Étape 3 : Synthèse (inchangé)
   - Étape 4 : **Validation** (ex "Facturation")
   - `stepLabels` = `['Réglages', 'Diffusion', 'Synthèse', 'Validation']`
   - Stepper affiche les 4 étapes, étapes 1-3 cliquables (`<button>`), étape 4 non-cliquable (`<div>`)
   - Fichier : `campaigns/new/+page.svelte`

3. **Campaign Manager widget (sidebar layout)** :
   - **Emplacement** : `(dashboard)/+layout@.svelte` — widget flottant en bas de la sidebar gauche
   - Boutons raccourcis passés de **4 à 3** (`grid-cols-3`) : Réglages, Diffusion, Synthèse
   - Le 4ème bouton "Facturation" (step 4) a été retiré
   - Tooltip "Programme" → "Diffusion"
   - **Important** : Le "Campaign Manager" = ce widget sidebar, PAS la barre de navigation fixe en bas de la page d'édition

4. **Période obligatoire — Redesign start date + durée** :
   - **Supprimé** : toggle `noPeriod` (la période n'est plus optionnelle), label "(optionnel)", DatePicker de date de fin
   - **Ajouté** : `campaignDurationDays = $state(30)` — durée en jours, `durationDropdownOpen = $state(false)`, `durationOptions` (1 semaine → 1 an)
   - **`endDateValue`** : changé de `$state` à `$derived.by()` — calculé depuis `startDateValue + campaignDurationDays - 1`
   - **UI Step 1** : DatePicker date de début + dropdown durée côte-à-côte, résumé "Du DD/MM/YYYY au DD/MM/YYYY" en dessous (format français)
   - **UI Step 2 (mode manuel)** : Même layout (DatePicker + dropdown durée)
   - **Sync RangeCalendar → dates** : Quand l'utilisateur modifie la plage en step 3, on calcule la durée la plus proche dans `durationOptions` au lieu d'assigner `endDateValue` directement
   - **Chargement campagne existante** : On calcule `campaignDurationDays` depuis start/end dates de la DB, en trouvant l'option la plus proche
   - **Validation** : `startDateValue` requis (au lieu de `!noPeriod && startDateValue`)
   - **`originalStep1`** : propriété `noPeriod` retirée de l'objet d'initialisation
   - Fichier : `campaigns/new/+page.svelte`

4. **Cards campagnes (dashboard/campaigns) — Layout responsive** :
   - Ligne stats passée de `flex items-center justify-between` à `grid grid-cols-2 lg:grid-cols-4`
   - Chaque stat (Période, Réservations, Budget, Progression) a maintenant le label au-dessus et la valeur en dessous
   - Le bouton d'action (Continuer/Payer) est séparé au-dessus de la grille stats

5. **Logo — View transition fix** :
   - `view-transition-name: logo` ajouté sur le lien du logo (`(dashboard)/+layout@.svelte`)
   - Animation désactivée (`animation: none`) comme sidebar et header — ne clignote plus

6. **Filtre "Type de lieu" — Redesign + logique** :
   - **UI** : Cadre `border + bg-card + p-4`, titre "Filtrer par type de lieu" avec icône `Building2`, toggle switch (style iOS) pour tout sélectionner/désélectionner, compteur `X/Y sélectionnés`, coche `Check` sur les pills sélectionnées, alerte amber avec `AlertTriangle`
   - **Logique** : `filteredScreensForMap` filtre désormais par `selectedLocationTypes` — retourne `[]` si aucun type sélectionné
   - **Fix InteractiveMap** : `displayScreenMarkers()` supprime maintenant les anciens marqueurs AVANT de vérifier si `screens` est vide (fix du bug où les marqueurs restaient visibles)
   - **Messages d'avertissement conditionnels** : Les alertes "aucun écran trouvé/disponible" ne s'affichent plus quand `selectedLocationTypes.length === 0` (pour éviter les messages redondants avec l'alerte du filtre de types)
   - Fichiers : `campaigns/new/+page.svelte`, `InteractiveMap.svelte`

2. **Step 3 (Synthèse campagne) — Redesign 2 modes switchables** :
   - **Toggle** : Composant toggle `Calendrier | Carte` dans un inline-flex `bg-muted` avec effet actif `bg-background shadow-sm`
   - **Mode Calendrier** : 
     - Gauche : `CampaignWeekCalendar` + résumé prix (coût, barre budget, restant)
     - Droite : **Liste d'écrans catégorisée** (Région > Département > Ville avec accordéons), **flex-1 pour remplir la hauteur du parent** (pas de max-h fixe)
     - Clic semaine dans calendrier → `selectedCalendarWeek` → filtre header "Écrans — Semaine X"
   - **Mode Carte** :
     - Gauche (2/5) : **height: 600px flex column** — Liste d'écrans groupée (flex-1 scroll) + résumé budget en-dessous (shrink-0)
     - Droite (3/5) : `ReadOnlyScreenMap` sticky 600px, focus sur écran cliqué dans la liste
   - **Container dynamique** : `max-w-6xl` pour step 3, `max-w-4xl` pour les autres steps
   - **`step3ViewMode`** : `$state<'calendar' | 'map'>('calendar')`
   - **`groupedAllScreens`** : $derived qui regroupe TOUS les écrans (confirmedAuto + selectedAuto + bookings) par région/département/ville
   - **`expandedRegions` / `expandedDepts`** : Sets de `$state` pour l'état des accordéons, auto-expand au premier chargement
   - **`screensForSelectedWeek`** : $derived filtré par semaine sélectionnée (placeholder, retourne tous pour l'instant)
   - Fichier : `src/routes/(dashboard)/dashboard/campaigns/new/+page.svelte`

2. **`CampaignWeekCalendar.svelte` — Nouvelles props + theme-aware redesign** :
   - `onWeekClick?: (weekNumber: number) => void` — callback au clic sur une semaine (fonctionne même en readonly)
   - `activeWeek?: number | null` — semaine visuellement mise en surbrillance (bleu foncé + ring)
   - `canToggle` vs `canClick` séparés : `canToggle` pour le mode édition, `canClick` = `canToggle || !!onWeekClick`
   - **Redesign visuel** : `bg-white` → `bg-card`, `slate-*` → `muted/foreground/border`, `rounded-xl shadow-sm`, `gap-0.5`, `py-2`, `rounded-l-md`/`rounded-r-md` sur les rangées, dark mode support
   - **Couleurs**: Semaines sélectionnées → bleu clair (`bg-blue-100 text-blue-700`), semaine active → bleu foncé (`bg-blue-600 text-white`)
   - **Supprimé** : légende (Sélectionné/Disponible/Indisponible) + "X semaines sélectionnées"
   - Fichier : `src/lib/components/CampaignWeekCalendar.svelte`

3. **Fix Leaflet `_leaflet_pos` TypeError** :
   - **Cause** : Erreur pendant les animations de zoom (`fadeAnimation`) quand le conteneur est détruit
   - **Fix ReadOnlyScreenMap** : `fadeAnimation: false` dans `L.map()` init + `onDestroy` wrapped in try-catch avec `map.stop()` avant `map.remove()`
   - **Fix MediaMap** : Même pattern — `fadeAnimation: false` + `map.stop()` + try-catch sur `map.remove()`
   - Fichiers : `src/lib/components/ReadOnlyScreenMap.svelte`, `src/routes/(dashboard)/dashboard/map/+page.svelte`

4. **Chargement bookings côté serveur** :
   - `+page.server.ts` charge les bookings en parallèle avec la campagne via `Promise.all`
   - `data.campaignBookings` utilisé directement au lieu d'un fetch client-side
   - Performance : évite le flash de chargement, les écrans et prix sont disponibles immédiatement

5. **Nettoyage console.log/debug** :
   - 11 `console.info` avec préfixe `🔍` retirés du store `selectedCampaign.ts`
   - 3 variables `performance.now()` inutilisées retirées de `+page.svelte`

### Session précédente (12 février 2026 - après-midi)

1. **Custom dropdown filters (style ads) sur toutes les pages dashboard** :
   - Médiathèque, Campagnes et Publicités utilisent tous des `<button>` natifs avec icônes Lucide (ArrowUpDown, Filter, Layers, Folder) et chevrons animés au lieu de `<select>` natifs
   - Chaque dropdown a un overlay `fixed inset-0 z-40` pour fermer au clic extérieur + un menu `z-50`
   - Taille compacte uniforme : `h-8`, `text-xs`, `px-2.5`, `gap-1.5`, icônes `h-3.5`, chevrons `h-3`
   - État actif : `border-primary text-primary`

2. **Folder filter + move-to-folder (médiathèque)** :
   - `+page.server.ts` charge les `media_folder` depuis la DB
   - Dropdown "Tous les dossiers" filtre par dossier
   - Bouton "Déplacer (N)" dans la toolbar quand items sélectionnés → modal avec liste des dossiers
   - `handleMoveToFolder(folderId)` : PATCH `/api/media/:id` avec `folder` field

3. **⚠️ IMPORTANT : `confirm()` bloqué par Safari dans certains contextes** :
   - `confirm()` ne fonctionne PAS quand appelé depuis un handler attaché via `use:` action Svelte ou un `addEventListener` avec `preventDefault()`
   - Safari bloque silencieusement le dialogue — la fonction retourne immédiatement sans afficher la popup
   - **Solution** : Remplacer `confirm()` par un modal custom (`showBulkDeleteModal = true`) avec boutons Annuler/Supprimer
   - Le modal doit avoir un z-index très élevé (`z-[200]`) pour passer au-dessus de tout

4. **⚠️ IMPORTANT : `$state` arrays et itération** :
   - Quand on boucle sur un `$state<string[]>` dans une fonction async, utiliser `$state.snapshot()` :
   ```typescript
   const itemsToDelete = [...$state.snapshot(selectedItems)];
   for (const id of itemsToDelete) { ... }
   ```
   - Sans snapshot, le proxy Svelte 5 peut causer des comportements imprévisibles pendant l'itération

5. **Bulk delete médiathèque — solution finale** :
   - Bouton rouge "Supprimer (N)" ouvre `showBulkDeleteModal` (pas de `confirm()`)
   - Modal custom avec `executeBulkDelete()` qui fait `$state.snapshot(selectedItems)` puis boucle DELETE
   - Fonctionne en grid ET list view

6. **Sélection en vue liste (médiathèque)** :
   - Colonne checkbox ajoutée dans le `<thead>` (select all/deselect all de la page courante)
   - Checkbox par ligne avec `toggleSelection(mediaId)`
   - Ligne sélectionnée : `bg-primary/5`
   - Même `selectedItems` array partagé entre grid et list view

7. **Campaign stats dynamiques** :
   - Stats calculées côté serveur depuis les campagnes chargées (plus de subquery `count(SELECT...)` séparée)
   - Plus de filtre URL `?status=` côté serveur — toutes les campagnes chargées, filtrage côté client
   - `campaignStats` = `$derived` côté client qui compte par statut

8. **Ordre des filtres médiathèque** :
   - Recherche → Tri → Type → Dossiers → Pagination → Fichiers (N)

9. **`cursor: pointer` global** :
   - Ajouté dans `app.css` base layer pour `button`, `[role="button"]`, `a[href]`, `input[type="checkbox"]`, `input[type="radio"]`, `select`, `label[for]`

10. **Suppression média = DB + R2** :
    - `DELETE /api/media/:id` supprime de Cloudflare R2 (`deleteFromR2(cloudflare_key)`) ET de la DB SurrealDB
    - Continue même si R2 échoue (try/catch isolé)

### Session précédente (12 février 2026 - matin)

1. **Totaux campagne centralisés via `cached_budget_used` / `cached_screens_count`** :
   - **Problème** : L'API recalculait les totaux côté serveur (via bookings + `campaign.screens[]` + prix hebdo) et obtenait un résultat différent de celui du frontend, qui est la source de vérité
   - **Solution** : Le frontend persiste ses totaux calculés dans la campagne en DB comme `cached_budget_used` et `cached_screens_count`
   - **`$effect` dans `+page.svelte`** : Sync le store ET persist en DB via PATCH debounced (1.5s) quand `totalScreensPrice` ou `totalScreensCount` change
   - **API GET `/api/campaigns/[id]`** : Lit simplement `cached_budget_used` et `cached_screens_count` au lieu de recalculer (plus de subqueries booking, plus de batch queries screen)
   - **API PATCH** : Accepte `cached_budget_used` et `cached_screens_count`
   - **API POST** : Stocke les valeurs initiales à la création
   - **API GET `/api/campaigns`** (liste) : Utilise aussi les champs cachés
   - **Résultat** : Widget affiche toujours le même total que la page synthèse, sur TOUTES les pages

2. **Protection de la source de vérité sur la page d'édition** :
   - `afterNavigate`, `campaign-updated` event, `visibilitychange`, et le polling 30s sont tous ignorés quand `isOnCampaignEditPage` est `true`
   - Le `$effect` local est la seule source qui met à jour le store sur cette page
   - Fichiers : `+layout@.svelte` (lignes 159, 167, 178, 191)

3. **Budget JAMAIS dans le cache localStorage** :
   - `budget` est destructuré et exclu du JSON avant écriture : `const { budget, ...toStore } = campaign`
   - Le widget lit `budget: undefined` du cache → `undefined > 0` = `false` → jamais de flash "/500€"
   - DB : PATCH immédiat (`immediate = true`) quand noBudget change → `budget: null` → `NONE` en SurrealDB

4. **Budget isolé des sources API — seules 2 origines possibles** :
   - **`$effect` sur page edit** : `noBudget ? undefined : budget` → seule source pour les campagnes en édition
   - **`selectCampaign` (dropdown)** : `campaign.budget || undefined` → seule source quand on change de campagne
   - **`refreshSelectedCampaignFromAPI`** : utilise `update` (pas `set`) et ne touche PAS au budget → empêche le flash
   - **Init page edit** : `selectedCampaignStore.set()` n'inclut PAS `budget` → laisse le `$effect` décider une fois `noBudget` déterminé
   - **Dérivation de `noBudget`** : `!cmp.budget && cmp.budget !== 0` → si DB a `NONE` → `true` → budget: undefined

5. **Alignement widget campaign manager** :
   - Le suffixe "€" est toujours sur le prix principal (plus de `AnimatedPrice` séparé pour le budget)
   - Le budget est affiché en texte simple `/ 5 000€` (pas d'animation nécessaire)
   - `line-height: 1` + `items-baseline` assurent l'alignement vertical
   - Le budget s'affiche conditionnellement APRÈS le prix, pas dans un branch if/else séparé

6. **Dashboard: clic publicité → page ads + dialog vidéo** :
   - Les cartes pub du dashboard (`+page.svelte`) linkent vers `/dashboard/ads?preview={adId}` au lieu de `/dashboard/ads/{adId}/edit`
   - La page ads (`dashboard/ads/+page.svelte`) lit `?preview=` via `$page.url.searchParams` dans un `$effect`
   - Auto-ouvre `openPlayerModal(ad)` et nettoie l'URL via `history.replaceState`

7. **MediaMap: fermeture auto écran + ouverture accordéon après ajout** :
   - Les 3 fonctions d'ajout (`addScreenToCampaignDirectly`, `addScreenWithSelectedWeeks`, `addScreenToCampaign`) ferment maintenant le panel détail (`panelView = "search"`, `selectedLocation = null`)
   - Ouvrent l'accordéon "Écrans" (`accordionScreensOpen = true`, ferment les autres)
   - Fichier : `src/routes/(dashboard)/dashboard/map/+page.svelte`

8. **MediaMap: calendrier affiché même sans dates de campagne** :
   - Si campagne sans dates (`!selectedCampaignData?.startDate`), affiche `WeeklyAvailabilityCalendar` (lecture seule) au lieu du message "Sélectionnez une campagne avec des dates"
   - Bouton "Ajouter à la campagne" (sans sélection de semaines) via `addScreenToCampaignDirectly`

9. **Step 2 campagne: réorganisation + améliorations** :
   - **Nouvel ordre** : Carte (h-[32rem]) → Type de lieu → Budget + Ciblage → GeographicTargeting → Warnings → Période → Estimation → Bouton "Sélection des écrans"
   - **Toggle filtre type de lieu** : Bouton `rounded-full` avec icône `ListFilter` (lucide) qui se remplit quand "Tous". Texte: "Tous" / "Filtrer". Placé juste à droite de "Type de lieu" avec `gap-3`
   - **Carte plus grande** : `h-[32rem]` (512px) au lieu de `h-96` (384px) en mode auto
   - **Carte en haut** : InteractiveMap positionné tout en haut du step 2, juste après le titre "Programme"

10. **Découplage sélection écrans / navigation step 2→3** :
    - **`nextStep` step 2→3** : N'appelle plus `loadFilteredScreens()` ni `saveAutoScreensToServer()`, fait juste `updateCampaign()` + `loadCampaignBookings()`
    - **Bouton "Sélection des écrans"** : Nouveau bouton en bas de step 2 (auto mode), appelle `loadFilteredScreens()` puis ouvre `showAutoScreensDialog`
    - **Dialog sélection écrans** : Modal custom (pas le composant Dialog) avec backdrop click-to-close
      - Header : titre + compteur écrans + ratio budget
      - Barre de progression budget (vert/ambre/rouge selon %)
      - Liste scrollable : `confirmedAutoScreens` + `selectedScreensForCampaign`, chacun avec prix et bouton X
      - Footer : total prix + "Annuler" / "Ajouter N écrans" (appelle `saveAutoScreensToServer()` + `loadCampaignBookings()`)
    - **État** : `showAutoScreensDialog = $state(false)` (ligne 86)
    - **Computed** : `autoScreensDialogTotal` = somme des prix des écrans auto (confirmés + courants) × semaines

11. **Fix dialog vidéo (page ads)** :
    - Bouton close : `type="button"` + `z-[60]` + `e.stopPropagation()` + svelte-ignore a11y
    - Backdrop : click-outside ferme la dialog

1. **Flux campagne unifié 5 étapes (auto & manuel)** :
   - `totalSteps` toujours 5 (avant : auto=5, manuel=4)
   - Étape 4 = récap écrans + carte (snippet `screenSelectionStep`), Étape 5 = résumé & paiement
   - `nextStep` charge les bookings pour les DEUX modes à l'étape 3→4 (force refresh `hasLoadedBookings = false`)
   - Step 2 : ajout mention "Ce choix n'est pas définitif..."
   - Fichier : `src/routes/(dashboard)/dashboard/campaigns/new/+page.svelte`

2. **Barre de navigation fixe en bas** :
   - Boutons Précédent/Suivant dans `fixed bottom-0 left-0 right-0 z-[100]` avec `bg-background backdrop-blur shadow`
   - Offset sidebar : `lg:left-64`
   - Content a `pb-24` pour ne pas être masqué
   - Map wrapper a `isolate` pour contenir z-index Leaflet
   - Fichier : `+page.svelte` + `ReadOnlyScreenMap.svelte`

3. **Liste d'écrans unifiée (étape 4)** :
   - Plus de séparation auto/manuel, un seul bloc affichant les 3 sources :
     - `confirmedAutoScreens` (rounds auto précédents) → X appelle `removeConfirmedScreen()`
     - `selectedScreensForCampaign` (round auto actuel) → X ajoute à `manuallyExcludedScreenIds`
     - `campaignBookings` (ajoutés via MediaMap) → X appelle `removeBooking()` (DELETE API)
   - Budget bar + total prix toujours visibles
   - Boutons "Ajouter via sélection auto" et "Ajouter via MediaMap" en dessous

4. **Fonction `removeBooking()`** :
   - Appelle `DELETE /api/campaigns/{id}/screens` avec `{ booking_id }`
   - Supprime localement du tableau `campaignBookings`
   - Dispatch `campaign-updated` event pour sync avec le floating panel

5. **Bouton "Ajouter ces écrans" à l'étape 3 auto** :
   - Le bouton "Suivant" à l'étape 3 en mode auto affiche "Ajouter ces écrans"
   - En mode manuel, reste "Suivant"

6. **switchToManualMode ne confirme plus les écrans** :
   - Le bouton "Mode manuel" en bannière change juste le mode sans sauvegarder/confirmer les écrans auto du round en cours
   - Avant : confirmait automatiquement les écrans auto dans `confirmedAutoScreens`

7. **Step 3 manuel simplifié** :
   - Suppression de la liste d'écrans et de la mini-carte en step 3 mode manuel
   - Reste : période, lien MediaMap, bouton switch vers auto

8. **Synchronisation écrans auto avec serveur, MediaMap et Campaign Manager** :
   - **`saveAutoScreensToServer()`** : Nouvelle fonction qui PATCH `campaign.screens[]` avec tous les IDs d'écrans auto (confirmés + round actuel + déjà sur serveur)
   - **Appelée automatiquement** : au step 3→4 (après `loadFilteredScreens()`), et dans `addMoreAutoScreens()`
   - **`removeConfirmedScreen()`** : Appelle maintenant `DELETE /api/campaigns/{id}/screens` avec préfixe `auto_` + dispatch `campaign-updated`
   - **Déduplication dans `screensForSummary`** : Évite les double-comptages entre écrans auto locaux et bookings serveur (via `is_auto` flag)
   - **Déduplication dans `totalScreensPrice`** : Exclut les bookings auto serveur déjà comptés localement
   - **Budget `screenSelection`** : `remainingBudget` tient compte de `campaignBookings` cost en plus de `confirmedAutoScreensCost`
   - **Résultat** : Les écrans ajoutés en mode auto sont immédiatement visibles dans la MediaMap (`GET /api/campaigns/{id}/screens`) et le floating Campaign Manager panel

### Session précédente (9 février 2026)

1. **Floating Campaign Panel amélioré** (layout dashboard) :
   - **Sélecteur de campagne** : Dropdown simplifié (boutons natifs, sans Command.Input)
   - **Masquage sur page édition** : La div flottante est cachée sur `/dashboard/campaigns/new`
   - **Fetch des campagnes** : Appel API `/api/campaigns` pour lister les campagnes disponibles
   - **Sync bidirectionnelle** avec la MediaMap via `selectedCampaignStore`
   - Fichier modifié : `src/routes/(dashboard)/+layout@.svelte`

2. **Fix syntax error selectedCampaignStore** :
   - Suppression d'une accolade fermante en trop qui causait une erreur 500
   - Fichier : `src/lib/stores/selectedCampaign.ts`

3. **Map centrée sur pays entreprise** :
   - `getCountryCode()` dans `$lib/data/locations.ts` convertit "France" → "FR"
   - `countryCenters` mapping dans map page (FR: zoom 5, etc.)
   - `defaultMapConfig` calculé dans `onMount` (pas au top-level, pour éviter l'erreur Svelte 5 "state_referenced_locally")
   - `isMapInitialLoad` flag empêche `fitBounds` les premières secondes
   - `fitBounds` ne se déclenche que si une campagne est sélectionnée (sinon garde le centre pays)
   - Fichiers : `src/lib/data/locations.ts`, `src/routes/(dashboard)/dashboard/map/+page.server.ts`, `src/routes/(dashboard)/dashboard/map/+page.svelte`

4. **Sync campagne MediaMap ↔ Floating Panel** :
   - **Problème résolu** : Cycle infini entre store subscribe et store sync
   - **Solution** : Variable `lastStoreSyncedId` pour tracker ce qu'on a nous-même écrit dans le store
   - L'effet "react to store" n'applique les changements que si `storeSelectedCampaignId !== lastStoreSyncedId` (changement externe)
   - Intermédiaire `storeSelectedCampaignId` ($state) mis à jour par subscribe, puis un $effect séparé synchro vers `selectedCampaign`
   - Fichier : `src/routes/(dashboard)/dashboard/map/+page.svelte`

5. **Badges de status uniformisés** :
   - Dashboard, page mes publicités, et page admin publicités utilisent les mêmes couleurs
   - Couleurs : draft=gris, ready=bleu, pending_review=jaune, approved=vert, rejected=rouge, live=violet, paused=orange, archived=gris
   - Format : `bg-XXX-100 text-XXX-700` (couleur de fond + texte)
   - Statut "ready" ajouté dans admin (statusOptions + statusConfig + statusCounts)
   - Fichiers : `dashboard/+page.svelte`, `dashboard/ads/+page.svelte`, `admin/ads/+page.svelte`

6. **Calendrier MediaMap - point unique** :
   - Un seul point vert par jour au lieu de multiples points colorés par écran
   - Les détails des écrans restent accessibles au clic sur le jour

7. **Fix ad_id.replace error** :
   - `campaign.ad_id` peut être un objet SurrealDB (pas un string)
   - Vérification du type avant d'appeler `.replace()` : `typeof campaign.ad_id === 'string' ? ... : campaign.ad_id?.id || null`

8. **Ads page - Status alignés avec frontend** :
   - Stats serveur mises à jour pour correspondre aux statuts frontend
   - Supprimé : `creating`, `validation`
   - Ajouté : `pending_review`, `approved`, `rejected`
   - Fichier : `src/routes/(dashboard)/dashboard/ads/+page.server.ts`

9. **Map page - Fix erreur 500** :
   - `campaignBookings` déplacé plus haut dans le fichier (avant référencement)
   - Fichier : `src/routes/(dashboard)/dashboard/map/+page.svelte`

### Session précédente (6 février 2026)

1. **Fix boucle infinie campagnes auto - MULTIPLE CAUSES** :
   
   **Cause 1 - syncingDates non réactif** :
   - `syncingDates` était `let syncingDates = false` au lieu de `$state(false)`
   - Les guards dans les effects ne fonctionnaient pas
   - **Fix** : Converti en `$state(false)` + `queueMicrotask()` pour le reset
   
   **Cause 2 - hasInitialized non réactif** :
   - Le flag d'init one-shot était une variable simple
   - L'effect 10 se re-déclenchait à chaque render
   - **Fix** : `hasInitialized = $state(false)`, vérifié EN PREMIER dans l'effect
   
   **Cause 3 - Appels async hors untrack()** :
   - `loadFilteredScreens()` et `loadCampaignBookings()` étaient HORS du `untrack()` block
   - Ils trackaient leurs dépendances et re-déclenchaient l'effect
   - **Fix** : Tout mis dans `untrack()` avec valeurs capturées localement
   
   **Cause 4 - Guards manquants sur fonctions async** :
   - `loadCampaignBookings` n'avait pas de guard pour éviter réappels
   - `loadFilteredScreens` pouvait être appelé pendant qu'il chargeait déjà
   - **Fix** : Ajout de `hasLoadedBookings` et guard `isLoadingScreens` au début
   
   **Cause 5 - Effect 11 (loadScreens) instable** :
   - Le check `if (isLoadingScreens) return` créait une dépendance sur `isLoadingScreens`
   - Quand le loading finissait, l'effect se re-déclenchait
   - **Fix** : 
     - Retirer `isLoadingScreens` du guard de l'effect
     - Utiliser `lastLoadedCriteria` pour tracker les vrais changements de critères
     - Ne recharger que si `criteriaKey !== lastLoadedCriteria`
     - Debounce augmenté à 300ms

2. **Fix zoom auto trop agressif sur InteractiveMap** :
   - **Problème** : Le `fitBounds` était appelé à chaque changement de `screens`, empêchant l'utilisateur de naviguer librement
   - **Solution** : Séparation des effects en 2 :
     - Un pour les changements de sélection GÉO (avec zoom via `shouldZoom=true`)
     - Un pour les changements de `screens` (sans zoom, juste `displayScreenMarkers()`)
   - Variable `lastGeoSelection` pour tracker si la sélection géo a changé
   - `updateHighlights(shouldZoom)` : le fitBounds n'est fait que si `shouldZoom` est true
   - Fichier modifié : `src/lib/components/InteractiveMap.svelte`

3. **Logs debugging toujours présents** :
   - 11 `console.log` avec format `[EFFECT X - description]`
   - Log `[loadFilteredScreens]` pour tracer les appels

### Session précédente (5 février 2026 - 18:15)

1. **Fix boucle infinie MediaMap (effect_update_depth_exceeded)** :
   - Les effects de sync geo (country/region/department) utilisaient `untrack()` qui ne fonctionnait pas car les effets s'exécutent dans le même microtask
   - **Solution** : Dépendance **explicite** sur `isLoadingCampaignFilters` au lieu de `untrack()`
   - Tous les effects geo (`selectedCountry`, `selectedRegion`, `selectedDepartment`) retournent immédiatement si `isLoadingCampaignFilters === true`
   - `applyCampaignGeoFilters()` met à jour `lastSavedFilters` AVANT de désactiver le flag
   - Cela évite que l'effet de sauvegarde considère que les filtres ont changé après initialisation
   - Fichier modifié : `src/routes/(dashboard)/dashboard/map/+page.svelte`

2. **Fix boucle infinie édition campagnes auto** :
   - Flag `isInitializingCampaign` initialisé à `!!data.campaign` DÈS LE DÉPART (pas dans l'effect)
   - Cela bloque TOUS les effets avant même qu'ils ne s'exécutent
   - Import de `untrack` de Svelte ajouté pour l'effet d'initialisation
   - Flag `isInitializingCampaign` vérifié dans TOUS les $effects de sync
   - Désactivation du flag via `queueMicrotask()` après initialisation
   - Fichier modifié : `src/routes/(dashboard)/dashboard/campaigns/new/+page.svelte`

3. **Tooltips calendrier dashboard - Fix overflow** :
   - Positionnement via attribut `style` inline (plus fiable que classes Tailwind)
   - `isRightSide >= 4` : tooltip aligné à droite
   - `isLeftSide <= 2` : tooltip aligné à gauche  
   - Centre : transform translateX(-50%)
   - Largeur max : `max-w-[160px]` et `max-w-[120px]` sur le texte
   - `overflow-hidden` sur la grille des 3 mois
   - Fichier modifié : `src/routes/(dashboard)/dashboard/+page.svelte`

4. **Sonner (Toast notifications)** :
   - Installé `svelte-sonner` pour les notifications toast
   - Composant wrapper créé : `$lib/components/ui/sonner/Sonner.svelte`
   - Ajouté au layout dashboard : `<Sonner position="bottom-right" />`
   - Remplacé tous les `alert()` de la MediaMap par `toast.success/error/warning`

5. **Indicateur écran dans campagne** :
   - Fonction helper `isScreenInCampaign(screenId)` dans MediaMap
   - Badge vert "Dans la campagne" affiché sur les écrans déjà sélectionnés
   - Bordure verte + fond vert clair pour distinguer visuellement

6. **Création de campagnes - Nouvelles étapes** :
   - Mode manuel : 4 étapes (info, mode, config+mediamap, récap)
   - Mode automatique : 5 étapes (info, mode, config, écrans, récap)
   - `totalSteps` est maintenant dérivé de `creationMode`
   - Étape finale : récapitulatif avec CGV checkbox, simulation paiement
   - Nouveau snippet `finalSummaryStep` pour le récapitulatif

7. **Thumbnails campagnes** :
   - `getAdThumbnail()` exclut maintenant les vidéos MP4
   - Utilise uniquement la thumbnail auto ou personnalisée

8. **Console logs nettoyés** :
   - Suppression des logs `[loadFilteredScreens]` qui polluaient la console

### Session précédente (5 février 2026 - matin)

1. **Thumbnails campagnes persistées** :
   - À la création d'une campagne (step 1), la thumbnail de la pub est sauvegardée sur la campagne
   - API POST `/api/campaigns` accepte maintenant `thumbnail_url`
   - Les pages campagnes utilisent `campaign.thumbnail_url` en priorité, puis fallback sur `ad_info`

2. **Page Abonnements - Prix dans le tableau** : Ligne "Prix" ajoutée avec 0€/mois et 19€/mois

3. **Admin - Page Publicités** : Corrigé l'affichage des colonnes
   - "Créé par" : affiche maintenant l'email/nom de l'utilisateur (`user.*`)
   - **Corrigé 404** : L'ID de l'ad est maintenant nettoyé (suppression du préfixe `ad:`)

4. **MediaMap - Zones campagnes manuelles** :
   - Chaque campagne manuelle garde maintenant ses propres filtres géographiques
   - Chargement prioritaire : DB → localStorage
   - Réinitialisation de `lastSavedFilters` au changement de campagne
   - Les champs vides (région, département) sont synchronisés correctement

### Session précédente (5 février 2026)

1. **Page Abonnements** : Nouvelle page `/dashboard/subscription`
   - Plans Gratuit (0€) et Premium (19€/mois)
   - Design adapté au style du site avec dégradés et cards
   - Lien ajouté dans le dropdown menu utilisateur avec icône Crown
   - Section FAQ avec details/summary

2. **Thumbnails campagnes** : Corrigé l'affichage des miniatures
   - Si `output_url` est une vidéo, cherche d'abord une thumbnail
   - Cherche les images dans `media_map` en priorité sur les vidéos
   - Fallback amélioré pour les pubs créées via template

3. **Logo** : Revenu au SVG inline (pas de fichier externe)

4. **MediaMap - Tooltip calendrier** : Corrigé le clipping du tooltip
   - Position `fixed` avec calcul dynamique via `calendarTooltip` state
   - Rendu en portal à la fin du DOM

5. **Édition de campagne** : Redirige vers l'étape 4 (sélection d'écrans)

6. **Optimisation DB** : Pool de connexions avec suivi d'activité
   - Skip health check si activité récente < 30s

7. **Prix écrans** : `weekly_price = 178€` pour tous les écrans

---

## 📋 Description du Projet

**NEOAD** est une plateforme de publicité digitale permettant :
- **Création de spots** : via templates personnalisables ou upload direct
- **Bibliothèque médias** : gestion des images/vidéos uploadées
- **Réservation d'écrans** : choix des emplacements, dates, créneaux
- **Paiement** : en ligne (carte) ou par facture

### 3 Espaces distincts :
1. **Public** (`/`) : Homepage, FAQ, Contact, CGV...
2. **Dashboard Client** (`/dashboard`) : Création, gestion, diffusion
3. **Admin** (`/admin`) : Gestion users, écrans, logs, stats

### Modèle de données (SurrealDB) :
- `user` : Utilisateurs (clients + admins)
- `session` : Sessions d'authentification
- `media` : Fichiers uploadés (images, vidéos)
- `template` : Templates de spots
- `spot` : Spots publicitaires créés
- `location` : Emplacements géographiques
- `screen` : Écrans de diffusion
- `availability` : Disponibilités des écrans
- `booking` : Réservations de créneaux
- `cart` : Panier utilisateur
- `order` : Commandes validées
- `invoice` : Factures
- `audit_log` : Logs d'activité

---

## ⚠️ IMPORTANT - Règles de base

### Toujours utiliser `bun` (pas npm, pas npx)

```bash
# ❌ NE PAS UTILISER
npm install
npx tsx script.ts

# ✅ UTILISER
bun install
bun run script.ts
bun run dev
```

---

## 🗄️ SurrealDB - Connexion

### Variables d'environnement (.env)

```bash
SURREAL_URL=wss://xxx.surreal.cloud
SURREAL_NAMESPACE=neoad
SURREAL_DATABASE=maindb
SURREAL_USER=rootuser
SURREAL_PASS=xxx
```

### ⚠️ ERREUR FRÉQUENTE - Auth

```typescript
// ❌ MAUVAIS - provoque "There was a problem with authentication"
await db.signin({
  namespace: "neoad",
  database: "maindb",
  username: "rootuser",
  password: "...",
});

// ✅ BON - auth root sans namespace/database
await db.signin({ username: "rootuser", password: "xxx" });
await db.use({ namespace: "neoad", database: "maindb" });
```

### Connexion dans SvelteKit (src/lib/server/db.ts)

```typescript
import { getSurrealDB, serializeData } from "$lib/server/db";

const db = await getSurrealDB();
const result = await db.query("SELECT * FROM table");
return serializeData(result);
```

### ⚠️ ERREUR FRÉQUENTE - Types de données (dates)

```typescript
// ❌ MAUVAIS - provoque "Expected a datetime but cannot convert 'xxx' into a datetime"
await db.query(`CREATE table CONTENT { date: $date }`, {
  date: new Date().toISOString()  // String au lieu de datetime
});

// ✅ BON - utiliser type::datetime() pour convertir
await db.query(`CREATE table CONTENT { date: type::datetime($date) }`, {
  date: new Date().toISOString()
});
```

---

## 🔐 Authentification

### Cookie de session
- Nom : `neoad_session`
- Durée : 30 jours (remember me) ou 1 jour
- httpOnly, secure en prod, sameSite: lax

### Protection des routes (hooks.server.ts)
- Routes publiques : `/`, `/login`, `/register`, `/faq`, etc.
- Routes client : `/dashboard/*` → requiert auth
- Routes admin : `/admin/*` → requiert auth + role admin

### Helpers auth (src/lib/server/auth.ts)
```typescript
import { createUser, authenticateUser, getSessionByToken } from "$lib/server/auth";

// Créer un utilisateur
await createUser({ email, password, first_name, last_name });

// Authentifier
const result = await authenticateUser(email, password);
// → { user, session, token }
```

---

## 🧩 Svelte 5 - Runes

### Déclaration de state

```typescript
let value = $state("");
let items = $state<Item[]>([]);
```

### Props avec bindable

```typescript
let { value = $bindable("") }: { value?: string } = $props();
```

### Derived values

```typescript
const computed = $derived(expression);
const complexComputed = $derived.by(() => {
  return result;
});
```

---

## 🔧 Commandes utiles

```bash
# Dev server
bun run dev

# Build
bun run build

# Check types
bun run check
```

---

## 📁 Structure du projet

```
src/
  lib/
    server/
      db.ts               # Connexion SurrealDB
    components/
      ui/                 # Composants UI
  routes/
    +layout.svelte        # Layout principal
    +page.svelte          # Homepage
static/
  favicon.svg
```

---

---

## 🐛 Debugging - Causes connues d'erreurs

### Erreur 500 / Chargement infini

1. **Import statique du module auth dans hooks.server.ts**
   - ❌ `import { getSessionByToken } from "$lib/server/auth"` au niveau module
   - ✅ Utiliser un import dynamique lazy:
   ```typescript
   let authModule: typeof import("$lib/server/auth") | null = null;
   async function getAuthModule() {
     if (!authModule) authModule = await import("$lib/server/auth");
     return authModule;
   }
   ```

2. **Connexion SurrealDB qui bloque**
   - Toujours utiliser `withTimeout()` pour les opérations DB
   - Le pool de connexion peut bloquer si la DB est inaccessible

3. **Variables d'environnement manquantes**
   - Vérifier `.env` contient: `SURREAL_URL`, `SURREAL_USER`, `SURREAL_PASS`, `SURREAL_NAMESPACE`, `SURREAL_DATABASE`

### Hot Module Reload (HMR) qui casse

- Après modification de `hooks.server.ts`, le serveur peut garder l'ancien code en cache
- Solution: Redémarrer complètement le serveur (`pkill bun && bun run dev`)

### Port déjà utilisé

```bash
lsof -ti:5173 | xargs kill -9
```

---

## 📅 RangeCalendar (bits-ui) - Problèmes et solutions

### Contexte
Le composant RangeCalendar de bits-ui est utilisé pour la sélection de plages de dates dans la création de campagne (étape 3).

### Comportement par défaut de bits-ui
Quand une plage (start + end) existe et qu'on clique sur une nouvelle date :
- bits-ui garde `start` et met `end` à la nouvelle date (si après start)
- Ou inverse start/end si la nouvelle date est avant start

### ⚠️ Ce qui NE MARCHE PAS (causes d'erreurs)

**Erreur : `undefined is not an object (evaluating 'dateRange.start.year')`**

Cause : On met `value = undefined` dans un handler, ce qui déclenche des `$derived` qui accèdent à `dateRange.start.year` sans garde.

Solutions :
1. **Ne jamais mettre `value = undefined`** dans un handler synchrone
2. Toujours utiliser des gardes : `if (!dateRange?.start || !dateRange?.end) return ...`

**Tentatives qui cassent l'app :**
- ❌ `onpointerdowncapture` avec `value = undefined` → l'erreur se produit avant que bits-ui puisse définir le nouveau start
- ❌ Handler dans page.svelte → même problème

### Solution actuelle
Laisser bits-ui gérer naturellement sans intervention. Le comportement n'est pas idéal (modifie la plage au lieu de reset) mais ne crash pas.

### Structure du composant
- Fichier : `/src/lib/components/ui/range-calendar/RangeCalendar.svelte`
- Props : `value` (bindable), `numberOfMonths`, `minValue`, `locale`, `showTodayButton`
- **Pas de handler custom** pour éviter les erreurs

---

## 🗺️ MediaMap (dashboard/map) - Fonctionnalités

### Fichiers
- Page : `/src/routes/(dashboard)/dashboard/map/+page.svelte`
- Server : `/src/routes/(dashboard)/dashboard/map/+page.server.ts`

### Fonctionnalités principales
1. **Sélection de campagne** : Dropdown pour choisir une campagne et voir ses écrans
2. **Couleurs des pins** :
   - Vert `#10b981` : Écran dans la campagne sélectionnée
   - Bleu clair `#60a5fa` : Disponible
   - Gris `#9ca3af` : Indisponible
3. **Clusters** : Bleu clair par défaut, vert si TOUS les écrans du cluster sont dans la campagne
4. **Menu dépliant** : Liste des écrans de la campagne (après bouton Rechercher)

### Mode de localisation
- **"around"** : Cercle de rayon autour d'une position
- **"country"** : Sélection par pays/région/département

### ⚠️ Suppression du cercle
Le cercle doit être supprimé quand on passe de "around" à "country".
L'effet doit capturer `locationType` comme dépendance et supprimer directement le layer :

```typescript
$effect(() => {
  const currentMode = locationType;
  if (map && L && currentMode === 'country') {
    if (radiusCircle) {
      map.removeLayer(radiusCircle);
      radiusCircle = null;
    }
  }
});
```

### Application des filtres de campagne
Quand une campagne est sélectionnée, ses paramètres de ciblage sont appliqués :
- `targeting_mode: 'zone'` → mode "country" + pays/région/département
- `targeting_mode: 'address'` → mode "around" + rayon

---

## 🎨 Admin Templates - Dropdown

### Problème d'overflow
Les dropdowns dans la grille de templates étaient coupés par `overflow-hidden` sur les cartes.

**Solution** : Retirer `overflow-hidden` de la div carte, le mettre uniquement sur la section thumbnail.

---

## 🎯 Campagnes - Sauvegarde des données

### Champs sauvegardés à chaque étape
- **Étape 1** : Nom, dates, description
- **Étape 2** : Mode (automatic/manual)
- **Étape 3** : Zone géographique (targeting_mode, selected_country, selected_region, selected_department) ou adresse (search_address, address_radius)
- **Étape 4** : Écrans sélectionnés (screens[])

### API /api/campaigns/[id] PATCH
Champs supportés :
- `name`, `description`, `status`, `mode`
- `targeting_mode`, `selected_country`, `selected_region`, `selected_department`
- `search_address`, `address_radius`
- `budget`, `start_date`, `end_date`
- `screens` (array d'IDs) - converti en `[type::thing("screen", "id"), ...]`

---

## 🗓️ Disponibilités hebdomadaires des écrans

### Structure de données
Chaque écran peut avoir :
- `unavailable_weeks: number[]` - Semaines ISO indisponibles (1-53)
- `availability_year: number` - Année concernée (ex: 2026)

### API /api/screens/[id]/availability GET
Retourne :
- `unavailableWeeks: number[]` - Semaines indisponibles de l'écran
- `bookedWeeks: { weekNumber: number, campaignName?: string }[]` - Semaines déjà réservées

### Composant WeeklyAvailabilityCalendar
- Fichier : `/src/lib/components/WeeklyAvailabilityCalendar.svelte`
- Affiche les semaines par mois avec états : disponible (vert), réservé (rouge), indisponible (gris)
- Support des périodes de campagne (filtre les semaines concernées)
- Multi-sélection possible

### Script de génération de données test
```bash
bun run tsx database/generate-screen-availability.ts
```
Génère des semaines indisponibles aléatoires pour 70% des écrans.

---

## 🗺️ Contours géographiques MediaMap

### APIs utilisées
- **Pays** : Nominatim OpenStreetMap (`polygon_geojson=1`)
- **Régions** : OpenDataSoft `georef-france-region`
- **Départements** : OpenDataSoft `georef-france-departement`

### Cache
`geoJsonCache: Record<string, any>` - Cache en mémoire pour éviter les appels répétés

### Bounds métropolitains
Pour les pays avec territoires éloignés (France DOM-TOM), utiliser les bounds métropolitains :
```typescript
const metropolitanBounds: Record<string, [[number, number], [number, number]]> = {
  "FR": [[51.1, -5.2], [41.3, 9.6]],
  // ...
};
```

### ⚠️ Problèmes avec les effects Svelte 5 et async

**Problème** : Quand un `$effect` déclenche une opération async, les autres `$effect` qui surveillent les mêmes variables réactives peuvent interférer.

**Solution adoptée dans MediaMap** :
1. Séparer la logique async dans une fonction dédiée (pas inline dans l'effect)
2. Utiliser un flag `isLoadingCampaignFilters` pour bloquer les autres effects
3. Lire le flag avec `untrack()` pour ne pas créer de dépendance

```typescript
import { untrack } from "svelte";

let isLoadingCampaignFilters = $state(false);

// Effect de campagne - appelle une fonction async séparée
$effect(() => {
  if (selectedCampaignData?.targetingMode === 'zone') {
    const { selectedCountry, selectedRegion, selectedDepartment } = selectedCampaignData;
    if (selectedCountry) {
      applyCampaignGeoFilters(selectedCountry, selectedRegion, selectedDepartment);
    }
  }
});

// Fonction séparée pour appliquer les filtres
async function applyCampaignGeoFilters(country: string, region?: string, department?: string) {
  isLoadingCampaignFilters = true;
  try {
    selectedCountry = country;
    await loadRegions(country);
    
    if (region) {
      selectedRegion = region;
      await loadDepartments(country, region);
      if (department) {
        selectedDepartment = department;
      }
    }
  } finally {
    await new Promise(r => setTimeout(r, 100));
    isLoadingCampaignFilters = false;
    updateGeoContours();
  }
}

// Effect du pays - lit le flag avec untrack pour ne pas créer de dépendance
$effect(() => {
  if (selectedCountry) {
    const isLoading = untrack(() => isLoadingCampaignFilters);
    if (!isLoading) {
      loadRegions(selectedCountry);
      selectedRegion = "";
    }
  }
});
```

**Points clés** :
- `untrack(() => value)` lit une valeur réactive SANS que l'effect ne soit re-déclenché quand cette valeur change
- La fonction async séparée évite les problèmes de closure avec les valeurs `$derived`
- Le flag est mis à `false` APRÈS un délai pour laisser Svelte propager les changements

### ⚠️ Closure dans les fonctions async des effects

**Problème** : Les valeurs `$derived` peuvent changer pendant l'exécution async.

```typescript
// ❌ MAUVAIS - selectedCampaignData peut changer pendant await
$effect(() => {
  (async () => {
    await loadRegions(selectedCampaignData.selectedCountry);
    selectedRegion = selectedCampaignData.selectedRegion; // Peut être undefined!
  })();
});

// ✅ BON - Capturer les valeurs au début
$effect(() => {
  const country = selectedCampaignData.selectedCountry;
  const region = selectedCampaignData.selectedRegion;
  (async () => {
    await loadRegions(country);
    selectedRegion = region;
  })();
});
```

---

## 🗓️ Disponibilités écrans (unavailable_weeks)

### Structure en base de données
```typescript
// Dans la table screen - IMPORTANT: champ défini dans le schéma SurrealDB
unavailable_weeks: number[]  // Ex: [6, 7, 8, 9] pour semaines 6-9 indisponibles
```

### ⚠️ IMPORTANT - Schéma SurrealDB strict
Si un nouveau champ n'est pas enregistré lors d'un UPDATE, c'est probablement parce que le schéma de la table est strict. Il faut d'abord définir le champ :

```sql
-- Définir le champ dans le schéma (IF NOT EXISTS pour éviter l'erreur si déjà défini)
DEFINE FIELD IF NOT EXISTS unavailable_weeks ON screen TYPE array<int> DEFAULT [] PERMISSIONS FULL;
DEFINE FIELD IF NOT EXISTS unavailable_weeks[*] ON screen TYPE int PERMISSIONS FULL;
```

### Script de génération de données test
```bash
bun run database/add-screen-availability.ts
```
- Génère des semaines indisponibles aléatoires pour ~40% des écrans
- Ajoute spécifiquement des indisponibilités sur les semaines 6-9 pour tests
- Définit automatiquement le champ dans le schéma avant la mise à jour

### Vérification des données
```bash
bun run database/check-availability.ts
```
- Compte les écrans avec des indisponibilités
- Affiche les locations entièrement indisponibles sur les semaines de campagne

### API /api/screens/[id]/availability
Retourne :
- `unavailableWeeks: number[]` - Semaines indisponibles de l'écran
- `bookedWeeks: { weekNumber: number, campaignName?: string }[]` - Réservations existantes

### Fonction de vérification
```typescript
function isScreenAvailableForCampaign(screen: any): boolean {
  if (campaignWeeks.length === 0) return true;
  const unavailableWeeks = screen.unavailable_weeks || [];
  for (const week of campaignWeeks) {
    if (unavailableWeeks.includes(week)) return false;
  }
  return true;
}
```

### Filtre "Indisponibles sur la période"
- Nécessite qu'une campagne avec dates soit sélectionnée
- Affiche les locations où TOUS les écrans sont indisponibles sur les semaines de la campagne
- 47 locations entièrement indisponibles sur les semaines 6-9 (données de test)

### Composant CampaignWeekCalendar
Nouveau composant de calendrier classique pour la sélection de semaines :
- Fichier : `src/lib/components/CampaignWeekCalendar.svelte`
- Affichage calendrier mensuel avec navigation
- Centré sur la période de la campagne
- Code couleur :
  - **Blanc** : Disponible
  - **Rouge** : Indisponible (unavailable_weeks)
  - **Bleu** : Sélectionné
- Cliquable sur les numéros de semaine ou les jours pour sélectionner

```svelte
<CampaignWeekCalendar
  campaignStartDate={selectedCampaignData.startDate}
  campaignEndDate={selectedCampaignData.endDate}
  bind:selectedWeeks={screenSelectedWeeks}
  unavailableWeeks={screenUnavailableWeeks}
  bookedWeeks={screenBookedWeeks}
/>
```

---
