preview->set(); $set = PublishedOptin::fromSet($preview ?? $this->publishedSet->all()); if ($set === []) { return; } // Degradation is applied HERE, on the way to the page, and not at // publish time — a premium rule is still in `published_config` when // [[Pro]] stops being loaded, and config outlives the code that reads // it (ADR 0012). What this passes down is not an entitlement: it is a // registry of the rule types that actually registered on this request // ({@see \WConvert\Rules\SuppliedRules}), which is why the front-end // path still asks no tier question of any kind (ADR 0015). $entries = Payload::forRequest($set, RequestContextFactory::forPublishedSet($set, $this->roles), $this->degradation); // An Optin that does not match this page costs this page nothing — // not a script, not a byte of payload. if ($entries === []) { return; } // **The renderer owns the privacy-policy link, and this is render // time** (ADR 0032). It is resolved here rather than baked into the // published set so that moving the policy page corrects every running // Optin without republishing one, and it is safe under the full-page // cache because `get_privacy_policy_url()` is a site-wide setting // identical for every visitor. $policy = get_privacy_policy_url(); $entries = array_map(static fn (array $entry): array => PolicyLink::into($entry, $policy), $entries); // **And the way back to the cart, on the same terms** (ADR 0025). The // cart page is site-local, so a [[Playbook]] cannot name it and the // published set must not freeze it — the merchant who moves their cart // page corrects every running Optin without republishing one. // // Keyed on the [[Goal]] rather than on the shape of the button, // because *Promote a sale or offer* ships the same click-metered CTA // with no href and its destination is the merchant's. `CartLink` is // where that argument lives. // // **Which Optins** is answered from the set that was already parsed; // **where the cart is** is read here and passed down, so the rule // itself stays a pure function of (entries, ids, url) — the same // arrangement `PolicyLink` has one line up, and what lets both be // tested without a WordPress install or a WooCommerce one. // // `wc_get_cart_url()` reads a WooCommerce option and runs a filter, so // it is asked only where a cart Optin actually matched this page, // which is almost never: an Optin that does not match costs the page // nothing (ADR 0003). $cartOptins = self::cartOptinsIn($set); if ($cartOptins !== []) { $entries = self::withCartUrl( $entries, $cartOptins, function_exists('wc_get_cart_url') ? (string) wc_get_cart_url() : null ); } // A preview is not a page view anyone should count, so the observers // that report one — Pro's analytics among them — are not told. if ($preview === null) { do_action('wconvert_frontend_entries', $entries); } else { self::anchorPreviewedInline($entries); } $dist = WCONVERT_DIR . self::DIST; // **Reported from wp-admin, not from here.** This method runs on // `wp_enqueue_scripts` and returns above on `is_admin()`, so an // `admin_notices` callback added at this point would be registered on // a front-end request — where `admin_notices` never fires and nobody // would ever have read it. {@see \WConvert\Admin\AdminNotices::checkLoaderBundle()} // asks the same question where the answer can be seen. All this can // honestly do on a visitor's page is print nothing. if (!is_file($dist)) { return; } $phoneDist = WCONVERT_DIR . 'public/phone/phone.js'; if (is_file($phoneDist) && self::hasPhoneField($entries)) { wp_enqueue_script(self::PHONE_HANDLE, WCONVERT_URL . 'public/phone/phone.js', [], BuiltAsset::version($phoneDist), true); wp_add_inline_script(self::PHONE_HANDLE, 'window.__wcPhoneLabels=' . wp_json_encode([ 'fallback' => __('Include + and the country code, for example +1 202 555 0123.', 'wconvert'), 'select' => __('Select country', 'wconvert'), /* translators: 1: a country's name, 2: its calling code without the plus sign, for example 44. */ 'trigger' => __('Select country: %1$s (+%2$s)', 'wconvert'), 'closeSelector' => __('Close country selector', 'wconvert'), 'close' => __('Close', 'wconvert'), 'search' => __('Search…', 'wconvert'), 'searchCountries' => __('Search countries', 'wconvert'), 'countries' => __('Countries', 'wconvert'), // One string for every count: the phone bundle has no wp.i18n // to evaluate a plural rule with, and a language with more // than two forms cannot be served by a "one" and a "many". /* translators: %s: how many countries match the search. */ 'results' => __('Results: %s', 'wconvert'), 'too_short' => __('Enter a longer phone number.', 'wconvert'), 'too_long' => __('This phone number is too long.', 'wconvert'), 'invalid_length' => __('Check the phone number length.', 'wconvert'), 'invalid_country' => __('Choose a supported country or check the country code.', 'wconvert'), ], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';', 'before'); } wp_enqueue_script( self::HANDLE, WCONVERT_URL . self::DIST, wp_script_is(self::PHONE_HANDLE, 'enqueued') ? [self::PHONE_HANDLE] : [], BuiltAsset::version($dist), true ); // Priority 5 on `wp_head`: after `wp_enqueue_scripts` (which core runs // at 1) so this callback is registered in time, and early enough that // the payload precedes anything an optimizer aggregates into the head. // The loader still must not ASSUME that — Autoptimize's force-in-head // moves it above the payload and strips its `defer`, which is why the // loader retries after DOMContentLoaded (ADR 0004). $captureUrl = rest_url(Routes::NAMESPACE . '/capture'); $beaconUrl = rest_url(Routes::NAMESPACE . '/beacon'); // ==================================================================== // TWO FACTS ABOUT THE SITE, BOTH READ HERE RATHER THAN FROZEN INTO THE // PUBLISHED SET. // ==================================================================== // Same property `PolicyLink` and `CartLink` have one line up: a // merchant who changes either corrects every running Optin without // republishing one, and both are safe under the full-page cache // because both are site-wide settings identical for every visitor. // // **The allowance the whole site shares** is null on every install // that has not asked for one (ADR 0047), and it is read after the // payload is known to be non-empty — so a page with no matching Optin // pays for no option read at all. // // **The site's own clock** is for the rules that ask what time it is // THERE (#92). It is a zone rather than an instant because a recurring // window has no finite set of instants to resolve and the offset a // named zone is on moves twice a year, so the browser's own tzdata // answers and a page cached before a daylight-saving transition is // still right after one. A [[Schedule]]'s instants are the opposite // case and are resolved at publish, because they happen once — // `src/Optin/Schedule.php` is where that split lives. $siteAllowance = $this->siteFrequency->forPayload(); $timezone = wp_timezone()->getName(); // A preview records nothing: no beacon, no capture, and no allowance // shared with the rest of the site. A submit shows the form's own // "not confirmed" state rather than creating a Lead (ADR 0151). if ($preview !== null) { $captureUrl = ''; $beaconUrl = ''; $siteAllowance = null; } add_action('wp_head', static function () use ($entries, $captureUrl, $beaconUrl, $siteAllowance, $timezone): void { // Not escaped, and correctly so: PayloadTag renders JSON with // JSON_HEX_TAG, which is the escaping this context needs. Running // esc_html() over it would escape the quotes and produce invalid // JSON — a silent break, because the tag still renders and only // the loader's JSON.parse fails, in the browser, at runtime. The // tag itself is wp_get_inline_script_tag()'s, which escapes every // attribute, and the two route URLs go through esc_url_raw() first. // // PHPCS sees `echo ` and can see neither of those // facts. {@see PayloadTag::render()} is where they are enforced, // and tests/unit/Frontend/PayloadTest.php is what holds them. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- PayloadTag::render() escapes for this context: JSON_HEX_TAG on the body, wp_get_inline_script_tag() on the attributes. echo PayloadTag::render($entries, $captureUrl, $beaconUrl, $siteAllowance, $timezone); }, 5); } /** * An `inline` campaign renders at its anchor, and a preview on a page * without its block would show nothing. So the preview adds one at the end * of the page. Where the page already has the merchant's block, that one * comes first in the document and the loader uses it. * * @param list> $entries */ private static function anchorPreviewedInline(array $entries): void { foreach ($entries as $entry) { if (($entry['display_type'] ?? null) !== 'inline') { continue; } $anchor = InlineAnchor::html((string) ($entry['id'] ?? '')); // Before the footer scripts print at 20, so the anchor is in the // document when the loader decides. add_action('wp_footer', static function () use ($anchor): void { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- InlineAnchor::html() refuses anything but a ULID and escapes the attribute. echo $anchor; }, 5); } } /** * The feature bundle is loaded only on a page with a matching phone campaign. * @param list> $entries */ private static function hasPhoneField(array $entries): bool { foreach ($entries as $entry) { $found = false; TemplateTree::rewrittenIn($entry, static function (array $node) use (&$found): array { if (($node['type'] ?? null) === 'field' && ($node['name'] ?? null) === 'phone' && !empty($node['phone_country'])) $found = true; return $node; }); if ($found) return true; } return false; } /** * Which of this page's Optins exist to send a shopper back to their cart. * * **Keyed on the [[Goal]] rather than on the shape of the button.** * *Promote a sale or offer* ships the same click-metered CTA with no href * and its destination is the merchant's, so a purely structural rule — the * one {@see PolicyLink} can afford, because a site has exactly one privacy * policy — would silently point an unconfigured sale Optin at the cart. * * A lookup rather than a list, so the map below tests membership with * `isset`. The Optins were parsed once at the top of {@see self::enqueue()} * and the Goal rides the projection beside the payload, so this walks what * has already been read rather than the option again (ADR 0003). * * **Separate from {@see self::withCartUrl()} rather than folded into it**, * because the answer decides whether to ASK where the cart is at all: * `wc_get_cart_url()` reads an option and runs a filter, and a page with * no cart Optin on it must pay neither. One walk, then a decision — the * alternative is walking the set twice to keep that laziness. * * Public and static for the same reason its partner is: both are pure * functions of what the enqueue path has already read, and everything * between them is WordPress calls a unit suite cannot make. * * @param list $set * @return array */ public static function cartOptinsIn(array $set): array { $ids = []; foreach ($set as $optin) { if ($optin->goal === Goal::RecoverCart) { $ids[$optin->id] = true; } } return $ids; } /** * Those entries, pointed back at the cart. * * With no WooCommerce there is no URL, and none is invented — never a dead * `#`. Such an Optin is [[Suspended]] anyway, because both cart * [[Condition]]s carry `on_absence: suspend` and neither is supplied * without a store (ADR 0027), so that branch is the belt beside this * brace. * * Public and static for the reason {@see Payload::forRequest()} is: it is * a pure function of what the enqueue path has already read, and the whole * of the surrounding method is WordPress calls a unit suite cannot make. * * @param list> $entries * @param array $cartOptins As {@see self::cartOptinsIn()} built it. * @param string|null $url `wc_get_cart_url()`, or null where there is no store. * @return list> */ public static function withCartUrl(array $entries, array $cartOptins, ?string $url): array { return array_map( static fn (array $entry): array => isset($cartOptins[$entry['id'] ?? '']) ? CartLink::into($entry, $url) : $entry, $entries ); } }