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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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.