PageSourceSearch

https://staging-grandsprojets.netlify.app/modules/citymanager.js

js staging-grandsprojets.netlify.app collected 2026-10-03 11:26:49 UTC 16,208 bytes, 464 lines download raw bytes

1// modules/citymanager.js
2// Gestion des villes : détection, branding, logos, UI du sélecteur
3
4;(function(win) {
5  'use strict';
6
7  const CityManager = {
8    VALID_CITIES: new Set(),
9    CITY_BRANDING_CACHE: new Map(),
10    _branding: null,
11    _activeCity: '',
12    _cityMenuOpen: false,
13    _cityMenuDocHandler: null,
14
15    // Helpers de validation
16
17    isValidCity(city) {
18      try { 
19        return this.VALID_CITIES.has(String(city || '').toLowerCase()); 
20      } catch (e) { 
21        console.debug('[citymanager] isValidCity failed:', e);
22        return false; 
23      }
24    },
25
26    // Détection de ville
27
28    getRawCityFromPath(pathname) {
29      try {
30        const path = String(pathname || location.pathname || '').toLowerCase();
31        return path.split('?')[0].split('#')[0].split('/').filter(Boolean)[0] || '';
32      } catch (e) { 
33        console.debug('[citymanager] getRawCityFromPath failed:', e);
34        return ''; 
35      }
36    },
37
38    parseCityFromPath(pathname) {
39      const raw = this.getRawCityFromPath(pathname);
40      return this.isValidCity(raw) ? raw : '';
41    },
42
43    getCityFromQuery(_defaultCity = '') {
44      try {
45        const sp = new URLSearchParams(location.search);
46        const raw = String(sp.get('city') || '').toLowerCase().trim();
47        return this.isValidCity(raw) ? raw : '';
48      } catch (e) {
49        console.debug('[citymanager] getCityFromQuery failed:', e);
50        return '';
51      }
52    },
53
54    getRawCityFromQueryParam() {
55      try {
56        const sp = new URLSearchParams(location.search);
57        const raw = String(sp.get('city') || '').toLowerCase().trim();
58        return raw;
59      } catch (e) { 
60        console.debug('[citymanager] getRawCityFromQueryParam failed:', e);
61        return ''; 
62      }
63    },
64
65    // Persistance
66
67    persistCity(city) {
68      try { 
69        if (this.isValidCity(city)) localStorage.setItem('activeCity', city); 
70      } catch (e) { console.debug('[citymanager] persistCity failed:', e); }
71    },
72
73    restoreCity() {
74      try {
75        const v = localStorage.getItem('activeCity');
76        return this.isValidCity(v) ? v : '';
77      } catch (e) { 
78        console.debug('[citymanager] restoreCity failed:', e);
79        return ''; 
80      }
81    },
82
83    clearPersistedCity() {
84      try { 
85        localStorage.removeItem('activeCity'); 
86      } catch (e) { console.debug('[citymanager] clearPersistedCity failed:', e); }
87    },
88
89    // Chargement des villes valides
90
91    async loadValidCities() {
92      try {
93        if (!win.supabaseService || typeof win.supabaseService.getValidCities !== 'function') return;
94        const list = await win.supabaseService.getValidCities();
95        if (Array.isArray(list) && list.length) {
96          this.VALID_CITIES = new Set(list.map(v => String(v || '').toLowerCase().trim()).filter(Boolean));
97        }
98      } catch (e) { console.debug('[citymanager] loadValidCities failed:', e); }
99    },
100
101    getDefaultCity() {
102      // Ville par défaut : metropole-lyon
103      return 'metropole-lyon';
104    },
105
106    resolveActiveCity() {
107      const routeCity = this.parseCityFromPath(location.pathname);
108      if (routeCity) return routeCity;
109      const queryCity = this.getCityFromQuery('');
110      if (queryCity) return queryCity;
111      const saved = this.restoreCity();
112      if (saved) return saved;
113      // Aucune ville trouvée : retourner metropole-lyon par défaut
114      return 'metropole-lyon';
115    },
116
117    // Branding (logos, favicon)
118
119    /**
120     * Retourne le branding complet stocké (chargé en Phase 2)
121     * @returns {Object|null}
122     */
123    getBranding() {
124      return this._branding;
125    },
126
127    /**
128     * Stocke le branding (appelé par main.js Phase 2)
129     * @param {Object|null} branding
130     */
131    setBranding(branding) {
132      this._branding = branding || null;
133    },
134
135    selectLogoForTheme(branding, theme) {
136      if (!branding) return null;
137      if (theme === 'dark' && branding.dark_logo_url) return branding.dark_logo_url;
138      return branding.logo_url || null;
139    },
140
141    /**
142     * Charge le branding depuis Supabase et met à jour les logos dans le DOM.
143     * Le favicon est délégué à CityBrandingModule.
144     * @param {string} city - Code de la ville
145     */
146    async updateLogoForCity(city) {
147      try {
148        const targets = [
149          document.querySelector('#nav-panel .logo img'),
150          document.querySelector('.mobile-fixed-logo img'),
151          document.querySelector('#dock-logo img'),
152          document.querySelector('.gp-sidebar__logo img')
153        ].filter(Boolean);
154
155        // Fetch branding une seule fois (stocké pour toute l'app)
156        let branding = null;
157        if (win.supabaseService && typeof win.supabaseService.getCityBranding === 'function' && city) {
158          branding = await win.supabaseService.getCityBranding(city);
159        }
160        this._branding = branding || null;
161
162        if (!branding) {
163          console.debug('[CityManager] Pas de branding trouvé pour la ville:', city);
164          return;
165        }
166
167        const theme = (document.documentElement.getAttribute('data-theme') || 'light').toLowerCase();
168        const picked = this.selectLogoForTheme(branding, theme);
169        const altText = branding.brand_name || city.charAt(0).toUpperCase() + city.slice(1);
170
171        // Appliquer le logo du branding
172        targets.forEach((img) => {
173          if (!img || !picked) return;
174          img.style.opacity = '0';
175          img.src = picked;
176          img.alt = altText;
177          img.style.display = '';
178          img.onerror = null;
179          img.onload = () => { img.style.transition = 'opacity 0.2s ease'; img.style.opacity = '1'; };
180          if (img.complete && img.naturalWidth > 0) {
181            img.style.transition = 'opacity 0.2s ease';
182            img.style.opacity = '1';
183          }
184        });
185      } catch (e) { console.debug('[citymanager] updateLogoForCity failed:', e); }
186    },
187
188    applyCityInitialView(_city) {
189      try {
190        const branding = this._branding || null;
191        const hasCoords = branding && typeof branding.center_lat === 'number' && typeof branding.center_lng === 'number';
192        const hasZoom   = branding && typeof branding.zoom === 'number';
193        if (!hasCoords || !hasZoom) return;
194        
195        const center = [branding.center_lat, branding.center_lng];
196        const zoom   = branding.zoom;
197        if (window.MapModule?.map?.setView) {
198          window.MapModule.map.setView(center, zoom);
199        }
200      } catch (e) { console.debug('[citymanager] applyCityInitialView failed:', e); }
201    },
202
203    // UI du sélecteur de ville
204
205    async getCityBrandingSafe(ville) {
206      const key = String(ville || '').toLowerCase();
207      if (!key) return null;
208      if (this.CITY_BRANDING_CACHE.has(key)) return this.CITY_BRANDING_CACHE.get(key);
209      
210      try {
211        const data = await (win.supabaseService?.getCityBranding ? win.supabaseService.getCityBranding(key
211) : null);
212        const minimal = data ? {
213          ville: data.ville || key,
214          brand_name: data.brand_name || '',
215          logo_url: data.logo_url || '',
216          dark_logo_url: data.dark_logo_url || ''
217        } : null;
218        this.CITY_BRANDING_CACHE.set(key, minimal);
219        return minimal;
220      } catch (e) {
221        console.debug('[citymanager] getCityBrandingSafe failed:', e);
222        this.CITY_BRANDING_CACHE.set(key, null);
223        return null;
224      }
225    },
226
227    /**
228     * Récupère le branding complet d'une ville (avec enabled_cities, enabled_toggles, etc.)
229     * @param {string} ville - Code de la ville
230     * @returns {Promise<Object|null>} Branding complet ou null
231     */
232    async getFullBrandingSafe(ville) {
233      const key = String(ville || '').toLowerCase();
234      if (!key) return null;
235      
236      try {
237        const data = await (win.supabaseService?.getCityBranding ? win.supabaseService.getCityBranding(key) : null);
238        return data || null;
239      } catch (e) {
240        console.debug('[citymanager] getFullBrandingSafe failed:', e);
241        return null;
242      }
243    },
244
245    // Menu de sélection d'espace
246
247    /**
248     * Peuple le menu de sélection d'espace avec les villes de enabled_cities
249     * @param {string} activeCity - Ville actuellement active
250     */
251    async renderCityMenu(activeCity) {
252      try {
253        const menu = document.getElementById('city-menu');
254        if (!menu) return;
255
256        // Récupérer le branding de la ville active pour avoir enabled_cities
257        const branding = await this.getFullBrandingSafe(activeCity);
258        const enabledCities = branding?.enabled_cities;
259
260        // Si pas d'espaces configurés, menu vide
261        if (!Array.isArray(enabledCities) || enabledCities.length === 0) {
262          menu.innerHTML = '<div class="dock-panel__header"><span class="dock-panel__title">Espace</span></div><div class="dock-panel__body" style="padding:12px;color:var(--text-tertiary);font-size:0.78rem;">Aucun espace configuré</div>';
263          return;
264        }
265
266        // Récupérer les brandings de toutes les villes
267        const brandings = await Promise.all(
268          enabledCities.map(c => this.getCityBrandingSafe(c))
269        );
270
271        const _esc = win.SecurityUtils ? win.SecurityUtils.escapeHtml : (s => String(s || ''));
272        const itemsHtml = enabledCities.map((cityCode, idx) => {
273          const isActive = String(cityCode).toLowerCase() === String(activeCity).toLowerCase();
274          const b = brandings[idx] || {};
275          const displayName = _esc(b.brand_name?.trim() || cityCode.charAt(0).toUpperCase() + cityCode.slice(1));
276          const logo = (document.documentElement.getAttribute('data-theme') === 'dark' && b.dark_logo_url) 
277            ? b.dark_logo_url 
278            : (b.logo_url || '');
279          const logoHtml = logo 
280            ? `<img src="${_esc(logo)}" alt="${displayName}" class="city-menu-logo" />`
281            : `<i class="fas fa-building"></i>`;
282
283          return `
284            <button class="dock-panel__item city-menu-item${isActive ? ' is-active' : ''}" data-city="${_esc(cityCode)}" role="menuitem">
285              <span class="dock-panel__item-icon">${logoHtml}</span>
286              <span class="dock-panel__item-label">${displayName}</span>
287            </button>
288          `;
289        }).join('');
290
291        menu.innerHTML = `
292          <div class="dock-panel__header">
293            <span class="dock-panel__title">Espace</span>
294            <button class="dock-panel__close" aria-label="Fermer le menu espace"><i class="fas fa-times"></i></button>
295          </div>
296          <div class="dock-panel__body">${itemsHtml}</div>
297        `;
298
299        // Bind close button
300        menu.querySelector('.dock-panel__close')?.addEventListener('click', (e) => {
301          e.stopPropagation();
302          win.toggleManager?.setState('city', false);
303        });
304
305        // Bind click events
306        menu.querySelectorAll('.city-menu-item').forEach(item => {
307          item.addEventListener('click', (e) => {
308            e.preventDefault();
309            e.stopPropagation();
310            const city = item.dataset.city;
311            if (city) this.selectCity(city);
312          });
313        });
314      } catch (err) {
315        console.debug('[CityManager] Error rendering city menu:', err);
316      }
317    },
318
319    /**
320     * Sélectionne une ville et navigue vers elle
321     * @param {string} city - Code de la ville
322     */
323    selectCity(city) {
324      try {
325        const cityCode = String(city || '').toLowerCase();
326        if (!cityCode) return;
327
328        // Fermer le menu
329        if (win.toggleManager) {
330          win.toggleManager.setState('city', false);
331        }
332
333        // Si c'est déjà la ville active, ne rien faire
334        if (cityCode === String(this._activeCity).toLowerCase()) return;
335
336        // Persister et naviguer
337        this.persistCity(cityCode);
338        
339        // Construire l'URL avec le paramètre city
340        const sp = new URLSearchParams(location.search);
341        sp.set('city', cityCode);
342        const target = location.pathname + '?' + sp.toString();
343        location.href = target;
344      } catch (err) {
345        console.debug('[CityManager] Error selecting city:', err);
346      }
347    },
348
349    /**
350     * Initialise le menu d'espace
351     * @param {string} activeCity - Ville active
352     */
353    async initCityMenu(activeCity) {
354      await this.renderCityMenu(activeCity);
355    },
356
357    /**
358     * Retourne la ville active en utilisant toutes les sources disponibles
359     * @returns {string} Ville active ou chaîne vide
360     */
361    getActiveCity() {
362      return this.parseCityFromPath(location.pathname) || 
363             this.getCityFromQuery('') || 
364             this.restoreCity() || 
365             this._activeCity || 
366             this.getDefaultCity();
367    },
368
369    /**
370     * Initialise et résout la ville active avec gestion de la persistance
371     * Utilise metropole-lyon par défaut si aucune ville valide
372     * @returns {string} Ville active résolue (toujours une ville, jamais null)
373     */
374    initializeActiveCity() {
375      const rawQueryCity = this.getRawCityFromQueryParam();
376      const rawPathCity = this.getRawCityFromPath();
377      
378      // Si pas de villes valides chargées, utiliser metropole-lyon par défaut
379      if (!this.VALID_CITIES || this.VALID_CITIES.size === 0) {
380        console.debug('[CityManager] Aucune ville valide chargée → Utilisation de metropole-lyon');
381        this._activeCity = 'metropole-lyon';
382        return this._activeCity;
383      }
384
385      // Si ville invalide dans l'URL, utiliser metropole-lyon
386      if (rawQueryCity && !this.isValidCity(rawQueryCity)) {
387        console.debug('[CityManager] Ville invalide détectée:', rawQueryCity, '→ Utilisation de metropole-lyon');
388        this.clearPersistedCity();
389        this._activeCity = 'metropole-lyon';
390        return this._activeCity;
391      }
392      
393      if (rawPathCity && !this.isValidCity(rawPathCity)) {
394        console.debug('[CityManager] Ville invalide dans le path:', rawPathCity, '→ Utilisation de metropole-lyon');
395        this.clearPersistedCity();
396        this._activeCity = 'metropole-lyon';
397        return this._activeCity;
398      }
399
400      // Résolution normale
401      const city = this.resolveActiveCity();
402      
403      // Si ville résolue mais invalide, utiliser metropole-lyon
404      if (city && !this.isValidCity(city)) {
405        console.debug('[CityManager] Ville résolue invalide:', city, '→ Utilisation de metropole-lyon');
406        this.clearPersistedCity();
407        this._activeCity = 'metropole-lyon';
408        return this._activeCity;
409      }
410      
411      this._activeCity = city;
412      
413      // Gérer la persistance
414      if (city && this.isValidCity(city)) {
415        if (this.restoreCity() !== city) this.persistCity(city);
416      } else {
417        this.clearPersistedCity();
418      }
419      
420      return city;
421    },
422
423    /**
424     * Récupère les informations d'une ville (version synchrone depuis le cache)
425     * @param {string} cityCode - Code de la ville
426     * @returns {Object|null} - Informations de la ville ou null
427     */
428    getCityInfo(cityCode) {
429      if (!cityCode) return null;
430      if (!this.isValidCity(cityCode)) return null;
431      
432      const key = String(cityCode).toLowerCase();
433      // Retourner depuis le cache si disponible
434      if (this.CITY_BRANDING_CACHE.has(key)) {
435        return this.CITY_BRANDING_CACHE.get(key);
436      }
437      
438      // Sinon retourner un objet minimal
439      return {
440        ville: key,
441        brand_name: key.charAt(0).toUpperCase() + key.slice(1),
442        logo_url: '/img/logos/default.svg',
443        dark_logo_url: null
444      };
445    },
446
447    /**
448     * Récupère les informations d'une ville (version async avec fetch)
449     * @param {string} cityCode - Code de la ville
450     * @returns {Promise<Object|null>} - Informations de la ville ou null
451     */
452    async getCityInfoAsync(cityCode) {
453      if (!cityCode) return null;
454      if (!this.isValidCity(cityCode)) return null;
455      
456      // Utiliser getCityBrandingSafe qui gère le cache et fetch si nécessaire
457      return await this.getCityBrandingSafe(cityCode);
458    }
459  };
460
461  // Exposer le module globalement
462  win.CityManager = CityManager;
463
464})(window);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.