PageSourceSearch

https://nexly-app.fr/extensions-ui.js

js nexly-app.fr collected 2026-10-02 01:54:20 UTC 36,328 bytes, 614 lines download raw bytes

1/* ═══════════════════════════════════════════════════════════
2   NEXILY EXTENSIONS — Logique catalogue & page extension
3   (chargé après extensions-data.js, extensions-data-en.js et i18n.js)
4   Bilingue FR / EN via window.NEXILY_LANG.
5   ═══════════════════════════════════════════════════════════ */
6(function () {
7  'use strict';
8  var DATA = window.NEXILY_EXTENSIONS;
9  if (!DATA) return;
10
11  var LANG = window.NEXILY_LANG === 'en' ? 'en' : 'fr';
12  var EN = window.NEXILY_EXTENSIONS_EN || { products: {}, categories: {} };
13  function isEN() { return LANG === 'en'; }
14
15  /* ── Libellés d'interface (hors contenu produit) ────────── */
16  var STR = {
17    fr: {
18      soonStores: 'Bientôt sur les stores', soonAvail: 'Bientôt disponible',
19      install: 'Installer', installChrome: 'Installer sur Chrome', installFirefox: 'Installer sur Firefox',
20      details: 'Détails', discover: 'Découvrir l’extension →', free: 'Gratuit', popular: '★ Populaire',
21      screenshotOf: 'Capture d’écran de ', screenshot: 'Capture ',
22      allCats: '✦ Toutes', noMatch: 'Aucune extension ne correspond à votre recherche.', reset: 'Réinitialiser',
23      extOne: 'extension', extMany: 'extensions',
24      home: 'Accueil', extensions: 'Extensions', browser: 'navigateur', extensionWord: 'Extension',
25      freeStart: 'Gratuit pour commencer', comparePricing: 'Comparer les tarifs',
26      goPro: 'Passer au Pro', goProYear: 'Pro annuel', goProLife: 'Pro à vie',
27      secDescription: 'Description', secFreeOrPro: 'Gratuit ou Pro ?', secFaq: 'Questions fréquentes',
28      sideBrief: 'En bref', sidePrivacy: 'Confidentialité', sideInstall: 'Installation',
29      privacy1: 'Aucun compte requis', privacy2: 'Aucune donnée envoyée sur un serveur', privacy3: 'Zéro publicité, zéro tracker',
30      stepChrome: 'Sur Chrome (ou Edge, Brave, Opera) : ouvrez la fiche Chrome Web Store et cliquez sur « Ajouter à Chrome ».',
31      stepFirefox: 'Sur Firefox : ouvrez la fiche Mozilla Add-ons et cliquez sur « Ajouter à Firefox ».',
32      stepNotPublished: 'L’extension n’est pas encore publiée sur les stores.',
33      stepPin: 'Épinglez l’icône dans la barre d’outils et c’est parti.',
34      similar: 'Dans le même esprit',
35      guarantee1: '🔒 Données locales par défaut',
36      guarantee2: '⚡ Sans compte, opérationnelle en moins d’une minute',
37      guarantee3: '💳 Pro via Stripe · remboursement 14 jours',
38      guarantee4: '🇫🇷 Support en français — [email protected]',
39      ldFreeStart: 'Gratuit pour commencer — '
40    },
41    en: {
42      soonStores: 'Coming soon to the stores', soonAvail: 'Coming soon',
43      install: 'Install', installChrome: 'Install on Chrome', installFirefox: 'Install on Firefox',
44      details: 'Details', discover: 'Discover the extension →', free: 'Free', popular: '★ Popular',
45      screenshotOf: 'Screenshot of ', screenshot: 'Screenshot ',
46      allCats: '✦ All', noMatch: 'No extension matches your search.', reset: 'Reset',
47      extOne: 'extension', extMany: 'extensions',
48      home: 'Home', extensions: 'Extensions', browser: 'browser', extensionWord: 'Extension',
49      freeStart: 'Free to start', comparePricing: 'Compare pricing',
50      goPro: 'Go Pro', goProYear: 'Pro yearly', goProLife: 'Pro for life',
51      secDescription: 'Description', secFreeOrPro: 'Free or Pro?', secFaq: 'Frequently asked questions',
52      sideBrief: 'In brief', sidePrivacy: 'Privacy', sideInstall: 'Installation',
53      privacy1: 'No account required', privacy2: 'No data sent to a server', privacy3: 'Zero ads, zero trackers',
54      stepChrome: 'On Chrome (or Edge, Brave, Opera): open the Chrome Web Store listing and click “Add to Chrome”.',
55      stepFirefox: 'On Firefox: open the Mozilla Add-ons listing and click “Add to Firefox”.',
56      stepNotPublished: 'The extension is not published on the stores yet.',
57      stepPin: 'Pin the icon to your toolbar and you’re set.',
58      similar: 'You might also like',
59      guarantee1: '🔒 Local data by default',
60      guarantee2: '⚡ No account, up and running in under a minute',
61      guarantee3: '💳 Pro via Stripe · 14-day refund',
62      guarantee4: '✉️ Support in English — [email protected]',
63      ldFreeStart: 'Free to start — '
64    }
65  };
66  var S = STR[LANG];
67
68  /* ── Localisation du contenu produit ────────────────────── */
69  function localizeStore(url) {
70    if (!url || !isEN()) return url;
71    return url.replace('hl=fr', 'hl=en')
72              .replace('addons.mozilla.org/fr/', 'addons.mozilla.org/en-US/');
73  }
74  function localizeProduct(x) {
75    if (!isEN()) return x;
76    var o = EN.products[x.id] || {};
77    var m = {}; for (var k in x) m[k] = x[k];
78    ['catLabel', 'tagline', 'lead', 'metaline', 'proLabel', 'desc', 'tiers', 'faq', 'kw',
79     'fusion', 'checkoutPrice', 'checkoutYearPrice', 'checkoutLifePrice'].forEach(function (f) {
80      if (o[f] != null) m[f] = o[f];
81    });
82    m.screens = (o.screens && o.screens.length) ? o.screens : x.screens;
83    m.store = localizeStore(x.store);
84    m.storeFF = localizeStore(x.storeFF);
85    return m;
86  }
87  var PRODUCTS = DATA.products.map(localizeProduct);
88
89  var CATS = {};
90  DATA.categories.forEach(function (c) {
91    CATS[c.id] = { id: c.id, icon: c.icon, name: (isEN() && EN.categories[c.id]) ? EN.categories[c.id] : c.name };
92  });
93
94  /* ── Helpers ──────────────────────────────────────────── */
95  function esc(s) {
96    return String(s == null ? '' : s).replace(/[&<>"']/g, function (m) {
97      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m];
98    });
99  }
100  /* Icônes navigateurs (inline, aucune requête externe) */
101  var ICO_CHROME = '<svg class="br-ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="9.6" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 7.8h9.4M7.8 14.1 3.1 6.1M16.2 14.1 11.5 22.2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
102  var ICO_FIREFOX = '<svg class="br-ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12.8" r="8.9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 3.9c2.7 0 4.1 1.8 4.1 1.8M6.6 8.3C7.7 5.9 9.6 4.4 11.8 4.3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M4.9 9.6c-.6 4.4 2.5 8 6.7 8 3.4 0 5.7-2.3 5.7-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
103
104  /* Bouton unique (cartes du catalogue) : Chrome en priorité, Firefox en repli */
105  function installBtn(x, cls, label) {
106    var url = x.store || x.storeFF;
107    if (!url) return '<span class="' + cls + ' soon">' + S.soonStores + '</span>';
108    return '<a class="' + cls + '" href="' + esc(url) + '" target="_blank" rel="noopener" data-umami-event="install-' + esc(x.id) + '">' + label + ' ↗</a>';
109  }
110  /* Récap Gratuit vs Pro affiché en haut de fiche (parcours Free→Pro sans défilement) */
111  function freeProSummary(x) {
112    if (!x.tiers || x.tiers.length < 2) return '';
113    var f = x.tiers[0], p = x.tiers[1];
114    var fp = (f.price || '0 €').split('·')[0].trim();                 // « 0 € »
115    // Prix Pro repris du même champ que le badge (proLabel) pour rester cohérent au-dessus de la ligne de flottaison.
116    var pp = (x.proLabel && x.proLabel.indexOf('·') > -1)
117      ? x.proLabel.split('·').slice(1).join('·').trim()
118      : (p.price || '').split('·')[0].trim();
119    return '<div class="pr-fp">' +
120      '<div class="pr-fp-col free"><span class="pr-fp-k">' + esc(S.free) + (fp ? ' · ' + esc(fp) : '') + '</span><div class="pr-fp-d">' + esc((f.items && f.items[0]) || '') + '</div></div>' +
121      '<div class="pr-fp-col pro"><span class="pr-fp-k">Pro' + (pp ? ' · ' + esc(pp) : '') + '</span><div class="pr-fp-d">' + esc((p.items && p.items[0]) || '') + '</div></div>' +
122    '</div>';
123  }
124  /* Pastilles « disponible sur » — informatif */
125  function storePills(x) {
126    if (!x.store && !x.storeFF) return '<span class="br-pill soon">' + S.soonAvail + '</span>';
127    return (x.store ? '<span class="br-pill">' + ICO_CHROME + 'Chrome</span>' : '') +
128           (x.storeFF ? '<span class="br-pill">' + ICO_FIREFOX + 'Firefox</span>' : '');
129  }
130  /* Double CTA — fiche produit et bandeau collant */
131  function installBtns(x, cls) {
132    if (!x.store && !x.storeFF) return '<span class="' + cls + ' soon">' + S.soonStores + '</span>';
133    var both = x.store && x.storeFF;
134    return (x.store ? '<a class="' + cls + '" href="' + esc(x.store) + '" target="_blank" rel="noopener" data-umami-event="install-' + esc(x.id) + '">' + ICO_CHROME + (both ? 'Chrome' : S.installChrome) + '</a>' : '') +
135           (x.storeFF ? '<a class="' + cls + ' ff" href="' + esc(x.storeFF) + '" target="_blank" rel="noopener" data-umami-event="install-' + esc(x.id) + '-ff">' + ICO_FIREFOX + (both ? 'Firefox' : S.installFirefox) + '</a>' : '');
136  }
137  /* Achat du Pro — CTA direct vers Stripe, sans passer par la page tarifs.
138     Rendu uniquement si un lien de paiement existe pour cette extension. */
139
140/* ── Packs et All Access (« L'échelle Nexily », sections 03 et 04) ─────────
141   Doit rester synchronisé avec PACKS dans l'edge function nexly-license et
142   avec _shared/nexily-packs.js côté extensions.
143   Search est absent de Workspace : son Pro est validé contre une constante en
144   dur côté client, une clé de pack ne peut pas le débloquer. */
145var NX_PACKS = {
146  career:    { nom: 'Career',    prix: '69 €', apps: ['atsmatch','track','template'],
147               pour: 'Chercheur d’emploi',
148               url: 'https://buy.stripe.com/14A6oH53Pb0JcTmaaKaAw0V' },
149  study:     { nom: 'Study',     prix: '79 €', apps: ['clipdeck','clipnest','easyread','focus'],
150               pour: 'Étudiant, reconversion',
151               url: 'https://buy.stripe.com/cNieVd9k56Kt8D62IiaAw0W' },
152  workspace: { nom: 'Workspace', prix: '79 €', apps: ['tab','clipnest','template','focus'],
153               pour: 'Freelance, indépendant',
154               url: 'https://buy.stripe.com/dRm28rbsdfgZaLe5UuaAw0X' },
155  reader:    { nom: 'Reader',    prix: '49 €', apps: ['easyread','focus'],
156               pour: 'Dyslexie, TDAH, lecture longue',
157               url: 'https://buy.stripe.com/aFaeVd9k5ecV6uYdmWaAw0Y' }
158};
159var NX_ALL = { prix: '99 €', url: 'https://buy.stripe.com/14A9AT1RD2ud4mQaaKaAw0Z' };
160var NX_LIBRES = ['dark', 'stack'];
161
162/* Le pack le plus étroit contenant cette extension passe en premier. */
163function nxPacksDe(id) {
164  return Object.keys(NX_PACKS)
165    .filter(function (k) { return NX_PACKS[k].apps.indexOf(id) > -1; })
166    .sort(function (a, b) { return NX_PACKS[a].apps.length - NX_PACKS[b].apps.length; })
167    .map(function (k) { return NX_PACKS[k]; });
168}
169
170function nxNomsAutres(pack, id) {
171  return pack.apps.filter(function (a) { return a !== id; })
172    .map(function (a) { return a.charAt(0).toUpperCase() + a.slice(1); });
173}
174
175/* Bloc « fait partie d'un pack » + All Access, au pied de chaque fiche. */
176// Fusion annoncée (Search → Tab, UnitWise → Price). Tant qu'elle n'est pas
177// faite, on l'annonce : découvrir après coup que son extension a fondu dans
178// une autre, c'est ce qui fait perdre un client pour de bon. Le texte vient
179// des données, pas d'ici.
180function nxBlocFusion(x) {
181  var f = x.fusion;
182  if (!f) return '';
183  return '<div class="nxf-w rv">' +
184    '<p class="nxf-w-t">' + esc(f.titre) + '</p>' +
185    '<p class="nxf-w-d">' + esc(f.texte) + '</p>' +
186    (f.url && f.lien
187      ? '<a class="nxf-w-a" href="' + esc(f.url) + '" data-umami-event="fusion-' + esc(x.id) + '">' + esc(f.lien) + '</a>'
188      : '') +
189  '</div>';
190}
191
192function nxBlocPacks(x) {
193  var EN = (window.NEXILY_LANG === 'en');
194  if (NX_LIBRES.indexOf(x.id) > -1) return '';   // gratuites : rien à vendre
195  var liste = nxPacksDe(x.id), h = '';
196
197  if (liste.length) {
198    var p = liste[0], autres = nxNomsAutres(p, x.id);
199    var avec = autres.length > 1
200      ? autres.slice(0, -1).join(', ') + (EN ? ' and ' : ' et ') + autres[autres.length - 1]
201      : autres[0];
202    h += '<div class="nxp-card nxp-card-best">' +
203           '<div class="nxp-flag">' + (EN ? 'Pack' : 'Pack') + '</div>' +
204           '<div class="nxp-t">' + esc(x.name.replace('Nexily ', '')) +
205             (EN ? ' is part of the ' : ' fait partie du pack ') + esc(p.nom) + '</div>' +
206           '<p class="nxp-d">' + (EN ? 'With ' : 'Avec ') + esc(avec) +
207             (EN ? ' — one key for the whole pack. ' : ' — une seule clé pour tout le pack. ') +
208             '<strong>' + esc(p.prix) + (EN ? ' once' : ' une fois') + '</strong>.</p>' +
209           '<a class="nxp-b" target="_blank" rel="noopener" href="' + p.url + '" data-umami-event="fiche-pack-' + esc(p.nom.toLowerCase()) + '">' +
210             (EN ? 'See the ' : 'Voir le pack ') + esc(p.nom) + ' →</a>' +
211         '</div>';
212  }
213
214  h += '<div class="nxp-card">' +
215         '<div class="nxp-flag">' + (EN ? 'Everything' : 'Tout') + '</div>' +
216         '<div class="nxp-t">Nexily All Access — ' + NX_ALL.prix + '</div>' +
217         '<p class="nxp-d">' + (EN
218            ? 'All 11 paid extensions, plus everything released later. One key for all of them. Launch price, capped at the first 200 licences.'
219            : 'Les 11 extensions payantes, plus tout ce qui sortira ensuite. Une seule clé pour tout. Prix de lancement, limité aux 200 premières licences.') + '</p>' +
220         '<a class="nxp-b" target="_blank" rel="noopener" href="' + NX_ALL.url + '" data-umami-event="fiche-allaccess">' +
221           (EN ? 'See All Access →' : 'Voir l’All Access →') + '</a>' +
222       '</div>';
223
224  return '<section class="pr-sec rv"><h2 class="pr-sec-t">' +
225           (EN ? 'Cheaper together' : 'Moins cher ensemble') +
226         '</h2><div class="nxp-grid">' + h + '</div>' +
227         '<p class="nxp-credit">' + (EN
228            ? 'Already bought this extension? What you paid is deducted in full from the pack or All Access price — ask for your credit from inside the extension.'
229            : 'Vous avez déjà acheté cette extension ? Ce que vous avez payé est déduit intégralement du prix du pack ou de l’All Access — demandez votre crédit depuis l’extension.') +
230         '</p></section>';
231}
232
233  function hasCheckout(x) { return !!x.checkout; }
234  function proCtas(x, cls) {
235    if (!hasCheckout(x)) return '';
236    /* Jusqu'à trois paliers : mensuel (ou paiement unique), annuel, à vie.
237       Chaque palier a son propre événement Umami pour savoir lequel convertit. */
238    var plans = [
239      { url: x.checkout,     price: x.checkoutPrice,     label: S.goPro,     mod: '',      ev: '' },
240      { url: x.checkoutYear, price: x.checkoutYearPrice, label: S.goProYear, mod: ' year', ev: '-year' },
241      { url: x.checkoutLife, price: x.checkoutLifePrice, label: S.goProLife, mod: ' life', ev: '-life' }
242    ];
243    return plans.filter(function (p) { return !!p.url; }).map(function (p) {
244      return '<a class="' + cls + p.mod + '" href="' + esc(p.url) + '" target="_blank" rel="noopener" ' +
245        'data-umami-event="buy-' + esc(x.id) + p.ev + '">' +
246        esc(p.label + (p.price ? ' · ' + p.price : '')) + '</a>';
247    }).join('');
248  }
249
250  /* Avis réels (Chrome Web Store) — preuve sociale sur la fiche produit. */
251  var REVIEWS = {
252    clipnest: [
253      { name: 'Théo A.', stars: 5, date: '4 sept. 2026', dateEn: 'Sep 4, 2026',
254        text: "Deuxième extension Nexily que j'installe, et toujours aussi convaincu. ClipNest fait exactement ce qu'on lui demande : je capture un article et je récupère un Markdown propre, sans les pubs, les menus et la mise en forme cassée. Le rendu dans Notion est nickel (titres, listes, tableaux bien convert
254is), et le raccourci clavier rend la capture ultra rapide. J'apprécie aussi que tout se passe en local, sans données envoyées ailleurs. Sobre, efficace, et fidèle à l'esprit de la suite. Je recommande." },
255      { name: 'Brandon M.', stars: 5, date: '24 juil. 2026', dateEn: 'Jul 24, 2026',
256      text: "I almost never leave reviews, but ClipNest impressed me so much I had to. The Markdown output is unbelievably clean — it drops straight into Notion or Obsidian like it was handcrafted. The clipping is buttery smooth: one click, instant capture, everything exactly where it should be. Easily one of the best extensions I've ever used."  }
257    ],
258    ticker: { name: 'Théo A.', stars: 4, date: '17 mai 2026', dateEn: 'May 17, 2026',
259      text: "The real-time updates make a real difference versus the free version. The price alerts work perfectly — I got my notification the moment AAPL hit my threshold. The profile page (analyst consensus, 52-week range) saves me from opening TradingView every time. Great value for the money." }
260  };
261  function starsHTML(n) {
262    var s = '';
263    for (var i = 0; i < 5; i++) s += (i < n) ? '★' : '<span class="o">★</span>';
264    return s;
265  }
266  function reviewsHTML(x) {
267    var r = REVIEWS[x.id];
268    if (!r) return '';
269    /* Un avis ou plusieurs : la table accepte les deux, du plus récent au
270       plus ancien dans l'ordre où ils sont écrits. */
271    var liste = Array.isArray(r) ? r : [r];
272    if (!liste.length) return '';
273    var label = LANG === 'en' ? 'What users say' : 'Ce qu\'en disent les utilisateurs';
274    return '<section class="pr-sec rv"><h2 class="pr-sec-t">' + label + '</h2>' +
275      liste.map(function (a) {
276        return '<div class="pr-review">' +
277          '<div class="rr"><span class="rn">' + esc(a.name) + '</span><span class="rev-stars">' + starsHTML(a.stars) + '</span></div>' +
278          '<div class="rt">“' + esc(a.text) + '”</div>' +
279          '<div class="rd">' + esc('Chrome Web Store · ' + (LANG === 'en' ? a.dateEn : a.date)) + '</div>' +
280        '</div>';
281      }).join('') +
282      /* Art. L111-7-2 du Code de la consommation : origine des avis, absence de
283         contrôle et de contrepartie, critère de classement. */
284      '<p class="pr-avis-legal">' + (LANG === 'en'
285        ? 'Reviews published by their authors on the Chrome Web Store, reproduced unmodified and without any compensation. We neither collect nor moderate reviews: they are shown from most recent to oldest.'
286        : 'Avis publiés par leurs auteurs sur le Chrome Web Store, reproduits sans modification et sans contrepartie. Nous ne collectons ni ne modérons les avis : ils sont affichés du plus récent au plus ancien.') + '</p>' +
287    '</section>';
288  }
289  function cardHTML(x) {
290    var cat = CATS[x.category] || { name: x.category, icon: '' };
291    return '<article class="tp-card" data-href="/extension/' + esc(x.id) + '.html" style="--a:#FF6B00;--a2:#FF8C38">' +
292      '<div class="tp-cover">' +
293        '<img class="real" src="' + esc(x.screens[0]) + '" alt="' + esc(S.screenshotOf + x.name) + '" loading="lazy">' +
294        '<div class="tp-badges">' + (x.popularity >= 90 ? '<span class="tp-badge best">' + S.popular + '</span>' : '') + '<span class="tp-badge free">' + S.free + '</span></div>' +
295        '<div class="tp-over"><span>' + S.discover + '</span></div>' +
296      '</div>' +
297      '<div class="tp-body">' +
298        '<div class="tp-plat">' + esc(cat.icon) + ' ' + esc(cat.name) + '</div>' +
299        '<div class="tp-titlerow">' +
300          '<h3 class="tp-name"><a href="/extension/' + esc(x.id) + '.html">' + x.icon + ' ' + esc(x.name) + '</a></h3>' +
301        '</div>' +
302        '<p class="tp-tagline">' + esc(x.tagline) + '</p>' +
303        '<div class="tp-meta"><span>' + esc(x.proLabel) + '</span></div>' +
304        '<div class="br-pills">' + storePills(x) + '</div>' +
305        '<div class="tp-actions">' +
306          installBtn(x, 'tp-buy', S.install) +
307          '<a class="tp-demo" href="/extension/' + esc(x.id) + '.html">' + S.details + '</a>' +
308        '</div>' +
309      '</div>' +
310    '</article>';
311  }
312  function bindCards(root) {
313    root.querySelectorAll('.tp-card[data-href]').forEach(function (card) {
314      card.addEventListener('click', function (e) {
315        if (e.target.closest('a')) return;
316        window.location.href = card.getAttribute('data-href');
317      });
318    });
319  }
320  function reveal() {
321    if (!('IntersectionObserver' in window)) {
322      document.querySelectorAll('.rv').forEach(function (el) { el.classList.add('on'); });
323      return;
324    }
325    var io = new IntersectionObserver(function (entries) {
326      entries.forEach(function (en) {
327        if (en.isIntersecting) { en.target.classList.add('on'); io.unobserve(en.target); }
328      });
329    }, { threshold: .12 });
330    document.querySelectorAll('.rv:not(.on)').forEach(function (el) { io.observe(el); });
331  }
332
333  /* ── Nav scrolled ─────────────────────────────────────── */
334  var nav = document.querySelector('nav.site-nav');
335  if (nav) {
336    var onScroll = function () { nav.classList.toggle('scrolled', window.scrollY > 30); };
337    onScroll();
338    window.addEventListener('scroll', onScroll, { passive: true });
339  }
340
341  /* ═══════════ CATALOGUE (extensions.html) ═════════════ */
342  var grid = document.getElementById('extGrid');
343  if (grid) {
344    /* Données structurées : ItemList du catalogue (aide au référencement). */
345    (function () {
346      var li = DATA.products.map(function (x, i) {
347        return { '@type': 'ListItem', position: i + 1, url: 'https://nexily-app.fr/extension/' + x.id + '.html', name: x.name };
348      });
349      var sc = document.createElement('script'); sc.type = 'application/ld+json';
350      sc.textContent = JSON.stringify({ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Extensions Nexily', itemListElement: li });
351      document.head.appendChild(sc);
352    })();
353    var qs = new URLSearchParams(location.search);
354    var state = {
355      q: qs.get('q') || '',
356      cat: qs.get('cat') || (location.hash ? location.hash.slice(1) : 'all'),
357      sort: qs.get('tri') || 'pop'
358    };
359    if (!CATS[state.cat] && state.cat !== 'all') state.cat = 'all';
360    var input = document.getElementById('extSearch');
361    var sortSel = document.getElementById('extSort');
362    var chipsWrap = document.getElementById('extCats');
363    var countEl = document.getElementById('extCount');
364
365    var counts = {};
366    PRODUCTS.forEach(function (x) { counts[x.category] = (counts[x.category] || 0) + 1; });
367    var chips = '<button class="tpl-chip" data-cat="all">' + S.allCats + ' <span class="n">' + PRODUCTS.length + '</span></button>';
368    DATA.categories.forEach(function (c) {
369      if (!counts[c.id]) return;
370      chips += '<button class="tpl-chip" data-cat="' + c.id + '">' + c.icon + ' ' + esc(CATS[c.id].name) + ' <span class="n">' + counts[c.id] + '</span></button>';
371    });
372    chipsWrap.innerHTML = chips;
373
374    function apply() {
375      var q = state.q.trim().toLowerCase();
376      var list = PRODUCTS.filter(function (x) {
377        if (state.cat !== 'all' && x.category !== state.cat) return false;
378        if (q) {
379          var hay = (x.name + ' ' + x.tagline + ' ' + x.catLabel + ' ' + x.kw.join(' ')).toLowerCase();
380          if (hay.indexOf(q) === -1) return false;
381        }
382        return true;
383      });
384      list.sort(function (a, b) {
385        if (state.sort === 'az') return a.name.localeCompare(b.name, LANG);
386        return b.popularity - a.popularity;
387      });
388      if (!list.length) {
389        grid.innerHTML = '<div class="tpl-empty"><div class="big">🔍</div>' + S.noMatch + '<br><button id="extReset">' + S.reset + '</button></div>';
390        document.getElementById('extReset').addEventListener('click', function () {
391          state = { q: '', cat: 'all', sort: 'pop' };
392          input.value = ''; sortSel.value = 'pop'; syncUI(); apply();
393        });
394      } else {
395        grid.innerHTML = list.map(cardHTML).join('');
396        bindCards(grid);
397      }
398      countEl.textContent = list.length + ' ' + (list.length > 1 ? S.extMany : S.extOne);
399      var p = new URLSearchParams();
400      if (state.q) p.set('q', state.q);
401      if (state.cat !== 'all') p.set('cat', state.cat);
402      if (state.sort !== 'pop') p.set('tri', state.sort);
403      history.replaceState(null, '', location.pathname + (p.toString() ? '?' + p.toString() : ''));
404    }
405    function syncUI() {
406      chipsWrap.querySelectorAll('.tpl-chip').forEach(function (c) {
407        c.classList.toggle('on', c.getAttribute('data-cat') === state.cat);
408      });
409    }
410    chipsWrap.addEventListener('click', function (e) {
411      var chip = e.target.closest('.tpl-chip');
412      if (!chip) return;
413      state.cat = chip.getAttribute('data-cat');
414      syncUI(); apply();
415    });
416    var debounce;
417    input.value = state.q;
418    input.addEventListener('input', function () {
419      clearTimeout(debounce);
420      debounce = setTimeout(function () { state.q = input.value; apply(); }, 160);
421    });
422    sortSel.value = state.sort;
423    sortSel.addEventListener('change', function () { state.sort = sortSel.value; apply(); });
424
425    syncUI(); apply(); reveal();
426  }
427
428  /* ═══════════ PAGE EXTENSION (extension.html) ═════════ */
429  /* Bloc « épinglez l'icône » : Chrome range les nouvelles extensions dans le menu
430     pièce de puzzle, l'icône reste invisible et l'extension est oubliée (1 683
431     installations pour 44 utilisateurs actifs). Les extensions ouvrent cette fiche
432     juste après l'installation : on le met donc avant l'argumentaire, et on le
433     met en avant quand le visiteur arrive justement de là. */
434  function pinHTML(x) {
435    var EN = LANG === 'en';
436    var frais = /utm_medium=onboarding|[?&]installed=1/.test(location.search);
437    var etapes = EN
438      ? ['Click the puzzle piece 🧩 at the top right of Chrome.',
439         'Find <strong>' + esc(x.name) + '</strong> in the list.',
440         'Click the pin 📌: the icon stays in the toolbar.']
441      : ['Cliquez sur la pièce de puzzle 🧩, en haut à droite de Chrome.',
442         'Repérez <strong>' + esc(x.name) + '</strong> dans la liste.',
443         'Cliquez sur l’épingle 📌 : l’icône reste affichée dans la barre.'];
444    var visuel = '/images/epingler/' + x.id + '-' + (EN ? 'en' : 'fr') + '.webp';
445    return '<section class="nx-pin' + (frais ? ' nx-pin-fresh' : '') + '" id="epingler">' +
446      '<h2 class="nx-pin-t">' + (EN ? 'Just installed? Pin the icon.' : 'Vous venez d’installer ? Épinglez l’icône.') + '</h2>' +
447      '<p class="nx-pin-d">' + (EN
448        ? 'Chrome files new extensions away in the puzzle-piece menu: without pinning, the icon stays invisible.'
449        : 'Chrome range les nouvelles extensions dans le menu « pièce de puzzle » : sans épinglage, l’icône reste invisible.') + '</p>' +
450      '<img class="nx-pin-img" src="' + esc(visuel) + '" width="1200" height="630" loading="eager" decoding="async" alt="' +
451        (EN ? 'How to pin ' : 'Comment épingler ') + esc(x.name) + (EN ? ' in Chrome' : ' dans Chrome') + '">' +
452      '<ol class="nx-pin-l">' + etapes.map(function (t) { return '<li>' + t + '</li>'; }).join('') + '</ol>' +
453      '<p class="nx-pin-ff">' + (EN
454        ? 'On Firefox: right-click the icon, then “Pin to Toolbar”.'
455        : 'Sur Firefox : clic droit sur l’icône, puis « Épingler à la barre d’outils ».') + '</p>' +
456      '</section>';
457  }
458
459  var exRoot = document.getElementById('exRoot');
460  if (exRoot) {
461    var id = window.NEXILY_PAGE_ID || new URLSearchParams(location.search).get('e');
462    var x = null;
463    PRODUCTS.forEach(function (p) { if (p.id === id) x = p; });
464    if (!x) { window.location.replace('extensions.html'); return; }
465    var cat = CATS[x.category] || { name: x.category, icon: '' };
466
467    var browsers = [x.store ? 'Chrome' : null, x.storeFF ? 'Firefox' : null].filter(Boolean);
468    /* Fiche statique française : le titre et la description générés (écrits pour
469       Google) font foi. On ne les remplace que pour un visiteur anglophone. */
470    if (!window.NEXILY_PAGE_ID || window.NEXILY_LANG === 'en') {
471      document.title = x.name + ' — ' + S.extensionWord + ' ' + (browsers.join(' & ') || S.browser) + ' ' + cat.name + ' | Nexily';
472      var md = document.querySelector('meta[name="description"]');
473      if (md) md.setAttribute('content', x.lead);
474    }
475    var ld = {
476      '@context': 'https://schema.org', '@type': 'SoftwareApplication',
477      name: x.name, applicationCategory: 'BrowserApplication',
478      operatingSystem: browsers.join(', ') || 'Chrome',
479      description: x.lead,
480      url: 'https://nexily-app.fr/extension/' + x.id + '.html',
481      offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR', description: S.ldFreeStart + x.proLabel }
482    };
483    if (x.store || x.storeFF) ld.installUrl = x.store || x.storeFF;
484    var s = document.createElement('script');
485    s.type = 'application/ld+json';
486    s.textContent = JSON.stringify(ld);
487    document.head.appendChild(s);
488
489    var thumbs = '';
490    if (x.screens.length > 1) {
491      thumbs = '<div class="pr-thumbs">' + x.screens.map(function (src, i) {
492        return '<button class="pr-thumb' + (i === 0 ? ' on' : '') + '" data-src="' + esc(src) + '" aria-label="' + esc(S.screenshot + (i + 1)) + '">' +
493          '<img src="' + esc(src) + '" alt="" loading="lazy"><span class="lbl">' + (i + 1) + '</span></button>';
494      }).join('') + '</div>';
495    }
496    var metaBits = x.metaline ? x.metaline.split('·').map(function (m) { return m.trim(); }) : [];
497
498    exRoot.innerHTML =
499      '<div class="pr-wrap">' +
500        pinHTML(x) +
501        '<div class="pr-crumbs"><a href="index.html">' + S.home + '</a><span class="sep">/</span><a href="extensions.html">' + S.extensions + '</a><span class="sep">/</span><a href="extensions.html?cat=' + esc(x.category) + '">' + esc(cat.name) + '</a><span class="sep">/</span><span>' + esc(x.name) + '</span></div>' +
502        '<div class="pr-hero">' +
503          '<div class="pr-gallery rv"><div class="pr-main"><img class="real" id="exMainImg" src="' + esc(x.screens[0]) + '" alt="' + esc(S.screenshotOf + x.name) + '"></div>' + thumbs + '</div>' +
504          '<div class="pr-info rv">' +
505            '<div class="tp-plat">' + esc(cat.icon) + ' ' + esc(x.catLabel) + '</div>' +
506            '<h1 class="pr-title">' + x.icon + ' ' + esc(x.name) + '</h1>' +
507            '<p class="pr-tagline">' + esc(x.lead) + '</p>' +
508            '<div class="pr-badges"><span class="tp-badge free">' + S.freeStart + '</span><span class="tp-badge new">' + esc(x.proLabel) + '</span></div>' +
509            freeProSummary(x) +
510            '<div class="pr-ctas">' +
511              installBtns(x, 'pr-buy') +
512              (hasCheckout(x)
513                ? '<div class="pr-pro">' + proCtas(x, 'pr-pro-btn') + '</div>'
514                : '') +
515              '<a class="pr-demo" href="tarification.html">' + S.comparePricing + '</a>' +
516            '</div>' +
517            '<div class="pr-guarantee">' +
518              '<span>' + S.guarantee1 + '</span>' +
519              '<span>' + S.guarantee2 + '</span>' +
520              '<span>' + S.guarantee3 + '</span>' +
521              '<span>' + S.guarantee4 + '</span>' +
522            '</div>' +
523          '</div>' +
524        '</div>' +
525      '</div>' +
526
527      '<div class="pr-sections">' +
528        '<div class="pr-col-main">' +
529          nxBlocFusion(x) +
530          '<section class="pr-sec rv"><h2 class="pr-sec-t">' + S.secDescription + '</h2><div class="pr-desc">' +
531            x.desc.map(function (p) { return '<p>' + esc(p) + '</p>'; }).join('') + '</div></section>' +
532          reviewsHTML(x) +
533          '<section class="pr-sec rv"><h2 class="pr-sec-t">' + S.secFreeOrPro + '</h2><div class="pr-tiers">' +
534            x.tiers.map(function (t, i) {
535              var parts = t.price.split('·');
536              var isPro = i === 1;
537              var cta = isPro ? proCtas(x, 'tier-cta') : '';
538              return '<div class="pr-tier' + (isPro ? ' pro' : '') + '"><div class="tn">' + esc(t.name) + '</div>' +
539                '<div class="tprice">' + esc(parts[0].trim()) + (parts[1] ? ' <small>· ' + esc(parts[1].trim()) + '</small>' : '') + '</div>' +
540                '<ul>' + t.items.map(function (li) { return '<li>' + esc(li) + '</li>'; }).join('') + '</ul>' + cta + '</div>';
541            }).join('') + '</div></section>' +
542          nxBlocPacks(x) +
543          '<section class="pr-sec rv"><h2 class="pr-sec-t">' + S.secFaq + '</h2><div class="pr-faq">' +
544            x.faq.map(function (f) {
545              return '<details><summary>' + esc(f.q) + '</summary><div class="fa">' + esc(f.a) + '</div></details>';
546            }).join('') + '</div></section>' +
547          (window.NexilyPonts ? NexilyPonts.bloc('ext', x.id) : '') +
548          '<section class="pr-sec rv"><div class="pr-kw">' +
549            x.kw.map(function (k) { return '<span>' + esc(k) + '</span>'; }).join('') + '</div></section>' +
550        '</div>' +
551        '<aside class="pr-col-side">' +
552          (metaBits.length ? '<div class="side-card rv"><div class="sc-t">' + S.sideBrief + '</div><ul class="pr-list">' +
553            metaBits.map(function (m) { return '<li>' + esc(m) + '</li>'; }).join('') + '</ul></div>' : '') +
554          '<div class="side-card rv"><div class="sc-t">' + S.sidePrivacy + '</div><ul class="pr-list">' +
555            '<li>' + S.privacy1 + '</li><li>' + S.privacy2 + '</li><li>' + S.privacy3 + '</li></ul></div>' +
556          '<div class="side-card rv"><div class="sc-t">' + S.sideInstall + '</div><ol class="pr-steps">' +
557            (x.store ? '<li>' + S.stepChrome + '</li>' : '') +
558            (x.storeFF ? '<li>' + S.stepFirefox + '</li>' : '') +
559            (!x.store && !x.storeFF ? '<li>' + S.stepNotPublished + '</li>' : '') +
560            '<li>' + S.stepPin + '</li></ol></div>' +
561        '</aside>' +
562      '</div>';
563
564    var mainImg = document.getElementById('exMainImg');
565    document.querySelectorAll('.pr-thumb').forEach(function (th) {
566      th.addEventListener('click', function () {
567        mainImg.src = th.getAttribute('data-src');
568        document.querySelectorAll('.pr-thumb').forEach(function (t2) { t2.classList.remove('on'); });
569        th.classList.add('on');
570      });
571    });
572
573    /* Extensions similaires (même univers) */
574    var simWrap = document.getElementById('exSimilar');
575    if (simWrap) {
576      var sims = PRODUCTS.filter(function (p) { return p.id !== x.id && p.category === x.category; });
577      if (sims.length < 3) {
578        PRODUCTS.forEach(function (p) {
579          if (sims.length < 3 && p.id !== x.id && sims.indexOf(p) === -1) sims.push(p);
580        });
581      }
582      sims = sims.slice(0, 3);
583      simWrap.innerHTML = '<h2 class="rv">' + S.similar + '</h2><div class="tpl-grid">' + sims.map(cardHTML).join('') + '</div>';
584      bindCards(simWrap);
585    }
586
587    /* CTA sticky */
588    var sticky = document.getElementById('exSticky');
589    if (sticky) {
590      sticky.querySelector('.st-ico').textContent = x.icon;
591      sticky.querySelector('.st-name').textContent = x.name;
592      sticky.querySelector('.st-price').textContent = S.free + ' · ' + x.proLabel;
593      sticky.querySelector('.st-actions').innerHTML =
594        installBtns(x, 'tp-buy') + proCtas(x, 'tp-buy pro');
595      var hero = document.querySelector('.pr-hero');
596      var footer = document.querySelector('footer.site-footer');
597      var pastHero = false, atFooter = false;
598      if ('IntersectionObserver' in window && hero) {
599        new IntersectionObserver(function (en) {
600          pastHero = !en[0].isIntersecting && en[0].boundingClientRect.bottom < 0;
601          sticky.classList.toggle('show', pastHero && !atFooter);
602        }, { threshold: 0 }).observe(hero);
603        if (footer) {
604          new IntersectionObserver(function (en) {
605            atFooter = en[0].isIntersecting;
606            sticky.classList.toggle('show', pastHero && !atFooter);
607          }, { threshold: 0 }).observe(footer);
608        }
609      }
610    }
611
612    reveal();
613  }
614})();

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.