PageSourceSearch

https://www.datafirefly.com/wp-content/themes/datafirefly/assets/js/theme.js?ver=1.30.1

js datafirefly.com collected 2026-09-24 08:45:59 UTC 25,114 bytes, 661 lines download raw bytes

1/* global DataFirefly */
2/**
3 * DataFirefly — frontend behaviour.
4 */
5( function () {
6	'use strict';
7
8	const L10n = window.DataFirefly || {};
9
10	/* ---------- Theme (light / dark) ---------- */
11	function initTheme() {
12		const html = document.documentElement;
13		// Cookie lookup.
14		const m = document.cookie.match( /(?:^|;\s*)df_theme=([^;]+)/ );
15		const stored = m ? m[ 1 ] : null;
16		// Respect user choice, else fall back to OS.
17		const initial = stored || ( window.matchMedia && window.matchMedia( '(prefers-color-scheme: dark)' ).matches ? 'dark' : 'light' );
18		html.setAttribute( 'data-theme', initial );
19
20		document.querySelectorAll( '[data-df-theme-toggle]' ).forEach( ( btn ) => {
21			btn.addEventListener( 'click', () => {
22				const cur  = html.getAttribute( 'data-theme' ) === 'dark' ? 'light' : 'dark';
23				html.setAttribute( 'data-theme', cur );
24				document.cookie = 'df_theme=' + cur + ';path=/;max-age=31536000;SameSite=Lax';
25			} );
26		} );
27	}
28
29	/* ---------- Language switcher ---------- */
30	function initLangSwitcher() {
31		document.querySelectorAll( '.df-lang-switch' ).forEach( ( sw ) => {
32			const btn = sw.querySelector( '.df-lang-btn' );
33			if ( ! btn ) { return; }
34			btn.addEventListener( 'click', ( e ) => {
35				e.preventDefault();
36				sw.classList.toggle( 'is-open' );
37			} );
38		} );
39		document.addEventListener( 'click', ( e ) => {
40			document.querySelectorAll( '.df-lang-switch.is-open' ).forEach( ( sw ) => {
41				if ( ! sw.contains( e.target ) ) { sw.classList.remove( 'is-open' ); }
42			} );
43		} );
44	}
45
46	/* ---------- Newsletter form ---------- */
47	function initNewsletter() {
48		document.querySelectorAll( '[data-df-newsletter]' ).forEach( ( form ) => {
49			form.addEventListener( 'submit', ( e ) => {
50				e.preventDefault();
51				const email = form.querySelector( 'input[type=email]' ).value.trim();
52				const status = form.querySelector( '.df-news-status' );
53				const btn   = form.querySelector( 'button[type=submit]' );
54				if ( ! email ) { return; }
55				status.textContent = L10n.i18n?.subscribing || 'Subscribing…';
56				status.classList.remove( 'is-ok', 'is-err' );
57				btn.disabled = true;
58				fetch( ( L10n.restUrl || '/wp-json/datafirefly/v1/' ) + 'subscribe', {
59					method:      'POST',
60					credentials: 'same-origin',
61					headers:     { 'Content-Type': 'application/json' },
62					body:        JSON.stringify( { email } ),
63				} ).then( ( r ) => r.json() ).then( ( j ) => {
64					if ( j.ok ) {
65						status.textContent = L10n.i18n?.subscribed || 'Thanks — keep an eye on your inbox.';
66						status.classList.add( 'is-ok' );
67						form.reset();
68					} else {
69						status.textContent = j.error || ( L10n.i18n?.subscribeError || 'Could not subscribe. Please try again.' );
70						status.classList.add( 'is-err' );
71					}
72				} ).catch( () => {
73					status.textContent = L10n.i18n?.subscribeError || 'Could not subscribe. Please try again.';
74					status.classList.add( 'is-err' );
75				} ).finally( () => {
76					btn.disabled = false;
77				} );
78			} );
79		} );
80	}
81
82	/* ---------- FAQ — close siblings when one opens ---------- */
83	function initFaq() {
84		document.querySelectorAll( '.df-faq-list' ).forEach( ( list ) => {
85			list.addEventListener( 'toggle', ( e ) => {
86				const t = e.target;
87				if ( t.tagName === 'DETAILS' && t.open ) {
88					list.querySelectorAll( 'details[open]' ).forEach( ( d ) => {
89						if ( d !== t ) { d.open = false; }
90					} );
91				}
92			}, true );
93		} );
94	}
95
96	/* ---------- Hero firefly twinkle (cheap) ---------- */
97	function initFireflies() {
98		document.querySelectorAll( '.df-hero-fly' ).forEach( ( el, i ) => {
99			el.style.animationDelay = ( i * 0.8 ) + 's';
100		} );
101	}
102
103	/* ---------- Cart fragments (vanilla, sans wc-cart-fragments) ---------- *
104	 *
105	 * v1.24.1 — wc-cart-fragments n'est plus chargé (perf : jQuery + un POST
106	 * get_refreshed_fragments sur chaque page vue). On applique nous-mêmes
107	 * les fragments { 'sélecteur': 'html' } renvoyés par les endpoints
108	 * wc-ajax (add_to_cart / remove_from_cart / get_refreshed_fragments).
109	 */
110	let dfApplyingFragments = false; // guard : évite la double application via l'écouteur de compat jQuery
111	const DF_FRAG_KEY = 'df_cart_fragments';
112
113	function applyFragments( fragments, cartHash ) {
114		if ( ! fragments ) { return; }
115		dfApplyingFragments = true;
116		Object.keys( fragments ).forEach( ( selector ) => {
117			document.querySelectorAll( selector ).forEach( ( el ) => {
118				el.outerHTML = fragments[ selector ];
119			} );
120		} );
121		dfApplyingFragments = false;
122		// Cache sessionStorage : les pages suivantes servies d'un cache
123		// pleine page pourront ré-appliquer sans requête (cf. initCartSync).
124		if ( undefined !== cartHash ) {
125			try {
126				sessionStorage.setItem( DF_FRAG_KEY, JSON.stringify( { hash: cartHash || '', fragments } ) );
127			} catch ( e ) { /* quota / private mode — non bloquant */ }
128		}
129		document.body.dispatchEvent( new CustomEvent( 'df:fragments_applied', { detail: { fragments } } ) );
130	}
131
132	function readCookie( name ) {
133		const m = document.cookie.match( new RegExp( '(?:^|;\\s*)' + name + '=([^;]+)' ) );
134		return m ? decodeURIComponent( m[ 1 ] ) : '';
135	}
136
137	/**
138	 * Synchro au chargement : si la page vient d'un cache pleine page, le
139	 * drawer / badge rendus côté serveur peuvent être périmés. Le cookie
140	 * `woocommerce_cart_hash` (posé par WC à chaque changement de panier)
141	 * fait foi : s'il diffère du hash rendu (L10n.cartHash), on ré-applique
142	 * depuis sessionStorage si le hash y correspond (zéro requête), sinon
143	 * on rafraîchit UNE fois via get_refreshed_fragments. Cas nominal (pas
144	 * de panier, ou page rendue à jour) : rien.
145	 */
146	function initCartSync() {
147		const cookieHash   = readCookie( 'woocommerce_cart_hash' );
148		const renderedHash = L10n.cartHash || '';
149		if ( cookieHash === renderedHash ) {
150			try { if ( ! cookieHash ) { sessionStorage.removeItem( DF_FRAG_KEY ); } } catch ( e ) {}
151			return;
152		}
153		try {
154			const stored = JSON.parse( sessionStorage.getItem( DF_FRAG_KEY ) || 'null' );
155			if ( stored && stored.hash === cookieHash && stored.fragments ) {
156				applyFragments( stored.fragments );
157				return;
158			}
159		} catch ( e ) { /* JSON corrompu → on refetch */ }
160		refreshCartFragments();
161	}
162
163	async function refreshCartFragments() {
164		const drawer = document.querySelector( '[data-df-cart-drawer]' );
165		if ( drawer ) { drawer.classList.add( 'is-loading' ); }
166		try {
167			const resp = await fetch( wcAjaxUrl( 'get_refreshed_fragments' ), {
168				method:      'POST',
169				credentials: 'same-origin',
170				headers:     { 'X-Requested-With': 'XMLHttpRequest' },
171			} );
172			const data = await resp.json();
173			if ( data && data.fragments ) {
174				applyFragments( data.fragments, data.cart_hash || readCookie( 'woocommerce_cart_hash' ) );
175			}
176		} catch ( err ) {
177			// Réseau KO : on garde le rendu serveur, pas bloquant.
178		} finally {
179			const d = document.querySelector( '[data-df-cart-drawer]' );
180			if ( d ) { d.classList.remove( 'is-loading' ); }
181		}
182	}
183	window.dfRefreshCart = refreshCartFragments;
184
185	/* ---------- Side cart drawer ---------- */
186	function initCartDrawer() {
187		const drawer = document.querySelector( '[data-df-cart-drawer]' );
188		if ( ! drawer ) { return; }
189
190		// Defensive: move the drawer to be a direct child of <body> so no
191		// transformed / overflow:hidden ancestor can break its fixed positioning.
192		if ( drawer.parentElement !== document.body ) {
193			document.body.appendChild( drawer );
194		}
195
196		function open() {
197			drawer.classList.add( 'is-open' );
198			drawer.setAttribute( 'aria-hidden', 'false' );
199			document.body.classList.add( 'df-cart-open' );
200		}
201		function close() {
202			drawer.classList.remove( 'is-open' );
203			drawer.setAttribute( 'aria-hidden', 'true' );
204			document.body.classList.remove( 'df-cart-open' );
205		}
206		window.dfCartDrawer = { open, close };
207
208		// Click on the header cart button opens the drawer. Le drawer est
209		// rendu côté serveur et synchronisé par initCartSync() — plus besoin
210		// de wc-cart-fragments pour l'ouvrir (v1.24.1).
211		document.querySelectorAll( '[data-df-cart-toggle]' ).forEach( ( a ) => {
212			a.addEventListener( 'click', ( e ) => {
213				e.preventDefault();
214				open();
215			} );
216		} );
217
218		// Close when clicking the overlay or X button
219		drawer.querySelectorAll( '[data-df-cart-close]' ).forEach( ( el ) => {
220			el.addEventListener( 'click', ( e ) => { e.preventDefault(); close(); } );
221		} );
222
223		// ESC closes
224		document.addEventListener( 'keydown', ( e ) => {
225			if ( 'Escape' === e.key && drawer.classList.contains( 'is-open' ) ) { close(); }
226		} );
227
228		// Auto-open quand un produit est ajouté (nos flows vanilla).
229		document.body.addEventListener( 'df:added_to_cart', open );
230
231		// Compat jQuery (v1.24.1) : les boutons de loop WC (wc-add-to-cart.js)
232		// déclenchent `added_to_cart` avec les fragments en payload —
233		// cart-fragments.js les appliquait, on prend le relais. Le guard
234		// dfApplyingFragments évite la double application quand c'est NOUS
235		// qui avons déclenché l'événement après avoir déjà appliqué.
236		if ( window.jQuery ) {
237			window.jQuery( document.body ).on( 'added_to_cart', function ( e, fragments, cartHash ) {
238				if ( ! dfApplyingFragments && fragments ) { applyFragments( fragments, cartHash ); }
239				open();
240			} );
241			window.jQuery( document.body ).on( 'removed_from_cart', function ( e, fragments, cartHash ) {
242				if ( ! dfApplyingFragments && fragments ) { applyFragments( fragments, cartHash ); }
243			} );
244			// Visual loading state during AJAX mini-cart refresh
245			window.jQuery( document.body ).on( 'wc_fragments_loaded wc_fragments_refreshed', function () {
246				drawer.classList.remove( 'is-loading' );
247			} );
248			window.jQuery( document.body ).on( 'wc_fragment_refresh adding_to_cart', function () {
249				drawer.classList.add( 'is-loading' );
250			} );
251		}
252	}
253
254	/* ---------- Shared AJAX add-to-cart routine ---------------- *
255	 *
256	 * v1.26.1 — extrait du handler `submit` pour être réutilisable par les
257	 * boutons de liste `[data-df-add]` (partial product/row), qui ne sont
258	 * plus des <form> : voir template-parts/product/row.php.
259	 *
260	 * @param {string}   productId ID du produit.
261	 * @param {FormData} fd        Charge utile (doit contenir product_id).
262	 * @param {Element}  button    Bouton à passer en état busy/added.
263	 */
264	async function dfAjaxAdd( productId, fd, button ) {
265		try {
266			const url  = wcAjaxUrl( 'add_to_cart' );
267			const resp = await fetch( url, {
268				method:      'POST',
269				body:        fd,
270				credentials: 'same-origin',
271				headers:     { 'X-Requested-With': 'XMLHttpRequest' },
272			} );
273			const data = await resp.json();
274
275			if ( data && data.error && data.product_url ) {
276				window.location = data.product_url;
277				return;
278			}
279
280			if ( data && data.fragments ) {
281				applyFragments( data.fragments, data.cart_hash );
282			}
283
284			// Événement vanilla : ouvre le drawer (voir initCartDrawer).
285			document.body.dispatchEvent( new CustomEvent( 'df:added_to_cart', {
286				detail: { fragments: data && data.fragments, cartHash: data && data.cart_hash },
287			} ) );
288
289			// Compat jQuery : d'autres scripts (WC, plugins) écoutent ces
290			// événements. dfApplyingFragments reste posé le temps du
291			// trigger pour que notre propre écouteur de compat ne
292			// ré-applique pas les fragments déjà appliqués ci-dessus.
293			if ( window.jQuery ) {
294				dfApplyingFragments = true;
295				try {
296					window.jQuery( document.body ).trigger( 'wc_fragments_refreshed' );
297					window.jQuery( document.body ).trigger( 'added_to_cart', [
298						data && data.fragments,
299						data && data.cart_hash,
300						button ? window.jQuery( button ) : window.jQuery(),
301					] );
302				} finally {
303					dfApplyingFragments = false;
304				}
305			}
306
307			// Belt-and-suspenders : if any WC "added to cart" notice DOM
308			// element slipped through (legacy filter conflicts, plugin
309			// notices), strip them. The drawer is showing the cart already.
310			document.querySelectorAll( '.woocommerce-message, .wc-block-components-notice-banner.is-success' ).forEach( ( el ) => {
311				if ( /(panier|cart|carrito|warenkorb)/i.test( el.textContent || '' ) ) {
312					el.remove();
313				}
314			} );
315
316			if ( button ) {
317				button.classList.remove( 'df-busy' );
318				button.classList.add( 'df-added' );
319				button.disabled = false;
320				setTimeout( () => button.classList.remove( 'df-added' ), 2000 );
321			}
322		} catch ( err ) {
323			if ( button ) {
324				button.classList.remove( 'df-busy' );
325				button.disabled = false;
326			}
327			console.error( '[df] add-to-cart failed', err );
328		}
329	}
330
331	/* ---------- AJAX add-to-cart on any custom form ----------- *
332	 *
333	 * The DataFirefly theme uses several custom forms with an
334	 * `[name="add-to-cart"]` hidden input (df-sb-form on single product,
335	 * forms on hero / row partials, etc.) — none of them have the WC
336	 * default `class="cart"`. We delegate on submit at document level so
337	 * we catch all of them without enumerating selectors.
338	 *
339	 * We intercept the submit, do the AJAX ourselves, apply fragments,
340	 * and trigger `added_to_cart` so the drawer opens via its existing
341	 * listener.
342	 *
343	 * Skipped : grouped products (.grouped_form), forms with the opt-out
344	 * attribute `data-df-disable-ajax`, and submits on /cart/ or /checkout/
345	 * (where WC's own logic should run).
346	 *
347	 * v1.8.7 : double-add bug fixes ────────────────────────────────────
348	 *   1. stopImmediatePropagation() so jQuery handlers bound by WC on
349	 *      `body` (wc-cart-fragments, wc-add-to-cart) cannot re-process
350	 *      the submit and fire a second AJAX call.
351	 *   2. In-flight guard via `data-df-submitting` on the <form> — even
352	 *      if the user double-clicks faster than the disabled flip, we
353	 *      bail out on the second submit.
354	 *   3. Idempotent init — if init runs twice (DOMContentLoaded fired
355	 *      after the listener was already wired by some race), we attach
356	 *      only once via a window flag.
357	 */
358	function initSingleProductAjax() {
359		// Idempotent : never attach twice.
360		if ( window.__dfAtcWired ) { return; }
361		window.__dfAtcWired = true;
362
363		// Don't intercept on cart / checkout — WC's cart.js owns those forms.
364		if ( document.body.classList.contains( 'woocommerce-cart' ) ||
365		     document.body.classList.contains( 'woocommerce-checkout' ) ) {
366			return;
367		}
368
369		document.addEventListener( 'submit', async function ( e ) {
370			const form = e.target;
371			if ( ! ( form instanceof HTMLFormElement ) ) { return; }
372			if ( form.classList.contains( 'grouped_form' ) ) { return; }
373			if ( form.hasAttribute( 'data-df-disable-ajax' ) ) { return; }
374
375			const productInput = form.querySelector( '[name="add-to-cart"]' );
376			if ( ! productInput ) { return; }
377			const productId = productInput.value;
378			if ( ! productId || '0' === productId ) { return; }
379
380			// In-flight guard — drop any concurrent submit on the same form.
381			if ( form.hasAttribute( 'data-df-submitting' ) ) {
382				e.preventDefault();
383				e.stopImmediatePropagation();
384				return;
385			}
386			form.setAttribute( 'data-df-submitting', '1' );
387
388			// Block native submit AND any other listener (jQuery on body, WC
389			// frontend handlers) from re-processing this same event.
390			e.preventDefault();
391			e.stopImmediatePropagation();
392
393			const button = form.querySelector( 'button[type=submit], .single_add_to_cart_button, .df-sb-ad
393d' );
394			if ( button ) {
395				button.classList.add( 'df-busy' );
396				button.disabled = true;
397			}
398
399			const fd = new FormData( form );
400			// CRITICAL — strip the `add-to-cart` field before sending.
401			//
402			// WC has TWO server-side handlers that both react to that param :
403			//   1. WC_Form_Handler::add_to_cart_action()  (wp_loaded   prio 20)
404			//      reads $_REQUEST['add-to-cart'] regardless of GET/POST/AJAX.
405			//   2. WC_AJAX::add_to_cart()                  (template_redirect)
406			//      reads $_POST['product_id'].
407			//
408			// If both fire, the cart receives qty=1 twice → bug "ajouté ×2".
409			// We send only `product_id` to keep handler #2 active and starve #1.
410			fd.delete( 'add-to-cart' );
411			fd.set( 'product_id', productId );
412
413			try {
414				await dfAjaxAdd( productId, fd, button );
415			} finally {
416				form.removeAttribute( 'data-df-submitting' );
417			}
418		}, true );  // capture phase to fire before any other listener
419	}
420
421	/* ---------- Loop "Add to cart" buttons ([data-df-add]) ------ *
422	 *
423	 * v1.26.1 — CORRECTIF CRITIQUE.
424	 *
425	 * Les lignes de la vue liste (template-parts/product/row.php) sont
426	 * rendues À L'INTÉRIEUR du <form data-df-cat-filters> de la page
427	 * catégorie / boutique. Elles embarquaient un <form> imbriqué —
428	 * invalide en HTML5 : le parser ignore la balise ouvrante et le
429	 * </form> ferme le formulaire PARENT. Résultat :
430	 *
431	 *   1. l'<input name="add-to-cart"> se retrouvait rattaché au
432	 *      formulaire de FILTRES → new FormData(form) le récupérait →
433	 *      chaque clic sur un filtre partait avec &add-to-cart=ID et
434	 *      ajoutait le produit au panier ;
435	 *   2. le pointeur de formulaire étant nullifié après le premier
436	 *      </form>, les boutons des lignes suivantes ajoutaient le
437	 *      produit de la PREMIÈRE ligne.
438	 *
439	 * Le partial rend désormais un simple <a data-df-add="ID"> — aucun
440	 * contrôle de formulaire, donc invisible pour FormData. On intercepte
441	 * le clic ici ; sans JS le href reste un add-to-cart fonctionnel.
442	 */
443	function initLoopAddButtons() {
444		if ( window.__dfLoopAtcWired ) { return; }
445		window.__dfLoopAtcWired = true;
446
447		document.addEventListener( 'click', async function ( e ) {
448			const el = e.target.closest( '[data-df-add]' );
449			if ( ! el ) { return; }
450
451			// Nouvel onglet / clic milieu : comportement natif du lien.
452			if ( e.metaKey || e.ctrlKey || e.shiftKey || 1 === e.button ) { return; }
453
454			const productId = el.getAttribute( 'data-df-add' );
455			if ( ! productId || '0' === productId ) { return; }
456
457			e.preventDefault();
458			e.stopPropagation();
459
460			// Garde anti double-clic.
461			if ( el.hasAttribute( 'data-df-submitting' ) ) { return; }
462			el.setAttribute( 'data-df-submitting', '1' );
463
464			el.classList.add( 'df-busy' );
465
466			const fd = new FormData();
467			fd.set( 'product_id', productId );
468			fd.set( 'quantity', el.getAttribute( 'data-quantity' ) || '1' );
469
470			try {
471				await dfAjaxAdd( productId, fd, el );
472			} finally {
473				el.removeAttribute( 'data-df-submitting' );
474				el.classList.remove( 'df-busy' );
475			}
476		}, true ); // capture : avant wc-add-to-cart.js s'il est présent
477	}
478
479	/* ---------- AJAX remove-from-cart inside the drawer ---------- *
480	 *
481	 * WC's cart.js only handles `a.remove` on the /cart/ and /checkout/ pages.
482	 * Anywhere else, clicking ✕ in the mini-cart follows ?remove_item=KEY,
483	 * triggers a full page reload, and closes the drawer.
484	 *
485	 * We delegate clicks on `a.remove` inside the drawer, do the AJAX
486	 * ourselves, and refresh fragments — the drawer stays open.
487	 */
488	function initDrawerRemoveAjax() {
489		const drawer = document.querySelector( '[data-df-cart-drawer]' );
490		if ( ! drawer ) { return; }
491
492		drawer.addEventListener( 'click', async function ( e ) {
493			const link = e.target.closest( 'a.remove' );
494			if ( ! link ) { return; }
495
496			const cartItemKey = link.getAttribute( 'data-cart_item_key' );
497			if ( ! cartItemKey ) { return; }   // unknown payload → safer to let it navigate
498
499			e.preventDefault();
500			drawer.classList.add( 'is-loading' );
501
502			try {
503				const url  = wcAjaxUrl( 'remove_from_cart' );
504				const body = new URLSearchParams( { cart_item_key: cartItemKey } );
505				const resp = await fetch( url, {
506					method:      'POST',
507					body:        body,
508					credentials: 'same-origin',
509					headers: {
510						'X-Requested-With': 'XMLHttpRequest',
511						'Content-Type':     'application/x-www-form-urlencoded; charset=UTF-8',
512					},
513				} );
514				const data = await resp.json();
515
516				if ( data && data.fragments ) {
517					applyFragments( data.fragments, data.cart_hash );
518					document.body.dispatchEvent( new CustomEvent( 'df:removed_from_cart', {
519						detail: { fragments: data.fragments, cartHash: data.cart_hash },
520					} ) );
521					if ( window.jQuery ) {
522						dfApplyingFragments = true;
523						try {
524							window.jQuery( document.body ).trigger( 'wc_fragments_refreshed' );
525							window.jQuery( document.body ).trigger( 'removed_from_cart', [
526								data.fragments, data.cart_hash,
527							] );
528						} finally {
529							dfApplyingFragments = false;
530						}
531					}
532				}
533			} catch ( err ) {
534				// Fallback to the link's href so the user can still remove the item.
535				window.location = link.getAttribute( 'href' );
536			} finally {
537				drawer.classList.remove( 'is-loading' );
538			}
539		} );
540	}
541
542	function wcAjaxUrl( endpoint ) {
543		// v1.24.1 — endpoint localisé par le thème : dispo sur TOUTES les
544		// pages, y compris hors WooCommerce (wc_*_params n'y existent pas).
545		if ( L10n.wcAjax ) {
546			return L10n.wcAjax.toString().replace( '%%endpoint%%', endpoint );
547		}
548		if ( window.wc_cart_fragments_params && window.wc_cart_fragments_params.wc_ajax_url ) {
549			return window.wc_cart_fragments_params.wc_ajax_url.toString().replace( '%%endpoint%%', endpoint );
550		}
551		if ( window.wc_add_to_cart_params && window.wc_add_to_cart_params.wc_ajax_url ) {
552			return window.wc_add_to_cart_params.wc_ajax_url.toString().replace( '%%endpoint%%', endpoint );
553		}
554		return '/?wc-ajax=' + endpoint;
555	}
556
557	/* ---------- Row-card click navigation -------------------- *
558	 *
559	 * The category list-view row (`.df-mrow[data-df-row-link]`) used to be
560	 * a wrapping <a>, which meant we had to nest the buy <form> inside it
561	 * (invalid HTML5). We replaced it with a <div> + delegated click; this
562	 * function is the JS half of that contract.
563	 *
564	 * Click on the row navigates to the product, EXCEPT when the click
565	 * originates from an interactive child (a button, a link, a form
566	 * control) — those handle themselves.
567	 */
568	function initRowLinks() {
569		document.addEventListener( 'click', function ( e ) {
570			const row = e.target.closest( '.df-mrow[data-df-row-link]' );
571			if ( ! row ) { return; }
572			// Let real interactive children handle their own click.
573			if ( e.target.closest( 'a, button, form, input, [role="button"]' ) ) { return; }
574			// Honor modifier keys / middle-click for new-tab UX.
575			if ( e.metaKey || e.ctrlKey || e.shiftKey || 1 === e.button ) { return; }
576			const href = row.getAttribute( 'data-df-row-link' );
577			if ( href ) { window.location = href; }
578		} );
579	}
580
581	/* ---------- Team bio "Read more / Read less" toggle ----------
582	 *
583	 * Pour chaque bio de la page À propos :
584	 *   1. Détecte si la bio dépasse vraiment la limite visuelle (clamp CSS
585	 *      à 4 lignes). On mesure scrollHeight vs clientHeight + 1px de
586	 *      tolérance pour éviter les faux-positifs sur les bios qui
587	 *      remplissent pile 4 lignes.
588	 *   2. Si NON → masque le bouton + retire le clamp (sinon on aurait une
589	 *      bio raccourcie SANS toggle, ce qui n'a aucun intérêt).
590	 *   3. Si OUI → branche le toggle. À chaque clic : flip de la classe
591	 *      .is-expanded sur la bio, flip du texte du bouton via les
592	 *      data-label-more / data-label-less, flip de aria-expanded.
593	 *
594	 * Recalcul au resize : sur passage portrait↔paysage mobile la mesure
595	 * peut changer (font-size CSS responsive). On debounce 150ms.
596	 */
597	function initTeamBioToggle() {
598		const toggles = document.querySelectorAll( '.df-team-bio-toggle' );
599		if ( ! toggles.length ) { return; }
600
601		function evaluate( btn ) {
602			const bio = btn.previousElementSibling;
603			if ( ! bio || ! bio.classList.contains( 'df-team-bio' ) ) { return; }
604
605			// On retire d'abord is-expanded pour mesurer dans l'état clampé
606			const wasExpanded = bio.classList.contains( 'is-expanded' );
607			bio.classList.remove( 'is-expanded' );
608
609			const overflows = bio.scrollHeight > bio.clientHeight + 1;
610			if ( overflows ) {
611				btn.hidden = false;
612				bio.classList.add( 'df-team-bio-clamp' );
613			} else {
614				btn.hidden = true;
615				bio.classList.remove( 'df-team-bio-clamp' );
616			}
617
618			// Restaure l'état si l'utilisateur avait déjà déplié
619			if ( wasExpanded && overflows ) {
620				bio.classList.add( 'is-expanded' );
621			}
622		}
623
624		toggles.forEach( ( btn ) => {
625			evaluate( btn );
626			btn.addEventListener( 'click', () => {
627				const bio = btn.previousElementSibling;
628				if ( ! bio ) { return; }
629				const expanded = bio.classList.toggle( 'is-expanded' );
630				btn.setAttribute( 'aria-expanded', expanded ? 'true' : 'false' );
631				btn.textContent = expanded
632					? ( btn.dataset.labelLess || 'Read less' )
633					: ( btn.dataset.labelMore || 'Read more' );
634			} );
635		} );
636
637		// Re-évaluer si la fenêtre change de taille (debounced).
638		let resizeTimer = null;
639		window.addEventListener( 'resize', () => {
640			clearTimeout( resizeTimer );
641			resizeTimer = setTimeout( () => {
642				toggles.forEach( evaluate );
643			}, 150 );
644		} );
645	}
646
647	document.addEventListener( 'DOMContentLoaded', () => {
648		initTheme();
649		initLangSwitcher();
650		initNewsletter();
651		initFaq();
652		initFireflies();
653		initCartDrawer();
654		initCartSync();
655		initSingleProductAjax();
656		initLoopAddButtons();
657		initRowLinks();
658		initDrawerRemoveAjax();
659		initTeamBioToggle();
660	} );
661} )();

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.