'
. '';
/**
* When this runs on `admin_enqueue_scripts`.
*
* A named constant for the reason {@see \WConvert\Frontend\LoaderEnqueue::PRIORITY}
* is one: Pro replaces this bundle on the same hook, later, and "later" has
* to be a fact the two plugins share rather than two numbers. It must not
* depend on load order — WordPress loads active plugins in the order its own
* option lists them, so a swap that worked because `wconvert-pro` happened
* to be read after `wconvert` would work by accident.
*
* 10 is `add_action`'s own default, which is what this hook ran at before
* it was written down. Naming it changes nothing about when it fires.
*/
public const PRIORITY = 10;
private string $screenId = '';
public function __construct(
private readonly AdminNotices $notices,
) {
}
public function hooks(): void
{
add_action('admin_menu', [$this, 'registerMenu']);
add_action('admin_enqueue_scripts', [$this, 'enqueueAssets'], self::PRIORITY);
}
public function registerMenu(): void
{
$screenId = add_menu_page(
__('WConvert', 'wconvert'),
__('WConvert', 'wconvert'),
'manage_options',
self::SLUG,
[$this, 'renderScreen'],
'data:image/svg+xml;base64,' . base64_encode(self::MENU_ICON),
26
);
// false when the current user lacks the capability, in which case there
// is no screen to match against and nothing to enqueue.
$this->screenId = is_string($screenId) ? $screenId : '';
// The one place this suffix is learned, and both things that scope by
// screen read it from here: the bundle enqueue below, and the notice
// suppression that empties `admin_notices` on WConvert's screens and
// nowhere else (ADR 0035).
$this->notices->owns($this->screenId);
$this->registerSubmenu();
}
/**
* WConvert → Campaigns, then WConvert → Help.
*
* The first entry repeats the top level's slug, which is how WordPress
* renames the submenu's automatic first item: without it the list would
* open on a second "WConvert". Help's slug is a URL, so WordPress links it
* as written. Both entries are one page, so which is `current` is decided
* in the browser from the hash (`shell/menuHighlight.ts`).
*/
public function registerSubmenu(): void
{
add_submenu_page(self::SLUG, __('Campaigns', 'wconvert'), __('Campaigns', 'wconvert'), 'manage_options', self::SLUG);
add_submenu_page(self::SLUG, __('Help', 'wconvert'), __('Help', 'wconvert'), 'manage_options', self::HELP_HREF);
}
/**
* The plugin's own notices, then the mount node.
*
* **No `.wrap`.** That class is what insets a WordPress screen and draws
* WordPress's heading rhythm around it, and this page is WConvert's
* (ADR 0035) — the frame, the type and the spacing are the admin bundle's,
* over tokens the plugin owns.
*
* The notices come first and come from PHP, because
* {@see AdminNotices::renderOwned()} is the only path that can report a
* missing admin bundle: that failure leaves no React to render a message
* with. It runs after `admin_notices` was emptied on this screen, which is
* the whole reason it exists.
*/
public function renderScreen(): void
{
$this->notices->render();
echo '
';
}
/**
* Enqueue the admin bundle on the WConvert screen only.
*
* Matching on the hook suffix add_menu_page() returned rather than on a
* hand-built string: the suffix is what admin_enqueue_scripts is passed,
* and building it here would be a second spelling of the same fact.
*/
public function enqueueAssets(string $hookSuffix): void
{
if ($this->screenId === '' || $hookSuffix !== $this->screenId) {
return;
}
ViteHelper::enqueueAdmin(self::SCRIPT_HANDLE, $this->notices);
// **WordPress's own media picker, for the builder's image slot.** An
// `image`'s `src` was a text box asking a merchant to type a URL for a
// file they had already uploaded — and `MerchantsOwn` carries that
// value across a design switch precisely because it is theirs to set.
//
// It is WordPress's script, on one screen, and the admin bundle has no
// byte gate — its size is printed at every build rather than enforced
// (ADR 0038) — so this is reportable rather than a budget decision. The
// editor degrades to the URL field where it is absent, so nothing here
// is load-bearing for setting an image.
wp_enqueue_media();
/*
* ====================================================================
* THE THEME'S OWN FACES, SO THE FONT PICKER DRAWS ITS ROWS IN THEM.
* ====================================================================
* The picker offers the families this SITE declares and sets each row
* in the face it names — which is the whole value of the control, and
* which needs the `@font-face` rules to be on this page. Core prints
* them on the front end; wp-admin gets them only where something asks.
*
* Core's own, since 6.4, and so on every WordPress this plugin
* supports (6.8 and up). **Nothing is fetched and no face is declared
* here**: this prints the site's, or nothing
* (`docs/adr/0055-the-font-list-is-the-sites.md`).
*/
add_action('admin_print_styles', 'wp_print_font_faces');
add_action('admin_print_styles', [\WConvert\Template\BundledFonts::class, 'printAdminFaces'], 30);
// `wp_add_inline_script()` rather than `wp_localize_script()`, and the
// difference is not stylistic: `localize` casts every value to a
// STRING, so `true` arrives in the browser as `"1"` and `false` as
// `""`. A boolean that reads as `"1"` is worse than one that reads as
// `true` and worse than one that is absent — the screen tests it and
// is quietly wrong, which is how the dev export shipped invisible on
// an install that had `WP_DEBUG` on. `wp_json_encode()` keeps the type
// the setting actually has.
wp_add_inline_script(
self::SCRIPT_HANDLE,
'window.wconvertAdmin = ' . wp_json_encode(self::settings(), JSON_HEX_TAG | JSON_HEX_AMP) . ';',
'before'
);
}
/**
* What the screen is handed before its bundle runs.
*
* ========================================================================
* PUBLIC BECAUSE PRO REPLACES THE SCRIPT AND WOULD OTHERWISE LOSE THESE.
* ========================================================================
* `wp_add_inline_script()` attaches to a HANDLE, and Pro deregisters this
* one (ADR 0014) — so the inline script above goes with it, and a Pro
* install would boot the admin with no `window.wconvertAdmin` at all: no
* export URL, no policy link, no inspector door. Every one of those fails
* as a missing feature rather than as an error, which is the shape that
* ships.
*
* So the settings are a value both sides can ask for rather than a
* statement made once at the moment of enqueue. Pro attaches the SAME
* values to its own handle ({@see \WConvert\Pro\Admin\ProAdminEnqueue}),
* which is the one direction the split allows — Pro reaches into free.
*
* Static, because none of it is a fact about this menu instance: every
* value is read from WordPress or from a free constant at the moment the
* screen is enqueued.
*
* @return array
*/
public static function settings(): array
{
$manifest = TierManifest::load();
$pro = new WpProPresence();
return [
// The CSV download is a navigation to `admin-post.php`, so the
// screen needs the nonced URL rather than a REST path —
// `apiFetch` would read the file into memory and then have to turn
// it back into a download.
'exportUrl' => LeadExport::url(),
// The renderer's own words, so a preview speaks the merchant's
// language as the published page does (`page-labels.ts`).
'pageLabels' => (object) \WConvert\Frontend\PageLabels::differing(),
'installedTier' => $pro->installedTier()->value,
// The contextual Pro hints, and whether they say "Activate it" (ADR 0147).
'proHints' => ProHints::shown($pro),
'proInstalledInactive' => ProHints::proInstalledInactive($pro),
// Registered by Pro's journeys module, never inferred from a tier:
// the builder offers questions, results and paths only where the
// code that draws them is on disk (ADR 0116).
'commerce' => \WConvert\Template\CommerceSupport::active(),
'journeys' => \WConvert\Template\JourneySupport::active(),
// Template packs appear only once a catalog service is configured.
// Guarded like PhoneCountry::siteDefault(): a suite without
// WordPress reads the settings too.
'catalogConfigured' => function_exists('get_option') && \WConvert\Template\Catalog\TemplateCatalog::configured(new \WConvert\Storage\WpOptionStore()),
// **Authoring is the settings panel plus a DEV-ONLY export**
// (ADR 0010). Gated on `WP_DEBUG` rather than on a capability:
// everyone who reached this screen already has `manage_options`,
// so it is not a permission question — it is that a merchant has
// no use for the library entry behind their popup, and a control
// they cannot act on is one they learn to ignore.
'dev' => defined('WP_DEBUG') && WP_DEBUG,
// **The consent link the admin draws, resolved here** (#77).
// `PolicyLink::into()` runs on the published payload and on the
// capture path, and neither is a path the admin reads — so every
// preview, every gallery card and the creation flow's last step
// rendered the fine print as "See our." while the front end
// rendered it correctly, under a field labelled "leave empty for
// your privacy policy".
//
// It travels as the site's URL rather than as a resolved tree, and
// that is the whole of why it is here: the builder PATCHes the
// config it was handed straight back, so an href resolved into a
// config on the way out is an href stored on the way back —
// frozen at publish, which is exactly what ADR 0032 refuses. The
// admin resolves it at the render instead (`builder/policy.ts`).
'policyUrl' => (string) get_privacy_policy_url(),
// **Where the eligibility inspector opens.** The merchant does not
// describe a URL — they visit one, because a `RequestContext`
// cannot be built from a URL and must not be faked (ADR 0048). So
// the list offers a door, and the door needs somewhere to start.
//
// The home URL rather than the parameter appended to it: the
// dialog takes whichever page the merchant is asking about, and
// this is only what its field is prefilled with. And it comes from
// `home_url()` rather than from `location.origin`, so a
// subdirectory install lands on the site rather than on the domain
// root — which is the same `/blog/pricing` confusion half the real
// Targeting tickets are about.
'homeUrl' => (string) home_url('/'),
// Where a "Shop now" button starts when the form switch adds one (ADR 0163).
'offerLink' => OfferLinks::onThisSite(),
// Shown in Help beside What's new; WCONVERT_VERSION is a public fact (constants.php).
'version' => WCONVERT_VERSION,
'siteName' => wp_specialchars_decode((string) get_bloginfo('name'), ENT_QUOTES),
'phoneDefaultCountry' => \WConvert\Optin\PhoneCountry::effectiveDefault(),
'inspectParam' => InspectorEnqueue::PARAM,
'previewParam' => DraftPreview::PARAM,
// WordPress owns site-wide layout. WConvert only names the native
// editor this theme actually exposes, and only when the current
// user may edit that layout. A classic theme with no registered
// widget areas has nowhere useful for widgets.php to lead.
'placementEditor' => self::placementEditor(),
'timezone' => wp_timezone_string(),
// **What one design may cost, so the builder can draw a meter.**
// The same constant `LibraryLintTest` caps a shipped design at, sent
// over rather than written a second time in TypeScript — a meter
// measured against its own copy of the number is a meter that stays
// green on the day the cap moves. Same reason `inspectParam` is
// here, one line up.
'designBudget' => DesignBudget::PER_DESIGN,
// **What to call each paid tier**, read from `tiers.json` rather
// than written into five components as the literal "Pro", so
// renaming the product is an edit to that file rather than five
// strings and a release.
'tiers' => $manifest->forTheAdmin(),
// **Whether this install can run an A/B test**, resolved on the
// server so no surface recombines two facts in an order of its own
// (ADR 0026).
//
// ================================================================
// THE TIER IS READ OUT OF `tiers.json`, NOT WRITTEN DOWN HERE.
// ================================================================
// A/B testing is a MODULE — `pro/modules/ab-testing/` — so the
// question *"which tier supplies it"* already has an answer in the
// one file the build is checked against. Naming {@see Tier::Pro}
// here instead would be a second declaration of the same fact.
//
// It never renders `unavailable`: nothing about the SITE makes a
// test impossible, so this is `ready` or `locked` and the `locked`
// card names {@see self::VARIANT_MODULE}'s tier.
'variants' => self::whetherTestsCanRun($manifest, $pro),
];
}
/**
* The native WordPress surface that can place a block site-wide.
*
* @return array{type: 'site_editor'|'widgets', url: string}|null
*/
private static function placementEditor(): ?array
{
if (!current_user_can('edit_theme_options')) {
return null;
}
if (function_exists('wp_is_block_theme') && wp_is_block_theme()) {
return ['type' => 'site_editor', 'url' => admin_url('site-editor.php')];
}
global $wp_registered_sidebars;
if (is_array($wp_registered_sidebars) && $wp_registered_sidebars !== []) {
return ['type' => 'widgets', 'url' => admin_url('widgets.php')];
}
return null;
}
/**
* Whether a merchant on this install may start an A/B test, and what to
* call the tier if not.
*
* **The absence of the routes is the actual enforcement** (ADR 0015): they
* live in the `ab-testing` module's own PHP, so a build without the module
* registers none of them and there is nothing to guard. This is what stops
* the merchant meeting a control that will be refused, which is a separate
* obligation and the one ADR 0042 names — *"never offer what will be
* refused; mark it before the click, with the reason."*
*
* @return array{availability: string, tier: string|null}
*/
private static function whetherTestsCanRun(TierManifest $manifest, ProPresence $pro): array
{
$tier = $manifest->tierThatShips(self::VARIANT_MODULE);
return [
// `true` for the site half, always. A test needs no store, no MailPoet
// and no other plugin — so `unavailable` is unreachable here, and
// `Availability::of()` is still what answers rather than a boolean
// this file recombines (ADR 0026).
'availability' => Availability::of(true, $tier !== null && $tier->isSuppliedBy($pro))->value,
'tier' => $tier?->value,
];
}
}