" (meme source que build_post_name()) et promo-page.js passe // ?lang= a l'appel -- indispensable pour que la page EN cible une AUTRE offre EN, pas FR. // (2) H2 FAQ disait "Code promo {name} -- questions frequentes" sur les pages SANS code (ex. // reduction-monty-esim) : $faq_title_text lisait TOUJOURS le titre "code", jamais un equivalent // no-code. Nouvelle cle 'faq_title_text_nocode' (ui_nocode, 26 langues, mbs-promo-nocode-i18n.php) // -- meme gabarit que le titre "code", seul le mot "code" est remplace par le mot "reduction/ // discount" DEJA utilise ailleurs dans le no-code (coupon_sub_nocode), jamais un mot invente. // build_promo_faq() (deja modifiee 1.11.12) n'a pas besoin d'un nouveau parametre -- juste la ligne // $faq_title_text branchee sur $is_no_code, meme patron que le reste de $ui deja surcharge. // (3) Meta description no-code trop longue (~200 caracteres rendus sur monty-esim, tronquee dans // la SERP Google ~155-160 affiches) alors que les pages a code font ~141 (bon) : 24 langues // raccourcies a ~120-155 caracteres rendus (garde l'essentiel : reduction -X%, nom fournisseur, // "sans code"/"reduction automatique", verifie {mois} {annee} -- retire le detail "via notre // lien"/"par MyBestSim" quand necessaire pour tenir la longueur). ja/ko deja courts (~98-103 // caracteres), non touches -- proportionnes a leur propre gabarit "code" (87-93). // gabarit NO-CODE (reduction) -- verifie en direct : code-promo-japan-wireless a une FAQPage // schema (28 mentions), reduction-monty-esim (no-code) en avait ZERO. Root cause : le no-code // reutilisait le meme FAQ "code" (9 questions) en RETIRANT purement et simplement les 8 questions // a propos d'un code litteral (exists/how_to/expiry/reusable/why_not/stackable/plans/terms) -- // ne laissant QUE 'discount', lui-meme EGALEMENT exclu des qu'il n'y a pas de vrai catalogue ou // de reduction exploitable en simulation -> 0 question, exactement le bug constate (monty-esim, // lotso-travel, mobimatteer). Fix : NOUVEAU mbs-promo-nocode-i18n.php 'faq_nocode' (8 questions, // 25 langues, adaptees "reduction automatique via le lien" -- jamais "comment utiliser le code") // ; build_promo_faq() choisit desormais la SOURCE (code vs no-code) via un nouveau parametre // $is_no_code au lieu de retirer des cles -- le chemin code-based est INCHANGE (meme source, // 9 questions, verifie par harnais). Trouve EN ECRIVANT ce contenu : dedupe_esim_repeat() // (class-provider-promo-pages-generator.php) avait un vrai trou -- le \b final ne matchait // jamais apres un "eSIM" au pluriel anglais/allemand ("eSIM eSIMs", deja present en prod dans // le lead_html DE) NI apres un "eSIM" immediatement suivi d'une particule coreenne sans espace // -- (s?) ajoute avant le \b final resout le cas pluriel (garde l'ancien comportement intact, // verifie par harnais sur les 12 fournisseurs deja corriges). // 1.11.9 (assignation) a bien résolu l'auto-guérison (confirmé en live : un orphelin esim-agora // corrigé en 30s) mais mesure live a trouvé le VRAI blocage restant : mbs_promo_gen_needed_% // contenait ~124 fournisseurs dont ~117 DÉJÀ 25/25 -- WP-Cron passait son temps à re-hash- // skipper ces complets, tenait les verrous, affamait les ~10 réellement incomplets (busy // permanent). Root cause du bulk-enqueue : run_monthly_refresh() bouclait sur TOUS les // fournisseurs ayant déjà une page promo (~124-130) en UNE SEULE exécution PHP, tous les 30 // jours -- pour rouler la date "vérifié" (verified_at), un VRAI changement de donnée // intentionnel, donc jamais absorbé par une garde "skip si inchangé". Fix (class-provider- // promo-pages-generator.php) : (a) NOUVEAU promo_provider_up_to_date() -- 25/25 pages réelles // + hash à jour (sonde 1 langue, même donnée pour les 25) -- réutilisé par (b) // enqueue_promo_generation() : skip TOTAL (aucune option créée) si le fournisseur est déjà // complet ET inchangé -- un re-déclenchement redondant (publish, reconcile incompleteMax) // devient un vrai no-op ; (c) run_monthly_refresh() : DAILY (pas 30 jours) + batché // (MONTHLY_REFRESH_BATCH_SIZE=8/jour, curseur round-robin persisté) -- ~130 fournisseurs // couverts en ~17 jours, la file ne contient plus jamais qu'un petit lot à la fois ; (d) // NOUVEAU endpoint admin/purge-complete-promo-queue -- purge SÉLECTIVE (promo_provider_up_ // to_date() sur CHAQUE entrée) des complets déjà enfilés MAINTENANT, ne touche JAMAIS un // fournisseur incomplet ni une page publiée -- contrairement à clear-promo-queue (existant, // tout efface, y compris le travail en cours réel). // 1.11.10 (2026-08-28) : nettoyage post-migration Yesim // (in-house -> Impact, cf. cloudflare/src/utils/affiliate-network-detect.txt) -- retrait de la // detection/attribution Yesim in-house (isYesimUrl, withYesimAttribution, sub_id) devenue morte // dans affiliate-network-detect.js, provider-tracking.js, banner-tracking.js et promo-page.js // (isImpactUrl est teste en premier partout, 'yesim'/'yesim-banner' etant desormais dans // IMPACT_SLUGS). Durcissement create_redirect() (class-api.php) : match_data.flag_query=pass // explicite ajoute au $details de l'upsert Redirection -- sans ca, un upsert future pouvait // reinitialiser flag_query et casser le forwarding sharedid/aesubid/ws sur TOUS les cloaks // /affiliation/ (cf. docs/affiliate-placement-attribution.md §2ter/§9). // 1.11.9 (2026-08-28, Etienne, "plus jamais bloqué" -- assignation) : // 1.11.8 (débit) a débloqué le rythme mais mesure live post-déploiement (WP REST direct, // cache-indépendant) a trouvé DEUX problèmes liés : (1) 0 convergence réelle en 20 min, // gen_status="busy" en continu (verrou tenu par WP-Cron) ; (2) pages créées mal-assignées // en langue Polylang -- monty-esim/lotso-travel : pages au slug ro/pt correctement formé, // mais Polylang les tague 'fr' (langue par défaut), FLAKY (la même page pt tantôt correcte // tantôt pas). Root cause UNIFIÉE : find_promo_page_by_slug()/generate_one_language() ne // se fiaient QU'À pll_get_post_language() pour décider "cette langue a-t-elle déjà sa // page ?" -- une page mal-assignée devenait invisible à ce check pour TOUJOURS -> jamais // adoptée -> re-créée en boucle (le doublon-poubelle observé, #222029/#222031 sous /fr/) // -> ET explique la non-convergence (WP-Cron trouve toujours "du travail" pour ces // fournisseurs, tourne en boucle quasi continue, affamant le verrou pour le Worker). // Fix (class-provider-promo-pages-generator.php) : (a) find_promo_page_by_slug()/ // generate_one_language() se fient désormais à `_mbs_page_language` postmeta (posé une // fois, déterministe, jamais faux) comme source de vérité, Polylang en repli seulement ; // (b) assign_promo_page_language() vérifie désormais via une lecture DB DIRECTE // (get_post_language_from_db(), contourne tout cache) au lieu de pll_get_post_language() // seul -- confirme/corrige l'hypothèse déjà documentée dans clear_polylang_language_cache() // ("le terme n'a jamais été commité, seule sa lecture semblait à jour") -- retries 3->5 // avec un backoff de 50ms (laisse une vraie propagation asynchrone se résorber) ; (c) // auto-guérison : une page déjà mal-assignée est corrigée la prochaine fois qu'un drain // la touche (generate_one_language() re-vérifie/ré-applique avant update_single_promo_ // page()), pas seulement les nouvelles créations ; (d) rest_pause_promo_autodrain() // couvre désormais AUSSI rest_drain_promo_now() (avant : cron seulement) -- un seul // interrupteur WP-side stoppe complètement la génération, plus besoin de couper // AUTOPUBLISH_PROMO_ENABLED côté Worker en plus pour un arrêt complet. // 1.11.8 (2026-08-28, Etienne, "plus jamais bloqué" -- débit) : // 1.11.7 a stoppé la boucle infinie (confirmé en prod : monty-esim ne meurt plus sur `cs`) // mais le débit restait inutilisable (~1 langue/3min, gen_status="busy" en continu) -- // DEUX causes, corrigées ensemble dans class-provider-promo-pages-generator.php : // (1) drain_provider_batch() comptait CHAQUE langue (y compris les hash-skips, gratuits/ // sans écriture) contre le plafond par appel ($processed) -- un fournisseur partiel a ses // langues déjà faites en TÊTE de $this->languages, donc chaque appel épuisait son plafond // à re-vérifier l'existant SANS jamais atteindre une langue manquante. Fix : nouveau // compteur $real_work (créé/mis à jour/erreur UNIQUEMENT, jamais un skip) qui seul gate le // plafond -- un appel traverse tous les skips gratuitement. QUEUE_BATCH_SIZE/SYNC_DRAIN_CAP // 3->8 et MAX_FRESH_CREATIONS_PER_BATCH 2->6 (BATCH_TIME_BUDGET_SECONDS, pas ces comptes, // est désormais le VRAI garde-fou anti-hard-kill -- ces comptes redeviennent un garde-fou // généreux, pas le frein). (2) enqueue_promo_generation() réinitialisait `pending` aux 25 // langues à CHAQUE re-déclenchement (event publish, reconcile incompleteMax) -- combiné à // (1), un fournisseur re-déclenché en boucle ne progressait JAMAIS (simulé : monty-esim // 10/25 -> 61 appels sans une seule création). Fix : si une génération est déjà en cours // avec les MÊMES promo/website (comparaison ksort()+wp_json_encode(), même canonicalisation // que compute_signature()), `pending` est préservé au lieu d'être réinitialisé -- un // re-déclenchement redondant devient un vrai no-op pour la file. Mesuré (harnais PHP // autonome, hors WP) : monty-esim 10/25->25/25 en 3 appels au lieu de ~11 (bug #1 seul) ou // jamais (bug #1+#2 combinés, re-déclenchement en boucle) ; les 7 partiels connus // convergent tous en 3-4 appels. Cf. le docblock complet de drain_provider_batch() // ("DÉBIT") et enqueue_promo_generation() ("PROGRESS PRESERVATION"). // 1.11.7 (2026-08-28, Etienne, "plus jamais bloqué") : drain // des pages code promo rendu résilient (class-provider-promo-pages-generator.php, // drain_provider_batch()/rest_drain_promo_now()) -- root cause monty-esim (coincé à 9/25, // 503 déterministe sur la 10e langue) : @set_time_limit(0) désactive la limite PHP mais PAS // le hard-kill externe Hostinger/LiteSpeed, et la progression n'était persistée qu'APRÈS // chaque langue -> un kill en plein rendu ne persistait rien, la même langue rejouait à // l'infini au call suivant. Fix : (1) claim "en cours" persisté AVANT chaque langue (jamais // rejouée en boucle après un kill, requeue en FIN de file) ; (2) auto-arrêt sur budget de // TEMPS (BATCH_TIME_BUDGET_SECONDS=15s, entre deux langues, jamais en plein rendu) au lieu // de compter seulement sur le plafond de langues (SYNC_DRAIN_CAP réduit 5->3 en défense // supplémentaire) ; (3) une langue qui échoue (\Throwable capté) est requeue en FIN de file // au lieu d'être abandonnée -- remaining=0 redevient un signal honnête "25/25 réellement // abouti" ; (4) rest_drain_promo_now() expose total_languages/stuck_lang pour un diagnostic // direct depuis le dashboard. Purement défensif, ne change AUCUN gabarit -> pas de bump // TEMPLATE_VERSION nécessaire cette fois. // 1.11.6 (2026-08-28) : Yesim in-house -> Impact, 'yesim'/'yesim-banner' migres vers IMPACT_SLUGS (affiliate-network-detect.js) -> tagging sharedid. // 1.11.5 (2026-08-28, Etienne) : le fix "eSIM eSIM" 1.11.4 // ne touchait QUE dedupe_esim_repeat()/tr()/schema_product_name (class-provider-promo-pages- // generator.php) -- le réellement rendu vient d'AIOSEO v4 (son propre modèle // wp_aioseo_posts, écrit par apply_aioseo_meta()), appelé UNIQUEMENT quand update_single_ // promo_page() détecte un hash de contenu changé (compute_signature()) -- or la donnée promo // des fournisseurs "...eSIM" n'a pas changé, donc le hash non plus : un no-op PUT régénère bien // la page (last-modified change) mais SANS jamais rappeler apply_aioseo_meta(), laissant le // <title> AIOSEO périmé malgré le code corrigé. Bump de TEMPLATE_VERSION (class-provider- // promo-pages-generator.php) -- PAS juste MBS_PROVIDERS_VERSION -- pour invalider le hash de // TOUTES les pages promo une fois et forcer le vrai re-render (build_view_model()+ // apply_aioseo_meta()) au prochain passage (no-op PUT OU cron) qui les touche. Ce bump-ci ne // sert qu'à l'invalidation OPcache habituelle. // 1.11.4 (2026-08-27, Etienne) : cosmétique "eSIM eSIM" en // double sur les pages code promo des fournisseurs dont le nom finit déjà par "eSIM" (Monty eSIM, // etc.) -- title/aioseo_title/h1_html/lead_html/meta_description/aioseo_meta_description + JSON-LD // Product.name dédupliqués (dedupe_esim_repeat()/tr(), class-provider-promo-pages-generator.php + // class-provider-promo-pages-seo.php). N'affecte QUE les pages code promo (pas les avis, gabarits // séparés). Les pages déjà générées pour les 12 fournisseurs "...eSIM" actuellement candidats promo // gardent l'ancien texte jusqu'à leur prochaine régénération (_mbs_promo_view figée en base) -- // re-déclenchement + purge CDN à faire séparément, cf. rapport de livraison. Bump = invalidation // OPcache (plugin+thème). // 1.11.3 (2026-08-25, Etienne) : CTA "Comparer les offres" // des pages avis (fournisseur NON affilié) pointait vers /{lang}/comparateur-esim/ = 404 (page // inexistante + slug FR figé) -> corrigé vers la HOME de la langue (racine = comparateur, 200 // partout). 2 occurrences dans class-avis-v2-preview.php. Bump = invalidation OPcache (plugin+theme). // 1.11.2 (2026-08-24, Etienne) : retrait de l'index texte // [mbs_promo_hub_links] (approche abandonnee : doublon avec le hub des avis + ajout de texte // non voulu). Le maillage hub -> pages promo se fait desormais DANS le tableau lui-meme // (nom du fournisseur = lien interne dofollow, cote mbs-widget). Suppression du require + // du fichier class-promo-hub-links.php. Bump = invalider OPcache (plugin + theme discount-v3). // 1.11.1 (2026-08-24, Etienne) : maybe_invalidate_opcache_once() // invalide desormais AUSSI le theme enfant (get_stylesheet_directory(), rehub-child), pas // seulement le plugin -- sinon un changement de TEMPLATE (ex. la section liens crawlables // du hub posee sur discount-v3.php en 1.11.0) reste fige en OPcache indefiniment sur cet // hote (plugin bien recharge = asset ver a jour, mais template ancien). Voir la fonction. // 1.11.0 (2026-08-24, Etienne) : hub codes promo -- // nouveau shortcode [mbs_promo_hub_links] (class-promo-hub-links.php), section HTML server- // rendered avec de vrais <a href> dofollow vers CHAQUE page "code promo {fournisseur}" // publiee + approuvee a l'index, pour les 25 langues -- le hub ne redistribuait jusque-la // AUCUN jus SEO vers ces pages (widget promo-ranking.js = navigation JS au clic, aucun // <a href> dans le HTML brut). Pose sur discount-v3.php (template Discount V3 Page). // 1.10.1 (2026-08-24, Etienne) : process_update() // (class-api.php, POST /provider/{slug}/profile -- l'endpoint appele a CHAQUE // sauvegarde depuis le dashboard MBS) ecrit desormais _mbs_rating quand le payload // contient "rating", exactement comme patch_provider()/create_provider() -- fix // "pages avis figees" (la note MyBestSim est lue depuis cette seule postmeta par // TOUS les rendus, jamais depuis _mbs_profile_json ; cet endpoint ne l'ecrivait // jamais avant, donc une note editee apres la creation du fournisseur restait // figee indefiniment). Bump necessaire pour l'invalidation OPcache ciblee // (maybe_invalidate_opcache_once() ci-dessous) -- sans lui ce fichier PHP modifie // pourrait rester servi depuis le cache de bytecode de l'hote. // 1.10.0 (2026-08-23, Etienne) : 2 nouveaux // endpoints REST pour l'automatisation n°2 "devient non-affilié" -- GET .../provider/ // {slug}/promo-pages (nouveau, liste page_id+lang+status via get_promo_pages_for_provider(), // jamais un scan global) + purge-promo-page passe en shared-secret-ou-admin // (check_worker_or_admin_permission, meme pattern que drain-promo-now) pour que le // Worker Cloudflare puisse supprimer en boucle toutes les pages d'un fournisseur qui // vient d'etre desaffilie (deletePromoPagesForProvider(), admin-providers.js, cote // Worker : confirmation dure obligatoire cote dashboard AVANT tout appel). 1.9.2 // (2026-08-23, Etienne) : fix critique // regime permanent -- create_single_promo_page() posait _mbs_promo_indexable='1' mais // n'appelait JAMAIS sync_aioseo_robots() une 2e fois apres -- apply_aioseo_meta() (appele // AVANT dans la meme fonction) l'appelle deja une 1ere fois, mais a un moment ou la meta // indexable n'est pas encore posee -> AIOSEO ecrit robots_noindex=true dans SON PROPRE // modele (wp_aioseo_posts), et filter_wp_robots_promo_noindex() laisse cette sortie // intacte pour une page "approuvee" (elle ne force JAMAIS noindex=false, seulement // noindex=true pour les pages non approuvees) -- resultat : toute page CREEE par le // regime permanent ($auto=true) sortait publish MAIS invisible aux moteurs, contrairement // a la promesse "index,follow direct". Trouve en testant l'activation en LIVE (asimgo, // cinqstella) -- rest_approve_index() (endpoint manuel) faisait deja ce meme 2e appel, // d'ou l'absence de regression sur le test live pre-merge (qui passait par les endpoints // manuels, jamais par le vrai chemin $auto). Fix : re-appelle sync_aioseo_robots($page_id) // DANS le bloc if($auto), juste apres avoir pose la meta -- meme fonction idempotente/ // best-effort que l'endpoint manuel utilise deja. 1.9.1 (2026-08-23, Etienne) : 2 finitions // avant activation du régime permanent -- (1) IndexNow ping sur toute page code promo // créée par $auto=true (create_single_promo_page(), même pattern que l'avis) ; (2) // nouvel endpoint public GET .../provider/{slug}/promo-link-status?lang=xx // (class-avis-v2-preview.php) qui rejoue EXACTEMENT le calcul du lien CTA de l'avis // (load_profile()+promo_page_url()) -- consommé par le filet de réconciliation Worker // pour vérifier que l'avis REND réellement le lien dédié, pas juste que la page existe // (leçon purasim/esim-for-travels). 1.9.0 (2026-08-21, Etienne) : régime // permanent des pages code promo -- maybe_generate_from_payload()/enqueue_promo_ // generation()/drain_provider_batch()/create_single_promo_page() gagnent un // paramètre $auto ; process_update() (class-api.php, le SEUL "Publier vers WP" // réel) l'appelle avec $auto=true -> une page CRÉÉE de là sort publish+indexable // direct, hands-off, sans étape brouillon-à-approuver (tous les autres // déclencheurs -- set-promo/regenerate-promo/run_monthly_refresh -- gardent le // comportement par défaut draft+noindex-jusqu'à-approbation, campagne inchangée). // drain-promo-now gagne une auth secret-partagé-ou-admin (check_worker_or_admin_ // permission) pour que le Worker Cloudflare puisse chaîner le drain lui-même // (WP-Cron ne draine pas cette file de façon fiable sur cet hôte). Cf. // cloudflare/src/handlers/admin-providers.js (drainPromoPagesNow) + docs/ // promo-autopublish-permanent-regime.md pour la procédure de mise en service. // Fusionne au merge du 2026-08-23 les correctifs 1.8.101 -> 1.8.109 de main // (dont la VRAIE root cause de l'avis->hub -- rest_set_promo() n'ecrivait que // _mbs_profile_json['promo'], jamais _mbs_affiliate_link_*/shared.* -- et // IndexNow) : chaine complete ci-dessous. // 1.8.109 : feat(indexnow) -- // nouvelle classe MBS_IndexNow (includes/class-indexnow.php) : sert le fichier // de verification `https://mybestsim.com/{key}.txt` (le Worker Cloudflare ne // fronte PAS mybestsim.com -- verifie en direct, seule WordPress peut heberger // ce fichier au bon host) + expose MBS_IndexNow::ping()/ping_one(), cable sur // create_single_page() (class-provider-pages-generator.php) pour avertir // IndexNow (Bing/Yandex) a chaque publication d'une page avis fournisseur. // Meme cle que le secret Worker INDEXNOW_KEY (deja consomme par // article-finalize.js) -- un seul fichier de verif pour les deux cotes. // 1.8.108 : fix(promo-page-cpt) -- // suite immediate de 1.8.107 : 'no' manquait aussi de la table $slugs qui // enregistre le REWRITE RULE lui-meme (class-provider-page-cpt.php, // register_rewrite_rules() + custom_permalink(), 2 endroits) -- sans ca, // /no/tilbyder/... 404 quoi qu'il arrive (route_promo_slug() du CPT promo ne // fait que reutiliser cette regle, il n'en cree pas). Le bump de version // declenche le flush unique deja en place (maybe_flush_rewrite_rules_once()). // 1.8.107 : fix(promo-i18n) -- // 2 bugs langue trouves lors du chantier de couverture (round 35) : (1) 'uk' // (ukrainien) inaccessible -- SLUG_PREFIXES['uk'] identique a ['ru'] ('promokod'), // route_promo_slug() resolvait TOUJOURS 'ru' en premier peu importe l'URL visitee // -> 301 systematique /uk/.../promokod-{slug}/ -> /ru/... depuis la creation // (2026-08-03). Fix : slug uk distinct ('kod-znyzhky', mot ukrainien reel et // naturel), contenu on-page inchange. (2) 'el' (grec) n'est PAS une langue // Polylang reelle (confirme : /el/ 301 vers un post HU sans rapport) -- tout // l'effort des rounds 30-34 pour "reparer" el chassait une langue qui n'existe // pas. Remplace par 'no' (norvegien bokmal, langue reelle jamais generee) dans // le tableau de langues du generateur promo + slugs dedies (distincts de 'nn' // pour eviter la meme collision). Voir class-provider-promo-page-cpt.php et // class-provider-promo-pages-generator.php pour le detail. // 1.8.106 : chore(avis-v2) -- // retire la derniere trace TEMP (has_promo, purasim) -- diagnostic clos, fix // #94 confirme en trace prod : purasim a-t-il bien has_promo=true et rend la // page promo dediee (voir businesses/mbs/avis-promo-link-duplicate-provider-bug.md). // 1.8.105 : TEMP diagnostic 3 -- // trace has_promo pour purasim uniquement, meme incident. A retirer. // 1.8.104 : fix(avis-v2) -- // root cause REELLE de l'avis->hub (le fix 1.8.101 etait correct mais insuffisant, // confirme par trace prod) : render() derive $is_affiliate/$cta UNIQUEMENT de // _mbs_affiliate_link_* (postmeta), et $promo_code/$promo_discount UNIQUEMENT de // shared.* + /api/promo-ranking (table de classement CUREE manuellement) -- la // campagne code-promo a onboarde ~113 fournisseurs via rest_set_promo() SEUL // (promo-only, n'ecrit QUE _mbs_profile_json['promo'], jamais ces champs par // design). $has_promo restait donc FAUX -> branche hub codee en dur, meme quand // promo_page_url() trouve la vraie page dediee publiee. Fix : $cta et // $promo_code/$promo_discount retombent desormais sur promo.affiliate_url / // promo.code / promo.discount_label (garantis presents par rest_set_promo(), // champs requis) avant le hub. Retire aussi les traces TEMP de 1.8.102/1.8.103. // 1.8.103 : TEMP diagnostic 2 -- // trace 2 dans promo_page_url() (get_post/permalink), meme incident. // 1.8.102 : TEMP diagnostic -- // trace promo_page_url() en prod (avis->promo link incident) -- a retirer une // fois la cause confirmee. Bump seul necessaire pour invalider l'OPcache. // 1.8.101 : fix(avis-v2) -- // load_profile() (class-avis-v2-preview.php) resolvait le mbs_provider d'un // avis via get_page_by_path(), dont le SQL n'a AUCUN ORDER BY -- quand un slug // a un mbs_provider duplique (post_name partage), ca retourne silencieusement // le PREMIER trouve par MySQL (en pratique le plus ancien) au lieu du plus // recent/canonique que resolve_provider_id_by_slug()/update_provider_profile_ // by_slug() (get_posts(), date DESC) utilisent partout ailleurs. L'avis rendait // alors avec le meta VIDE du duplicata -> promo_page_url() retombait sur le hub // codes-promo au lieu de la page dediee du fournisseur (21/113 fournisseurs // affilies-a-code affectes, ex purasim/esim-for-travels). Fix : load_profile() // resout desormais get_posts() explicite, meme ordre que le reste du pipeline. // Bump seul necessaire pour invalider l'OPcache de ce fichier deploye en // 1.8.100 (voir maybe_invalidate_opcache_once() plus bas) -- pas de logique // changee ici. 1.8.100 : fix(attribution) -- // tagAffiliateLinks() (provider-tracking.js) ecrasait le sharedid/ws DEJA pose // par un widget (hub codes promo, classement fournisseurs, page promo dediee -- // tous rendent un <a href> persistant, contrairement aux bannieres data-href // click-time) en 'avis'/'article' : son garde-fou Impact ne regardait QUE // subId1 (jamais pose par un widget), et sa branche Daisycon ajoutait un ws= // DUPLIQUE au lieu de remplacer. Fix : reconnait un sharedid/ws deja tague par // un token de placement connu (KNOWN_SOURCE_TOKENS, miroir // affiliate-cron.js:IMPACT_SOURCE_TOKENS repo MyBestSimProduct) -> laisse // intact ; sinon (absent/vide/legacy type 'mybestsim') tague avis/article // comme avant, sans regression. Decouvert + fix demandes suite a PR // MyBestSimProduct #768 (fallback affilie hub codes promo). // 1.8.99 : cutover hub live -- balayage // complet (2e passe) du gabarit CUSTOM esim-page-* sur capture pleine page (haut, // milieu, bas jusqu'au footer) : masque aussi .esim-featured-image (image a la // une de l'ANCIEN article, affichee au-dessus du hero du hub). Confirme absents // sur ce gabarit : extrait/chapo, meta categorie/date, tags, partage, articles // lies, commentaires -- aucune de ces classes n'existe dans le HTML rendu. // 1.8.98 : cutover hub live -- fix 1 : le // gabarit CUSTOM des pages hub LIVE (esim-page-*, absent des brouillons preview qui // tournent sur le gabarit rehub par defaut) rendait son propre the_title() dans // .esim-page-title, doublant le H1 du hub avec l'ANCIEN titre -- masque via // body:has(.mbs-hub-page) .esim-page-title (esim-hub-page.css). Bump = cache-bust // du CSS enqueue avec ce ver() (wp_enqueue_style). // 1.8.97 : cutover hub live (etape A, FR+EN) -- // maybe_noindex() ne noindexe plus INCONDITIONNELLEMENT toute page portant // [mbs_esim_hub_page] : comportement par defaut INCHANGE (noindex, aucune page // preview existante a toucher), sauf attribut explicite indexable="1" pose // UNIQUEMENT sur les pages live basculees (jamais par defaut). Sans ce correctif, // basculer une page live indexee sur ce shortcode l'aurait fait passer noindex. // 1.8.96 : fix defensif simulateur prix promo (discount_type=percent + discount_label non-% -> "Pay $0.00"/-100% fabrique) -- resolve_discount() partagee compute_cart()/build_price_table_rows()/promo-page.js // 1.8.95 : hub round 17 (vague 4, DERNIÈRE) // -- ja/ko/ru/bg/hr/sk/sl/no/nn actives : 25/25 langues du site. Correctifs // STRUCTURELS : (1) bokmål cablé sous la cle Polylang REELLE 'no' (content_nb() // alimente 'no' dans content_map(), jamais 'nb') ; (2) review_bases()['no'] absent // -> repli sur les fiches nynorsk (/nn/leverandor/vurdering-) plutot que FR ; // (3) byline JA/KO en ordre latin [label][mois][annee] structurellement inverse -- // nouveau byline_updated_text($lang, $c) : annee->mois + label en SUFFIXE // ("2026年8月更新", "2026년 8월 업데이트") ; (4) detect_lang() $langs aligne sur les // 24 vraies langues du site (+bg/hr/sk/sl, -el, -nb redondant avec no) ; // (5) profile_description() separait en ', '/'.' latins meme en ja/ko -> nouveaux // profile_desc_comma()/profile_desc_period() (、/。 natifs). Pieges de slug // respectes (about_page_url()) : sk=o-nas-3, sl=o-nas-4, no=om-oss-no, nn=om-oss-2 ; // ru/bg slugs cyrilliques, ja/ko slugs japonais/coreens (promo_page_url()). Parite // de cles verifiee programmatiquement -- 43 cles x 25 langues actives. // 1.8.94 : hub round 16 (vague 3) -- // CS/HU/RO/TR/UK actives (content_cs()/content_hu()/content_ro()/content_tr()/content_uk(), // seo-localizer, parite de cles verifiee programmatiquement -- 43 cles chacune, comme les // vagues precedentes). Plomberie etendue aux 5 langues, toutes verbatim des docblocks // sources. 2 pieges de slug respectes (about_page_url()) : CS = "o-nas-2/" (PAS "o-nas/", // qui fait un 301 vers le polonais -- slug pris en premier par la page PL) ; UK = slugs // CYRILLIQUES ("про-нас/", et "промо-коди-esim-карти/" cote promo_page_url()). Aucun // changement necessaire sur format_rating()/label_colon() : ces 5 langues tombent deja sur // le comportement par defaut (virgule, ":" sans espace) pose au round 14. // 1.8.93 : hub round 15 -- NL/PT/SV/DA/FI/PL // actives (content_nl()/content_pt()/content_sv()/content_da()/content_fi()/content_pl(), // seo-localizer, parite de cles verifiee programmatiquement -- 43 cles chacune, comme // es/de/it au round 14). Plomberie etendue : month_name/profile_labels/promo_page_url/ // about_page_url/profile_description_phrases/fallback_pick_label (les 6 langues, toutes // verbatim des docblocks sources -- aucun gap cette fois). Aucun changement necessaire // sur format_rating()/label_colon() : ces 6 langues tombent deja sur le comportement par // defaut (virgule, ":" sans espace) pose au round 14. // 1.8.92 : pages promo -- garde-fou structurel // pour les 7 fournisseurs exclus (roamic, yesim, quibity, esim-sm, gomoworld, jambosim, // esim-dog -- meme liste que mbs_avis_v2_excluded_slugs(), reutilisee comme source unique). // class-provider-promo-pages-generator.php : is_promo_excluded_slug() ajoute et verifie a // TOUS les points d'entree ecriture -- resolve_provider_and_promo() (regenerate-promo, // drain-promo-now), resolve_provider_id_by_slug() (set-promo, publish-review, approve-index, // repair-promo-pages), maybe_generate_from_payload() ("Publier vers WP" + cron mensuel // run_monthly_refresh()), et drain_provider_batch() en dernier recours (efface toute file // residuelle avant d'ecrire). Generation/regeneration/refresh mensuel desormais structurellement // impossibles pour ces 7, meme par erreur. yesim garde sa page promo preexistante intacte -- // ce garde-fou bloque seulement les FUTURES ecritures, ne supprime rien. // 1.8.91 : set-promo -- retrait du blocage "0 // produit" sur un promo A CODE (rest_set_promo(), class-provider-promo-pages-generator.php). // Bloquait tout fournisseur affilie-seul reel (Holafly, Nomad -- is_affiliate=1, 0 ligne // products) alors que ce cas a deja un precedent live (pages promo Nomad originales, // 2026-07-22, "panier indicatif") genere sans ce garde-fou via le flux "Publier vers WP" -- // le template degrade deja proprement vers le panier indicatif quand D1 n'a aucun produit. // provider_has_products() conservee comme aide de diagnostic, plus appelee par ce endpoint. // 1.8.90 : hub round 14 -- ES/DE/IT actives // (content_es()/content_de()/content_it(), seo-localizer, parite de cles verifiee // programmatiquement -- 43 cles chacune). Plomberie etendue : month_name/profile_labels/ // promo_page_url/about_page_url (es/de/it) ; profile_description_phrases (it verbatim, // es/de authores faute de valeurs source -- signale) ; fallback_pick_label (de/it verbatim, // es authore). Fix format_rating() (virgule par defaut, point reserve a en/ja/ko -- l'ancien // modele mettait un point pour TOUTES les langues sauf fr, y compris de/es/it qui utilisent // pourtant la virgule). Fix label_colon() : separateur "label :" avec espace avant n'est plus // hardcode -- espace seulement pour fr (axes + profils complementaires dans render()). Fix // content_en() : mot-cle "best eSIM" (pas "best eSIM for travel"). // 1.8.89 : hub round 13 -- i18n VRAIMENT // multilingue. render()/schema() 100% pilotes par content($lang) (plus aucune chaine // visible en dur) ; EN active (content_en(), seo-localizer, parite de cles verifiee // avec content_fr()) ; 5 helpers lang-aware extensibles (review_bases, profile_labels, // profile_description_phrases, promo_page_url/about_page_url, month_name, // format_rating) ; noindex force en sortie brute (maybe_noindex(), independant // d'AIOSEO) ; page de preview EN creee (id 218075, /en/esim-hub-en-preview/). // 1.8.88 : avis V2 -- rollout complet. mbs_avis_v2_page_is_pilot() // (mbs-avis-i18n.php) passe d'une allowlist (5 pilotes) a une blocklist mbs_avis_v2_excluded_slugs() // (7 fournisseurs gardes sur l'ancien template, groupe temoin) : TOUTE page avis rend desormais le // template V2 sauf ces 7. Gate au rendu (the_content) uniquement -- rien republie, aucune purge ici. // 1.8.87 : hub round 12 -- H1 sans italique (garde le // bleu), methodo (mention partenaires deplacee de method_p3b vers method_p1), inconvenient #1 // en phrase complete. Note Orange Travel montee a 4.7 en D1 (canonique, hors code) -- bump ici // invalide aussi la transient SSR versionnee (mbs_esim_hub_default_v2_<version>, round 10/11). // 1.8.86 : classement par defaut du hub -- podium // manuel (positions 1-3, inchange) + tri par score decroissant a partir de la position 4 // (pure changement cote Worker, provider-ranking-resolved.js) ; bump PUR pour invalider la // transient SSR "mbs_esim_hub_default_v2_<version>" (round 5/1.8.85), sinon le nouvel ordre // resterait colle jusqu'a 1h. // 1.8.85 : audit 5G (Etienne) -- colonne 5G du hub // corrigee (product_force_5g branche sur prod_any_5g cote Worker, fix + data en D1) + la // transient SSR "mbs_esim_hub_default_v2" (mbs-widget-provider-hub-ssr.php, plugin voisin) // est desormais versionnee par MBS_PROVIDERS_VERSION (meme fix que fetch_json() ci-dessous, // round 5) pour ne plus jamais rester collee jusqu'a 1h apres une correction Worker/D1. // 1.8.84 : hub eSIM round 9 -- contenu (seo-writer) : // methodo remaniee (6 axes qualitatifs service/fiabilite/reputation, plus de double-comptage // couverture/prix note+bonus), method_p3b desormais rendu (existait mais n'etait jamais echo), // chapo/usecases_prose/3 etapes du guide reformules, "MBS" -> "MyBestSim" partout dans le texte // visible (score MyBestSim /100, FAQ). {n}/tested_count() retires (method_p2 en texte fixe). // 1.8.79 : pages avis -- meta description servie par // le filtre aioseo_description (AIOSEO ne lit pas l'extrait, il aspirait le corps de page : // 7 255 fiches sur 7 403 servaient les glyphes etoiles et le libelle du bouton d'affiliation) // + table de descriptions portee de 5 a 24 langues (les 19 autres recevaient du FRANCAIS). // 1.8.78 : hub eSIM round 8 -- SEO title/meta desc // honnetes ("26 Fournisseurs Testes" -> "300+ Fournisseurs", "teste et note 26" -> "note plus de // 300") ; prise en charge par filtres aioseo_title/aioseo_description/pre_get_document_title // (absents jusqu'ici de class-esim-hub-page.php -- le <title> live venait d'une valeur posee // en direct dans wp_aioseo_posts, jamais reliee au code, resynchronisee au meme moment via // l'endpoint REST AIOSEO). Corps methodo (method_p2, "26" = catalogue synchronise) inchange. // 1.8.77 : hub eSIM round 7 -- correction du round 6 // mal compris : le bouton d'affiliation devient un bouton UNIQUE "Lire l'avis" (plus de lien // affilie dans ce widget, cf. plugin voisin mbs-widget) ; fil d'Ariane .hp-bc retire (n'etait // pas rendu par le theme, c'etait du markup a nous, retire directement) ; infographie methodo // sans cadre exterieur + grossie (esim-hub-page.css) ; widget_prose remis a jour. // 1.8.76 : hub eSIM round 6 -- 2e CTA "Lire l'avis" // par ligne (mbs-widget-provider-hub-ssr.php, plugin voisin) + widget_prose mis a jour // (class-esim-hub-page.php) + profils complementaires diversifies (assignation gloutonne // dans profile_picks() -- Yesim ne raflait plus 3 niches sur 4). Bump obligatoire (PHP + // cle transient category=...&limit=5 desormais differente de limit=1, invalide l'ancien // cache API en meme temps que le HTML/CSS). // 1.8.75 : hub eSIM -- cle transient versionnee // (class-esim-hub-page.php fetch_json()) -- bug trouve en verif live round 5 : une // reponse /api/provider-ranking-v2?category=long-stay mise en cache PENDANT la // fenetre de deploiement Worker (retombee sur "default", categorie pas encore live // cote edge) restait collee jusqu'a 1h meme apres le fix cote Worker -- la // transient WP ne savait pas qu'un deploiement avait eu lieu. La cle transient // inclut desormais MBS_PROVIDERS_VERSION : un bump invalide maintenant aussi les // reponses API en cache (pas seulement CSS/OPcache PHP). // 1.8.74 : hub eSIM round 5 -- gros lot // (class-esim-hub-page.php + esim-hub-page.css + mbs-widget-top-fournisseurs.php via // only_with_logo deja livre) -- chapo retouche, aeration paragraphes, cons en mini- // cartes, CTA de fin (remplace la conclusion), 2 liens maillage (calculateur + // verificateur compatibilite), profils complementaires 100% dynamiques (nouvelle // migration 059 cote Worker : prod_europe_regional/prod_africa_country_count/ // prod_has_short_stay). Bump obligatoire (CSS ET PHP touches). // 1.8.73 : hub eSIM round 4 -- zero tiret cadratin // (class-esim-hub-page.php) -- Etienne : regle ABSOLUE aucun tiret cadratin/demi-cadratin // dans ses redactions. 8 occurrences trouvees dans content_fr() + le template profils // (— -> ":"), remplacees par virgule ou deux-points selon le sens. PHP uniquement, // cette version bump sert avant tout a invalider l'OPcache (aucun asset CSS/JS touche // cette fois -- voir maybe_invalidate_opcache_once() plus bas dans ce fichier). // 1.8.72 : 4 retours Etienne round 2 sur le hub // (class-esim-hub-page.php + esim-hub-page.css) -- cadre du thème vraiment tué (Simple // Author Box masqué en plus de rh-post-wrapper), separateur ":" explicite sur les 6 axes, // annuaire filtre aux fournisseurs avec logo (only_with_logo). BUMP OBLIGATOIRE : les 2 // premiers rounds de ce chantier n'avaient PAS bumpé la version -> l'URL de // esim-hub-page.css est restee ?ver=1.8.71 sur les 2 déploiements précédents, donc tout // navigateur l'ayant déjà chargée gardait la VIEILLE feuille en cache jusqu'à 30 jours // (Cache-Control: max-age=2592000, immutable, sur les assets statiques /wp-content/) -- // cadre/separateurs "non résolus" constatés en review = cache navigateur, pas un bug CSS. // 1.8.71 : nouveau shortcode [mbs_esim_hub_page] // (class-esim-hub-page.php) -- page complete du hub "Meilleures eSIM" (brouillon de // review, docs/hub-esim-widget.maquette-ref.html + docs/hub-fr-content.reference.md du // repo Worker) : hero, methode (vraie formule note-led 60+40 pts, gate ^3.5), module // "selon votre profil", guide d'achat, eSIM vs SIM, annuaire, FAQ+FAQPage, bio auteur + // schema Article/Person. N'ecrase RIEN -- assemble les 3 shortcodes deja live du // plugin mbs-widget ([mbs_esim_hub]/[mbs_esim_usecases]/[mbs_providers_listing_v2]). // 1.8.70 : audit LIVE de la whitelist Impact // (affiliate-network-detect.js) contre l'export reel des redirections /affiliation/* du plugin // Redirection -- 'amigosim' n'existait sur AUCUNE redirection reelle (0 resultat), renomme // 'amigo' (le vrai slug live, 50 clics actifs) ; 3 fournisseurs Impact reels et actifs oublies // depuis toujours ajoutes : gohub (38 clics), holafly (64), superalink (190). Voir // docs/affiliate-placement-attribution.md (repo MyBestSimProduct) section 2quater. // 1.8.69 : fiabilisation attribution source (mission // complete du 2026-08-10) -- (1) NOUVEAU assets/js/affiliate-network-detect.js = source unique de // detection reseau (whitelists Impact/Affilae/Yesim/Daisycon, brut+cloake), enqueue en dependance // de provider-tracking.js/banner-tracking.js/promo-page.js (class-provider-stats.php, // class-banner-stats.php, ce fichier) -- avant, les 3 whitelists etaient dupliquees a l'identique ; // ajouter un fournisseur ne demande plus qu'un seul edit. (2) provider-tracking.js pose enfin // sharedid=avis|article sur les liens Impact (subId1/subId2 seuls avant) -- les ventes Impact // generees par les pages avis/articles restaient jusque-la non rattachees a un placement (trou // distinct de celui du hub codes promo corrige en 1.8.68-). (3) Redirection JamboSIM // (/affiliation/jambosim, item Redirection #697) corrigee cote donnees : la cible portait // "&ws=" VIDE en dur, ce qui bloquait le mode "pass" pour CE parametre sur TOUTES les surfaces // cloakees (hub, page promo, sticky, articles/avis, bannieres) -- retire, verifie en LIVE // (curl -I, ws=<source> passe desormais, lien nu toujours fonctionnel). Voir // docs/affiliate-placement-attribution.md (repo MyBestSimProduct) pour la matrice complete et la // procedure "ajouter un nouveau provider". // 1.8.68 : banner-tracking.js -- ajoute le tagging Daisycon // (JamboSIM) sur les bannieres (ws=banniere+ws2=session), trou decouvert lors de l'audit // attribution source=promo (2026-08-10, cf. docs/affiliate-placement-attribution.md du repo // MyBestSimProduct). Impact/Affilae/Yesim etaient deja tagues sur cette surface, Daisycon non. // 1.8.67 : sticky avis EN aligne sur promo -- class-avis-v2-preview.php // passait $st['cta'] ("See the offer") au cta_text du sticky partage, sans tenir compte de // cta_named (utilise par le generateur promo -> "See deals", retour Etienne 2026-08-07). Meme // logique reprise ici (isset($st['cta_named']) ? str_replace('{name}', ...) : $st['cta']) : le // bouton sticky avis EN affiche desormais "See deals" comme le sticky promo ; les 24 autres // langues (pas de cta_named) sont deja identiques des deux cotes, inchangees. // 1.8.66 : sticky bas de page UNIFIE avis+promo -- // nouveau includes/mbs-sticky-render.php (mbs_render_sticky_bar()) = SOURCE UNIQUE du markup // .mbs-promo-sticky-bar/.mps-*, appelee par class-provider-promo-pages-generator.php ET // class-avis-v2-preview.php (l'ancien markup maison .mbs-avis-v2-sticky/.payg est retire). // CSS extrait dans assets/css/sticky-bar.css (enqueue par les deux types de page), retire de // promo-page.css et d'avis-v2.css -- plus aucune duplication du composant. Bugfix associe // (decouvert pendant ce refactor) : class-avis-v2-preview.php reutilisait $st comme variable // de boucle how_to.steps, ecrasant le $st = mbs_sticky_i18n($lang) des que des steps existaient // -- le bouton CTA de l'ancien sticky avis se retrouvait alors VIDE sur la quasi-totalite des // fiches pilotes. Renomme en $step_raw. // 1.8.65 : AVIS — (1) fil d'Ariane : filtre aioseo_breadcrumbs_trail (class-provider-pages-seo) remplace le dernier crumb (post_title « … Code Promo … ») par « {Fournisseur} eSIM » sur TOUTES les pages avis (mbs_provider_page, 7000+), sans toucher aux 7000 titres — retire « code promo » du fil d'une page avis (retour Etienne). (2) cleanup : cle i18n l_grab_sub (devenue inutile apres iterations banniere) + variable $grab_sub supprimees. // 1.8.64 : tagAffiliateUrl() de promo-page.js reconnaissait // UNIQUEMENT les liens Impact BRUTS (/c/5411968/) -- or en prod la quasi-totalite des CTA de page // promo sont des liens CLOAKES /affiliation/<slug> (verifie live : bcengi/airalo -> /affiliation/ // bcengi|airalo), le modele du widget comparateur (liens Impact directs) ne s'appliquait donc a // AUCUN cas reel. Porte le modele provider-tracking.js (CE plugin, meme contexte de liens cloakes) : // 4 whitelists slug (IMPACT_SLUGS/AFFILAE_SLUGS/YESIM_SLUGS/DAISYCON_SLUGS, memes valeurs). Verifie // EMPIRIQUEMENT (curl -I sur /affiliation/bcengi) que le mode Redirection "pass" repercute bien // subId1/subId2/sharedid=promo sur l'URL Impact finale, sans collision. // 1.8.63 : maillage hub codes promo -- PREREQUIS attribution : // tagAffiliateUrl (source=promo, Impact/Affilae/Daisycon/Yesim) appliqué au CTA .btn-claim // ET a la barre sticky mobile .mps-in de la page code promo (assets/js/promo-page.js) -- // les deux ouvraient l'affilié SANS AUCUN tagging reseau jusqu'ici (trou decouvert 2026-08-08, // brain: promo-hub-affiliate-linking.md) ; + nouvel endpoint public GET /wp-json/mbs/v1/ // promo-page-urls?lang=xx (class-provider-promo-pages-generator.php:rest_promo_page_urls, // cache transient 15 min) consomme par le Worker Cloudflare pour router le hub codes promo // vers la page interne quand elle existe (feature flag app_settings, deployee OFF). // 1.8.62 : PAGES CODE PROMO -- sticky (bcengi + nadanada) : le texte LONG (d'avant le raccourcissement 1.8.61, rdc/conn/lecode a-code ou nc_l1_long/nc_l2_long sans-code, recupere tel quel depuis git v1.8.60) est REINTRODUIT en DOM a cote du texte COURT (retour Etienne "long sur desktop, court seulement la ou le long ne rentre plus") -- render_promo_page() emet desormais .mps-txtset--long ET .mps-txtset--short pour chaque ligne, promo-page.css bascule l'affichage via UN SEUL media query desktop-first (@media max-width:599px), PAS de bascule par langue malgre l'intuition initiale : mesure (fixture Playwright, police self-hosted reelle, 26 langues x 2 variantes x largeurs 300-1920px par pas de 4px, marge = max(getBoundingClientRect, Range.getClientRects()) croises) montre que la marge, une fois le texte LONG decompresse par le flex (pire cas mesure 480px, PT a-code), devient EXACTEMENT --mps-gap-in(largeur) -- une variable CSS PARTAGEE independante du contenu -- qui ne franchit le plancher de 10px que vers ~495px pour TOUTES les 52 combinaisons a la fois : un seuil UNIQUE (600px, marge reelle 11.2px) couvre donc les 26 langues et les 2 variantes sans perte par rapport a un decoupage par langue. Aucun clamp() de taille touche (--mps-l1/--mps-l2/--mps-code-font/--mps-bt-* INCHANGES a la decimale pres, y compris tous les overrides par langue) -- seul le CONTENU affiche a chaque largeur change. Sous 600px (tout mobile), le texte COURT 1.8.61 reste affiche tel quel (marge >= 8px INCHANGEE, verifiee sur toute la plage 300-599px). Zero chevauchement, zero regression de monotonie, fixture == live (memes 4 fichiers woff2 self-hosted que omgf/poppins/poppins.css). Defense ajoutee : .mps-mavec/.mps-davec (classes de l'ancienne bascule mobile/desktop du connecteur, retirees du CSS en 1.8.61) sont explicitement cachees si une page promo tres ancienne sert encore un post_content baque d'avant cette date -- sans consequence en pratique (le rendu reel passe par live_render_content()/the_content, pas ce post_content baque). // 1.8.61 : PAGES CODE PROMO -- sticky (bcengi + nadanada) : RACCOURCI + AGRANDI (retour Etienne, 11e iteration GROWTH). Texte raccourci (mbs-sticky-i18n.php) : variante a code -- ligne 1 perd 'rdc' (devient juste "{Nom} {discount}"), ligne 2 remplace conn+lecode+bascule mobile/desktop (.mps-mavec/.mps-davec) par un seul champ 'code_pre' court (ex EN "with MYBESTSIM", FR "avec MYBESTSIM", RU "кодом MYBESTSIM" instrumental seul -- "по"/"за" auraient change de sens, voir docblock mbs-sticky-i18n.php). Variante sans code -- nc_l1/nc_l2 reecrits : verbe TOUJOURS garde en ligne 1 (jamais vide, jamais le nom seul), "{discount} <mot-remise-court>" en ligne 2 (reutilise 'rdc', deja en prod dans la variante a code). Le slack libere par ce raccourcissement est reinjecte en AGRANDISSEMENT (mesure reellement, pas deduit -- fixture Playwright, Range.getClientRects(), police self-hosted reelle) : bouton partage +40% (23 langues/25), bouton HU dedie +22% (le plus contraint, CTA "Ajanlat megtekintese" reste long), bouton EN dedie +75% (le plus de marge, "See deals" court) ; --mps-l1/--mps-l2/--mps-code-font AGRANDIS PAR LANGUE (cible : 5px de slack naturel residuel a 300px, garantit une marge texte<->bouton qui reste toujours >= --mps-gap-in/8px). es/it recoivent leur premier --mps-code-font dedie (avant : defaut jamais agrandi). Override CSS dedie HU no-code (.mps-lang-hu .mps-txt--nc .mps-l2) RETIRE (texte HU sans-code desormais du meme ordre de longueur que la variante a code, teste sans regression). Classes .mps-rdc/.mps-mavec/.mps-davec retirees du RENDU PROMO uniquement (mbs_sticky_i18n() garde rdc/conn/lecode pour class-avis-v2-preview.php, non touche par ce round). MAX (>=1280px) INCHANGE partout (desktop deja genereux, "reste tasteful"). Resweep complet 25 langues x 2 variantes x largeurs 300-1440, police self-hosted reelle : zero chevauchement (marge >= 8px partout, jusqu'a 19px a 1440px), zero violation de monotonie. // 1.8.60 : PAGES CODE PROMO -- sticky (bcengi + nadanada) : chevauchement texte<->bouton CORRIGE a la racine (10e iteration, retour Etienne "chevauchement persistant 300-315px + fixture qui divergeait du live"). Cause reelle mesuree en DIRECT SUR LE LIVE (pas seulement en fixture) : la marge a 320px n etait que de ~3.5px pour le pire cas (nadanada EN) -- comme tout est plafonne (clamp) en dessous de 320px, le manque de largeur entre 300 et 320px (jusqu a 20px de moins) se traduisait en chevauchement REEL (jusqu a ~16px mesure a 300px), sur la quasi-totalite des 25 langues x 2 variantes. Fixe en redefinissant la marge de securite jusqu a 300px (pas seulement 320) : pastille/bouton/texte reduits (dans cet ordre, comme demande) juste assez pour que la marge atteigne le regime "confortable" (garanti par --mps-gap-in, porte a 8px) a 300px -- mesure (fixture Playwright VALIDEE mot pour mot contre le live, Range.getClientRects()) : marge >= 8px sur les 50 combinaisons langue x variante a 300px (8 a 9.67px). Fuite de theme neutralisee par hygiene (line-height/box-sizing explicites sur tous les elements du sticky, notamment .mps-bt qui heritait le line-height:28px fixe de article,.post du theme rehub) -- sans lien avec le chevauchement (axe vertical) mais le sticky ne depend plus d aucune regle du theme. MAX (>=1280px) INCHANGE a la decimale pres sur les 101 clamp() du bloc (verifie programmatiquement). Desktop 1280px inchange, monotonie verifiee 300-1440px. // 1.8.59 : PAGES CODE PROMO -- sticky (bcengi + nadanada) : MOBILE hauteur+air (retour Etienne, 9e iteration, cf promo-page.css docblock pour le detail complet) -- hauteur de barre (--mps-pad-v) encore augmentee (~63px -> ~76-77px a 375px EN, mesure Playwright reelle) ; logo et bouton "colles" au texte (retour precedent au round 1.8.58) corriges en ajoutant de l'air autour du texte (--mps-gap-lft 2px->8px, --mps-gap-in 2px->9px, rendu ~9-10px a 375px) -- finance en reduisant le logo mobile (--mps-lg-size 40px->34px, --mps-lg-radius suit proportionnellement), texte et bouton INCHANGES (jamais sous leur taille de production actuelle). Zero debordement 320-1440px x 25 langues x 2 variantes (sweep Playwright, Range.getClientRects()), zero regression de monotonie, desktop >=1280px inchange a moins de 0.03px pres (verifie getComputedStyle avant/apres -- ecart residuel = arrondi flottant deja present partout dans ce fichier, pas une regression visuelle). Effet de bord DIVULGUE : le clamp() unique ancre 320/1280px deplace aussi les largeurs intermediaires (640px : hauteur ~74px->~92px, logo 45px->41px) -- consequence mathematique inevitable d'un seul clamp par element (deja le cas a chaque round precedent), pas un palier cache. 1.8.58 : PAGES CODE PROMO -- sticky (bcengi + nadanada) : MOBILE x1,25 (retour Etienne, "que les bannieres paraissent encore plus hautes/plus grandes sur mobile, sous ~500px, garde le desktop inchange") -- hauteur de barre (--mps-pad-v) x1,25 FRANC (vertical, aucun cout horizontal) ; logo (--mps-lg-size) 34px -> 40px (x1,18, tempere depuis le 42px suggere au depart -- mesure : chaque px de logo (carre) coute 1px de largeur dispo pour le texte, 42px cassait le budget texte pour la moitie des langues avant meme de toucher au texte) ; texte (--mps-l1/--mps-l2/--mps-code-font) calibre PAR LANGUE au maximum qui tient a 320px avec >=2px de marge, JAMAIS sous la taille de production actuelle -- de 0% (hu, la langue la plus contrainte) a +22% (langues courtes) selon la langue, PAS un x1,25 uniforme (mathematiquement intenable pour la moitie des 25 langues sans repasser sous leur taille actuelle, mesure en direct). Bouton EN x1,25 franc (CTA court "See deals", le plus de marge) ; bouton HU INCHANGE (son bouton actuel absorbe deja tout le supplement logo+hauteur) ; bouton partage (23 autres langues) INCHANGE (CTA longs type HU/NL/RO). Gaps/marges horizontales (--mps-gap-in/--mps-gap-lft/--mps-pad-h) reduits sous leur valeur actuelle pour compenser une partie du cout du logo (effet visuel nul a cette echelle, marge deja quasi nulle avant ce round). MAX (desktop, >=1280px) de chaque clamp() INTACT -- verifie bit pour bit identique a avant. Piege corrige en cours de route : mesurer .getBoundingClientRect() sur .mps-l1/.mps-l2 est FAUX (DIV bloc nowrap dans un parent flex-shrink -- la boite reste reduite mais le texte peint par-dessus, invisible a un simple rect) -- seule une Range.getClientRects() sur le contenu reel capture le vrai bord peint. Resweep complet 25 langues (+no calibre separement) x largeurs 300-1440 x 2 variantes, police self-hosted reelle, fixture Playwright + Range.getClientRects() : zero debordement reel (marge >=2px partout), zero regression de monotonie, zero langue sous sa taille de production actuelle. 1.8.57 : PAGES CODE PROMO -- sticky (bcengi + nadanada) : 3 reglages fins sur le scaling fluide 1.8.56 (retour Etienne, valide) -- (1) interligne resserre .mps-l1 line-height 1.15->1.05 + .mps-l2 recoit ENFIN un line-height propre (1.05, il heritait avant une fuite absolue 28px du theme rehub `article,.post{line-height:28px}`, gonflant aussi les pastilles) ; (2) pastilles .mps-code/.mps-amt-chip resserrees et identiques (memes variables px --mps-code-pad-v/-h desormais partagees, .mps-amt-chip passe d'un padding em a px) ; (3) logo mobile agrandi --mps-lg-size MIN 28px->34px (MAX 55px desktop inchange), compense par un tour de vis partage sur gaps/paddings + ajustements cibles des overrides de/el/hu/sv + nouveaux overrides en/nn. Resweep complet 25 langues x largeurs 320-1440 x 2 variantes (fixture statique, police self-hosted reelle) : zero debordement, zero regression de monotonie. 1.8.56 : PAGES CODE PROMO — sticky (bcengi + nadanada) : scaling FLUIDE CONTINU (retour Etienne, "il veut que la taille varie en continu avec la largeur, pas par saut") en remplacement du système à 2 paliers fixes de 1.8.55 -- UN SEUL clamp(MIN, A + Bvw, MAX) par élément (.mps-l1/.mps-l2/.mps-code/.mps-bt/.mps-lg/gaps/padding de .mps-in) couvrant toute la plage 320-1280px, ZÉRO media query de taille (le connecteur "avec"/"with" .mps-mavec/.mps-davec/.mps-rdc reste, lui, un vrai palier à 640px -- décision de CONTENU/quel mot va sur quelle ligne, pas une taille, aucun scaling continu possible pour ça). Bornes : MIN (320px) = valeurs du palier mobile ≤639px de 1.8.55 INCHANGÉES (déjà agrandies x1.3, jugées "bien" par Etienne) ; MAX (≥1280px, plateau) = valeurs du palier desktop ≥640px x~1.2 (retour Etienne : "notablement plus grand qu'avant sur grand écran, le texte doit s'étaler"), ex. .mps-l1 23px -> 28px. Monotonie garantie PAR CONSTRUCTION (une seule fonction affine croissante par élément, aucune media query ne peut plus la recroiser -- c'est précisément ce qui causait le bug d'inversion des rounds précédents) : confirmé par sweep réel getComputedStyle tous les 20px de 320 à 1440px sur .mps-l1/.mps-l2/.mps-bt/.mps-lg, zéro inversion sur les 50 combinaisons langue x variante testées. Toutes les tailles/espacements exposés en custom properties CSS sur .mbs-promo-sticky-bar (héritées par les enfants via var()) ; les overrides par langue redéfinissent la variable sur le même élément au lieu de dupliquer toute la règle. Resweep complet 25 langues (bcengi/nadanada n'ont pas de page 'no', confirmé via l'API REST avant ce commit) x largeurs 320-1440 x 2 variantes, police self-hosted réelle (Poppins woff2 latin + latin-ext, mêmes fichiers que la prod) : a mis au jour PLUSIEURS débordements PRÉEXISTANTS sur nadanada à 320px jamais couverts par un override avant ce commit (uk, ru, el, nl, da, de, cs, hr, it, pt, sl -- le sweep d'origine de 1.8.54/1.8.55 n'avait audité que le no-code) -- tous corrigés ici (nouveaux overrides clamp() par langue sur .mps-l1/.mps-l2, + réduction ciblée de la pastille .mps-code pour de/el/ru/uk, le texte fixe "MYBESTSIM" étant celui qui perd le moins en lisibilité à être réduit). // 1.8.55 : PAGES CODE PROMO — sticky (bcengi + nadanada), 3 retours Etienne : (1) logo décalé vers le haut corrigé (font-size/line-height:0 sur .mps-lg + display:block sur l'img -- le logo est servi en <picture><img> par le plugin webp/EWWW, <picture> forcé display:block par le thème sans hauteur propre, héritait une ligne fantôme 16px/28px qui poussait l'image vers le haut de ~8px en mobile, confirmé 0 sur desktop) ; (2) bouton EN 'See {name} deals' -> 'See deals' (mbs-sticky-i18n.php, plus de nom fournisseur) + bouton beaucoup plus gros SCOPÉ .mps-lang-en (desktop 19px->27px, mobile 11px->13px -- .mps-bt partagé non touché pour ne pas casser les libellés longs HU/NL/RO) ; (3) mobile agrandi x1.3 (bar/logo/.mps-l1 13->17/.mps-l2 11->14/pastille/bouton), desktop INCHANGÉ. Resweep complet 26 langues x 9 largeurs x 2 variantes (bcengi + nadanada, vraie police self-hosted, union Range.getClientRects()) : 7 langues + hu nécessitent un override mobile .mps-lang-xx (bg/de/el/hu/nl/sv/hr), calibré au minimum nécessaire. hr@640 desktop ('nadanada -10% popusta s' chevauche déjà le bouton de ~13px) est un bug PRÉEXISTANT confirmé sur le CSS de PROD avant ce commit -- hors périmètre (desktop non touché sauf le bouton EN), à traiter séparément. 1.8.54 : PAGES CODE PROMO — sticky sans-code, découpage 2 lignes ÉQUILIBRÉ (nouvelles clés nc_l1/nc_l2 par langue dans mbs-sticky-i18n.php, remplace le split-au-placeholder {discount} qui laissait une ligne 2 quasi vide dès que {discount} arrivait en fin de phrase, ex FR "Bcengi Obtenir ma réduction de" / "-7$" jugé "horrible") + montant en PASTILLE .mps-amt-chip (fond façon .mps-code de nadanada, texte ambre conservé) au lieu du texte ambre nu .mps-amt retiré + resweep taille par langue REFAIT DE ZÉRO sur ce nouveau découpage (mesuré avec le vrai CSS de ce fichier + les 4 .woff2 self-hosted réels téléchargés depuis mybestsim.com/omgf + le vrai texte bouton localisé par langue, 26 langues × 9 largeurs 320-1280, débordement du texte réel via union de Range.getClientRects() contre .mps-bt, zéro violation) : une seule langue déborde à taille pleine, hongrois (hu, .mps-l2 seul, ≥640px seul, 23/19->23/17). Toutes les autres langues (dont EN, qui reste 23/19 = nadanada) et hu lui-même en mobile restent à taille pleine, sans override. Ancienne liste d'overrides (calibrée pour l'ancien découpage, ne s'applique plus) retirée. Nadanada (variante à code) inchangée : markup/CSS partagés (.mps-l1/.mps-l2/.mps-code/.mps-bt/.mps-in) non touchés, seuls les sélecteurs .mps-txt--nc/.mps-l2 .mps-amt-chip/.mps-lang-xx (déjà scopés no-code) changent. 1.8.53 : PAGES CODE PROMO — sticky sans-code, recalibrage hr/sv (+ re-confirmation nn) : leurs URL bcengi redirigent (301, bug Polylang préexistant hors périmètre) vers la page d'une AUTRE langue (hr→sl, nn→de, sv→de) — les mesurer directement aurait recalibré hr/sv sur le texte d'une autre langue sans le savoir. Recalibrées isolément (page live qui résout normalement, même thème/police/logo, injection JS du vrai texte $NC['cta_nocode']['main'] ciblé) : hr passe de 19.5/12 (contaminé par le texte SL) à 18.5/11 (rejoint le groupe es/uk desktop, es/nl mobile) ; sv, jugé à tort intact à 23/19 (contaminé par le texte DE, lui aussi intact), déborde réellement et reçoit 21/12.5 (nouveau, rejoint fr en mobile) ; nn reconfirmé intact à 23/19 (déjà calibré par la même méthode d'injection dès 1.8.51, inchangé). Zéro violation revérifiée. 1.8.52 : PAGES CODE PROMO — sticky sans-code, FIX cascade CSS trouvé en vérifiant sk/fi/cs/it en direct après le déploiement 1.8.51 : les règles PAR LANGUE ≥640px (sélecteur à 4 classes .mbs-promo-sticky-bar.mps-lang-xx .mps-txt--nc .mps-l1) n'étaient scopées par AUCUN @media — leur spécificité, plus forte que la règle mobile par défaut à 2 classes (≤639px, 13px), les faisait s'appliquer aussi en mobile pour toute langue n'ayant qu'une réduction desktop sans réduction mobile dédiée (sk/fi/cs/it) : sk affichait 22.5px à 320px au lieu de 13px, débordement massif (marge -111px mesurée en direct). Fix : le bloc entier des règles PAR LANGUE ≥640px est maintenant wrappé dans `@media (min-width:640px)`, qui exclut ces règles sous 640px indépendamment de toute spécificité. Revérifié EN DIRECT sur les 24 pages bcengi live (nn exclu, pas de page live) × 9 largeurs : zéro violation. EN et nadanada inchangés. 1.8.51 : PAGES CODE PROMO — sticky sans-code, RECALIBRAGE des tailles par langue posées en 1.8.50 : le sweep 1.8.50 mesurait sur une page de test locale avec Poppins chargée via Google Fonts en proxy du self-hosted OMGF réel — métriques légèrement différentes, sous-estimant le débordement. Vérifié en direct sur les 25 pages bcengi live : FR desktop (jugé « ne déborde jamais » par le proxy, laissé à 23px) chevauchait réellement le bouton de ~14px à 640px. Nouveau sweep Playwright EN DIRECT sur les 25 pages bcengi live (pas de proxy) : desktop el 17 / nl 18 / es+uk 18.5 / bg 19 / hr+pt+sl 19.5 / ru 20 / ro 20.5 / fr+it 21.5 / fi 22 / cs+sk 22.5 (13 langues touchées, 10 intactes à 23px : da/de/en/hu/ja/ko/nn/pl/sv/tr) ; mobile el 9.5 / bg+uk 10.5 / es+nl 11 / pt+ru 11.5 / hr+ro+sl 12 / fr 12.5 (10 langues touchées, 15 intactes à 13px). Zéro violation (chevauchement + débordement viewport) vérifiée sur les 25 langues × 9 largeurs, marge ≥4px partout. EN et nadanada inchangés (23/19 desktop, 13/11 ≤639px, identiques). 1.8.50 : PAGES CODE PROMO — sticky sans-code, taille PAR DÉFAUT = nadanada (retour Etienne, « à largeur égale, ça ressemble pareil ») : l'override global 20/16 desktop & 9.5/7.5 ≤639px posé en 1.8.49 est retiré — le no-code hérite désormais pile .mps-l1/.mps-l2 (23/19 desktop, 13/11 ≤639px) pour TOUTES les langues qui rentrent, EN comprise (désormais identique pixel-pour-pixel à nadanada). Seules les langues qui débordent RÉELLEMENT à taille pleine reçoivent une réduction ciblée, calibrée par sweep Playwright réel (25 langues × 9 largeurs 320-1280, bcengi, mesure du débordement du TEXTE réel via Range — pas juste la box flex-shrink) : desktop el 20px / nl 21px / es+hr 21.5px / pt+uk 22.5px (6 langues touchées, 19 intactes à 23px) ; mobile el 9.5px / nl+uk 10.5px / es+hr+ru 11px / pt+sl 11.5px / bg+ro+sv 12px / fr+it 12.5px (13 langues touchées, 12 intactes à 13px, dont en/de/pl/da/cs/fi/hu/nn/sk/tr/ja/ko). Scoping par langue via nouvelle classe `.mps-lang-xx` posée par le générateur sur `.mbs-promo-sticky-bar` depuis $v['lang'] — PAS via `<html lang>` : vérifié en direct sur une page bcengi/el réelle, l'attribut lang du document est incorrect (bug Polylang préexistant, hors périmètre de ce correctif), alors que $v['lang'] (qui pilote déjà tout le contenu traduit affiché) est fiable à 100%. `.mps-lang-xx` est posée sur TOUTES les pages (nadanada compris) mais sans aucun effet dessus : chaque règle exige aussi `.mps-txt--nc`, vérifié par test automatisé dédié (nadanada taggé de la langue la plus réduite reste 23/19 et 13/11 à l'identique). nadanada intégralement inchangé : aucune règle partagée (.mps-l1/.mps-l2/.mps-code/.mps-bt/.mps-in) modifiée. 1.8.49 : PAGES CODE PROMO — sticky sans-code (bcengi) refondu pour reprendre EXACTEMENT le layout .mps-l1/.mps-l2 de la variante à code (nadanada) au lieu de l'ancien .mps-payg (nom eyebrow gris + phrase bridée 16ch, jugé petit/coincé) — wording INCHANGÉ (phrase existante $v['grab_tmpl'] coupée au niveau de {discount} : "avant + nom" en L1, "montant ambre .mps-amt + après" en L2), retour Etienne 2026-08-07. .mps-payg retiré du générateur (n'est plus jamais émis) et de la CSS (règles mortes supprimées). Ajout ciblé : `.mps-l2 .mps-amt { color:#fbbf24 }` (le montant reste ambre) + 2 tailles scoped `.mps-txt--nc` (posé UNIQUEMENT en no-code, jamais sur nadanada) : 20px/16px desktop (≥640px) et 9.5px/7.5px ≤639px, calibrées par test réel (Playwright headless, 26 langues × 9 largeurs 320-1280, bcengi) — aux tailles nadanada (23/19 et 13/11) la ligne 1 chevauchait le bouton à 320px et 640px pour ~12 langues dont la phrase no-code est mécaniquement plus longue (nom + verbe/objet localisé entier, ex. ES "Bcengi Conseguir mi descuento de") ; les tailles scoped ci-dessus passent les 26 langues aux 9 largeurs avec marge ≥4px, jamais de chevauchement, toujours exactement 2 lignes, bouton toujours à droite. nadanada (variante à code) intégralement inchangée : aucune règle partagée (.mps-l1/.mps-l2/.mps-code/.mps-bt/.mps-in) modifiée, seul un sélecteur scopé ajouté. 1.8.48 : PAGES CODE PROMO — sticky, 5e itération (retour Etienne, règles fermes) : (1) bouton TOUJOURS à droite, jamais empilé — .mps-in passe en flex-wrap:nowrap (était wrap), retrait total de la règle ≤480px qui renvoyait le bouton en pleine largeur ; (2) fix du bug d'inversion "plus de place = plus petit" — toutes les tailles (gap/logo/police/padding) repassent en valeurs FIXES (aucun clamp()/vw) : un premier palier fixe ≥640px (les valeurs qui rendaient "parfaites" à 640px selon Etienne), strictement CONSTANT jusqu'à 1280px (l'espace en trop devient de la marge via le centrage existant, jamais des éléments plus petits) ; un second palier fixe ≤639px, plus compact, calibré par test réel pour que même la langue/remise la plus longue (grec "-50%") tienne sur exactement 2 lignes à 320px SANS jamais faire passer le bouton en dessous (le bouton nowrap+flex-shrink:0 ne bouge jamais de la ligne) ; (3) .mps-code (pastille code) agrandie, police 18px→20px et padding 2px 12px→4px 14px, aux deux paliers ; (4) diagnostic du "trou" signalé en no-code : ce n'est PAS un élément DOM vide (.mps-code n'est jamais émis en no-code, vérifié) mais l'espace non utilisé à DROITE de chaque ligne à l'intérieur de la boîte .mps-cta — max-width en `ch` fixe la largeur de la BOÎTE, pas la largeur réellement occupée par le texte une fois enroulé sur 2 lignes inégales (aucune propriété CSS ne fait "épouser la ligne la plus longue après enroulement" sans JS, hors scope 100% CSS) ; seul levier possible : resserrer le plafond au plus juste — max-width descendu de 18ch à 16ch (valeur la plus serrée qui garde encore les 25 langues à exactement 2 lignes, testé), ce qui réduit mécaniquement l'espace inutilisé donc l'effet "trou" perçu. Eyebrow nom toujours visible (inchangé du round précédent). Vérifié Chromium headless (Playwright) : bouton jamais empilé + tailles rigoureusement identiques (JSON égal) entre 640/768/900/1024/1280px + eyebrow visible + exactement 2 lignes + texte intégral + zéro débordement, sur les 9 largeurs demandées (320-1280) × 10 langues/remises pour le no-code, + régression code (12 largeurs). Captures d'écran régénérées dans scratchpad/sticky-check/ (bcengi no-code + simbye code, 9 largeurs). Aucun fichier PHP touché, uniquement assets/css/promo-page.css. // 1.8.47 : PAGES CODE PROMO — refonte complète du sticky SANS-CODE (retour Etienne, 4e itération, capture à l'appui) : les rounds précédents ont fait diverger le sans-code (.mps-payg) du mode À CODE (.mps-l1/.mps-l2, stable et validé) avec une cascade de paliers de plus en plus agressifs -> eyebrow nom masqué sous 900px (INTERDIT), gros vide texte/bouton, bouton déséquilibré, tout minuscule. Directive : MÊME modèle que le mode à code, contenu seul change. Fix : (1) eyebrow (.mps-nm) TOUJOURS visible, plus jamais masqué ; (2) retrait de tout flex-grow/flex-shrink custom sur .mps-lft/.mps-txt (comme le mode à code, jamais touché) ; (3) .mps-bt repasse en flex-shrink:0 (jamais écrasé/pixelisé) — seul mécanisme de secours : .mps-in en flex-wrap + palier fixe ≤480px où le bouton bascule en pleine largeur sur sa propre rangée (pas une cascade de rétrécissements) ; (4) .mps-cta (texte du deal) bridé à max-width:18ch (unité proportionnelle à sa propre police -> se remet à l'échelle seule à chaque palier, pas de valeur par palier à deviner), calibré par test réel (Chromium headless) pour tomber sur EXACTEMENT 2 lignes (jamais 1, jamais 3) sur les 10 combinaisons langue/remise testées (dont l'anglais "Unlock my -7$ discount", le texte exact cité par Etienne) ; (5) gap .mps-in resserré (clamp 12-20px, était jusqu'à 36px) pour éliminer le vide visuel entre texte et bouton aux largeurs intermédiaires (900-1280px), bénéficie aux deux modes. Vérifié Chromium headless (Playwright) : 160 mesures (16 largeurs × 10 langues/remises) — eyebrow toujours visible + exactement 2 lignes + texte intégral + zéro débordement, sur les 160. Mode code re-vérifié sans régression (12 largeurs). Captures d'écran prises aux 9 largeurs demandées (320/375/414/480/640/768/900/1024/1280) pour bcengi sans-code ET simbye à code, dans scratchpad/sticky-check/. Aucun fichier PHP touché, uniquement assets/css/promo-page.css. // 1.8.46 : PAGES CODE PROMO — sticky : le texte du deal ne doit JAMAIS être tronqué (retour Etienne : « tronquer c'est non négociable »), le line-clamp du 1.8.45 réglait le « jamais 3 lignes » mais coupait le texte (« … ») en no-code étroit -- retiré (plus de -webkit-line-clamp/overflow:hidden). Nouveau modèle : le texte du deal (.mps-payg .mps-cta, et .mps-l1/.mps-l2 en mode code) est PRIORITAIRE et ne rétrécit JAMAIS ; c'est le bouton rouge (.mps-bt) qui cède — sous 900px, .mps-lft/.mps-txt passent en flex:1 1 auto (reçoivent tout l'espace restant après logo+bouton+gaps, jamais un chiffre deviné) pendant que .mps-bt reste flex:0 0 auto avec sa propre police/padding réduits par palier (900/640/360/330/300px, ce dernier pour le cas extrême Z Fold plié ~280px). En dessous de 330px le bouton est autorisé à s'écrire sur 2 lignes lui-même (overflow-wrap/word-break en secours, jamais un mot coupé au milieu — vérifié, largeur ajustée 68→84px pour l'éviter). Desktop (>900px) inchangé. Vérifié en Chromium headless (Playwright) : 18 largeurs (280-1280px) × 10 langues/remises (fr/de/hu/fi/es/it/ro/el/en×2, dont hu+el = les combinaisons texte+bouton les plus longues) = 180 mesures, TOUTES : texte intégral (zéro troncature), ≤2 lignes, zéro chevauchement/débordement horizontal. Mode code (nowrap, 2 lignes fixes par construction) re-vérifié sans régression sur les mêmes largeurs. Aucun fichier PHP touché, uniquement assets/css/promo-page.css. // 1.8.45 : PAGES CODE PROMO — corrections round 25 (retours Etienne sur les 3 retouches précédentes), 100% CSS : (1) lien "Lire notre avis complet sur {name}" (.about) : ANNULE le format bouton (bordure/fond/padding retirés) -> mis en valeur en LIEN (couleur accent #0ea5e9 identique à .av-h2rule/.av-faq-q .sg + flèche "→" + soulignement au survol seulement), texte/URL inchangés, toujours ciblé par structure [href] donc live sans régénération ; (2) sticky bar, garantie ABSOLUE "jamais 3 lignes" (25 langues, vérifié par un vrai navigateur headless Playwright, pas une estimation) : .mps-payg .mps-cta passe en -webkit-line-clamp:2 (+ line-clamp:2 standard) comme filet de sécurité INCONDITIONNEL, combiné à un vrai effort de taille (clamp() resserré, palier intermédiaire ≤900px ajouté pour la zone la plus à risque -- eyebrow déjà masquée mais logo/bouton pas encore réduits -- + palier ≤360px) et de largeur (.mps-lg et gaps .mps-in/.mps-lft resserrés, bénéficie aussi au mode code) ; mesuré à 13 largeurs (320-1280px) × 8 langues/remises (dont fr = le cas exact signalé, + de/hu = les chaînes les plus longues) : ZÉRO occurrence de 3 lignes, la troncature line-clamp ne se déclenche que sur 3 langues aux 2 plus petites largeurs (comportement voulu, jamais une 3e ligne visible) ; bouton rouge .mps-bt élargi et agrandi une 2e fois (desktop clamp 20-29px, mobile clamp 14-19px, était 19-26px/13-17px) ; garde .mps-code:empty (round 25 précédent) conservée. (3) bande "Comparer avant de commander" : inchangée depuis le round précédent (déjà 100% CSS, largeur égale confirmée par mesure navigateur : 283px = 283px). Vérifié par render_promo_page() réel (Reflection) + rendu RÉEL dans Chromium headless (Playwright) : mesure de lignes/hauteur/largeur, pas une supposition. Aucun fichier PHP de template touché, uniquement assets/css/promo-page.css. // 1.8.44 : PAGES CODE PROMO — 3 retouches UX/CSS (retours Florentin/Etienne 2026-08-06), template-wide (code + sans-code), CSS pur dans promo-page.css, ZÉRO texte/i18n touché : (1) lien "Lire notre avis complet sur {name}" (bloc .about) transformé en bouton secondaire visible (bordure + fond léger, plus juste un texte souligné) — ciblé par structure ([href], pas de nouvelle classe) donc live immédiatement même sur les pages déjà générées (l'inline style existant du render ne fixe que display/align-items/gap/font-size/font-weight, jamais bordure/fond/padding, donc aucun conflit de spécificité) ; (2a) bouton CTA rouge de la barre sticky (.mps-bt) élargi + police agrandie (desktop et mobile ≤640px) ; (2b) enquête "boîte sombre vide" en sans-code entre le texte et le CTA : relecture complète de l'historique git du bloc confirme que la variante sans-code (.mps-payg) n'a JAMAIS émis de pastille .mps-code depuis son tout premier commit (ce n'est pas un bug du template actuel) — ajout d'une garde défensive .mps-code:empty{display:none} au cas où une page très ancienne servirait encore du HTML figé d'avant la bascule sans-code, sans dépendre d'une régénération ; (3) bande "Comparer avant de commander" (.band-row) passée de flex à grille 2 colonnes égales (1fr/1fr) pour garantir au bouton rouge la MÊME largeur que le bleu au-dessus quelle que soit la langue (le texte le plus long n'est pas toujours le même selon la langue — en FR c'est même le rouge), avec repli 1 colonne ≤480px. Vérifié par render_promo_page() réel (Reflection) sur Simbye EN (code), Bcengi EN + Bcengi FR (sans-code) : structure des 3 zones identique avant/après (aucun fichier PHP touché, uniquement assets/css/promo-page.css) — les 3 correctifs sont donc 100% CSS, LIVE dès le déploiement, sans régénération d'aucune page. // section "vérification/confiance" ("Autres codes testés") REMISE en mode sans-code (décision Etienne), reframée en argument de confiance ("pas de code chez {name}, la réduction est automatique via notre lien, méfiez-vous des faux codes") au lieu d'être masquée. Nouveau mbs-promo-nocode-verify-i18n.php (25 langues, seo-localizer, 6 clés : other_codes_title/intro/note_nocode + oc_label/oc_valid_desc/oc_invalid_desc_nocode). build_view_model() : titre/intro/note branchent sur $NC3 en no-code (titre unique, plus de distinction multi/single) ; ui.oc_label_nocode/oc_valid_desc_nocode/oc_invalid_desc_nocode ajoutés. render_promo_page() : le bloc .oc-card ne se masque plus en no-code ; la ligne verte n'itère plus $v['oc_valid'] en no-code (un seul <li>, réduction automatique au lieu d'un code) ; les lignes rouges (faux codes) restent une boucle inchangée, description reframée. ui.oc_verified/oc_fail INCHANGÉS (réutilisés tels quels, vérifiés code-free). // 1.8.42 : PAGES CODE PROMO — (A) diagnostic renforcé du "figement" steps/FAQ : le fix OPcache ciblé v1.8.41 (déployé + déclenché) n'a PAS résolu le symptôme sur simbye EN, et une suppression+régénération, elle, l'a résolu (0 See the offer, 6 See Simbye deals) — preuve que render_current()/live_render_content() ne s'exécutait PROBABLEMENT PAS DU TOUT pour ces pages (retour null sur un garde AMONT, jamais un cache) : hero (hero_cta_named) et steps/FAQ viennent du MÊME appel build_view_model() lisant le MÊME mbs_promo_i18n() — architecturalement impossible que l'un soit frais et l'autre figé au sein d'un rendu réussi. AVANT ce correctif, chaque retour anticipé de render_current() ET le cas "live_render_content() reçoit null" étaient TOTALEMENT SILENCIEUX (zéro log) — rendait ce symptôme indiscernable d'un problème de cache. Ajoute un log mbs_api_log() à CHAQUE point de sortie anticipée (raison + contexte : provider_id, lang, présence profil/vue stockée) — aucun changement de comportement, uniquement de la visibilité pour la prochaine occurrence. (B) Section "Comment la réduction est-elle appliquée ?" REMISE en mode sans-code (décision Etienne) : nouveau fichier mbs-promo-nocode-howto-i18n.php (25 langues, seo-localizer) — bandeau adapté (coupon_exclusive_line_nocode), libellé téléphone sans code (phone_discount_label_nocode remplace "Code {code}"), intro dédiée (howto_nocode.intro_html, mêmes attrs data-cart-intro-*), 3 steps sans code (ouvrir la boutique / réduction automatique / payer le prix réduit, citant le vrai libellé du bouton cta_nocode.short). Le <div class="cart"> ne se masque plus en no-code ; gate = is_no_code_promo(). GAP CONNU signalé, non comblé (hors périmètre seo-localizer) : howto_name (JSON-LD HowTo.name uniquement, jamais de texte visible) mentionne encore "code" dans les 25 langues, y compris en mode sans-code. // 1.8.41 : FIX RACINE OPcache — invalidation ciblee des fichiers du plugin a chaque bump de version (resout « deploye mais pas affiche » : rsync copie bien le fichier mais OPcache sert le vieux bytecode). // 1.8.40 : AVIS V2 — cas « sans code » : le CTA promo de la page avis (pastille + lien fin de page) pointe vers la PAGE REDUCTION DEDIEE (promo_page_url -> reduction-{slug}) au lieu du lien affilie direct, comme les fournisseurs A code pointent vers leur page code-promo (retour Etienne option b : coherence split + nourrit l'asset SEO). Lien interne. Bouton principal + sticky restent sur l'affilie. // 1.8.39 : PAGES CODE PROMO — cohérence CTA EN, 3e emplacement : le bouton de la section preuve ("preuve que le code fonctionne") lisait encore ui.claim_label ("See the offer") ; passe à ui.hero_cta_named ("See {name} deals", même clé/repli que le bouton héros — round CTA précédent) puisqu'il concerne le MÊME fournisseur que la page (contrairement à data-cta-label de la grille "autres codes promo populaires", qui reste ui.claim_label générique, INCHANGÉ, pour ne pas fuiter le nom du fournisseur courant sur les cartes des AUTRES fournisseurs). Diagnostic demandé sur les steps/FAQ soi-disant "figés" (round précédent, lignes ~376/387 de mbs-promo-i18n.php) : AUCUN bug de rendu trouvé — build_view_model()/build_promo_faq()/la boucle steps n'ont ZÉRO mécanisme de cache/override qui pourrait expliquer un texte figé alors que le CTA héros/sticky (même $I=mbs_promo_i18n() dans le MÊME appel) est frais ; vérifié par un rendu simulé complet de render_promo_page() (Simbye EN, code) : exactement 1 "See the offer" restant dans TOUT le HTML, et c'est le data-cta-label générique de la grille alts (attendu, hors périmètre) — 0 ailleurs. Cause la plus probable du décalage constaté en prod : ce repo (MyBestSimWordpress) déploie par SSH/rsync séparé (pas de pipeline auto comme le Worker Cloudflare) — mbs-promo-i18n.php sur le serveur live était probablement encore sur la version du round CTA précédent (hero_cta_named présent) sans les 2 correctifs steps/FAQ du round suivant (déployés/rsync plus tard) ; à vérifier côté serveur (fichier + reset OPcache si validate_timestamps=0) plutôt qu'un fix de code. // 1.8.38 : PAGES CODE PROMO — aligne le TEXTE d'instruction EN sur le nouveau CTA "See {name} deals" (round précédent) : la mention du bouton dans le step "Open the {name} store" (How is the discount applied?) et dans la FAQ "how_to" ("How do I use the {name} promo code?") disait encore "See the offer" (\u{201c}See the offer\u{201d} -> \u{201c}See {name} deals\u{201d}), texte visible ET JSON-LD (HowTo/FAQPage lisent le même $v[\'steps\']/$v[\'faq\']). Bloc en de mbs-promo-i18n.php uniquement ; claim_label INCHANGÉ (bouton CTA de la section preuve + libellé générique de la grille "autres codes promo populaires" pour d\'autres fournisseurs — hors périmètre, partagé, cf. round précédent). Vérifié : plus aucune occurrence de "See the offer" dans le bloc en hors claim_label. // 1.8.37 : PAGES CODE PROMO — CTA EN « See {provider} deals » (héros + sticky, clés hero_cta_named/cta_named additives). // 1.8.37 : AVIS V2 — CTA principal EN « See the offer {name} » -> « See {name} deals » (retour Etienne, EN uniquement via cle b_deals_cta ; les autres langues gardent « Voir l'offre {name} » etc. par repli). Ne touche PAS le CTA no-code « Unlock my {disc} discount » (garde tel quel, demande Etienne). // 1.8.36 : PAGES CODE PROMO — barre sticky no-code alignee sur l'avis V2 1.8.34 (retour Etienne : « mettre pareil sur le code promo »). promo-page.css classes .mps-* : sous 900px on masque l'eyebrow du nom (le logo porte la marque) + CTA text-wrap:balance -> 2 lignes MAX, montant jamais orphelin ; mobile plus grand (clamp 16-21px) + logo/bouton/gap compacts. Corrige le meme bug mobile (3 lignes/-7$ seul) que l'avis avait avant 1.8.33. // 1.8.35 : PAGES CODE PROMO — variante sans-code passe en wording « réduction » (retrait total de « code promo » : title/H1/meta/lead/slug reduction-{p}/CTA depuis mbs-promo-nocode-i18n.php, 25 langues) + bloc grec 'el' ajouté à mbs_avis_ui + fix dup AIOSEO. // 1.8.34 : AVIS V2 — barre sticky « sans code » mobile : layout compact (logo/bouton/marges/gap reduits) pour garantir le CTA sur 2 LIGNES MAX a taille lisible (mesure DOM 360-430px : 2 lignes, police 17-20px, jamais le montant orphelin via text-wrap:balance). // 1.8.33 : AVIS V2 — barre sticky « sans code » FIX mobile/etroit (retour Etienne : montant orphelin en bas + 3 lignes + trop petit). Sous 900px le label texte du nom est masque (le LOGO porte la marque) -> CTA « OBTENIR MA REDUCTION DE {disc} » sur 2 lignes MAX equilibrees (text-wrap:balance, jamais le montant seul), plus grand. Desktop >900px inchange (eyebrow + CTA nowrap). Gap .in reduit. // 1.8.32 : PAGES CODE PROMO — variante « sans code » pour les fournisseurs affilies avec une remise mais AUCUN code a copier (ex. bcengi, "-7$" applique automatiquement via le lien) : gate is_affiliate && code vide && discount non-vide (MEME gate que promo_link_only sur l'avis) ; hero = plus de boite code/bouton Copier, CTA "Obtenir ma reduction de {disc}" (l_grab/l_grab_sub avis reutilises, aucune chaine inventee) pointant direct sur le lien affilie ; panier phone-demo, "autres codes testes" et preuve capture masques entierement (tous fondamentalement centres code, jamais reformules a la volee) ; FAQ : 8 des 9 questions (centrees code) exclues + "discount" (simulation prix) exclue si 0 forfait reel, section entiere masquee si vide ; HowTo/FAQPage JSON-LD omis si steps/faq vides (jamais de schema creux) ; Offer.description sans tiret cadratin en trop si code vide ; is_promo_complete()/rest_set_promo() acceptent code OU discount (plus code obligatoire), garde provider_has_products desactivee pour le cas sans-code ; sticky bas de page = mirror EXACT du sticky sans-code avis 1.8.31 (.mps-payg/.mps-nm/.mps-cta/.mps-amt, nom en eyebrow majuscules + CTA MAJUSCULES montant ambre) ; GAP CONNU non corrige ici : mbs_avis_ui() n'a pas de bloc 'el' (grec), fallback EN pour l_grab/l_grab_sub sur les pages grecques (~40 cles, a localiser par seo-localizer). // 1.8.31 : AVIS V2 — barre sticky cas « sans code » rendue « sexy » facon code promo (retour Etienne) : nom du fournisseur en LABEL majuscules (eyebrow, gris, lettres espacees) sur ligne 1, CTA « OBTENIR MA REDUCTION DE {disc} » en MAJUSCULES bold 900 sur ligne 2 (montant en accent ambre #fbbf24). 2 lignes forcees (.payg .nm + .cta en block), CSS avis-v2.css + mobile. // 1.8.30 : AVIS V2 — barre sticky cas « sans code » : UN SEUL bloc « {nom} Obtenir ma reduction de {disc} » qui s'enroule sur ~2 lignes (white-space normal, l1/l2 etaient nowrap), montant en gras. AUCUN doublon (nom/reduction/montant chacun une seule fois). Retour Etienne (fin des allers-retours). // 1.8.29 : AVIS V2 — barre sticky cas « sans code » remise au MEME FORMAT que le cas code (retour Etienne) : nom du fournisseur TOUJOURS + 2 lignes. l1 = nom + reduction (montant en gras via .disc), l2 = CTA court « Obtenir ma reduction » (l_grab_sub) sur une seule ligne. // 1.8.28 : AVIS V2 — BUGFIX tuile Donnees « Pay -> per -> kilobyte » : data_tile() transformait TOUS les tirets en fleches (prevu pour une plage « 0,5 Go - Illimite »), donc « Pay-per-kilobyte » (fournisseur pay-as-you-go, ex. bcengi sans forfait data) devenait « Pay -> per -> kilobyte ». Fix : fleche UNIQUEMENT sur les plages chiffrees ; valeur pay-as-you-go detectee -> label localise u_payg (« Paiement a l'usage » / « Pay as you go », 25 langues) ; tout autre texte non numerique garde tel quel. // 1.8.27 : AVIS V2 — cas « affilie sans code » : barre sticky = UNE SEULE ligne = le CTA « Obtenir ma reduction de {disc} » (montant en gras via .disc), plus de headline redondant au-dessus (evite « reduction » repete). Uniquement ce cas, aucune autre page touchee. // 1.8.26 : PAGES CODE PROMO — tuile logo fournisseur auto-résolue depuis _mbs_logo_id (même source que les pages avis) quand promo.logo_url est vide → le vrai logo médiathèque s'affiche automatiquement pour tout fournisseur. // 1.8.25 : AVIS V2 — cas « affilie sans code », affinages Etienne : (1) barre sticky ligne 2 = CTA COMPLET avec le montant « Obtenir ma reduction de {disc} » (l_grab, 25 langues) au lieu de la version courte ; (2) PAS DE BANNIERE si l'affilie n'a AUCUN deal (ni code ni reduction) : garde `if (\$has_promo)` au lieu de `if (\$is_affiliate)` ; branche note supprimee de la sticky (jamais de note dans la banniere), remplacee par le cas code-sans-reduction. // 1.8.24 : PAGES CODE PROMO — CAUSE RACINE du 404 /el/ trouvee via la trace prod (checkpoint entree + SQL final) : le swap route_promo_slug() est et a toujours ete correct (post_type bien bascule sur mbs_provider_promo, prefixe grec bien matche) -- le vrai bug est que pll_current_language() de Polylang detecte 'fr' au lieu de 'el' pour une URL /el/..., donc le JOIN posts_clauses de Polylang cible le terme de langue FR (term_taxonomy_id confirme = 132) au lieu du terme el que porte reellement la page -> 0 ligne -> 404, alors que routage/slug/langue-de-la-page sont tous corrects. Fix : route_promo_slug() force desormais $query->set('lang', $matched_lang) au moment du swap (suffixe ET prefixe, la boucle suffixe capture maintenant aussi la cle de langue) -- mecanisme de surcharge de langue par-requete documente de Polylang, s'execute pendant pre_get_posts donc AVANT que posts_clauses construise le JOIN. No-op pour les 24 langues deja correctement detectees, corrige el sans devoir comprendre pourquoi Polylang le mal-detecte (hypothese non verifiee : slug d'URL configure differemment du code 'el' cote reglages Polylang). Trace de diagnostic temporaire (route_promo_slug + trace_promo_sql + hook posts_request) RETIREE dans ce meme commit. // 1.8.23 : PAGES CODE PROMO — sous-probleme (a) grec RESOLU (retry 1->3 confirme en prod par Etienne, page el naissante desormais correcte). Sous-probleme (b) persiste : joli-permalien /el/parohos/{slug-promo}/ 404 alors que la query-var directe (?mbs_provider_promo=...) rend 200 et que l'avis grec /el/parohos/avis-.../ rend 200 -- isole au swap route_promo_slug() (CPT review -> promo) OU a un filtrage Polylang par langue APRES le swap, specifiquement pour el. TRACE TEMPORAIRE ajoutee (a retirer une fois la cause trouvee) : route_promo_slug() logue 4 points de sortie (entree, guard post_type, swap suffixe, swap prefixe, aucun match) + trace_promo_sql() logue le SQL FINAL (posts_request) pour toute requete dont le nom contient 'kodikos-ekptosis' -- but : distinguer "le swap ne se produit jamais" (confirme l'hypothese d'Etienne) de "le swap reussit mais une clause WHERE/JOIN langue exclut quand meme la ligne" (SQL visible dans le log). class-provider-promo-page-cpt.php uniquement. // 1.8.22 : PAGES CODE PROMO — routage grec (el) : retry pll_set_post_language 1→3 + maybe_flush_rewrite_rules_once (flush une fois par bump de version, CPT nouvellement traduisible). // 1.8.21 : AVIS V2 — cas « affilie sans code » (bcengi), retours Etienne : (1) wording CTA option C « Obtenir ma reduction de {disc} » (l_grab) + montant en gras/agrandi dans la pastille du heros ; (2) barre sticky = 2 lignes SANS note (jamais de note dans la banniere) : ligne 1 = nom + reduction (mise en avant), ligne 2 = incitation « Obtenir ma reduction » (nouvelle cle l_grab_sub). l_grab re-localise en C + l_grab_sub, 25 langues. // 1.8.20 : AVIS V2 — barre sticky bas de page adaptee au cas « affilie sans code » (bcengi) : elle affiche desormais le DEAL (nom + reduction + « de reduction ») comme le cas avec code, mais SANS ligne « avec le code XXX » (remplacee par la note ★). Branche promo_link_only ajoutee dans la sticky ; aucune mention de code. // 1.8.19 : AVIS V2 — CTA « Profiter de {reduction} » pour fournisseur affilie avec reduction SANS code (ex. bcengi -7$) : la pastille promo devient un CTA d'action (cle i18n l_grab, 25 langues) pointant DIRECT sur le lien affilie, aucune mention de code ; gate promo_link_only (is_affiliate && promo_code==='' && promo_discount!=='') -> fiches avec un vrai code inchangees. // 1.8.18 : PAGES CODE PROMO — CAUSE RACINE trouvee (a confirmer en prod, pas prouvable sans acces Polylang live) : ni mbs_provider_promo ni mbs_provider_page n'etaient JAMAIS enregistres via le filtre pll_get_post_types (grep sur tout le plugin = zero resultat) -> la traduisibilite dependait ENTIEREMENT d'une case wp-admin non garantie par le code -> pll_set_post_language() pouvait no-op silencieusement pour les fournisseurs neufs (yesim a ses langues car generees a une epoque ou la case l'etait). Fix : add_filter('pll_get_post_types') enregistre mbs_provider_promo en dur, independant de tout reglage admin (class-provider-promo-page-cpt.php). + assign_promo_page_language() vide desormais un cache (clean_object_term_cache + tentative best-effort PLL()->model, jamais fatal) entre set et verify. + PAUSE auto-drain (option mbs_promo_autodrain_paused, verifiee en tete de process_promo_queue) + POST /admin/pause-promo-autodrain + /admin/resume-promo-autodrain + /admin/clear-promo-queue (vide la file ET les verrous) pour generer les pilotes en isolation totale. + repair-promo-pages decouvre desormais TOUTES les pages par PATTERN DE SLUG (post_name__in, jamais tributaire d'une meta qui a pu ne jamais s'ecrire), fusionne avec la decouverte par meta, re-remplit _mbs_provider_id manquant. // 1.8.17 : PAGES CODE PROMO — fiabilite generation NOUVEAU fournisseur (airalo/nadanada/simbye salis en prod). (A) drain_provider_batch() plafonne desormais les CREATIONS fraiches a MAX_FRESH_CREATIONS_PER_BATCH=2 par requete (independant de QUEUE_BATCH_SIZE/SYNC_DRAIN_CAP) -- 5 creations neuves suffisaient a elles seules a faire 503 sur Hostinger, jamais mesure avant sur un lot 100% neuf. (B) create_single_promo_page() pose desormais _mbs_page_language + assign_promo_page_language() (Polylang, verifie+retry) IMMEDIATEMENT apres wp_insert_post, avant les 3 autres postmeta + AIOSEO -- une interruption (503) laissait avant une page SANS langue, que custom_permalink() routait alors par defaut sous /en/ quel que soit le slug reel, invisible au lookup langue-scope -> doublon au prochain essai. (C) mutex de drain mbs_promo_queue_running desormais PAR FOURNISSOR (option _{provider_id}) au lieu de global -- drainer un fournisseur ne bloque plus le cron des autres (4 fournisseurs en file se contendaient sur UN seul verrou). (D) nouvel endpoint POST /provider/{slug}/repair-promo-pages : reconcilie un fournisseur (langue deduite du slug reel via build_post_name() inverse, gere le cas ambigu ru/uk, canonical=publie>brouillon puis plus ancien, supprime les doublons, jamais les autres fournisseurs). // 1.8.16 : PAGES CODE PROMO — nouvel endpoint POST /provider/{slug}/set-promo : injection manuelle du bloc promo (code, discount_label, discount_type, affiliate_url + rating/logo_url/verified_at/codes optionnels) pour tester le gabarit sur les fournisseurs non branches au dashboard. MERGE dans _mbs_profile_json['promo'] (jamais d'ecrasement des autres cles du profil, jamais de perte d'un champ promo deja pose par un appel precedent). Erreur claire si fournisseur introuvable ou 0 forfait en D1 (echec ouvert sur panne API). Enfile la generation via le meme chemin que regenerate-promo (drain mutex + noindex-par-defaut deja en place, inchanges). // 1.8.15 : AVIS V2 — FIX tuile « Calls / SMS » (retour Maria/Simbye). build_essentials() lisait $profile['essentials'] et ['provider_meta'] en TOP-LEVEL alors que le plugin les stocke sous $profile['shared'] (comme le reste du rendu, l.~971) -> $ess toujours vide -> « Calls / SMS » affichait toujours « No » (les autres tuiles s'en sortaient via le fallback produits). Corrige en lisant $profile['shared'] EN PREMIER (repli top-level pour compat). Impacte aussi réseau (provider_meta) et rechargeable. Fix GLOBAL (tous fournisseurs), pas propre à Simbye. // 1.8.14 : AVIS V2 — CORRECTIFS AUDIT SEO (seo-strategist). (1) BLOQUANT noms de pays du tableau des forfaits localisés (dest_name() via code ISO2 + Locale intl -> plus de « Émirats Arabes Unis » sur une page RU/TR) ; repli sur le nom brut si intl absent. (2) BLOQUANT lien code-promo qui faisait 404 : promo_page_url() -> page dédiée si générée+publiée, sinon HUB localisé (promo_hub_url désormais résolu par Polylang, plus fr/en en dur). (3) BLOQUANT doublon hreflang : ancien add_hreflang_tags neutralisé sur les pilotes (laisse AIOSEO). (4) FAQ « inconvénients » : ponctuation cassée corrigée (rtrim des points avant jointure). (5) devise TR ajoutée (try/₺). (6) prix : 2 décimales conservées (« 2,60 € » pas « 2,6 € »). RESTE à traiter (signalé Etienne) : post_title WP « Code Promo » dans le breadcrumb JSON-LD AIOSEO, témoignages placeholder, conflit hreflang AIOSEO vs Polylang (config). // 1.8.13 : AVIS V2 — MISE EN PROD PILOTE (retour Etienne « tu peux balancer »). Les VRAIES pages avis live de 5 fournisseurs (airalo, simbye, esimo, bcengi, nadanada) rendent désormais le template V2 (contenu + SEO title/meta/schema/H1) au lieu de l'ancien design, dans TOUTES les langues. Mécanisme GATED + RÉVERSIBLE : fonctions mbs_avis_v2_pilot_slugs()/mbs_avis_v2_page_is_pilot() (helper) ; seo_ctx() reconnaît maintenant une page avis live d'un pilote (via _mbs_provider_id->slug + _mbs_page_language) -> tous les filtres SEO V2 s'appliquent ; filtre the_content prio 20 (render_live_content) remplace le contenu stocké par le rendu V2 (l'ANCIEN HTML reste en base = filet + rollback instantané en retirant un slug de la liste, contenu jamais touché) ; enqueue avis-v2.css en <head> sur ces pages (wp_enqueue_scripts). Anti-doublon : l'ancien add_structured_data (class-provider-pages-seo) est neutralisé sur les pilotes (le V2 émet son schéma) ; hreflang ancien conservé (pas de conflit). Non-pilotes = 0 changement. Baseline avant figée (Artifacts + brain). php -l OK. // 1.8.12 : PAGES CODE PROMO — fix concurrence drain : mutex mbs_promo_queue_running (add_option atomique) desormais partage entre process_promo_queue() (cron) et rest_drain_promo_now() (REST) — un cron et un appel REST (ou 2 appels REST) ne peuvent plus draine_provider_batch() en parallele sur le meme fournisseur (repro prod x2 : sv/da dupliques ~5s d'ecart). 2e appelant refuse proprement (busy:true, retry). Belt-and-suspenders deja en place (round 7, find_promo_page_by_slug avant wp_insert_post) desormais reellement efficace grace a la serialisation. // 1.8.11 : PAGES CODE PROMO — noindex tant que non approuve : meta _mbs_promo_indexable + filtre wp_robots (noindex,follow force via le pipeline core que AIOSEO utilise, priorite 999) + backstop regex sur le buffer <head> (un seul tag robots, jamais deux en conflit) + sync AIOSEO robots_default/robots_noindex (sitemap). 2 endpoints : POST /provider/{slug}/publish-review (publie tout, force noindex, sans regen — jamais de course avec le drain) et POST /provider/{slug}/approve-index (reversible, par langue ou tout). // 1.8.10 : STICKY i18n — ajout bloc 'el' (grec) manquant (barre sticky tombait en anglais sur les pages promo/avis grecques). Cache-bust. // 1.8.9 : PAGES CODE PROMO — fix collision de slug INTER-LANGUE (ru/uk partagent "promokod", romanisation identique) : uk adoptait la page ru par lookup de slug language-blind, ecrasait son contenu (page 212588 : URL ru, contenu uk). Fix en 3 parties : (1) find_promo_page_by_slug() language-scope (Polylang pll_get_post_language) reutilise aux 2 points d'adoption ; (2) meta _mbs_generated_promo_page_ids revalidee contre la langue Polylang reelle de la page pointee (auto-guerit une meta deja corrompue, comme ru+uk pointant la meme page) ; (3) create_single_promo_page() reapplique le slug exact APRES pll_set_post_language() — wp_insert_post tourne avant l'affectation de langue donc wp_unique_post_slug ne sait pas encore que c'est un doublon inter-langue legitime et suffixe -2 ; le forcer une fois la langue posee le corrige, zero effet sur les slugs uniques. + endpoint one-shot POST .../purge-promo-page (page_id explicite, jamais devine) pour nettoyer la page 212588 corrompue. Verifie en isolation (harness) : ru et uk obtiennent 2 pages distinctes au meme slug exact, auto-guerison sans purge meme necessaire, zero regression sur les 24 autres langues. // 1.8.8 : PAGES CODE PROMO — i18n vague 3-4 (15 langues : pt/ro/el/fi/hu/nn/sk/sl/hr/bg/ru/uk/tr/ja/ko, produites par 5 agents seo-localizer, mot-cle DataForSEO geocible + SERP reelle, {name}/{code} jamais declines) + routing (TYPE_SLUGS cross-checkes vs pages avis, SLUG_PREFIXES/SUFFIXES par langue) + $this->languages generateur (10->25) + format_money() locale-aware (0 decimale jpy/krw/huf, groupement des milliers derive de decimal_sep/money_sep — corrige ¥7500.00 en ¥7,500 / 12 990 Ft, zero regression verifiee sur les 10 langues live). Defauts provisoires assumes (a valider Etienne sur brouillons) : bg mot-cle upgrade data-driven, ru en RUB, slugs ja/ko romanises, tr Title/H1 vs slug divergents, fi dates numeriques. // 1.8.7 : AVIS V2 — TABLEAU DES FORFAITS EN DEVISE LOCALE (retour Etienne : « en France ça doit être des euros, pas des dollars »). Les prix étaient affichés en USD en dur (« $ ») — jamais converti (ma passe précédente ne portait que sur les textes). Nouveau : map devise par langue (fr/de/es/it/nl/fi/hr/sk/sl=eur, en=usd, pt=brl Brésil, pl=pln, da=dkk, no/nn=nok, sv=sek, cs=czk, hu=huf, ro=ron, bg=bgn, ru=rub, ja=jpy, ko=krw ; uk sans UAH dans /api/fx -> usd) + fetch /api/fx caché 1h (mêmes taux que widget/promo) + price_local() = conversion USD->devise marché, miroir EXACT de convertPriceWithParity() du widget (gère eur_parity), symbole + décimales par devise (0 pour jpy/krw/huf..., séparateur selon la langue via u_dec). Branché sur plans_from_products (tableau) + essentials_from_products (tuile « Forfaits à partir de »). Vérifié render : FR 0,45 € / PT 2,63 R$ / JA 82 ¥. // // 1.8.6 : PROMO — libelle '1 code promo actif' (.coupon-sub) en gras (retour Etienne). Cache-bust CSS. // 1.8.5 : AVIS V2 — « Alternatives populaires » passées de 4 à 6 fournisseurs (retour Etienne : 6 rend mieux, équilibré) + grille responsive 3 colonnes (3+3 sur desktop, 2 colonnes < 720px, 1 colonne < 460px). Serveur (popular_offers limit 6) + fallback JS (slice 0,6). // 1.8.4 : PAGES CODE PROMO — retouches template (retours Etienne, toutes langues via live render) : (1) libelle note = 'Note MyBestSim' ; (2) code promo actif en gras ; (3) carte hero entierement cliquable vers l'offre sauf bouton Copier ; (4) caption 'Capture d'ecran reelle du paiement' (FR/ES) ; (5) retrait '(capture ci-dessus)' 10 langues ; (6) section autres codes = 6 cards, grille flex centree (fin des cartes orphelines) ; (7) retrait de TOUTES les fleches CTA. Bump = cache-bust JS/CSS. // 1.8.3 : AVIS V2 — CTA sous le verdict passé au MÊME format que le CTA sous le tableau des forfaits (retour Etienne) : classe av-cta (bouton carré) -> av-pill av-pill-red (pilule arrondie, radius 999px). Cohérence visuelle des CTA. // 1.8.2 : AVIS V2 — retouches design (retours Etienne img 155/156). (a) icône coupon = SVG étiquette de prix PROPRE (fini le ticket avec « % » bricolé au milieu). (b) notes plateformes : étoile « avis vérifiés » passée en BLEU MBS #0ea5e9 (le vert #00b67a = marque Trustpilot, interdit) ; Google Play + Apple gardés. (c) TOUTES les flèches -> droite RETIRÉES de TOUS les CTA (hero, pill « voir les forfaits » x2, carte alternative « lire l'avis » SSR+JS, sticky/bannière). Seul le « → » de la PLAGE de données (« 0,5 Go → illimité ») gardé (pas un CTA). (d) FR « Forfait à partir de » -> « Forfaits à partir de » (pluriel, plusieurs forfaits) — u_plans_from. Validé php -l + render fr/de. Fichiers AVIS only. // 1.8.1 : PAGE CODE PROMO — i18n vague 2 (NL/PL/SV/DA/CS) : blocs localises data-backed (mots-cles DataForSEO : NL kortingscode, PL kod rabatowy, SV rabattkod, DA rabatkode, CS slevovy kod ; devises eur/pln/sek/dkk/czk ; parite clefs vs en, placeholders preserves, 0 orphelin verifie ; ordre marque/mot-cle adapte par langue via SERP, genitif pl/cs) + routing CPT cable (TYPE_SLUGS leverancier/dostawca/leverantor/leverandoer/poskytovatel ; suffixes nl=kortingscode/sv=rabattkod/da=rabatkode ; prefixes pl=kod-rabatowy/cs=slevovy-kod ; $languages += 5). Pages DRAFT a tester. // 1.8.0 : AVIS V2 — retouches template (retours Etienne, s'appliquent partout via {name}) + validation DataForSEO des 25 têtes. RETOUCHES : (a) icône coupon = SVG ticket-coupon visuel (remplace l'emoji 🎟) ; (c) notes plateformes (Google Play / App Store / avis vérifiés) mises en avant = carte blanche + petit LOGO SVG par plateforme (platform_icon()) + libellé plus lisible ; (d) « note moyenne MyBestSim » RETIRÉE sous la Note MyBestSim (redondant) ; (e) tableau forfaits resserré (grid 1.7/0.8/0.7/0.9 + column-gap, prix aligné à GAUCHE au lieu de flotter à droite) ; (f) CTA principal AJOUTÉ après le verdict (av-verdict-cta, réengagement) ; (g) pas de flèches sur ce CTA ni les liens finaux ; (h) « Aller plus loin » -> « Pour aller plus loin » (FR h2_g). VALIDATION 25 TÊTES au volume DataForSEO : confirmées FR/EN/DE/ES/NL/PL/IT(pluriel)/CS/FI/HR/HU/KO/SV/UK ; marchés négligeables gardés (BG/DA/RO/SK/SL/TR, SERP-choice ; RU=pas de Google Ads) ; CORRIGÉES par la data : NO+NN « anmeldelse/vurdering » -> « erfaringer/erfaringar » (80 vs 0, comme DE) et JA « 評判・口コミ » -> « 口コミ・評判 » (口コミ 1740 mène) — title/h1/b_hub. Validé php -l + render de/no/ja/pt. Fichiers AVIS only. // 1.7.99 : AVIS V2 i18n — passe VALIDÉE PAR LA DATA (DataForSEO volume + PAA). (1) IT : tête "Recensione" -> "Recensioni" (pluriel, +68% de volume : recensioni 3200 vs recensione 1900, intention avis confirmée par le SERP). (2) PT : basculé du portugais du Portugal vers le BRÉSIL — "opiniões" avait 0 volume ; tête refaite autour de "{name} é bom / vale a pena" (volume BR ~650), bloc pt entièrement re-localisé BR (mbs_avis_i18n + mbs_avis_ui : celular, operadora, cupom, "furada", "Verificado por"). (3) FAQ étoffée sur les VRAIES People Also Ask (fréquentes tous marchés) : 2 nouvelles FAQ templatisées localisées 25 langues via mbs_avis_ui (faq_c "Quels sont les inconvénients de {name} ?" bâtie sur les vrais cons du profil + repli localisé ; faq_d "{name} est-il un bon choix face aux autres eSIM ?" -> renvoi comparateur + note). Nouvelles méthodes cons_faq/compare_faq/templated_faqs ; ordre corps = fiabilité/couverture/inconvénients/comparaison + FAQ produit, MIROIR EXACT du schema FAQPage. 6/8 têtes de marché confirmées par le volume (FR avis 4020, EN review 4170, DE erfahrungen 4500, ES opiniones 1780, NL review 1420, PL opinie 3600). mbs_avis_ui passe à 62 clés. Validé php -l + render de/es/it/pt. Fichiers AVIS only. // 1.7.98 : PAGE CODE PROMO — i18n JS Phase C (retour Etienne "il faut TOUT traduire" + devise locale, screenshot ES) : libellés binaires FR/EN restants dans promo-page.js (label "Code promo"/"Promo code" + CTA "Voir l'offre"/"See the offer" des cartes "autres codes", en-têtes colonnes tableau data-label, unités Go/jour) déplacés vers includes/mbs-promo-i18n.php (nouvelles clés ui.alts_code_label + locale.currency, réutilise claim_label/price_table_col_*/gb_word/day_*) et poussés en data-attributes (data-mbs-promo-i18n) — plus de dictionnaire dupliqué en JS. Devise de marché (locale.currency, carte 25 langues documentée dans mbs-promo-i18n.php) : fr passe de $/USD à €/EUR (CHANGEMENT VISIBLE, cf. rapport), de/es/it passent à EUR, en reste USD. Conversion via nouveau resolve_currency_and_rate()/convert_price_with_parity()/fetch_fx_rates() (generator.php, cache transient 1h, /api/fx du Worker) côté SSR (build_price_table_rows() + compute_cart() — qui codaient TOUS DEUX un $price en USD en dur, bug distinct du diagnostic JS initial, trouvé et corrigé en même temps) et via fetch /api/fx + convertPriceWithParity() PORTÉ de cloudflare/src/widget.txt côté JS (fillCart() + fallback tableau). Gère eur_parity par produit. Seule 'uah' (uk) non couverte par /api/fx -> fallback eur documenté. TEMPLATE_VERSION bumped. Voir includes/class-provider-promo-pages-generator.php + includes/mbs-promo-i18n.php + assets/js/promo-page.js. // 1.7.97 : AVIS V2 i18n SEO Phase 2 : localisation COMPLETE 25 langues du RESTE du template avis (apres Phase 1 title/H1/H2 principaux). Nouveau helper mbs_avis_ui(lang) (57 cles x 25 langues, includes/mbs-avis-i18n.php) : meta description (gabarit du helper + {facts} localises + mois IntlDateFormatter), TOUS les libelles UI/unites (u_go/u_mo/u_day/u_countries/u_yes/u_no/u_unlimited/u_network/u_data...), titres de section restants (s_glance/s_reviews/s_install/s_verdict/s_trouble), byline (l_verified_by/l_mbs_rating/l_rating_note), reponses FAQ fiabilite+couverture localisees (FR/EN riche garde avec faits dynamiques, 23 autres = version simple faq_a_ans/faq_b_ans), prose corps (verdict head, Pour qui/Pas pour qui, On aime/moins, en-tetes tableau, intro destinations, sous-titres, Lire l'avis, CTA principal/forfaits/comparer, fil d'Ariane b_home/b_hub). Format de note LOCALE-AWARE (nouveau avis_num(n,lang) via u_dec : virgule partout sauf en/ja/ko) -> title/meta/note d'une page DE/ES/IT sortent 4,7 (plus 4.7) ; DECSEP injecte aussi dans le count-up JS. Data helpers essentials_from_products/plans_from_products/data_tile/build_essentials passes de $fr(bool) a $lang. reviews_hub 25 langues (etait 8 + fallback EN). Localisation seo-localizer (natif par marche). Valide php -l + test runtime 25 langues. Fichiers AVIS only. // 1.7.96 : PAGE CODE PROMO — i18n Phase B vague 1 (DE/ES/IT) : blocs de/es/it ajoutes a mbs-promo-i18n.php (localises mot-cle/marche par seo-localizer — DE Gutschein, ES codigo descuento, IT codice sconto ; placeholders preserves, parite des clefs vs en, 0 orphelin verifie) + routing CPT cable (TYPE_SLUGS anbieter/proveedor/fornitore ; SLUG_SUFFIXES de=gutschein ; SLUG_PREFIXES es=codigo-descuento / it=codice-sconto ; HUB_PATHS es ; generateur $languages += de/es/it). Slugs /de/anbieter/{x}-gutschein/, /es/proveedor/codigo-descuento-{x}/, /it/fornitore/codice-sconto-{x}/. Pages generees en DRAFT (a tester avant publication). // 1.7.95 : PAGE CODE PROMO — i18n Phase A (structurel) : refactor binaire fr/en -> helper data-driven mbs_promo_i18n(lang) (includes/mbs-promo-i18n.php ; ui/seo/faq/steps/tags/locale, gabarits a placeholders substitues en une passe) + routing CPT par table (TYPE_SLUGS/SLUG_PREFIXES/SUFFIXES/HUB_PATHS, pret pour N langues). fmt_data_ssr/fmt_days_ssr : $is_fr -> $lang. Zero regression FR/EN (harness diff byte-identique). fr/en peuples ; ajouter une langue = 1 entree helper + 1 entree routing. Prepare la traduction 25 langues. // 1.7.94 : AVIS V2 i18n SEO Phase 1 — title / H1 / 7 titres de section (H2) LOCALISÉS 25 langues via nouveau helper includes/mbs-avis-i18n.php (mbs_avis_i18n(lang), mot-clé+ordre optimaux par marché SERP réel — DE Erfahrungen, ES opiniones, IT/PT/RO mot-clé-first, JA 評判・口コミ, RU отзывы…). Remplace le FR/EN codé en dur + le H1 qui était FR-only. seo_title_core gère le cas sans note (strip clause). RESTE Phase 2 : meta, libellés UI, FAQ, H2 install/verdict/troubleshooting. Fichiers AVIS only (répartition inter-agents). // 1.7.93 : STICKY i18n 25 LANGUES (avis + code promo) — micro-copie localisee (seo-localizer, native/CTR par marche) branchee via helper partage includes/mbs-sticky-i18n.php (fonction mbs_sticky_i18n(lang), 4 champs rdc/conn/lecode/cta, fallback EN). Remplace le binaire FR/EN code en dur dans class-avis-v2-preview.php + class-provider-promo-pages-generator.php. 5 langues (fi/hu/tr/ja/ko) sans connecteur detachable -> conn vide, spans mavec/davec omis (ligne 1 mobile s'arrete apres la remise). Aucun changement CSS (classes .rdc/.mavec/.davec deja bien visibles desktop/mobile). // 1.7.92 : PAGE CODE PROMO — sticky RÉPLIQUÉ à l'identique depuis le sticky Avis V2 validé (demande Etienne). promo-page.css : bloc .mbs-promo-sticky-bar/.mps-* réécrit = traduction 1:1 du design avis (cluster centré, navy, 2 lignes, « avec » qui change de ligne mobile via .mps-mavec/.mps-davec, code plus gros mobile). Generator : HTML sticky (structure mps-lft/mps-txt/mps-l1/mps-l2/mps-pname/mps-disc/mps-rdc/mps-mavec/mps-davec/mps-code/mps-bt) mirroir de l'avis, i18n FR/EN gardée, wrapper .mbs-promo-sticky-bar[data-mbs-promo-sticky] conservé (révélation scroll + copier-code intacts). Rendu LIVE (the_content). TEMPLATE_VERSION bump. // 1.7.91 : AVIS V2 — sticky MOBILE decoupage exact (retour Etienne, 2 lignes) : ligne 1 « Yesim -20% avec », coupure APRES « avec », ligne 2 « le code MYBESTSIM20 ». Spans .mavec (mobile-only, « avec » sur l1) + .davec (desktop-only, « avec » sur l2). Desktop inchange (« Yesim -20% de reduction » / « avec le code MYBESTSIM20 »). Teste 320/375px : overflow ok, 2 lignes. // 1.7.90 : AVIS V2 — sticky MOBILE reagencement propre (retour Etienne img 153) : empilement a gauche sur 3 lignes, « Yesim -20% » (ligne 1, le nom ne bouge pas), « avec le code » (ligne 2), CODE seul sur sa ligne 3 en display:block plus gros (1.4em) -> plus de decalage en L. Retire les spans mcode/dcode (plus utiles). Desktop inchange. Teste 320/375px : overflow ok. // 1.7.89 : AVIS V2 — sticky MOBILE (retour Etienne img 152) : « avec le code » remonte sur la ligne 1 juste apres « -20% » (span .mcode, mobile-only), et le code seul sur la ligne 2 en PLUS GROS (1.3em) -> comble le trou apres « -20% ». l1 nowrap force une seule ligne « Yesim -20% avec le code » a toutes les largeurs (tailles clamp reduites pour tenir, teste 320+375px : overflow ok, l1lines=1). Desktop inchange (.mcode masque, .dcode « avec le code » sur l2). // 1.7.88 : AVIS V2 — sticky MOBILE/TABLETTE anti-vide (retour Etienne img 150+151) : passe de space-between (qui creait le vide a droite sur mobile et le gros trou au milieu sur tablette) a CENTRE (justify-content:center). Tailles RESPONSIVE clamp qui grandissent avec la largeur (l1 17->24px, l2 13->19px, logo 46->60px, CTA 13->18px, « -20% » a 1.18em plus gros) -> comble le range 320-640. Teste : 375px=12px de navy/cote, 640px=54px/cote, aucun overflow. // 1.7.87 : AVIS V2 — sticky MOBILE (retour Etienne img 148) : REAFFICHE le nom du fournisseur « Yesim » (etait masque), meme design que desktop. Ligne 1 « Yesim -20% » (« de reduction » retire pour la place), ligne 2 « avec le code MYBESTSIM20 » (le code passe seul en bas, entier). Tailles testees a 320/375/390px : overflow ok, aucune troncature. // 1.7.86 : AVIS V2 — sticky peaufinage espacement (retour Etienne img 147) : plus d'air entre les objets pour prendre un peu plus de largeur. gap .in 52->92px (logo/texte <-> CTA), gap .lft 26->34px (logo <-> texte), marge .55em entre « Yesim » et le « -20% ». Reste centre, un peu plus etale. // 1.7.85 : AVIS V2 — sticky reglage fin (retour Etienne img 146) : cluster CENTRE (justify-content:center) au lieu de space-between -> plus de gros vide navy a droite (navy symetrique reduit). TOUT plus gros : logo Yesim 64->78px, texte l1 ->31px, l2 ->26px, CTA ->22px. Texte REAGENCE sur 2 lignes REMPLIES : ligne 1 « Yesim -20% de reduction » (pname+disc), ligne 2 « avec le code MYBESTSIM20 » -> la ligne du haut n'est plus quasi vide. Mobile : pname+rdc masques -> « -20% » / « avec le code CODE » compact, pas de troncature. // 1.7.84 : AVIS V2 — sticky redesign (retour Etienne img 144) : barre BORD-A-BORD pleine largeur (bottom:0, padding:0, .in width:100% border-radius:0). « Yesim » + offre COLLES AU LOGO A GAUCHE (groupe .lft, text-align:left, plus centre). Texte BEAUCOUP plus gros (nm ->27px, sub ->25px) pour remplir. CTA « Voir l'offre » a droite (space-between). Mobile texte court « -20% avec le code CODE » lisible, pas de troncature. Responsive clamp. (rebase sur 1.7.83 promo apres collision). // 1.7.83 : PROMO — coherence navy (retour Etienne : « le navy = la couleur de la banniere, partout ») : tous les FONDS navy de la page alignes sur le #24365f exact de la sticky (badge -X% + band CTA passaient de #17264a a #24365f ; topbar coupon + numeros d'etapes passent du degrade #17264a->#24365f a l'aplat #24365f). Telephone (#0f172a, mockup) et texte navy inchanges. // 1.7.82 : PROMO — ROLLBACK du test heavy (retour Etienne : « c'etait mieux avant ») : les accents reviennent en bleu ciel (promo-page.css restauree depuis 3f01ef6) MAIS on GARDE le badge -X% des cartes en NAVY (#17264a) + le label « CODE PROMO » en noir. Etat final = 1.7.79 (label noir + discount navy + reste sky). // 1.7.81 : PROMO — TEST « heavy » (retour Etienne, REVERSIBLE) : tous les accents bleu ciel restants passent en navy marque pour que tout correle (souligne titres .av-h2rule, icones + FAQ, hover cartes, nav active, hover tableau, ::selection). Discount des cartes deja navy (1.7.79). Rollback = revenir a 1.7.79. // 1.7.80 : AVIS V2 — sticky refait (retour Etienne) : PLEINE LARGEUR (width:100%, max 1100px) avec le texte CENTRE (flex:1) qui remplit le milieu -> fini les trous ET le vide, quelle que soit la largeur. MOBILE corrige : texte court « -20% avec le code {CODE} » (« de reduction » masque), plus gros (14px), white-space normal -> plus de troncature du code. CTA du sticky = « Voir l'offre » (unifie avec la charte). // 1.7.79 : PROMO (retours Etienne 2026-07-31) — cartes « autres codes promo » : label « CODE PROMO » passe en noir (etait gris) + badge -X% passe en navy marque (#17264a, texte blanc) au lieu du bleu ciel (coherence palette navy/rouge/neutres). // 1.7.78 : AVIS V2 — retours Etienne : (1) sticky bas REFAIT — les « trous » (espaces vides du space-between pleine largeur) supprimes : la barre EPOUSE son contenu (width:fit-content, pilule centree, aucun trou par construction) avec contenu PLUS GROS (clamp : logo jusqu'a 60px, nom jusqu'a 21px, sous-texte 18px, -20% ~1.3em, CTA 18px) qui SE REDUIT avec la largeur (clamp responsive) ; layout flux unique logo + « {nom} / -X% de reduction avec le code {CODE} » + CTA « J'en profite ». (2) navy #24365f de la sticky code promo passe en SOLIDE (au lieu du degrade #17264a->#24365f) pour matcher exactement la sticky avis. // 1.7.77 : PROMO (retours Etienne 2026-07-31) — page code promo : sticky pleine largeur, fond navy marque (gauche = nom + « code promo CODE » / droite = reduction + bouton « Voir l'offre ») ; CTA « Voir l'offre » unifie dans le sticky, l'etape 2 du panier et la FAQ ; byline « Note X : Y » puis les etoiles ; code des cartes « autres codes promo » en noir + gras. // 1.7.76 : AVIS V2 — retours Etienne : (1) navy #24365f etendu au fond du header « Notre verdict » (.av-verdict-head, etait le quasi-noir #0f172a) -> coherence, plus de noir residuel. (2) sticky : « trop d'espace au milieu » -> ajout d'une 3e zone CENTRALE (note ★ {r}/5 · Note MyBestSim) pour remplir la barre pleine largeur (masquee < 560px). Proposition live a valider (alternatives proposees en chat). // 1.7.75 : AVIS V2 — retours Etienne : (1) navy des blocs code promo (hero .av-promo) + sticky un poil plus CLAIR (#17264a/#0f172a -> #24365f). (2) REFONTE du sticky bas : pleine largeur (fini fit-content) + nouveau layout — GAUCHE : logo + nom + « Code promo {CODE} » ; DROITE (empile) : « -20% de reduction » + bouton « J'en profite ». (Fait sur la page AVIS d'abord pour validation ; a repliquer a l'identique sur la page code promo apres go Etienne.) // 1.7.74 : AVIS V2 — retours Etienne (template, donc toutes les pages avis) : (1) title+H1 FR « Avis {nom} {annee} » au lieu de « {nom} Avis {annee} » (EN garde « {nom} Review » = marque d'abord, correct en anglais). (2) bloc code promo hero en NAVY (#17264a) au lieu du quasi-noir #0f172a. (3) ligne note reordonnee : « Note MyBestSim » + « 4,7/5 » PUIS les etoiles. (4) nombre d'avis (av-extnote-count) plus foncé/lisible (#cbd5e1 -> #64748b, 12px, weight 500). (5) verdict reword « {nom} est-il vraiment fiable ? » au lieu de « {nom}, ca vaut le coup ? ». (6) « Aller plus loin » CTA en navy/rouge/navy (comme la page code promo) au lieu du bleu. // 1.7.73 : AVIS V2 — fil d'Ariane : le lien mere pointait vers /fr/avis-esim/ (page INEXISTANTE -> 301 vers une fiche au hasard = lien casse) + tout etait FR hardcode. Corrige : nouveau helper reviews_hub($lang) qui pointe vers la page classement EXISTANTE « meilleurs-fournisseurs-esim » (reutilisee comme hub avis, anti-cannibalisation vs une nouvelle page), resolue PAR LANGUE via Polylang (pll_get_post_translations) + home + libelles localises (fallback EN). Le breadcrumb devient language-aware. // 1.7.72 : AVIS V2 — « Alternatives populaires » : CTA « Lire l'avis » RE-passé en ROUGE (#dc2626 / hover #b91c1c) — retour Etienne : j'avais mis du bleu en v1.7.69 (semantique « lire » vs « acheter ») mais le rouge est la couleur de CTA de la charte, on garde le rouge partout. // 1.7.71 : AVIS V2 — cohérence couverture : le texte « Notre verdict » ($verdict_lead, qui retombe sur le résumé) n'était pas passé par align_coverage_text() -> il restait un « plus de 200 destinations » visible. Normalisé lui aussi (couvre maintenant reviewBody + hero + « Qu'est-ce que » + verdict). // 1.7.70 : AVIS V2 + PROMO — (1) sticky bas : « de reduction » / « avec le code » passent en BLANC (#fff, comme le discount) au lieu du gris #e2e8f0, sur la page AVIS (.mbs-avis-v2-sticky .sub) ET la page CODE PROMO (.mbs-promo-sticky-bar .mps-sub, meme design recopie). (2) Coherence chiffre couverture (retour Etienne) : nouveau helper align_coverage_text() qui aligne tout « NNN destinations/pays/countries » du texte editorial (resume fige) sur le vrai nombre calcule depuis le catalogue /api/products -> fini la contradiction « 200 destinations » (resume) vs « 146 pays » (essentiel/FAQ). Applique au reviewBody du schema + chapô hero + corps « Qu'est-ce que ». Systemique = vaut pour Yesim et tous les fournisseurs (ne touche que les nombres suivis de destinations/pays/countries, jamais prix/durees). // 1.7.69 : AVIS V2 — « Alternatives populaires » : les cartes pointent desormais vers la page AVIS de chaque fournisseur (lien INTERNE dofollow, jus SEO conserve dans notre cluster avis) au lieu du lien affilie nofollow (decision Etienne : l'utilisateur d'une page avis est en recherche, pas en achat -> autant garder le jus). Carte = logo + nom + note + CTA « Lire l'avis » (bleu, action de lecture, plus le rouge d'achat). Source = top fournisseurs /api/promo-ranking, resolus vers leur vraie page avis (get_page_by_path + _mbs_generated_page_ids ; ecarte si pas de page). Fallback JS aligne (URL avis par pattern + langue). // 1.7.68 : AVIS V2 — fix liens morts « Alternatives populaires » (audit SEO) : popular_offers() mettait href="#" pour tout affiliate_url non-absolu, alors que /api/promo-ranking renvoie souvent un chemin relatif (/affiliation/{slug}) -> 3 cartes sur 4 avaient un lien mort. Fix : passe par full_url() (resolution home_url comme le CTA principal) + la carte est ecartee si l'URL ne resout pas en https absolu (plus jamais de href="#"). Meme fix sur le fallback JS (location.origin + chemin relatif). // 1.7.67 : AVIS V2 — sticky bas, reglages responsive (retours Etienne). (1) La barre EPOUSE son contenu (width:fit-content, pilule centree) au lieu d'etre etiree -> plus d'espace vide entre l'offre et le CTA a AUCUNE largeur (corrige mobile « trop d'espace » ET largeur intermediaire « trop d'espace »). (2) DESKTOP : saut de ligne « avec le code {CODE} » sur sa propre ligne (spans .l1/.l2 en block) -> lignage equilibre. (3) La version courte compacte (« de reduction » masque, texte reduit, nom masque) ne s'active plus qu'en dessous de 560px (vrais telephones) ; entre 560px et desktop on garde le texte complet + plus gros -> mieux equilibre. // 1.7.66 : AVIS V2 — refonte sticky bas (retours Etienne). MOBILE : plus clean/compact — nom du fournisseur masque (le logo suffit), « de reduction » masque (texte court « -20% avec le code {CODE} »), et le CTA rouge « J'en profite → » REAFFICHE (etait cache sur mobile, Etienne le veut). DESKTOP : texte promo plus GROS/flashy (clamp -> le « -20% » ressort a ~1.35em, sous-texte jusqu'a 17,5px) qui se REDUIT avec la largeur (clamp, pas de disproportion) ; CTA rouge plus en valeur (plus grand, glow rouge renforce, fleche gardee). // 1.7.65 : AVIS V2 — fix banniere du bas MOBILE : le sous-texte du sticky (« -20% de reduction avec le code {CODE} ») etait MASQUE par une regle display:none fantome d'une ancienne iteration (le sticky mobile n'affichait QUE le nom « Yesim ») -> regle supprimee + sous-texte mobile agrandi (11,5 -> 13,5px, code mis en valeur). + code promo du hero en BLANC par defaut (avant : blanc seulement au survol). // 1.7.64 : AVIS V2 — retours Etienne : texte promo (hero+sticky) plus en valeur (plus gras/gros) ;🎟 masqué sur MOBILE (il faisait passer la ligne). + doc process pages premium (docs/AVIS-PREMIUM-PAGES.md). // 1.7.63 : AVIS V2 — retours Etienne : bande promo hero SANS bouton « J'en profite » (toute la box cliquable, contenu centré) ; sticky bas = toute la barre cliquable (bouton retiré sur MOBILE, code promo affiché proprement sur mobile aussi). // 1.7.62 : AVIS V2 — retours Etienne : header « Notre verdict » en navy (même couleur que la bannière du bas) ; on AFFICHE le vrai code promo (dynamique) dans la bande hero ET le sticky, wording « -X% de réduction avec le code {CODE} » + « J'en profite » ; sticky = nom près du logo. // 1.7.61 : AVIS V2 — retours frère : flèches retirées des boutons « Aller plus loin » ; animation count-up sur les notes (externes + Note MyBestSim, décimales gérées) comme le nb de pays ; barre sticky bas PLUS GRANDE sur desktop (logo/texte/note/CTA) + responsive compact mobile. // 1.7.60 : AVIS V2 — ajustements retour frère : note 4,7 et /5 MÊME couleur (plus de gris bizarre) ; barre sticky bas en NAVY (plus le bleu) + CTA ROUGE + wording « J'en profite » (plus punchy que « Obtenir le rabais ») ; bloc notes externes plus ÉPURÉ (sans bordure/ombre). // 1.7.59 : AVIS V2 — barre sticky bas refaite façon simpackers : fond BLEU, logo, « -X% de réduction · {nom} » + étoiles + note, CTA blanc « Obtenir le rabais » (pas de code affiché). // 1.7.58 : AVIS V2 — retours frère (3 fixes) : note 4,7 du hero bien en GRAS (/5 en gris) ; bloc notes externes bien plus grand + pleine largeur sur DESKTOP (compact sur mobile, inchangé) ; boutons « Aller plus loin » en BLEU plein (aligné DA). // 1.7.57 : AVIS V2 — fix notes externes (lecture $shared['external_ratings'] + clé reviews_count) donc le bloc rend enfin + CTA de fin de page (comparateur / code promo / hub codes promo) pour redistribuer le jus et les utilisateurs. // 1.7.56 : AVIS V2 — retours frère (batch 2a) : note 4,7 en gras + « Note MyBestSim » en normal ; bande code promo aux couleurs de la page code-promo (navy + CTA rouge, discount affiche PAS le code) ; bloc notes externes (Google Play/App Store/« Avis verifies » = Trustpilot sans le citer) au-dessus de la Note MyBestSim pour la justifier. // 1.7.55 : AVIS V2 — retours frère (batch 1) : H1 « {nom} Avis {annee} - L'eSIM de {nom} est-elle fiable ? » (plus de bleu/italique) ; tag « Note MyBestSim » dans le hero ; L'essentiel labels au-dessus des valeurs + « Forfait à partir de » ; Avantages/inconvenients remontes juste apres les Avis ; header forfaits « À partir de » (plus d'uppercase) + CTA forfaits rouges ; « offres populaires » -> « alternatives populaires ». // 1.7.54 : AVIS V2 — section « Que faire si {nom} ne fonctionne pas ? » branchée sur la vraie data troubleshooting (jamais affichée jusqu'ici) + FAQ « fonctionne-t-il partout ? » templatisée (couverture réelle) en tête de FAQ visible ET du schema. // 1.7.53 : PROMO — retours frere d'Etienne : preuve V2 (boite bleue, 3 puces + CTA rouge « Voir l'offre », capture a droite) ; historique 5 dates espacees ~2 semaines (deterministe, jour complet) ; titres tableau des forfaits + « autres codes promo populaires » passes en H2 ; FAQ sur fond bleu (cartes blanches) ; CTA « Comparer toutes les offres » + bouton « Payer » du telephone passes en ROUGE ; label « Note {nom} » ajoute a cote de la note ; alternatives = liens INTERNES dofollow (hub des codes promo ; champ promo_url par fournisseur a exposer cote Worker) au lieu d'affilie nofollow ; reformulation du bloc « meilleur code promo » + phrase de reco + densite « code promo »/« moins cher ». // 1.7.52 : AVIS V2 — barre sticky bas masquee quand NON affilie (une autre pop-up gerera ce cas). // 1.7.51 : AVIS V2 — version SANS affiliation : CTA principal neutre (« Comparer les meilleures offres eSIM » -> comparateur, lien interne non-sponsor) au lieu de « Voir l'offre {nom} » ; bande code promo remplacee par un CTA hub general (« Nos meilleurs codes promo eSIM, jusqu'a -X% ») quand pas de vrai code ; sticky/forfaits idem. Bascule auto affilie<->non-affilie au rendu (lit le lien d'affiliation). Statut d'affiliation JAMAIS mentionne. // 1.7.50 : AVIS V2 — SEO wave 3 : « Offres populaires » rendues SERVER-SIDE (crawlables par Google/IA) ; le JS ne re-rend que si la grille est vide (fallback/degradation gracieuse). Aucun changement visuel. // 1.7.49 : AVIS V2 — SEO wave 2 : « Apercu des forfaits » derive des vrais produits (/api/products) quand pas de popular_plans cures (fin du tableau vide) + FAQ « fiabilite » templatisee dynamique (intention « avis fiable / arnaque / vitesse ») en tete de FAQ visible ET du schema JSON-LD. // 1.7.48 : AVIS V2 — SEO wave 1 : JSON-LD (Review reviewRating + FAQPage + Person, PAS d'AggregateRating ni Offer) via wp_head + suppression du H1 fantome du theme (the_title vide sur la page a shortcode). // 1.7.47 : PROMO — dedup breadcrumb via buffer du <head> (le filtre aioseo_schema_output ne prenait pas) : retire le BreadcrumbList du @graph AIOSEO, garde le notre 3 niveaux ; garde anti-buffer-imbrique. // 1.7.46 : AVIS V2 — title + meta : RETRAIT de toute mention « code promo » (retour Etienne : ne pas cannibaliser la page code-promo dediee). Title « {nom} Avis {annee} : Note {r}/5, Test Complet » (fallback « Test Complet & Verdict ») ; meta finit par « Test, avantages et verdict. Par Etienne Tillier, {mois} {annee}. » (plus de « -X% avec le code MYBESTSIM »). Intention « avis » pure. // 1.7.45 : PROMO — tableau des prix rendu CÔTÉ SERVEUR (en dur dans le HTML, crawlable Google + IA sans JS ; fetch /api/products cache 1h transient ; JS = fallback si tbody vide). + robustesse filtre strip breadcrumb AIOSEO (gere le cas STRING). // 1.7.44 : AVIS V2 — title : ajout du suffixe « | MyBestSim » (AIOSEO ne l'ajoutait pas sur cette page, il prend la chaine du filtre telle quelle) avec garde anti-debordement (droppe le suffixe si >62c). // 1.7.43 : AVIS V2 — TITLE + META-DESCRIPTION templatises (cible « {nom} avis », CTR/ranking, FR+EN, seo-strategist) poses via filtres AIOSEO + pre_get_document_title, gate sur le shortcode [mbs_avis_v2]. Title « {nom} Avis {annee} : Note {r}/5 + Code {disc} » (fallbacks) ; meta « {nom} est-il fiable ? … Par Etienne Tillier, {mois} {annee}. ». Donnees : nom+note postmeta, pays/prix /api/products, discount /api/promo-ranking (caches transient). // 1.7.42 : PROMO SEO (audit) — dedup BreadcrumbList (retrait celui d'AIOSEO, on garde le notre 3 niveaux), author Person (E-E-A-T) dans le schema Product, 9e FAQ « code promo / coupon / code de reduction » (FR+EN) = variantes de mot-cle tissees naturellement. // 1.7.41 : AVIS V2 round 13 (polish Etienne) — ligne « CODE PROMO » des offres populaires CENTREE dans la carte ; sur MOBILE le bandeau code promo du hero passe en colonne (label au-dessus, bouton pleine largeur dessous) au lieu de casser « Code promo Yesim » sur 3 lignes. // 1.7.40 : AVIS V2 round 12 (polish Etienne) — carte « Note MyBestSim » : retour fond CLAIR (le gros bleu faisait bizarre / trop de blanc), contraste via gros chiffre foncé sur carte surelevee. Offres populaires : CTA « Voir l'offre » RE-passe en ROUGE (meme rouge que le CTA principal). Cartes offres populaires restructurees en 3 rangees (logo+nom+discount / code promo+chip sur UNE ligne pleine largeur / CTA) -> code inline garanti + moins de hauteur (mobile). // 1.7.39 : PROMO — date de verification en VERT (byline + coupon), retour Etienne (patron) : signal de confiance vert (remplace le gris pose en r1 sur retour Florentin). // 1.7.38 : PROMO — alternatives mobile : label « Code promo » par carte retire (le titre de section suffit) -> le CODE s'affiche EN ENTIER sans troncature. // 1.7.37 : PROMO — byline mobile sur 2 lignes (nom / date verif + note, fini le serrage) + cards alternatives mobile resserrees (logo+CTA+label) pour afficher le CODE en entier sans depasser. // 1.7.36 : AVIS V2 round 11 (polish Etienne) — bandeau code promo CENTRE (au lieu de bord-a-bord) ; carte « Note MyBestSim » en degrade sky vif (contraste, la note ressort) ; sticky : fleche retiree du CTA ; « Voir tous les forfaits » RE-passe en BLEU ; offres populaires (alternatives) CTA+discount en BLEU (au lieu de rouge/vert) ; code promo des offres populaires sur une SEULE ligne (label+chip inline, gagne de la hauteur mobile). // 1.7.35 : AVIS V2 round 10 (retours Etienne, polish) — (A) bandeau code promo : bouton mieux cale (label a gauche / bouton a droite, space-between propre). (B) sticky bas : wording incitatif « Profiter de -X% » (au lieu de « Voir l'offre »), injecte dynamiquement, mene au lien affilie. (C) bloc « Pays couverts » descendu plus bas (apres « Apercu des forfaits », avant « Notre verdict »). (D) CTA en ROUGE partout : offres populaires « Voir l'offre » + pill « Voir tous les forfaits » (fini le bleu). // 1.7.34 : PROMO — retours Etienne : logo des cards alternatives SANS bordure (juste le logo, plus clean), tableau mobile empile = ligne « avec le code » teintee verte + prix vert gras plus grand (mis en avant comme desktop), section « meilleur code » = lignes compactees + wrap (ne debordent plus sur mobile). // 1.7.33 : PROMO — polish tableau mobile empile : drapeau + destination alignes a gauche dans l'en-tete de chaque carte. // 1.7.32 : AVIS V2 — drapeaux « Pays couverts » rendus SERVER-SIDE (PHP mb_chr depuis le code ISO2 de /api/countries, caches en transient) directement dans le HTML des chips. Fini le fetch client qui, en concurrence avec /api/promo-ranking au chargement, laissait des drapeaux vides/invisibles (bug non reproductible en manuel). + SEO-friendly (drapeaux dans le HTML brut). // 1.7.31 : PROMO — passe responsive mobile (retour Etienne) : tableau des prix EMPILE sur mobile (chaque ligne = une carte via data-label, plus AUCUN scroll horizontal), cards alternatives redessinees (logo pleine hauteur + corps 2 rangees nom+reduction / code+CTA, tout retrecit -> ne peut plus deborder), « A propos » logo en haut + texte dessous sur mobile, barre sticky code affiche EN ENTIER (fini « MYBESTS... » tronque ; badge -X% masque sur ultra-etroit), pill « Comparer » pleine largeur, historique de verif = 5 dernieres dates. // 1.7.30 : AVIS V2 — drapeaux pays robustes : emoji PRECALCULE dans la map (name_fr -> drapeau ISO2, fini le .replace/inner-fn qui laissait un espace vide au chargement) + garde anti-espace (on ne prepend que si vrai drapeau). // 1.7.29 : AVIS V2 — fix drapeaux « Pays couverts » : le champ flag de /api/countries est souvent un globe generique, on calcule donc l'emoji drapeau depuis le CODE ISO2 (name_fr -> code -> regional indicators). // 1.7.28 : AVIS V2 round 9 (retours Etienne) — (B1) bandeau code promo devient un CTA « Profiter de -X% → » (discount dynamique) qui mene a la page code (code jamais affiche). (B4) offres populaires : le CODE PROMO est reaffiche (label + chip mono) + fleche cassee corrigee (« → » -> →, WP texturize convertissait le → litteral) + CTA transforme en vrai bouton sky. (B2) pays couverts : toutes les regions REPLIEES par defaut (compact) + DRAPEAUX sur les chips (name_fr -> flag via /api/countries). // 1.7.27 : PROMO — rendu LIVE de la page (fini le HTML bake) : un filtre the_content re-render la page code promo depuis le profil stocke a chaque chargement (fallback sur le post_content baked si souci), date de verif + historique epingles sur la derniere generation. => tout changement de template/copie passe en ligne au deploy SANS relancer « Publier vers WP ». Corrige la cause racine de « je vois rien ». // 1.7.26 : PROMO round 2 (retour Etienne) — retrait de la section « Bon a savoir »/tags (repetitive avec le coupon + les conditions) ; « Voir les conditions » descendu juste sous le panier « Voyez la reduction appliquee » ; cards alternatives : label « Code promo » + chip sur UNE ligne (colonne gauche 2 lignes max, responsive mobile) ; titre « le meilleur code promo {name} disponible, teste et verifie » (FR+EN). Rappel : total + bouton du telephone en navy et colonne Duree du tableau = deja codes en r1 (1.7.22), s'appliquent a la regeneration. // 1.7.25 : AVIS V2 round 8 (retours Etienne) — (B1) bandeau code promo affiche le DISCOUNT dynamique (« : -20% », injecté client-side depuis /api/promo-ranking) SANS jamais montrer le code. (B2) nouveau bloc « Pays couverts par {nom} » : accordeon replie par region (Europe/Asie/…) avec chips pays, responsive, depuis destinations.by_region du snapshot. (B4) « Alternatives » -> « Offres populaires » DYNAMIQUES (cartes client-side depuis /api/promo-ranking, top 4 hors fournisseur courant, lien affilie ; bloc cache si vide) au lieu du bloc statique profile.alternatives. Une seule requete promo-ranking sert B1+B4. // 1.7.24 : AVIS V2 round 7 (retours Etienne) — (1) avis clients : ne mentionne PLUS jamais la plateforme (Trustpilot/App Store/Google Play), auteur seul. (2) L'essentiel : prix sans « dès » (fin du doublon « dès…dès » avec Données) + tuile Données montre l'ECART « 0,5 Go → illimité » (police reduite au lieu de juste « Illimité »). (3) hero : lead descriptif retiré du haut -> vit UNIQUEMENT dans « Notre verdict » en bas (fin du doublon). (4) verdict : bleu plus profond (0c4a6e→0369a1). (5) sticky : juste le nom du fournisseur (sans « eSIM »), note desktop-only. (6) mobile : byline recassée proprement (wrap + séparateur masqué + date insécable) + date FR (« 27 juil. » au lieu de « 27 Jul »). // 1.7.23 : PROMO — alignement des dates (retour Etienne/Florentin « meme date partout ») : les reponses FAQ/summary/conditions ecrites par Bobby (D1) pouvaient figer une date (« teste le 23 juillet 2026 ») qui derivait du verified_at du coupon (roule par le cron mensuel de fraicheur). normalize_verification_date() reecrit toute date longue FR/EN de ces overrides vers $verified_full = LA date unique du coupon -> la page ne montre plus jamais deux dates. // 1.7.22 : PROMO — refonte palette (retour Florentin) : navy + rouge + neutres, on tue le cyan/sky et l'ambre decoratif. Bandeau coupon navy (#17264a), etoile blanche, bordure du code grise (#94a3b8), « Verifie ... » en gris fonce (plus vert), « Sans expiration » capitalise. Tous les autres bleus cyan -> navy : total du telephone, bouton Payer, n° d'etapes, en-tetes du tableau, liens, chip code des alternatives, pill CTA, bouton band. Vert garde UNIQUEMENT pour l'economie (colonne « prix avec le code », code valide) ; or garde pour les vraies etoiles de note ; bouton Copier gris inchange. Badges dedupliques : « Bon a savoir » -> « Validite » (Sans expiration / Tous les forfaits et destinations / Nouveaux et anciens clients) + retrait de « Valable sur toutes les eSIM » du sous-titre coupon (deja dans les badges). Titres de section uniformises a 22px. « Voir les conditions » remonte juste sous la card du code. Colonnes tableau Data / Duree separees. « le seul code qui fonctionne » -> « le meilleur code promo teste et verifie » (FR+EN). Cards alternatives : label « Code promo » a cote du code + CTA « Voir l'offre » (au lieu de « Comparer » : le lien mene chez le fournisseur) ; fix d'une rule hover orpheline (sky) qui ecrasait le rouge du CTA. // 1.7.21 : AVIS V2 round 6 (retours Etienne) — (1) contenu RECENTRE : on masque le rail sponsors fixe du site sur les pages avis preview (il decalait/chevauchait le contenu) au lieu de reserver une gouttiere -> centrage propre. (2) « L'essentiel » : le tuile Donnees ne wrap plus sur 4 lignes (« 0,5 Go - Illimite » -> valeur=« Illimite », le plancher passe dans le label « Donnees · des 0,5 Go » via data_tile), + icones dans une pastille arrondie teintee. (3) « Avis & notes » : la note n'est affichee qu'UNE fois (carte gauche) ; le panneau droit (qui repetait « 4,7/5 sur l'ensemble ») est remplace par les VRAIS avis clients empiles. // 1.7.20 : AVIS V2 — gouttiere rail sponsors recalibree (la 1.7.19 sous-reservait : le theme enveloppe le contenu dans un conteneur ~1200px offset ~120px que le calc 100vw ignorait -> 64px de recouvrement sur la 3e carte avis). Fix robuste : margin-right:360px ancre sur le bord droit du conteneur (= bord droit viewport moins la gouttiere theme), qui degage TOUJOURS le rail fixe (>=40px) quelle que soit la largeur. // 1.7.19 : AVIS V2 preview round 5 (retours Etienne) — (1) « Avis & notes » branche enfin les VRAIS avis clients : les temoignages etaient lus au niveau top du profil alors qu'ils vivent dans le calque de langue ($lp['reviews'], pousse depuis D1 provider_contents.reviews) → 3 excerpts Trustpilot/App Store avec auteur + plateforme (« Bonnybridge · Trustpilot ») au lieu du bloc methodologie de secours. (2) accents restaures sur les libelles codes en dur (« Verifie par » → « Verifie », « Questions frequentes » → « frequentes »). (3) le rail sponsors fixe du site (.mbs-sponsor-floating, ancre a droite du viewport sur TOUTE page) recouvrait ~48px du contenu en pleine largeur → sur desktop la colonne est biaisee a gauche + largeur plafonnee responsive (min(1100px, 100vw-400px)) pour degager le rail avec marge. Grille temoignages en auto-fit (2 ou 3 cartes propres). // 1.7.17 : provider-tracking.js — parite attribution affiliee articles/avis : Affilae +aesubid2=page (confirme lu par affilae-cron.js -> colonne subid2, miroir du subId2 Impact) ; Daisycon/JamboSIM branche complete (ws=source + ws2=session) ; logger tracking_clicks etendu Impact -> Impact+Affilae+Yesim+Daisycon (jointure session_id = page/device/destination pour les reseaux sans slot page). ADDITIF : Impact & Yesim tagging inchanges (zero regression). 1.7.14 : AVIS V2 preview — page de test forcee en pleine largeur (CSS body:has(.mbs-avis-v2) masque la sidebar Rehub + etend #content.main-side + masque le titre de page du theme), pour voir le design « hero pleine largeur » sans la colonne sidebar 788px. Scope :has -> n'affecte QUE les pages portant le shortcode. 1.7.13 : AVIS V2 preview — branchements de donnees corriges (calques sur le generateur avis live) : nom=get_the_title, logo=_mbs_logo_id, CTA affilie=get_affiliate_link, lien code-promo gate sur is_affiliate (apparait pour Yesim), note=_mbs_rating/moyenne subscores, L'essentiel/subscores/how_to/alternatives lus aux VRAIES cles du profil. + meta robots noindex auto sur toute page portant le shortcode (page de test). 1.7.12 : NOUVEAU rendu AVIS V2 (page a vie) en PREVIEW isole — shortcode [mbs_avis_v2 slug lang] (includes/class-avis-v2-preview.php + assets/css/avis-v2.css scope .mbs-avis-v2) qui porte le design valide « Avis Roamic.dc.html » avec les vraies donnees Yesim (_mbs_profile_json du mbs_provider). Additif, ZERO impact sur le generateur/CPT/pages avis LIVE. Sert a iterer sur un brouillon (page de test noindex) avant toute migration. 1.7.11 : (retour Etienne) bandeau card coupon en degrade sky linear-gradient(135deg,#0ea5e9,#38bdf8) au lieu du bleu plat #007bff. CSS-only (pas de re-render, juste le cache-bust ?ver=). 1.7.10 : (retour Etienne) bandeau card coupon en bleu #007bff (au lieu du navy) ; partie « seul code » : REMISE des codes barres en rouge « ne fonctionne pas » (Etienne aime ce contraste) — intro/note reformules pour mener a la liste + garder la ref a la preuve (le code verifie reste la vraie affirmation forte, les faux codes sont des patterns illustratifs). 1.7.9 : (gros lot retours Etienne) CARD COUPON refaite facon Florentin (« plus pro & clean ») : bandeau navy « MEILLEURE OFFRE · CODE EXCLUSIF MYBESTSIM », gros « -20% / DE REDUCTION » a droite du nom, card blanche/coins arrondis, bouton Copier gris, J'en profite rouge, footer verifie. Titre TOUT en noir (.disc plus bleu ni souligne — la mise en valeur du -20% vit dans la card). Reordonnancement : historique de verification remonte JUSTE apres la preuve ; alternatives (autres codes) descendues APRES la FAQ (on finit Yesim avant de proposer d'autres fournisseurs). CTA alternatives = rouge/blanc comme J'en profite (format unique partout). Tableau prix : colonne « prix avec le code » remise a la MEME taille de police (fini le 18px qui faisait cheap), couleur emeraude gardee. Panier = tournure « exemple » (Par exemple, un forfait a X passe a Y... sur la boutique Yesim). « Pas encore decide ? » -> « Comparez avant de commander ». HONNETETE data : partie « seul code » ne liste PLUS de faux codes inventes (YESIMFREE...) affirmes « ne fonctionne pas » sans test ; on affirme UNIQUEMENT le code verifie (preuve a l'appui) + mise en garde generique. 1.7.8 : SEO image de la preuve — l'<img> passe par wp_get_attachment_image() (srcset + sizes responsive cales sur l'affichage reel 430/320px + loading lazy + width/height anti-CLS natifs) au lieu d'un <img> nu ; le mobile telecharge une version legere au lieu du PNG plein. Alt deja par langue + riche (code/reduction), filename SEO, title media OK. Fallback <img> nu si URL non resoluble. 1.7.7 : sous-partie PREUVE — section « La preuve que le code fonctionne » (E-E-A-T) : capture RÉELLE du recapitulatif de commande du fournisseur avec le code applique (checkout Yesim : €45 -> -€9 -> €36), encadree facon screenshot sur le fond clair, image laissee pristine (aucun overlay pour ne pas passer pour retouchee) ; titre a check vert + legende dynamique/traduite (code, reduction, date injectes) ; placee juste apres le bloc « Voyez la reduction appliquee » (mockup illustratif -> vraie preuve en dessous). Template-level + FR/EN + conditionnelle : rendue UNIQUEMENT si $promo['proof_image'] existe (URL WP media par fournisseur, posee via provider_contents.sections.promo.proof_image + passthrough Worker publish-wp) -> gracieux, jamais de bloc vide, scale aux 24 fournisseurs. 1.7.6 : (retour Etienne) carte coupon du haut : nom fournisseur sans « eSIM » derriere (« Yesim » seul, coherent avec le H1) ; sticky mobile REDESIGN (fini le wording « a l'arrache ») = badge rouge -X% + petit libelle « avec le code » au-dessus du code en chip pointille facon coupon, aligne en ligne ; bloc seul-code : le « -X% » du code qui marche mis en VALEUR (pastille verte au lieu du gras simple), statut aligne au centre. 1.7.5 : (retour Etienne) FAQ + conditions : format_rich_text (1 phrase par ligne = groupes lisibles, mobile-friendly, seul le code+reduction en gras, plus tout-en-gras) ; bloc seul-code : descripteur au milieu masque mobile (comble le vide desktop entre code et statut) + espace « -20% »/« verifie » (flex gap) ; sticky : phrase « -20% avec le code » au-dessus du code, mieux espace. 1.7.4 : // 1.7.4 : (retour Etienne mobile) bloc seul-code : titre « le/les » selon nb de codes valides + statuts « ne fonctionne pas » ALIGNES (margin-left auto) + « -20% verifie » sans le point (discount en gras, nowrap) ; tableau : prix barre en ROUGE (comme supprime) + prix nowrap (le « $ » ne passe plus a la ligne sur mobile) ; FAQ : reponses stylisees (1re phrase en lead + code/reduction en gras) ; CTA sticky : discount prominent au-dessus du code, mieux espace. 1.7.3 : // 1.7.3 : (retour Etienne) bouton « Comparer » des alternatives adouci (ghost/outline blanc + bordure sky au lieu du plein bleu trop fort/gros) ; RESPONSIVE MOBILE : le tableau des prix passe en white-space:normal sur mobile (les 4 colonnes WRAPPENT et tiennent dans ~346px sans scroll horizontal, au lieu d'un tableau 589px coupe qui ne montrait que 2 colonnes) + padding/police reduits ; garde-fou padding panier telephone a <=320px (prix plus coupe). Audit mobile complet (390/360/320) par mesure DOM : coupon 289px, panier/telephone, alternatives 1 colonne, blocs seul-code, tout tient sans debordement du body. 1.7.2 : // 1.7.2 : (retour Etienne design 2) carte coupon du haut = MEME fond que derriere le telephone (linear-gradient 140deg #f8fbff->#eef7fe, coherence) au lieu du bleu/indigo ; bloc « seul code » rangees remises en forme (pastille ronde ✓/✗ + code + statut groupes a GAUCHE, fini le grand vide au milieu qui faisait cheap) ; alternatives = CTA « Comparer » en VRAI bouton (fond sky, sous le discount, colonne droite), le code en chip. 1.7.1 : // 1.7.1 : lot design (retour Etienne) — carte coupon fond degrade sky/indigo (contraste), bloc « seul code » refait en LISTE (codes valides pill emeraude+coche, faux codes rouge BARRES), « Bon a savoir » remonte au-dessus du panier, wording (reduction/exclusif/teste-verifie) H1+lead, alternatives : code en chip bleu + discount plus gros + CTA « Comparer ». 1.7.0 : bloc « autres codes testes » (P1 du plan) — angle honnete : le SEUL code qui marche + mise en garde faux codes ({NAME}FREE...) qui circulent + tampon « Derniere verification : {mois} {annee} » (rafraichi par le cron). FR+EN, place juste avant la FAQ. Pas de faux « on a teste X » invente. 1.6.9 : CRON mensuel de fraicheur (retour Etienne « tres tres important ») — wp-cron ~30j (mbs_promo_monthly_refresh) qui republie toutes les pages promo en bumpant promo.verified_at a aujourd'hui → le mois « verifie en {mois} {annee} » (title/meta/date on-page) roule tout seul, sous la byline « Verifie par Etienne Tillier ». Idempotent dans la journee (compute_signature skip si inchange). 1.6.8 : H1 sans « eSIM » (coherent avec le title, mais distinct : le title garde la date, le H1 reste « Code promo {nom} : {reduction} » — la fraicheur est portee par la ligne « verifie le {date} » juste dessous). FR + EN. 1.6.7 : i18n des destinations du tableau/panier (retour Etienne « tout traduit dans toutes les langues ») — reutilise les localiseurs du comparateur (widget.txt) : PAYS via Intl.DisplayNames (24 langues, cle = country_code ISO) + REGIONS via REGION_ALIASES/REGION_NAMES (9 langues + fallback anglais canonique). destName(p) : region_name -> regionName ; sinon country_code ISO -> Intl ; sinon country_name FR. Tri/flagship par region_name (Global=Monde en tete). Title EN en Title Case (« Verified », le FR reste en casse phrase — pas de Title Case en francais). 1.6.6 : (retour Etienne) title SEO etoffe + « verifie » (« Code promo Yesim : -20% verifie en juillet 2026 » — AIOSEO flaguait 38 car. en rouge) ; colonne « prix avec le code » BEAUCOUP plus visible (18px, emeraude fonce, en-tete a fond emeraude degrade + separateur, entetes plus gras). i18n destinations toutes langues = a faire (mapping existant comparateur/countries a reutiliser). 1.6.5 : flagship panier + tri du tableau par region_name (Monde/global en tete — country_count peu fiable dans le feed, souvent null → Andorre passait avant Monde) ; phrase d'intro « passe de X a Y » synchronisee avec le forfait phare du panier via JS (data-cart-intro-before/after) — plus de mismatch 43,99→35,19 (SSR) vs 67,26→53,81 (panier JS). 1.6.4 : panier telephone + tableau de prix branches sur la MEME source live /api/products (retour Etienne : le panier montrait une fausse donnee figee « eSim - Coupon MYBESTSIM15 / -1 Go / 18,24 $ » = vieux cart_example de synthese gele dans le profil WP, jamais rafraichi). Panier = vrai forfait phare (nom+region+data+prix reel, reduction appliquee) rempli cote client ; compute_cart ne fait plus confiance a cart_example (fallback indicatif propre, is_real_data=false → Offer sans prix invente). Tableau = variete de DESTINATIONS (Monde/Europe/USA/Asie... avec drapeau, 1 forfait representatif/destination) au lieu de « eSIM Monde » xN ; colonne « prix avec le code » en emeraude + en-tete sky degrade + lignes alternees ; en-tete « Forfait/Plan » → « Destination ». Titre SEO : retrait de « eSIM » et du CODE, ajout du mois/annee dynamique (« Code promo Yesim : -20% (juillet 2026) ») — pousse au clic (le code se recupere apres le clic), le concurrent ne met pas le code non plus. 1.6.3 : contenu DATA-DRIVEN (summary/FAQ 8 questions/conditions lus depuis le payload, fallback template = zero regression) + TABLEAU DE PRIX dynamique (via /api/products, colonne « prix avec le code », masque si pas de catalogue). Build dev-mbs, rebase + review + bump version (CSS/JS busting) par Claude. 1.6.2 : cartes d'etapes REMPLIES de contenu (retour Etienne : cartes trop grosses/vides pour matcher la hauteur du telephone) — chaque etape = titre en gras + description, au lieu de gonfler le padding a vide. Meilleur design + plus clair UX + remplit la hauteur avec du CONTENU (padding 30px, align top). Schema HowTo enrichi (name+text par step). Bouton « Payer » du telephone conserve (ajoute en 1.6.1). 1.6.1 : telephone PORTRAIT (Etienne : plus HAUT pas plus large, « telephone pas tablette ») — largeur ramenee 380→312px MAIS ecran de checkout complet ajoute (bouton « Payer 35,19 $ » + « Paiement securise ») → phone 524px de haut, ratio 1.68 (vrai format telephone) ; cartes d'etapes agrandies (padding 40px, gap 20) pour rester a la meme hauteur (vide ~23px). 1.6.0 : telephone encore agrandi 330→380px (Etienne le trouvait encore petit) ; cartes d'etapes agrandies (padding 32px, gap 16) → equilibre parfait (vide ~1px). NB : deploiement precedent (home indicator) etait bien en place — la barre d'accueil le prouve. 1.5.9 : telephone ENCORE plus grand (retour Aubin/Etienne « comme un vrai telephone ») : 292→330px, border-radius 48, status bar 52px, + barre d'accueil (home indicator) en bas pour le look vrai telephone ; cartes d'etapes agrandies (padding 27px, gap 14) pour rester a la meme hauteur → vide haut/bas ~28px. Mobile : phone max-width 330 + step padding reduit. 1.5.8 : ALTERNATIVES (Etienne) — bloc « Autres codes promo populaires » rendu DYNAMIQUEMENT cote client depuis /api/promo-ranking (endpoint public cache edge, meme source que le hub) : exclut le fournisseur courant, prend le top 4, vraies data (logo+nom+code+reduction+lien affilie). Bloc cache tant que pas de resultats (degradation gracieuse, jamais vide). Futur : module de placement gere au dashboard (choix manuel des fournisseurs). Panier « fake » indicatif garde pour fournisseurs sans catalogue (Nomad = 0 produit D1, affilie seul). 1.5.7 : SEO — LE bug racine des schemas absents ENFIN trouve (via sonde HTML) : _mbs_promo_view (5272 car. bien stockes) mais json_decode ECHOUAIT (decoded=nope) → get_view() null → skip silencieux. Cause : update_post_meta() applique wp_unslash() qui retire les backslashes du JSON (\/, \uXXXX, \") et le corrompt. Fix : wp_slash(wp_json_encode($view)) a l'ecriture (create+update) → round-trip lossless → Product/Offer/HowTo/FAQPage/BreadcrumbList emis. Sonde de debug retiree. 1.5.6 : SEO P0 (audit) — RACINE des schemas Rich Results absents + hreflang casse : add_structured_data() ET add_hreflang_tags() lisaient global $post (pas fiable = le bon post au wp_head pour ce CPT a routing pre_get_posts) → get_view()/pll_get_post_translations() recevaient un mauvais ID → skip silencieux. Corrige : les deux utilisent get_queried_object_id() (comme les methodes title qui, elles, marchaient) → Product/Offer + HowTo + FAQPage + BreadcrumbList emis. hreflang : + pll_save_post_translations($page_ids) dans maybe_generate_from_payload (jamais appele = FR/EN non lies = pas de hreflang en/x-default ; le generateur avis le faisait deja) + log si view null. 1.5.5 : telephone plus grand + plus realiste (264→292px, Total 27px, plus de padding interne, cartes 18px pour rester a la meme hauteur) + RESPONSIVE tel etroits (retour Etienne Galaxy S8+/Z Fold) : .cart padding reduit sur mobile (36/40→22/16, etait jamais reduit → debordement), .phone width:100% max-width:300px, + breakpoint <=360px (page/bd/cart/code-box resserres). iPhone XR (414px) inchange. 1.5.4 : etoiles inversees (retour Etienne) — les 5 vraies etoiles passent en HAUT dans la byline sous le titre (etait une seule etoile), et le bloc "A propos" reprend l'etoile unique + note. Tags verts gardes (Etienne aime). Badge "Meilleure offre" : options a proposer (Etienne aime moins l'ambre). 1.5.3 : propositions design (badge/etoiles/tags) — badge "Meilleure offre" en degrade ambre + lueur (etait jaune pale plat) ; note "A propos" en 5 vraies etoiles (4 pleines + 1 partielle a 43%) au lieu d'une seule etoile ; tags "Bon a savoir" teinte emeraude (fond vert clair + bord + coche verte) au lieu du gris terne. 1.5.2 : panier — le VRAI fix des "espaces" : la colonne texte (246px) flottait au centre d'une carte haute (telephone 452px) → 124px de vide en haut ET en bas. Corrige : (1) etapes en cartes serrees (gap:12px) qui donnent de la masse a la colonne gauche ; (2) telephone raccourci — ligne "Sous-total" redondante retiree (43,99 → -8,80 → 35,19 se lit mieux) + paddings reduits → ~377px. Resultat mesure dans Chrome : vide haut/bas 124px → 35px, telephone et texte a la meme hauteur, aucun espace entre etapes. 1.5.1 : panier RETOUR EXACT a la maquette validee (promo-preview-fr.html "ce rendu est le bon") : retrait du bloc "Votre economie" (jamais demande par Etienne, ajoute de moi-meme) ; telephone repasse a 264px (plus grand, avec les 4 lignes plan/sous-total/code/total) ; .cart align-items:center (plus stretch) → fini le vide a droite sous le telephone ; etapes en lignes simples gap:18px (plus de mini-cartes/vide entre etapes). Le "nothing changes" venait du cache LiteSpeed du CSS statique : le bump de version change le ?ver= de promo-page.css et force le fichier frais. 1.5.0 : boutons CTA (bande + pill) agrandis (Etienne). 1.4.9 : panier — bloc "economie" ancre en bas (margin-top:auto, align-items:stretch) pour equilibrer la hauteur du telephone, sous-total retire, emojis loupe/manette retires des CTA. 1.4.8 : SEO — title/description ecrits via le modele Post d'AIOSEO (table wp_aioseo_posts) car ni les filtres aioseo_* ni pre_get_document_title ne prenaient sur ce CPT. 1.4.7 : SEO — title forcé via pre_get_document_title (priorité 999) car les filtres aioseo_title/aioseo_description ne prenaient pas sur le CPT mbs_provider_promo. 1.4.6 : SEO — title + meta-description promo via les FILTRES AIOSEO (aioseo_title/aioseo_description, gated is_singular('mbs_provider_promo')) car le postmeta _aioseo_* de la 1.4.5 ne nourrit PAS AIOSEO v4 (il lit sa table wp_aioseo_posts) → title restait à 84 car. + description auto 316 car. Les filtres overrident à la sortie (title court ~42 car., meta ~143 car.), H1 intact. 1.4.5 : promo pages — fidélité mobile + AIOSEO. (1) Responsive ≤480px porté depuis docs/design/promo-page-nomad.dc.html #1b (MyBestSimProduct repo) : carte-coupon (.tile-lg/.coupon-name/.coupon-sub remplacent des styles inline non surchargeables ; badge flex:none+white-space:nowrap — c'était la vraie cause du badge qui passait sur 2 lignes) ; byline compactée (.u-mobile-hide masque "Vérifié par"/"Mis à jour le"/séparateurs "·") ; ligne code+Copier tenue sur une ligne (bouton 44px de haut) ; H1 25px, .bd 16/18/24px. (2) Panier desktop : .cart repasse à align-items:center (plus stretch) + étapes resserrées (gap:18px seul, plus de flex:1/space-between) + téléphone du panier passé à 224px partout (desktop ET mobile, était 264px) — supprime les gros blancs entre étapes signalés par Etienne, sans media query dédiée puisque le téléphone compact convient aux deux gabarits. (3) Sticky bar mobile : bouton restylé en rouge #dc2626 autonome (.mbs-promo-sticky-btn) — il vivait hors de .mbs-promo-page donc ".mbs-promo-page .btn-claim" ne le stylait jamais (bouton nu). (4) SEO AIOSEO : _aioseo_title/_aioseo_description écrits explicitement par page (create + update) via apply_aioseo_meta() — title court sans "| MyBestSim" (AIOSEO l'ajoute déjà, d'où le doublon signalé) + description ~150 car. dédiée (au lieu de l'auto-génération AIOSEO depuis le fil d'Ariane/chapô) ; H1/post_title/excerpt inchangés. (5) Tags : eyebrow "Bon à savoir"/"Good to know" + coche ✓ devant chaque pill. TEMPLATE_VERSION bumpée. 1.4.4 : promo pages — lot de corrections design/liens : vrai logo fournisseur (promo.logo_url) dans les 3 tuiles restantes (carte-coupon, en-tête téléphone, "À propos"), fallback texte inchangé si pas de logo ; retrait du bloc .hero (doublon du code déjà dans la carte-coupon) ; retrait de la ligne .disclosure (le footer du thème l'affiche déjà) ; nouveaux liens promo_hub_url (hub codes promo FR/EN) et comparator_url (= ex-compare_url, renommé) — fil d'Ariane "Codes promo" + bande CTA rouge → hub, "Comparer les offres"/pill/bande CTA sky → comparateur, breadcrumb JSON-LD aligné ; module panier : .cart passe à align-items:stretch (au lieu de center) + colonne gauche en flex-column + bloc étapes flex:1/space-between pour combler le vide vertical à côté du téléphone (inerte en mobile, la grille repasse déjà en 1 colonne). TEMPLATE_VERSION bumpée. 1.4.3 : promo pages — fix wpautop (désactivé sur le CPT promo : the_content injectait des <br>/<p> à chaque saut de ligne du HTML → byline cassée + espacements parasites partout) + panier localisé FR (eSIM Monde / 10 Go · 30 jours au lieu de eSIM World / 10 GB · 30 days) + TEMPLATE_VERSION bumpée (force le re-render à la republication). 1.4.2 : promo pages — fidélité visuelle au design validé (docs/design/promo-preview-fr.html) : render_promo_page() reproduit le DOM/CSS de l'aperçu à l'identique (classes .card/.hd/.bd/.cart/.faq/… scopées sous .mbs-promo-page, plus aucune classe mbs-promo-* maison) ; ajoute FR (routing préfixe `code-promo-{provider}` dans /fr/fournisseur/, EN reste suffixe `{provider}-promo-code`) ; vraie photo Etienne (média #58671) au lieu du placeholder "ET" ; logo MyBestSim via get_theme_mod('custom_logo') ; domaine fournisseur dérivé de shared.company_info.website (jamais deviné) ; compteur "×N utilisé" animé + bonus sticky bar mobile. 1.4.1 : fix — nom du CPT ramené à 18 car. (`mbs_provider_promo`) ; à 23 car. `register_post_type` échouait (limite WP = 20 car.), le CPT ne s'enregistrait pas → 404 en prod + aucun draft fonctionnel. Renommé partout + fichier template `single-mbs_provider_promo.php`. 1.4.0 : CPT `mbs_provider_promo` — page "code promo {fournisseur}" séparée de l'avis (pilote nomad EN, générée en DRAFT). Fichiers neufs uniquement (class-provider-promo-page-cpt/generator/seo, template-loader, template, assets/css+js/promo-page) ; review CPT non touché. Déclenché par le même événement "Publier vers WP" (hook additif dans class-api.php::process_update). 1.3.0 : attribution Impact BANNIÈRES — banner-tracking.js pose sharedid=banniere-<bannerId> (+subId1/subId2) sur les liens Impact via resolveBannerHref (3e branche du dispatcher) ; roamic ajouté à IMPACT_SLUGS (provider-tracking.js + banner-tracking.js). Le Worker (parseImpactShared) en dérive source=banniere + placement_id. 1.2.9 : attribution Yesim (sub_id) — provider-tracking.js pose sub_id=<avis|article>-<sessionId> sur /affiliation/yesim et yesim.app ; banner-tracking.js pose sub_id=banniere-<bannerId>-<sessionId> via resolveBannerHref (dispatcher Affilae/Yesim). 1.2.8 : banner-tracking.js pose aussi aesubid2=page (même format que le widget) → la page d'origine s'affiche sur les ventes bannière dans le dashboard affiliation. 1.2.7 : attribution Affilae (Orange) — aesubid sur liens /affiliation/orange (provider-tracking.js avis/article + banner-tracking.js bannières). 1.2.6 : banner-tracking.js optimisé (D1 seul via sendBeacon + text/plain, fin de la double-écriture admin-ajax) — aligné sur le widget comparateur define('MBS_PROVIDERS_PATH', plugin_dir_path(__FILE__)); define('MBS_PROVIDERS_URL', plugin_dir_url(__FILE__)); class MBS_Providers_Manager { private static $instance = null; public static function instance() { if (null === self::$instance) { self::$instance = new self(); } return self::$instance; } private function __construct() { $this->includes(); $this->hooks(); } private function includes() { require_once MBS_PROVIDERS_PATH . 'includes/class-post-type.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-indexnow.php'; // fichier de verif + helper IndexNow -- chargé tôt (hook init prio 0) require_once MBS_PROVIDERS_PATH . 'includes/mbs-date-i18n.php'; // format de date + locale, 24 langues — SOURCE UNIQUE (les tables en dur ne couvraient que 5 langues et repliaient sur en_US) require_once MBS_PROVIDERS_PATH . 'includes/mbs-sticky-i18n.php'; // micro-copie sticky 25 langues (partagé avis + promo) require_once MBS_PROVIDERS_PATH . 'includes/mbs-sticky-render.php'; // mbs_render_sticky_bar() -- SOURCE UNIQUE markup barre sticky (avis + promo) require_once MBS_PROVIDERS_PATH . 'includes/mbs-avis-i18n.php'; // SEO i18n pages avis V2 (title/H1/H2/FAQ, 25 langues) require_once MBS_PROVIDERS_PATH . 'includes/mbs-promo-i18n.php'; // SEO i18n pages code promo (ui/seo/faq/steps/tags/locale, 25 langues) require_once MBS_PROVIDERS_PATH . 'includes/mbs-promo-nocode-i18n.php'; // SEO i18n variante "sans code" (title/H1/meta/lead/CTA, 25 langues) — voir docblock require_once MBS_PROVIDERS_PATH . 'includes/mbs-promo-nocode-howto-i18n.php'; // section "Comment la réduction est-elle appliquée ?" en mode sans-code (bandeau/intro/téléphone/3 étapes, 25 langues) — voir docblock require_once MBS_PROVIDERS_PATH . 'includes/mbs-promo-nocode-verify-i18n.php'; // section "vérification/confiance" en mode sans-code (titre/intro/footer + ligne verte unique + reframe faux codes, 25 langues) — voir docblock require_once MBS_PROVIDERS_PATH . 'includes/class-meta-boxes.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-rankings.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-shortcodes.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-provider-page-cpt.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-provider-pages-generator.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-provider-pages-seo.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-template-loader.php'; // Promo pages ("code promo {fournisseur}") — new, isolated CPT. Pilot: nomad EN. require_once MBS_PROVIDERS_PATH . 'includes/class-provider-promo-page-cpt.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-provider-promo-pages-generator.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-provider-promo-pages-seo.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-provider-promo-template-loader.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-avis-v2-preview.php'; // shortcode [mbs_avis_v2] — apercu isole, ne touche pas les pages avis live require_once MBS_PROVIDERS_PATH . 'includes/class-esim-hub-page.php'; // shortcode [mbs_esim_hub_page] — page complete hub "Meilleures eSIM" (brouillon), assemble les 3 shortcodes du plugin mbs-widget + habillage editorial require_once MBS_PROVIDERS_PATH . 'includes/class-admin-help.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-banners.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-page-type-patterns.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-banner-stats.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-provider-stats.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-documentation.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-api.php'; require_once MBS_PROVIDERS_PATH . 'includes/class-mbs-widget.php'; } private function hooks() { add_action('admin_enqueue_scripts', [$this, 'admin_scripts']); add_action('wp_enqueue_scripts', [$this, 'frontend_scripts']); add_action('wp_enqueue_scripts', [$this, 'promo_page_scripts']); // Same "run once per version bump" pattern as MBS_Provider_Promo_Page_CPT:: // maybe_flush_rewrite_rules_once() — see the method's own docblock for the // full incident (deploy reaches disk correctly, PHP OPcache serves stale // bytecode for hours regardless). add_action('init', [$this, 'maybe_invalidate_opcache_once'], 1); } /** * Targeted OPcache invalidation on version bump (2026-08-05) — root cause of * several "deployed but not showing" incidents this session, CONFIRMED via the * GitHub Actions deploy log itself (not guessed): the run for commit e2a28c3 * (v1.8.38) shows `<fcst...... includes/mbs-promo-i18n.php` in its rsync * itemized-changes output — the leading `<` + `c`(checksum)+`s`(size)+`t`(time) * flags mean the file WAS genuinely re-transferred with new content, landing * correctly on the server's disk. Yet the live page kept rendering the OLD * text for hours, surviving a Cloudflare purge, a LiteSpeed page-cache re-save, * AND a `?cb=` cache-buster — none of which touch PHP OPcache, which lives at * the PHP-FPM/LSAPI process level and (on this host) does not appear to pick up * a changed mtime/content promptly on its own. This is very likely the * underlying cause of more than one "code is deployed but the site doesn't * reflect it" symptom already hit this session. * * Runs ONCE per version bump (tracked via a plain option, same pattern as * maybe_flush_rewrite_rules_once()). Hooked HERE — never in a file that could * itself be the stale one, which would be a chicken-and-egg: THIS file * (mbs-providers-manager.php) is proof-of-freshness by construction, since its * own MBS_PROVIDERS_VERSION constant is exactly what changes on every single * deploy that needs this invalidation to run. * * TARGETED, never opcache_reset(): walks THIS plugin's own directory * (MBS_PROVIDERS_PATH) AND the child theme (get_stylesheet_directory(), * rehub-child — its templates call our shortcodes, e.g. discount-v3.php) and * calls opcache_invalidate($file, true) on every .php file found there — never * touches core/WooCommerce/other themes, so this can never cause a server-wide * recompilation spike. The theme was added 2026-08-24: without it, a changed * TEMPLATE stays frozen in OPcache indefinitely on this host even though the * plugin reloads fine (asset ver bumps but the template is stale). Silently * no-ops if OPcache is unavailable, disabled, or the relevant functions don't * exist (shared hosts sometimes disable opcache_invalidate() specifically even * with OPcache itself on) — never fatal either way. */ public function maybe_invalidate_opcache_once() { if (!function_exists('opcache_invalidate') || !function_exists('opcache_get_status')) { return; } $status = @opcache_get_status(false); if (!is_array($status) || empty($status['opcache_enabled'])) { return; } if (get_option('mbs_providers_opcache_invalidated_version', '') === MBS_PROVIDERS_VERSION) { return; } $invalidated = 0; // Répertoires dont on force le rechargement OPcache à chaque bump : le plugin // lui-même ET le thème enfant (rehub-child). Ce dernier héberge des templates // (ex. discount-v3.php) qui appellent nos shortcodes ; sur cet hôte l'OPcache ne // recharge PAS un fichier de thème modifié de lui-même (incident 2026-08-24 : la // section de liens crawlables du hub codes promo, ajoutée au template, restait // invisible en prod car le bytecode du fichier de thème restait figé, alors que // le plugin, lui, était bien invalidé -> asset ver=1.11.0 OK mais template ancien). // On reste CIBLÉ (jamais opcache_reset()) : uniquement NOS 2 répertoires, jamais // core/WooCommerce/autres thèmes -> aucun risque de spike de recompilation serveur. $dirs = [MBS_PROVIDERS_PATH]; if (function_exists('get_stylesheet_directory')) { $theme_dir = get_stylesheet_directory(); if (is_string($theme_dir) && $theme_dir !== '' && is_dir($theme_dir)) { $dirs[] = $theme_dir; } } try { foreach ($dirs as $dir) { $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS) ); foreach ($files as $file) { if ($file->isFile() && strtolower($file->getExtension()) === 'php') { if (@opcache_invalidate($file->getPathname(), true)) { $invalidated++; } } } } } catch (\Throwable $e) { if (function_exists('mbs_api_log')) { mbs_api_log('mbs_providers_manager: OPcache invalidation failed', ['error' => $e->getMessage()]); } return; // don't mark this version "done" if the walk itself blew up } update_option('mbs_providers_opcache_invalidated_version', MBS_PROVIDERS_VERSION, 'yes'); if (function_exists('mbs_api_log')) { mbs_api_log('mbs_providers_manager: OPcache invalidated for version bump', [ 'version' => MBS_PROVIDERS_VERSION, 'files_invalidated' => $invalidated, ]); } } public function admin_scripts($hook) { // Load on provider post edit pages and rankings page wp_enqueue_style('mbs-providers-admin', MBS_PROVIDERS_URL . 'assets/css/admin.css', [], MBS_PROVIDERS_VERSION); wp_enqueue_script('mbs-providers-admin', MBS_PROVIDERS_URL . 'assets/js/admin.js', ['jquery', 'jquery-ui-sortable'], MBS_PROVIDERS_VERSION, true); wp_localize_script('mbs-providers-admin', 'mbsProviders', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('mbs_providers_nonce'), ]); } public function frontend_scripts() { wp_enqueue_style('mbs-providers-frontend', MBS_PROVIDERS_URL . 'assets/css/frontend.css', [], MBS_PROVIDERS_VERSION); wp_enqueue_script('mbs-providers-frontend', MBS_PROVIDERS_URL . 'assets/js/frontend.js', [], MBS_PROVIDERS_VERSION, true); } /** * Loaded only on the promo CPT (not sitewide, unlike frontend.css/js above) — * the page has its own visual language (design system, see docs/design/) and * there is no reason to ship it on every page of the site. */ public function promo_page_scripts() { if (!is_singular('mbs_provider_promo')) { return; } wp_enqueue_style('mbs-promo-page', MBS_PROVIDERS_URL . 'assets/css/promo-page.css', [], MBS_PROVIDERS_VERSION); // Sticky bas de page PARTAGÉE avec les pages avis pilotes (2026-08-10) -- feuille // dédiée (assets/css/sticky-bar.css), SEULE source des règles `.mbs-promo-sticky-bar // .mps-*` -- extraite de promo-page.css, NE JAMAIS y dupliquer ces règles (voir // docblock de includes/mbs-sticky-render.php). wp_enqueue_style('mbs-sticky-bar', MBS_PROVIDERS_URL . 'assets/css/sticky-bar.css', ['mbs-promo-page'], MBS_PROVIDERS_VERSION); // Détection réseau affilié (Impact/Affilae/Yesim/Daisycon) partagée avec // provider-tracking.js et banner-tracking.js -- doit charger AVANT (deps). wp_enqueue_script('mbs-affiliate-network-detect', MBS_PROVIDERS_URL . 'assets/js/affiliate-network-detect.js', [], MBS_PROVIDERS_VERSION, true); wp_enqueue_script('mbs-promo-page', MBS_PROVIDERS_URL . 'assets/js/promo-page.js', ['mbs-affiliate-network-detect'], MBS_PROVIDERS_VERSION, true); } } // Initialize plugin MBS_Providers_Manager::instance(); new MBS_Widget(); // Activation hook - flush rewrite rules and create tables register_activation_hook(__FILE__, 'mbs_providers_activation'); function mbs_providers_activation() { // Load the CPT class to register post types require_once plugin_dir_path(__FILE__) . 'includes/class-provider-page-cpt.php'; $cpt = new MBS_Provider_Page_CPT(); $cpt->register_post_type(); $cpt->register_rewrite_rules(); // Create banner clicks and impressions tracking tables if (!class_exists('MBS_Banner_Stats')) { require_once plugin_dir_path(__FILE__) . 'includes/class-banner-stats.php'; } MBS_Banner_Stats::create_table(); MBS_Banner_Stats::create_impressions_table(); // Create provider clicks tracking table if (!class_exists('MBS_Provider_Stats')) { require_once plugin_dir_path(__FILE__) . 'includes/class-provider-stats.php'; } MBS_Provider_Stats::create_table(); MBS_Provider_Stats::create_impressions_table(); // Flush rewrite rules flush_rewrite_rules(); } // Deactivation hook - flush rewrite rules register_deactivation_hook(__FILE__, 'mbs_providers_deactivation'); function mbs_providers_deactivation() { flush_rewrite_rules(); wp_clear_scheduled_hook('mbs_sync_meta_to_d1_event'); } // ── D1 Metadata Sync ────────────────────────────────────────────── // Syncs provider names/logos + banner names to Cloudflare D1 // Requires MBS_D1_SYNC_TOKEN constant in wp-config.php define('MBS_D1_API_URL', 'https://mbs-api.mybestsim-business.workers.dev'); /** * Sync providers + banners metadata to D1 via /api/sync-meta */ function mbs_sync_meta_to_d1() { if (!defined('MBS_D1_SYNC_TOKEN') || !MBS_D1_SYNC_TOKEN) { return; } // Get all providers $providers_data = []; $providers = get_posts([ 'post_type' => 'mbs_provider', 'post_status' => 'publish', 'posts_per_page' => -1, ]); foreach ($providers as $prov) { $logo_id = get_post_meta($prov->ID, '_mbs_logo_id', true); $logo_url = $logo_id ? wp_get_attachment_url($logo_id) : ''; $providers_data[] = [ 'wp_id' => (string) $prov->ID, 'slug' => $prov->post_name, 'name' => $prov->post_title, 'logo_url' => $logo_url ?: null, ]; } // Get all banners $banners_data = []; $banners = get_posts([ 'post_type' => 'mbs_banner', 'post_status' => 'publish', 'posts_per_page' => -1, ]); foreach ($banners as $banner) { $banners_data[] = [ 'id' => (string) $banner->ID, 'name' => $banner->post_title, ]; } if (empty($providers_data) && empty($banners_data)) { return; } $response = wp_remote_post(MBS_D1_API_URL . '/api/sync-meta', [ 'timeout' => 15, 'headers' => [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . MBS_D1_SYNC_TOKEN, ], 'body' => wp_json_encode([ 'providers' => $providers_data, 'banners' => $banners_data, ]), ]); if (is_wp_error($response)) { error_log('MBS D1 sync-meta error: ' . $response->get_error_message()); } } // Schedule daily cron add_action('mbs_sync_meta_to_d1_event', 'mbs_sync_meta_to_d1'); if (!wp_next_scheduled('mbs_sync_meta_to_d1_event')) { wp_schedule_event(time(), 'daily', 'mbs_sync_meta_to_d1_event'); } // Also run once on admin_init with a transient to avoid hammering add_action('admin_init', function () { if (get_transient('mbs_d1_meta_synced')) { return; } set_transient('mbs_d1_meta_synced', 1, HOUR_IN_SECONDS); mbs_sync_meta_to_d1(); });