PageSourceSearch

https://www.woopets.ai/js/demo-news.js?v=8c92f8c0

js woopets.ai collected 2026-10-05 13:19:59 UTC 17,774 bytes, 352 lines download raw bytes

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.