1/** 2 * demo-news.js â Onglet « Actus » (vue #viewAppNews). [REFONTE-ENTREE, ex-V3.3b] 3 * 4 * Ce module porte désormais TOUT le rendu de la liste (filtres, pagination, badges 5 * « Nouveau »), sur le web mobile ET dans le shell. Le bridge natif garde ce qui lui 6 * appartient â l'ouverture de la vue, le pull-to-refresh, l'ouverture des articles dans 7 * le navigateur système â et délègue simplement l'affichage via W.newsLoad/W.newsRefresh. 8 * Sans ce partage, la même liste enrichie aurait dû être écrite deux fois. 9 * 10 * Les filtres sont DÃRIVÃS DES DONNÃES (facettes renvoyées par l'API), jamais d'une liste 11 * de catégories écrite en dur : `articles_cache.category` est du texte libre alimenté par 12 * l'ingestion, dont la taxonomie réelle n'est pas garantie. Une catégorie inconnue est 13 * affichée telle quelle, proprement mise en forme. 14 * 15 * Requires: demo-core.js (W.api, W.esc, W.escAttr, W.switchView, W.closeSidebar) 16 * Exposes: W.newsLoad, W.newsRefresh, W.openAppNews (si le shell ne l'a pas déjà posé) 17 * Gating: FR uniquement ; jamais en embed. 18 */ 19(function (W) { 20 'use strict'; 21 22 // [EMBED-HOOK] Jamais dans l'iframe partenaire (pas de tab bar, pas d'actus). 23 if (window.WOOPETS_IS_EMBED) { return; } 24 // Actus = FR uniquement (comme la famille clic : contenus woopets.fr). 25 if ((window.CURRENT_LOCALE || 'fr') !== 'fr') { return; } 26 27 var PAGE = 20; 28 var SEEN_KEY = 'woopets_news_seen_at'; 29 30 // Filtre courant + pile d'articles déjà chargés (la pagination empile, elle ne remplace pas). 31 var st = { cat: null, feelgood: false, offset: 0, items: [], facets: null, hasMore: false, loading: false }; 32 // Date de la dernière visite, CAPTURÃE à l'ouverture : les badges « Nouveau » doivent 33 // rester stables pendant toute la consultation, alors que le marqueur est réécrit 34 // immédiatement pour la visite suivante. 35 var seenAt = null; 36 37 function esc(s) { return W.esc ? W.esc(s) : ''; } 38 function escA(s) { return W.escAttr ? W.escAttr(s) : esc(s); } 39 function pwa() { return window.TRANSLATIONS_PWA || {}; } 40 function tp(k, fallback) { var v = pwa()[k]; return (typeof v === 'string' && v !== '') ? v : (fallback || k); } 41 42 function fmtDateFr(raw) { 43 var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(raw || '')); 44 return m ? (m[3] + '/' + m[2] + '/' + m[1]) : ''; 45 } 46 47 /** Un article publié après la dernière visite. Première visite (aucun marqueur) â aucun 48 * badge : marquer les 20 articles comme neufs ne dirait rien à personne. */ 49 function isNew(a) { 50 if (!seenAt || !a || !a.date) { return false; } 51 return String(a.date) > seenAt; 52 } 53 54 function readSeenAt() { 55 try { return localStorage.getItem(SEEN_KEY); } catch (e) { return null; } 56 } 57 function writeSeenAt() { 58 // Format aligné sur `published_at` (MySQL) pour que la comparaison de chaînes soit 59 // valide : « 2026-08-24 15:04:05 ». Les deux sont dans le même ordre lexicographique. 60 try { 61 var d = new Date(); 62 var p = function (n) { return (n < 10 ? '0' : '') + n; }; 63 localStorage.setItem(SEEN_KEY, d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) 64 + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())); 65 } catch (e) {} 66 } 67 68 /** Libellé d'une catégorie : traduction si on en a une, sinon la valeur brute mise en 69 * forme (tirets â espaces, initiale capitale). La taxonomie n'étant pas garantie, on 70 * n'invente rien et on n'écarte rien. */ 71 function catLabel(value) { 72 var key = 'news_cat_' + String(value).toLowerCase().replace(/[^a-z0-9]+/g, '_'); 73 var t = pwa()[key]; 74 if (typeof t === 'string' && t !== '') { return t; } 75 var s = String(value).replace(/[-_]+/g, ' ').trim(); 76 return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; 77 } 78 79 function skeleton() { 80 var item = '<div class="app-news-skel-item"><div class="app-news-skel-thumb"></div>' 81 + '<div class="app-news-skel-lines"><div class="app-news-skel-line"></div>' 82 + '<div class="app-news-skel-line short"></div></div></div>'; 83 return '<div class="app-news-skeleton">' + item + item + item + '</div>'; 84 } 85 86 // --------------------------------------------------------------- 87 // Rendu 88 // --------------------------------------------------------------- 89 function renderChips() { 90 var f = st.facets; 91 if (!f) { return ''; } 92 var parts = ['<div class="app-news-chips">']; 93 var allOn = (st.cat === null && !st.feelgood); 94 parts.push('<button type="button" class="app-news-chip' + (allOn ? ' on' : '') + '" data-news-cat="">' 95 + esc(tp('news_filter_all', 'Toutes')) + '</button>'); 96 if (f.feelgood_count > 0) { 97 parts.push('<button type="button" class="app-news-chip' + (st.feelgood ? ' on' : '') + '" data-news-feelgood="1">' 98 + '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"' 99 + ' stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' 100 + '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/></svg>' 101 + esc(tp('news_filter_feelgood', 'Belles histoires')) + '</button>'); 102 }
103 (f.categories || []).forEach(function (c) { 104 if (!c || !c.value) { return; } 105 var on = (st.cat === c.value); 106 parts.push('<button type="button" class="app-news-chip' + (on ? ' on' : '') + '" data-news-cat="' 107 + escA(c.value) + '">' + esc(catLabel(c.value)) + '</button>'); 108 }); 109 parts.push('</div>'); 110 return parts.join(''); 111 } 112 113 function renderHero(a) { 114 var badges = []; 115 if (isNew(a)) { badges.push('<span class="app-news-badge new">' + esc(tp('news_badge_new', 'NOUVEAU')) + '</span>'); } 116 if (a.is_feelgood) { badges.push('<span class="app-news-badge cat">' + esc(tp('news_filter_feelgood', 'Belles histoires')) + '</span>'); } 117 else if (a.category) { badges.push('<span class="app-news-badge cat">' + esc(catLabel(a.category)) + '</span>'); } 118 // La carte à la une s'affiche PLEINE LARGEUR : la vignette de liste (calibrée pour 119 // 64 px) y est visiblement floue. On demande les variantes haute résolution servies 120 // par woopets.fr et on laisse le navigateur choisir selon la densité d'écran. 121 // `data-fallback` : si une variante n'existait pas pour un vieil article, l'image 122 // ne doit pas rester cassée â le gestionnaire posé après le rendu repose la vignette. 123 var thumb; 124 if (a.thumbnail) { 125 var lg = a.thumbnail_lg || a.thumbnail; 126 var xl = a.thumbnail_xl || lg; 127 thumb = '<img class="app-news-hero-img" src="' + escA(lg) + '"' 128 + (xl !== lg ? ' srcset="' + escA(lg) + ' 768w, ' + escA(xl) + ' 1200w" sizes="100vw"' : '') 129 + ' data-fallback="' + escA(a.thumbnail) + '" alt="" loading="lazy">'; 130 } else { 131 thumb = '<span class="app-news-hero-img app-news-thumb--empty" aria-hidden="true"></span>'; 132 } 133 return '<a class="app-news-hero" href="' + escA(a.url) + '" target="_blank" rel="noopener"' 134 + (a.id ? ' data-news-id="' + escA(a.id) + '"' : '') + '>' 135 + thumb 136 + '<span class="app-news-hero-body">' 137 + (badges.length ? '<span class="app-news-badges">' + badges.join('') + '</span>' : '') 138 + '<span class="app-news-hero-title">' + esc(a.title || '') + '</span>' 139 + '<span class="app-news-date">' + esc(fmtDateFr(a.date)) + (a.source ? ' · ' + esc(a.source) : '') + '</span>' 140 + '</span></a>'; 141 } 142 143 function renderItem(a) { 144 var thumb = a.thumbnail 145 ? '<img class="app-news-thumb" src="' + escA(a.thumbnail) + '" alt="" loading="lazy">' 146 : '<span class="app-news-thumb app-news-thumb--empty" aria-hidden="true"></span>'; 147 var meta = []; 148 if (isNew(a)) { meta.push('<span class="app-news-badge new">' + esc(tp('news_badge_new', 'NOUVEAU')) + '</span>'); } 149 var line = (a.category ? catLabel(a.category) + ' · ' : '') + fmtDateFr(a.date); 150 return '<a class="app-news-item" href="' + escA(a.url) + '" target="_blank" rel="noopener"' 151 + (a.id ? ' data-news-id="' + escA(a.id) + '"' : '') + '>' 152 + thumb 153 + '<span class="app-news-texts">' 154 + '<span class="app-news-title">' + esc(a.title || '') + '</span>' 155 + '<span class="app-news-date">' + meta.join('') + esc(line) + '</span>' 156 + '</span></a>'; 157 } 158 159 /** Construit le HTML complet de la vue à partir de l'état courant. */ 160 function buildHtml() { 161 var usable = st.items.filter(function (a) { return a && /^https?:\/\//i.test(a.url || ''); }); 162 if (!usable.length) { 163 return renderChips() + '<p class="app-news-empty">' + esc(tp('news_empty', '')) + '</p>'; 164 } 165 var parts = []; 166 parts.push('<div class="app-news-intro">' + esc(tp('news_intro', '')) + '</div>'); 167 parts.push(renderChips()); 168 // La carte héro n'a de sens qu'en tête de première page non filtrée : ailleurs, elle 169 // donnerait à un article arbitraire un poids qu'il n'a pas. 170 var rest = usable; 171 if (st.cat === null && !st.feelgood) { 172 parts.push(renderHero(usable[0])); 173 rest = usable.slice(1); 174 } 175 parts.push('<div class="app-news-list">'); 176 rest.forEach(function (a) { parts.push(renderItem(a)); }); 177 parts.push('</div>'); 178 if (st.hasMore) { 179 parts.push('<button type="button" class="app-news-more" data-news-more="1">' 180 + esc(tp('news_load_more', 'Charger plus')) 181 + '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"' 182 + ' stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' 183 + '<path d="M12 5v14"/><path d="M5 12l7 7 7-7"/></svg></button>'); 184 } 185 return parts.join(''); 186 } 187 188 function paint() { 189 var scroll = document.getElementById('viewAppNewsScroll'); 190 if (!scroll) { return; } 191 scroll.innerHTML = buildHtml(); 192 bindOnce(scroll); 193 // Repli de la carte à la une : une variante haute résolution absente (vieil article 194 // dont les tailles n'ont pas été régénérées) doit revenir à la vignette, pas laisser 195 // une image cassée. Gestionnaire posé en JS et non en attribut `onerror` : la 196 // politique de sécurité du site interdit les gestionnaires en ligne. 197 var hero = scroll.querySelector('.app-news-hero-img[data-fallback]'); 198 if (hero) { 199 hero.addEventListener('error', function onErr() { 200 hero.removeEventListener('error', onErr); 201 hero.removeAttribute('srcset'); 202 hero.src = hero.getAttribute('data-fallback') || ''; 203 }); 204 } 205 } 206 207 // --------------------------------------------------------------- 208 // Données 209 // --------------------------------------------------------------- 210 function url() { 211 var q = '/api/v1/app/news/?limit=' + PAGE + '&offset=' + st.offset; 212 if (st.cat) { q += '&category=' + encodeURIComponent(st.cat); } 213 if (st.feelgood) { q += '&feelgood=1'; } 214 return q; 215 } 216 217 /** 218 * @param {boolean} append empiler (Charger plus) au lieu de remplacer 219 * @param {function} [done] appelé à la fin, succès comme échec (pull-to-refresh) 220 */ 221 function fetchPage(append, done) { 222 if (st.loading || !W.api) { if (done) { done(); } return; } 223 st.loading = true; 224 W.api(url(), { method: 'GET' }).then(function (data) { 225 var list = (data && data.articles) || []; 226 st.items = append ? st.items.concat(list) : list; 227 st.hasMore = !!(data && data.has_more); 228 // Les facettes ne sont renvoyées qu'en première page : on garde les précédentes 229 // sinon, pour que les puces ne disparaissent pas au « Charger plus ». 230 if (data && data.facets) { st.facets = data.facets; } 231 st.loading = false;
232 paint(); 233 if (done) { done(); } 234 }).catch(function () { 235 st.loading = false; 236 if (!append) { 237 var scroll = document.getElementById('viewAppNewsScroll'); 238 if (scroll) { scroll.innerHTML = '<p class="app-news-empty">' + esc(tp('news_empty', '')) + '</p>'; } 239 } 240 if (done) { done(); } 241 }); 242 } 243 244 function applyFilter(cat, feelgood) { 245 st.cat = cat; 246 st.feelgood = !!feelgood; 247 st.offset = 0; 248 st.items = []; 249 var scroll = document.getElementById('viewAppNewsScroll'); 250 // Le squelette remplace la liste mais garde les puces visibles : sans ça, la barre 251 // de filtres disparaîtrait le temps du chargement, juste après un tap dessus. 252 if (scroll) { scroll.innerHTML = renderChips() + skeleton(); bindOnce(scroll); } 253 fetchPage(false); 254 } 255 256 // --------------------------------------------------------------- 257 // Capteur de clics 258 // --------------------------------------------------------------- 259 260 /** 261 * Compte l'ouverture d'un article. Les liens ne portent volontairement PAS d'utm 262 * (le trafic doit rester attribué tel quel côté woopets.fr) : une fois l'article 263 * ouvert, il sort de notre périmètre de mesure â le seul moment où l'on peut 264 * compter est donc ce tap. 265 * 266 * Fire-and-forget, jamais bloquant : aucun preventDefault, la réponse n'est pas 267 * attendue et un échec ne se voit nulle part. Le serveur ne stocke qu'un compteur 268 * agrégé (jour à article à plateforme), aucune donnée personnelle. 269 */ 270 function trackClick(rawId) { 271 var articleId = parseInt(rawId, 10); 272 if (!articleId || !W.api) { return; } 273 try { 274 W.api('/api/v1/app/news/click/', { 275 method: 'POST', 276 body: { article_id: articleId }, 277 // [NATIVE-HOOK] Mesure anonyme : inutile de rafraîchir la session du 278 // shell pour un appel qui n'a aucun besoin d'identité. 279 skipAuthRefresh: true 280 }, 8000).catch(function () {}); 281 } catch (e) { /* best-effort */ } 282 } 283 284 var bound = false; 285 function bindOnce(scroll) { 286 if (bound || !scroll) { return; } 287 bound = true; 288 scroll.addEventListener('click', function (e) { 289 // Ouverture d'un article : on compte et on laisse filer. Dans le shell, le 290 // pont natif a déjà intercepté ce même clic en phase de CAPTURE pour ouvrir 291 // le navigateur système ; il ne stoppe pas la propagation, donc ce 292 // gestionnaire s'exécute quand même. 293 var link = e.target.closest ? e.target.closest('a[data-news-id]') : null; 294 if (link) { trackClick(link.getAttribute('data-news-id')); return; } 295 296 var el = e.target.closest ? e.target.closest('[data-news-cat],[data-news-feelgood],[data-news-more]') : null; 297 if (!el) { return; } 298 e.preventDefault(); 299 if (el.hasAttribute('data-news-more')) { 300 st.offset = st.items.length; 301 el.disabled = true; 302 fetchPage(true); 303 return; 304 } 305 if (el.hasAttribute('data-news-feelgood')) { applyFilter(null, !st.feelgood); return; } 306 var cat = el.getAttribute('data-news-cat') || ''; 307 applyFilter(cat === '' ? null : cat, false); 308 }); 309 } 310 311 // --------------------------------------------------------------- 312 // API du module (partagée avec le bridge natif) 313 // --------------------------------------------------------------- 314 315 /** Premier chargement de la vue : squelette + page 1 avec le filtre courant. */ 316 W.newsLoad = function () { 317 var scroll = document.getElementById('viewAppNewsScroll'); 318 if (!scroll) { return; } 319 seenAt = readSeenAt(); // capturé AVANT réécriture : fige les badges de la visite 320 writeSeenAt(); 321 st.offset = 0; 322 st.items = []; 323 scroll.innerHTML = skeleton(); 324 bindOnce(scroll); 325 fetchPage(false); 326 }; 327 328 /** Rafraîchissement silencieux (pull-to-refresh du shell) : garde le filtre courant. */ 329 W.newsRefresh = function (done) { 330 st.offset = 0; 331 fetchPage(false, done); 332 }; 333 334 // Le shell définit son propre openAppNews (bascule de vue + pull-to-refresh) AVANT ce 335 // module : on ne l'écrase pas, il appellera W.newsLoad pour le rendu.
336 if (!W.openAppNews) { 337 var opened = false; 338 W.openAppNews = function () { 339 if (!W.switchView) { return; } 340 if (W.closeSidebar) { W.closeSidebar(); } 341 W.switchView('app-news'); 342 if (!opened) { opened = true; W.newsLoad(); } 343 }; 344 } 345 346 // Révèle l'onglet Actus de la bottom tab bar (web mobile FR). La barre est gatée @media 347 // < 768px côté CSS â l'onglet reste invisible sur desktop même une fois dé-hidé. 348 document.addEventListener('DOMContentLoaded', function () { 349 var tab = document.querySelector('#appTabbar [data-tab="news"]'); 350 if (tab) { tab.hidden = false; } 351 }); 352})(window.WD);
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.