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() ) );
}
}