libellé de la barre de progression). * * @return array */ private static function steps(): array { return array( 'welcome' => __( 'Bienvenue', 'agec-ready' ), 'display' => __( 'Affichage', 'agec-ready' ), 'categories' => __( 'Catégories', 'agec-ready' ), 'done' => __( 'Terminé', 'agec-ready' ), ); } public function register(): void { add_action( 'admin_menu', array( $this, 'add_page' ) ); add_action( 'admin_init', array( $this, 'maybe_redirect_after_activation' ) ); add_action( 'admin_post_agec_ready_wizard', array( $this, 'handle_post' ) ); add_action( 'admin_post_agec_ready_wizard_cats', array( $this, 'handle_categories_post' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); } public function add_page(): void { add_submenu_page( 'options.php', __( 'Assistant AGEC Ready', 'agec-ready' ), __( 'Assistant AGEC Ready', 'agec-ready' ), self::CAP, self::PAGE, array( $this, 'render' ) ); } public function enqueue(): void { if ( ! $this->is_wizard_screen() ) { return; } // Tokens du design system (source de vérité --agec-*) chargés en premier. wp_enqueue_style( 'agec-ready-tokens', AGEC_READY_URL . 'assets/css/agec-design-tokens.css', array(), AGEC_READY_VERSION ); wp_enqueue_style( 'agec-ready-wizard', AGEC_READY_URL . 'assets/css/admin-wizard.css', array( 'agec-ready-tokens' ), AGEC_READY_VERSION ); // Synchronise aria-checked sur les bascules role="switch" (a11y, cf. §5). wp_enqueue_script( 'agec-ready-wizard', AGEC_READY_URL . 'assets/js/admin-wizard.js', array(), AGEC_READY_VERSION, true ); } /** * Redirige une seule fois vers l'assistant après l'activation du plugin. */ public function maybe_redirect_after_activation(): void { if ( ! get_transient( Meta::TRANSIENT_ACTIVATION_REDIRECT ) ) { return; } delete_transient( Meta::TRANSIENT_ACTIVATION_REDIRECT ); // Pas de redirection en activation en masse ou réseau (bonnes pratiques .org). if ( wp_doing_ajax() || is_network_admin() || isset( $_GET['activate-multi'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } if ( ! current_user_can( self::CAP ) ) { return; } wp_safe_redirect( self::step_url( 'welcome' ) ); exit; } /** * Enregistre l'étape « Affichage » puis passe à l'étape « Catégories ». */ public function handle_post(): void { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'Action non autorisée.', 'agec-ready' ) ); } check_admin_referer( self::NONCE ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- réduit à des booléens par sanitize_display_input(). $raw = isset( $_POST['agec'] ) && is_array( $_POST['agec'] ) ? wp_unslash( $_POST['agec'] ) : array(); $input = self::sanitize_display_input( $raw ); $settings = (array) get_option( Meta::OPTION_SETTINGS, array() ); update_option( Meta::OPTION_SETTINGS, array_merge( $settings, $input ) ); // Feedback immédiat (QR-16) : l'étape suivante confirme l'enregistrement (bandeau role="status"). wp_safe_redirect( add_query_arg( 'saved', '1', self::step_url( 'categories' ) ) ); exit; } /** * Enregistre les défauts Info-tri/Triman par catégorie puis passe à l'étape finale. */ public function handle_categories_post(): void { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'Action non autorisée.', 'agec-ready' ) ); } check_admin_referer( self::NONCE_CATS ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- normalisé par InfoTri / CategoryDefaults. $raw_default = isset( $_POST['agec_default'] ) && is_array( $_POST['agec_default'] ) ? wp_unslash( $_POST['agec_default'] ) : array(); $triman = ! empty( $raw_default['triman'] ); $components = InfoTri::sanitize_components( $raw_default['components'] ?? array() ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- réduit à des term_id entiers par sanitize_term_ids(). $raw_cats = isset( $_POST['agec_cats'] ) && is_array( $_POST['agec_cats'] ) ? wp_unslash( $_POST['agec_cats'] ) : array(); $defaults = array(); foreach ( self::sanitize_term_ids( $raw_cats ) as $term_id ) { $defaults[ $term_id ] = array( 'triman' => $triman, 'components' => $components, ); } update_option( Meta::OPTION_CATEGORY_DEFAULTS, CategoryDefaults::sanitize( $defaults ) ); // Instrumentation North Star (#13) : PQAS = affichage RÉELLEMENT rendu sur ≥1 fiche × qualification // (filière premium OU allégation à risque). On lit la vraie couverture (CatalogScan), pas le réglage. $settings = (array) get_option( Meta::OPTION_SETTINGS, array() ); $cov = CatalogScan::coverage(); $covered = max( 0, (int) ( $cov['scanned'] ?? 0 ) - (int) ( $cov['without'] ?? 0 ) ); $display_live = ! empty( $settings['display_product'] ) && $covered >= 1; Nsm::record( self::category_names(), $display_live, (int) ( $cov['at_risk'] ?? 0 ) ); wp_safe_redirect( self::step_url( 'done' ) ); exit; } /** * Normalise une liste de term_id soumise : entiers > 0 uniquement, non-scalaires écartés * (un POST forgé `agec_cats[][]=x` provoquait un warning « Array to int » — QR-17), dédupliquée. * * @param array $raw Valeurs brutes du formulaire. * @return array */ public static function sanitize_term_ids( array $raw ): array { $ids = array(); foreach ( $raw as $term_id ) { if ( ! is_scalar( $term_id ) ) { continue; } $term_id = (int) $term_id; if ( $term_id > 0 ) { $ids[ $term_id ] = $term_id; } } return array_values( $ids ); } /** * Phrase de couverture honnête quand la liste de catégories est tronquée (QR-17) — jamais de faux * « tout est là ». Chaîne vide si tout est affiché. Logique pure (unit-testable). * * @param int $shown Nombre de catégories listées. * @param int $total Nombre total de catégories de la boutique. */ public static function categories_truncation_hint( int $shown, int $total ): string { if ( $total <= $shown ) { return ''; } return sprintf( /* translators: 1: nombre de catégories affichées, 2: nombre total de catégories. */ __( 'Boutique très fournie : %1$s catégories affichées sur %2$s (ordre alphabétique). Les produits des autres catégories restent réglables produit par produit (onglet « AGEC »).', 'agec-ready' ), number_format_i18n( $shown ), number_format_i18n( $total ) ); } /** * Normalise les cases de l'étape « Affichage » en booléens. * * @param array $raw Données du formulaire. * @return array */ public static function sanitize_display_input( array $raw ): array { return array( 'triman_enabled' => ! empty( $raw['triman_enabled'] ), 'display_product' => ! empty( $raw['display_product'] ), ); } public function render(): void { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'Action non autorisée.', 'agec-ready' ) ); } $step = self::current_step(); echo '
'; $this->render_progress( $step ); // Confirmation d'enregistrement de l'étape précédente (annoncée aux lecteurs d'écran). // L'écran « Terminé » porte déjà son propre feedback (hero succès) → bandeau hors « done ». // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- lecture d'affichage uniquement. if ( 'done' !== $step && isset( $_GET['saved'] ) && '1' === sanitize_key( wp_unslash( $_GET['saved'] ) ) ) { echo '

' . esc_html__( 'Réglages enregistrés.', 'agec-ready' ) . '

'; } echo '
'; switch ( $step ) { case 'display': $this->render_display(); break; case 'categories': $this->render_categories(); break; case 'done': $this->render_done(); break; default: $this->render_welcome(); break; } echo '
'; } private function render_progress( string $current ): void { $keys = array_keys( self::steps() ); $index = (int) array_search( $current, $keys, true ); echo '
    '; $i = 0; foreach ( self::steps() as $label ) { $state = 'upcoming'; if ( $i < $index ) { $state = 'done'; } elseif ( $i === $index ) { $state = 'current'; } echo '
  1. ' . esc_html( $label ) . '
  2. '; ++$i; } echo '
'; } private function render_welcome(): void { echo '

' . esc_html__( 'Mettez votre boutique en conformité AGEC en 10 minutes', 'agec-ready' ) . '

'; echo '

' . esc_html__( 'AGEC Ready affiche automatiquement le Triman et les consignes de tri (Info-tri) sur vos fiches produits. Trois étapes suffisent.', 'agec-ready' ) . '

'; echo '
    '; echo '
  • ' . esc_html__( 'Activer l’affichage des consignes de tri.', 'agec-ready' ) . '
  • '; echo '
  • ' . esc_html__( 'Renseigner le tri par produit (onglet « AGEC »).', 'agec-ready' ) . '
  • '; echo '
  • ' . esc_html__( 'Vos clients voient les consignes de tri sur chaque fiche produit.', 'agec-ready' ) . '
  • '; echo '
'; echo '

' . esc_html__( 'Commencer', 'agec-ready' ) . '

'; } private function render_display(): void { $settings = (array) get_option( Meta::OPTION_SETTINGS, array() ); $triman = ! isset( $settings['triman_enabled'] ) || ! empty( $settings['triman_enabled'] ); $product = ! isset( $settings['display_product'] ) || ! empty( $settings['display_product'] ); echo '

' . esc_html__( 'Où afficher les consignes de tri ?', 'agec-ready' ) . '

'; echo '

' . esc_html__( 'Recommandé : afficher sur la fiche produit. Vous pourrez ajuster à tout moment.', 'agec-ready' ) . '

'; echo '
'; wp_nonce_field( self::NONCE ); echo ''; echo '
'; echo ''; echo ''; echo '
'; echo '

'; echo '' . esc_html__( 'Retour', 'agec-ready' ) . ' '; echo ''; echo '

'; } private function render_categories(): void { $stored = CategoryDefaults::get(); $default = self::default_editor_values( $stored ); $terms = self::product_categories(); echo '

' . esc_html__( 'Vos catégories de produits', 'agec-ready' ) . '

'; echo '

' . esc_html__( 'Renseignez le tri une seule fois par catégorie : chaque produit sans réglage propre en héritera. Filière emballages (Citeo), incluse dans la version gratuite.', 'agec-ready' ) . '

'; echo '
'; wp_nonce_field( self::NONCE_CATS ); echo ''; $this->render_default_editor( $default ); if ( empty( $terms ) ) { echo '

' . esc_html__( 'Aucune catégorie de produits pour le moment. Créez vos catégories puis revenez : vous pourrez appliquer ce réglage en un clic. Vous pouvez aussi renseigner le tri produit par produit (onglet « AGEC »).', 'agec-ready' ) . '

'; } else { // Couverture honnête (QR-17) : si la boutique a plus de catégories que la borne, on le dit. $hint = self::categories_truncation_hint( count( $terms ), self::total_product_categories() ); if ( '' !== $hint ) { echo '

' . esc_html( $hint ) . '

'; } echo '
'; echo '' . esc_html__( 'Appliquer ce réglage par défaut à :', 'agec-ready' ) . ''; foreach ( $terms as $term ) { $count = isset( $term->count ) ? (int) $term->count : 0; echo ''; } echo '
'; } $this->render_premium_hint( $terms ); echo '

'; echo '' . esc_html__( 'Retour', 'agec-ready' ) . ' '; echo ''; echo '

'; } /** * Éditeur du réglage Info-tri/Triman appliqué aux catégories cochées. * * @param array{triman:bool, components:array} $values Valeurs courantes. */ private function render_default_editor( array $values ): void { echo '
'; echo '' . esc_html__( 'Réglage par défaut', 'agec-ready' ) . ''; echo ''; foreach ( InfoTri::materials() as $material => $label ) { $selected = isset( $values['components'][ $material ] ) ? (string) $values['components'][ $material ] : ''; echo ''; } echo '
'; } /** * Upsell contextuel art. 13 (premium-d'abord) : si la boutique semble vendre des filières * soumises aux mentions environnementales. Aligné sur le cadrage du tableau de bord * (Settings::card_premium) — jamais d'éco-participation (phase 2). Factuel, non culpabilisant. * * @param array $terms Catégories de la boutique. */ private function render_premium_hint( array $terms ): void { $names = array(); foreach ( $terms as $term ) { $names[] = $term->name; } $filieres = Qualification::detect_premium_filieres( $names ); if ( empty( $filieres ) ) { return; } echo '
'; echo '

' . esc_html__( 'Article 13 — détecté', 'agec-ready' ) . '

'; echo '

' . esc_html__( 'Vos catégories suggèrent des filières soumises à l’article 13 :', 'agec-ready' ) . '

'; echo '
    '; foreach ( $filieres as $label ) { echo '
  • ' . esc_html( $label ) . '
  • '; } echo '
'; echo '

' . esc_html__( 'Affichez les caractéristiques environnementales obligatoires (matière recyclée, recyclabilité…) pour écarter l’amende, sans aucun calcul à faire. Inclus dans la version Premium.', 'agec-ready' ) . '

'; echo '' . esc_html__( 'Découvrir Premium', 'agec-ready' ) . ''; echo '
'; } /** * Catégories de produits de la boutique (vide si WooCommerce/taxonomie absente). * * @return array */ private static function product_categories(): array { if ( ! taxonomy_exists( 'product_cat' ) ) { return array(); } $terms = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => false, 'number' => self::CATS_LIMIT, ) ); if ( is_wp_error( $terms ) ) { // Pas d'échec silencieux : une erreur de taxonomie n'est pas « zéro catégorie ». Log::error( 'get_terms(product_cat) a échoué : ' . $terms->get_error_message() ); return array(); } return is_array( $terms ) ? $terms : array(); } /** Nombre total de catégories de produits (0 si taxonomie absente/erreur) — pour la couverture honnête. */ private static function total_product_categories(): int { if ( ! taxonomy_exists( 'product_cat' ) ) { return 0; } $count = wp_count_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => false, ) ); return is_wp_error( $count ) ? 0 : (int) $count; } /** * Valeurs initiales de l'éditeur : reprend la 1re catégorie stockée, sinon un défaut sensé. * * @param array}> $stored Défauts enregistrés. * @return array{triman:bool, components:array} */ private static function default_editor_values( array $stored ): array { foreach ( $stored as $def ) { return array( 'triman' => ! empty( $def['triman'] ), 'components' => $def['components'], ); } return array( 'triman' => true, 'components' => array( 'carton' => 'tri' ), ); } /** * Noms des catégories de produits de la boutique (pour la détection de filières). * * @return array */ private static function category_names(): array { $names = array(); foreach ( self::product_categories() as $term ) { $names[] = $term->name; } return $names; } /** * Écran « Terminé » — succès valorisé (aha), upsell art. 13 contextuel (revenu) et amorce de * veille (rétention). Rassemble l'état réel puis délègue à done_html() (pur, testable, rendu). */ private function render_done(): void { $settings = (array) get_option( Meta::OPTION_SETTINGS, array() ); $cov = CatalogScan::coverage(); $total = (int) $cov['total']; $without = (int) $cov['without']; $primary = self::done_primary(); $html = self::done_html( array( 'display_enabled' => ! empty( $settings['display_product'] ), 'total' => $total, 'covered' => max( 0, $total - $without ), 'without' => $without, 'cat_count' => count( CategoryDefaults::get() ), 'filiere_labels' => self::detected_filiere_labels(), 'upgrade_url' => Settings::upgrade_url(), 'primary_url' => $primary['url'], 'primary_label' => $primary['label'], 'primary_front' => $primary['front'], 'dashboard_url' => admin_url( 'admin.php?page=' . Settings::PAGE ), 'display_url' => self::step_url( 'display' ), 'veille_date' => self::veille_verified_date(), ) ); echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML construit et échappé dans done_html(). } /** * Construit le HTML de l'écran « Terminé » (pur, déjà échappé — testable & rendu hors WP). * * @param array $ctx Contexte d'affichage (compteurs, filières, URLs, date veille). */ public static function done_html( array $ctx ): string { $display = ! empty( $ctx['display_enabled'] ); $total = (int) ( $ctx['total'] ?? 0 ); $covered = (int) ( $ctx['covered'] ?? 0 ); $without = (int) ( $ctx['without'] ?? 0 ); $cats = (int) ( $ctx['cat_count'] ?? 0 ); $labels = isset( $ctx['filiere_labels'] ) && is_array( $ctx['filiere_labels'] ) ? $ctx['filiere_labels'] : array(); $h = '
'; // ── Hero succès : mark + titre + KPI chiffré ── $h .= '
' . self::done_mark() . '
'; if ( $display ) { $h .= '

' . esc_html__( 'L’affichage est activé.', 'agec-ready' ) . '

'; $h .= '

' . self::done_kpi_sentence( $total, $covered ) . '

'; } else { $h .= '

' . esc_html__( 'Vos réglages sont enregistrés.', 'agec-ready' ) . '

'; $h .= '

' . esc_html__( 'L’affichage du bloc « Tri & recyclage » est désactivé. Activez-le pour le montrer à vos clients.', 'agec-ready' ) . '

'; } $h .= '
'; // ── Ce qui est désormais affiché ── $h .= '
    '; $h .= $display ? self::check_line( __( 'Affichage activé sur la fiche produit — thème classique et bloc « Tri & recyclage » (FSE).', 'agec-ready' ) ) : self::check_line( __( 'Vos préférences d’affichage sont enregistrées.', 'agec-ready' ) ); if ( $cats > 0 ) { $h .= self::check_line( sprintf( /* translators: %d: nombre de catégories. */ _n( 'Réglage par défaut appliqué à %d catégorie — vos produits en héritent.', 'Réglage par défaut appliqué à %d catégories — vos produits en héritent.', $cats, 'agec-ready' ), $cats ) ); } // Vocabulaire marchand (QR-15) : « commandes hautes performances » est le libellé WooCommerce // visible en admin — jamais l'acronyme technique HPOS dans un écran marchand. $h .= self::check_line( __( 'Compatible avec les dernières versions de WooCommerce (commandes hautes performances, panier et validation de commande en blocs).', 'agec-ready' ) ); if ( $display && $without > 0 ) { $h .= self::check_line( sprintf( /* translators: %s: nombre de produits restant à compléter. */ _n( 'Il reste %s produit sans consignes — complétez-le quand vous voulez.', 'Il reste %s produits sans consignes — complétez-les quand vous voulez.', $without, 'agec-ready' ), number_format_i18n( $without ) ), false ); } $h .= '
'; // ── Upsell art. 13 contextuel + amorce veille ── $h .= self::done_upsell( $labels, (string) ( $ctx['upgrade_url'] ?? '' ) ); $h .= self::done_veille( (string) ( $ctx['veille_date'] ?? '' ) ); // ── Actions ── $h .= self::done_actions( $ctx, $display ); return $h . '
'; } /** Tuile de marque (anneau ouvert + coche) — décoratif, original (≠ Triman). */ private static function done_mark(): string { return ''; } /** * Phrase KPI du hero (gère catalogue vide / non couvert). */ private static function done_kpi_sentence( int $total, int $covered ): string { if ( $total <= 0 ) { return esc_html__( 'Ajoutez vos produits : l’Info-tri et la mention Triman s’afficheront automatiquement.', 'agec-ready' ); } if ( $covered <= 0 ) { return esc_html__( 'Renseignez le tri sur vos produits pour que l’Info-tri et la mention Triman s’affichent.', 'agec-ready' ); } $kpi = '' . esc_html( number_format_i18n( $covered ) ) . ''; /* translators: %s: nombre de produits (mis en valeur). */ $tpl = _n( 'L’Info-tri et la mention Triman s’affichent sur votre %s produit.', 'L’Info-tri et la mention Triman s’affichent sur vos %s produits.', $covered, 'agec-ready' ); return sprintf( esc_html( $tpl ), $kpi ); } /** Ligne « ce qui est conforme » (coche verte) ou « à compléter » (pastille atténuée). */ private static function check_line( string $text, bool $done = true ): string { $icon = $done ? '' : ''; $cls = $done ? 'agec-wizard__check' : 'agec-wizard__check agec-wizard__check--pending'; return '
  • ' . $icon . '' . esc_html( $text ) . '
  • '; } /** * Callout upsell art. 13 (or, premium-d'abord). Contextuel si filières détectées, sinon générique. * * @param array $labels Libellés de filières détectées. * @param string $upgrade_url URL d'offre premium. */ private static function done_upsell( array $labels, string $upgrade_url ): string { $h = '
    '; if ( ! empty( $labels ) ) { $count = count( $labels ); $h .= '

    ' . esc_html__( 'Article 13 — détecté', 'agec-ready' ) . '

    '; $h .= '

    ' . esc_html( sprintf( /* translators: %d: nombre de catégories. */ _n( '%d catégorie relève des mentions environnementales (art. 13).', '%d catégories relèvent des mentions environnementales (art. 13).', $count, 'agec-ready' ), $count ) ) . '

    '; $h .= '
      '; foreach ( $labels as $label ) { $h .= '
    • ' . esc_html( (string) $label ) . '
    • '; } $h .= '
    '; $h .= '

    ' . esc_html__( 'Affichez ces caractéristiques pour écarter l’amende (jusqu’à 15 000 €) — sans aucun calcul à faire.', 'agec-ready' ) . '

    '; } else { $h .= '

    ' . esc_html__( 'Pour aller plus loin', 'agec-ready' ) . '

    '; $h .= '

    ' . esc_html__( 'Mentions environnementales (art. 13)', 'agec-ready' ) . '

    '; $h .= '

    ' . esc_html__( 'Affichez la matière recyclée, la recyclabilité et les autres caractéristiques obligatoires sur vos fiches et une page dédiée — sans aucun calcul.', 'agec-ready' ) . '

    '; } if ( '' !== $upgrade_url ) { $h .= '' . esc_html__( 'Découvrir Premium', 'agec-ready' ) . ''; } return $h . '
    '; } /** Bandeau « On veille pour vous » (rétention/moat) — copy adoucie, sans promesse de résultat. */ private static function done_veille( string $date ): string { $h = '
    '; $h .= ''; $h .= '

    ' . esc_html__( 'On veille pour vous.', 'agec-ready' ) . ' ' . esc_html__( 'Chaque évolution de la réglementation AGEC est suivie, datée et sourcée. Le journal complet et les alertes e-mail sont inclus dans Pro.', 'agec-ready' ) . '

    '; if ( '' !== $date ) { $h .= '' . esc_html__( 'Suivi au', 'agec-ready' ) . ' '; } return $h . '
    '; } /** * Boutons d'action de l'écran « Terminé ». * * @param array $ctx Contexte d'affichage. * @param bool $display Affichage produit activé. */ private static function done_actions( array $ctx, bool $display ): string { $primary_url = (string) ( $ctx['primary_url'] ?? '' ); $primary_lbl = (string) ( $ctx['primary_label'] ?? '' ); $front = ! empty( $ctx['primary_front'] ); $h = '
    '; if ( ! $display ) { $h .= '' . esc_html__( 'Activer l’affichage', 'agec-ready' ) . ' '; } if ( '' !== $primary_url ) { $cls = $display ? 'button button-primary button-hero' : 'button'; $target = $front ? ' target="_blank" rel="noopener noreferrer"' : ''; $h .= '' . esc_html( $primary_lbl ) . ' '; } $h .= '' . esc_html__( 'Aller au tableau de bord', 'agec-ready' ) . ''; return $h . '
    '; } /** * Libellés des filières premium détectées dans le catalogue (pour l'upsell art. 13). * * @return array */ private static function detected_filiere_labels(): array { $all = Filieres::all(); $labels = array(); foreach ( Qualification::detect_slugs( self::category_names() ) as $slug ) { if ( isset( $all[ $slug ]['label'] ) ) { $labels[] = (string) $all[ $slug ]['label']; } } return $labels; } /** * CTA principal de l'écran « Terminé » : lien vers une vraie fiche produit (aha « voir le rendu ») * si la boutique en a une publiée ; sinon repli vers la liste des produits. * * @return array{url:string, label:string, front:bool} */ private static function done_primary(): array { $url = self::recent_product_url(); if ( '' !== $url ) { return array( 'url' => $url, 'label' => __( 'Voir une fiche produit', 'agec-ready' ), 'front' => true, ); } return array( 'url' => admin_url( 'edit.php?post_type=product' ), 'label' => __( 'Ouvrir mes produits', 'agec-ready' ), 'front' => false, ); } /** Permalien public du produit publié le plus récent (chaîne vide si WooCommerce/produit absent). */ private static function recent_product_url(): string { if ( ! post_type_exists( 'product' ) || ! function_exists( 'wc_get_product' ) ) { return ''; } $ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, ) ); if ( empty( $ids ) ) { return ''; } $url = get_permalink( (int) $ids[0] ); return is_string( $url ) ? $url : ''; } /** * Date de la dernière évolution réglementaire en vigueur suivie (bandeau veille). * Fournie par Pro via le filtre `agec_ready_veille_verified_date` (dataset strippé du zip .org) ; * vide en free → le bandeau « On veille pour vous » reste affiché, sans la ligne `