hookSuffix = add_menu_page( __( 'Double Opt-In', 'double-opt-in' ), __( 'Double Opt-In', 'double-opt-in' ), 'manage_options', self::PAGE_SLUG, array( $this, 'renderApp' ), $icon_url, 31 ); } /** * Enqueue React SPA assets only on our admin page. * * @param string $hook The current admin page hook suffix. * * @return void */ public function enqueueAssets( string $hook ): void { if ( $hook !== $this->hookSuffix ) { return; } $pluginDir = plugin_dir_path( dirname( __DIR__ ) ); $pluginUrl = plugin_dir_url( dirname( __DIR__ ) ); $buildDir = $pluginDir . 'admin-ui/build/'; // Enqueue the main JS bundle (single IIFE file with CSS injected). // Depends on `wp-i18n` so `window.wp.i18n` exists before the bundle // runs — the SPA's __() calls resolve against it (vite externalises // @wordpress/i18n to that global). $jsFile = $buildDir . 'index.js'; if ( file_exists( $jsFile ) ) { wp_enqueue_script( 'doi-admin-ui', $pluginUrl . 'admin-ui/build/index.js', array( 'wp-i18n' ), filemtime( $jsFile ), true ); // Feed the active locale's translations to wp.i18n for the // "double-opt-in" text domain, so the React admin renders in the // WP-admin language instead of the hardcoded English source. We // reuse the .mo already loaded by load_plugin_textdomain() rather // than shipping a separate JS translation pipeline. $this->injectSpaTranslations( 'doi-admin-ui' ); } // NOTE: the legacy flat `email-editor/build/` enqueue was removed // (2026-07). The editor bundle is enqueued solely by // EmailEditorAddon::enqueueEditorOnSpa() now. Enqueuing it here as // well caused the bundle to load twice on sites that still had the // pre-Phase-2 flat build, which double-mounted the editor App and // produced two "Save" buttons in the toolbar. // Enqueue CSS if it exists as a separate file (fallback for alternate builds) $assetsDir = $buildDir . 'assets/'; if ( is_dir( $assetsDir ) ) { $cssFiles = glob( $assetsDir . 'index-*.css' ); if ( ! empty( $cssFiles ) ) { $cssFile = $cssFiles[0]; $cssFilename = basename( $cssFile ); wp_enqueue_style( 'doi-admin-ui', $pluginUrl . 'admin-ui/build/assets/' . $cssFilename, array(), filemtime( $cssFile ) ); } } // Adjust WP admin layout for clean React SPA add_action( 'admin_notices', function () { echo ''; }, 999 ); // Inject configuration for the React app $config = array( 'restUrl' => esc_url_raw( rest_url( 'f12-doi/v1/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'isProActive' => (bool) apply_filters( 'f12_doi_is_pro_active', false ), 'isProInstalled' => defined( 'F12_DOI_PRO_VERSION' ), 'loadedAddons' => $this->getLoadedAddonIds(), 'registeredAddons' => $this->getRegisteredAddonIds(), 'version' => defined( 'FORGE12_OPTIN_VERSION' ) ? FORGE12_OPTIN_VERSION : '0.0.0', 'emailEditorUrl' => admin_url( 'admin.php?page=f12-doi-admin#/email-templates' ), 'upgradeUrl' => $this->productUrl( 'admin-upgrade', 'https://www.forge12.com' ), 'supportUrl' => $this->supportUrl(), 'feedbackUrl' => $this->feedbackUrl(), 'adminUrl' => admin_url(), // Nonce for the legacy admin-ajax `doi_export_consent` // handler. The handler hard-requires `_wpnonce` in // `$_REQUEST` keyed on action `doi_consent_export`; without // this the SPA's Export JSON/CSV links 403 with // "Sicherheitsprüfung fehlgeschlagen" (user-reported // 2026-05-13). REST routes use the separate `nonce` field // above with action `wp_rest` — they live in different // namespaces, so a single shared nonce won't work. 'consentExportNonce' => wp_create_nonce( 'doi_consent_export' ), ); /** * Filter the admin SPA configuration data. * * Allows Pro and other extensions to inject additional data * into the frontend configuration object. * * @param array $config The configuration array. * * @since 4.2.0 */ $config = apply_filters( 'f12_doi_admin_localize_data', $config ); wp_localize_script( 'doi-admin-ui', 'doiAdmin', $config ); } /** * Product-site link for the SPA. * * The URL builders live in core/feedback.php, a plain-function file in the * legacy namespace rather than an autoloaded class. The guard is not * ceremony: this controller is also exercised by tests that do not load that * file, and a fatal there would be a poor trade for a marketing link. * * @param string $from Entry point recorded on the link. * @param string $fallback Used when core/feedback.php is not loaded. */ private function productUrl( string $from, string $fallback ): string { $fn = '\\forge12\\contactform7\\CF7DoubleOptIn\\get_product_url'; return function_exists( $fn ) ? esc_url_raw( $fn( $from ) ) : $fallback; } /** * Support link for the SPA. Empty when unavailable, so the UI can hide it. */ private function supportUrl(): string { $fn = '\\forge12\\contactform7\\CF7DoubleOptIn\\get_support_url'; return function_exists( $fn ) ? esc_url_raw( $fn( 'admin-spa' ) ) : ''; } /** * Feedback link for the SPA. Empty when unavailable, so the UI can hide it. */ private function feedbackUrl(): string { $fn = '\\forge12\\contactform7\\CF7DoubleOptIn\\get_feedback_url'; return function_exists( $fn ) ? esc_url_raw( $fn( 'admin-spa' ) ) : ''; } /** * Feed the active locale's "double-opt-in" translations to wp.i18n so the * React admin renders in the WP-admin language. * * WordPress's own wp_set_script_translations() keys its JSON by the md5 of * each source JS file's path, which doesn't fit a single bundled IIFE. So * instead we build a Jed-format locale-data object by reading the plugin's * .mo directly and hand it to wp.i18n.setLocaleData() via an inline script * that runs *before* the bundle (hence the 'before' position + the wp-i18n * dependency). * * @param string $handle Enqueued script handle to attach the inline script to. * * @return void */ private function injectSpaTranslations( string $handle ): void { $locale = determine_locale(); // English is the source language — nothing to translate. if ( $locale === 'en_US' || $locale === 'en' ) { return; } // Read the .mo directly with POMO rather than // get_translations_for_domain(): under WP 6.5+'s new // WP_Translation_Controller that call returns a proxy whose ->entries // is EMPTY even when __() resolves, so iterating it injects nothing. $moFile = plugin_dir_path( dirname( __DIR__ ) ) . 'languages/double-opt-in-' . $locale . '.mo'; if ( ! is_readable( $moFile ) ) { return; } if ( ! class_exists( '\\MO' ) ) { require_once ABSPATH . WPINC . '/pomo/mo.php'; } $mo = new \MO(); if ( ! $mo->import_from_file( $moFile ) || empty( $mo->entries ) ) { return; } $locale_data = array( '' => array( 'domain' => 'double-opt-in', 'lang' => $locale, 'plural-forms' => $mo->headers['Plural-Forms'] ?? 'nplurals=2; plural=(n != 1);', ), ); foreach ( $mo->entries as $entry ) { // Jed prefixes context entries with "". $key = ( isset( $entry->context ) && $entry->context !== '' ) ? $entry->context . "\4" . $entry->singular : $entry->singular; $locale_data[ $key ] = $entry->translations; } wp_add_inline_script( $handle, 'wp.i18n.setLocaleData( ' . wp_json_encode( $locale_data ) . ', "double-opt-in" );', 'before' ); } /** * Render the React SPA mount point. * * @return void */ public function renderApp(): void { echo '
'; } /** * IDs of every addon currently loaded AND available on this site. * * Used by the React SPA to gate per-feature settings cards * (Reminder, MX Validation, Domain Blocklist, etc.) — drift to * gating on `isProActive` alone re-introduces the user-reported * bug where the Pro Bundle license shows feature settings for * addons that aren`t even installed on the site. * * Returns an empty list when AddonRegistry isn`t loaded yet (very * early boot or unit tests without it). The frontend treats an * empty list as "no addons available", which is the safe default. * * @return list */ private function getLoadedAddonIds(): array { if ( ! class_exists( '\\Forge12\\DoubleOptIn\\Addon\\AddonRegistry' ) ) { return array(); } $registry = \Forge12\DoubleOptIn\Addon\AddonRegistry::getInstance(); $available = $registry->available(); return array_values( array_keys( $available ) ); } /** * IDs of every addon plugin that is loaded/registered, regardless of * whether its license is currently active. * * Distinguishes "addon plugin missing" from "addon plugin installed * but not unlocked". The frontend ProGate component uses both lists * to render the right upsell. Under bundle-only licensing a covered * addon is unlocked whenever the Pro bundle is active, so the middle * state means the bundle isn't active — not a per-module license: * * - id in loadedAddons → fully active, no gate * - id in registeredAddons but NOT in loadedAddons * → "Pro Bundle Required" (plugin is here, * the bundle isn't active) * - id in neither → "Addon Required" (plugin not installed) * * @return list */ private function getRegisteredAddonIds(): array { if ( ! class_exists( '\\Forge12\\DoubleOptIn\\Addon\\AddonRegistry' ) ) { return array(); } $registry = \Forge12\DoubleOptIn\Addon\AddonRegistry::getInstance(); return array_values( array_keys( $registry->all() ) ); } }