pluginRootDir = rtrim( $trimmed, '/\\' ); $this->registrarOverride = $registrarOverride; $this->rendererOverride = $rendererOverride; } /** * Bind the {@see BlockRegistrar} and card block renderer into the * registry, then append the `affilun/product-card` {@see BlockDefinition} * with its `render_callback` populated. * * @param ServiceProviderRegistry $registry Registry driving this * bootstrap pass. * * @throws LogicException When the persistence or render * provider has not registered before * this one. */ public function register( ServiceProviderRegistry $registry ): void { $repository = $this->resolveProductRepository( $registry ); $cardFactory = $this->resolveViewModelFactory( $registry ); $cardRenderer = $this->resolveProductCardRenderer( $registry ); $arrayDumper = $this->resolveArrayDumper( $registry ); // KI-007: optional cache wiring. When the cache provider has // been registered upstream the block renderers read from / // write to the §4 render cache layer; otherwise they fall // back on the Phase 9 MVP "render every request" behavior. $renderCache = $this->resolveOptionalRenderCache( $registry ); $cacheKeys = $this->resolveOptionalCacheKeyFactory( $registry ); $providerRegistry = $this->resolveOptionalProviderRegistry( $registry ); $affiliateRouter = $this->resolveAffiliateRouter( $registry ); $urlRepository = $this->resolveOptionalProductUrlRepository( $registry ); $settings = $this->resolveSettingsRepository( $registry ); $cardBlockRenderer = $this->rendererOverride ?? new ProductCardBlockRenderer( $repository, $cardFactory, $cardRenderer, null, $renderCache, $cacheKeys, $arrayDumper, $providerRegistry, $affiliateRouter, $urlRepository, $settings ); $registrar = $this->registrarOverride ?? new BlockRegistrar(); $this->resolvedRegistrar = $registrar; $registry->bind( self::BINDING_BLOCK_REGISTRAR, $registrar ); $registry->bind( self::BINDING_PRODUCT_CARD_BLOCK_RENDERER, $cardBlockRenderer ); $registrar->add( new BlockDefinition( $this->pluginRootDir . DIRECTORY_SEPARATOR . 'blocks' . DIRECTORY_SEPARATOR . 'product-card', 'affilun/product-card', array( 'render_callback' => array( $cardBlockRenderer, 'render' ), ) ) ); } /** * Subscribe an `init` action callback that drives the bound * {@see BlockRegistrar::register()}. * * @param HookRegistrar $hooks Shared hook registrar. */ public function boot( HookRegistrar $hooks ): void { $hooks->addAction( 'init', array( $this, 'onInit' ) ); // `enqueue_block_assets` fires both on the front-end and inside // the Gutenberg editor's iframe canvas, so the card CSS reaches // the in-editor preview HTML emitted by SelectedProductSummary. // `enqueue_block_editor_assets` would only load the stylesheet // into the wp-admin host page, not into the iframe where the // block's edit() output is actually rendered. $hooks->addAction( 'enqueue_block_assets', array( $this, 'onEnqueueScripts' ) ); } /** * `init` action callback. * * Drives {@see BlockRegistrar::register()} so every * {@see BlockDefinition} appended during register() (and any * appended by subsequent slices' service providers) is * forwarded to WordPress at the canonical block-registration * moment. */ public function onInit(): void { $this->registerEditorPreviewScript(); if ( $this->resolvedRegistrar === null ) { return; } $this->resolvedRegistrar->register(); } /** * Register the shared no-build editor-preview helper before block * metadata registration reads `index.asset.php` dependencies. */ private function registerEditorPreviewScript(): void { if ( ! function_exists( 'wp_register_script' ) || ! function_exists( 'plugins_url' ) ) { return; } $entry_file = defined( 'AFFILUN_PLUGIN_FILE' ) ? (string) constant( 'AFFILUN_PLUGIN_FILE' ) : dirname( __DIR__, 3 ) . '/affilun.php'; $rel_path = 'assets/js/affilun-editor-preview.js'; $abs_path = dirname( $entry_file ) . '/' . $rel_path; $src = plugins_url( $rel_path, $entry_file ); $version = file_exists( $abs_path ) ? (string) filemtime( $abs_path ) : ( defined( 'AFFILUN_VERSION' ) ? (string) constant( 'AFFILUN_VERSION' ) : '0' ); wp_register_script( self::EDITOR_PREVIEW_SCRIPT_HANDLE, $src, array( 'wp-api-fetch' ), $version, false ); } /** * `enqueue_block_assets` callback (post-KI design refresh). * * Registers + enqueues the front-end card stylesheet so a post that * embeds an `affilun/product-card` block renders the modern affiliate-card * layout. The same stylesheet is enqueued in the block editor so the * server-rendered preview and the front-end render share visuals. * * The asset URL is computed from the plugin entry file so the * lookup is independent of the active theme. The handle is * versioned by the plugin's own version constant when defined, * otherwise by the file's mtime (the same fallback wp-scripts * uses). */ public function onEnqueueScripts(): void { if ( ! function_exists( 'wp_enqueue_style' ) || ! function_exists( 'plugins_url' ) ) { return; } $entry_file = defined( 'AFFILUN_PLUGIN_FILE' ) ? (string) constant( 'AFFILUN_PLUGIN_FILE' ) : dirname( __DIR__, 3 ) . '/affilun.php'; $rel_path = 'assets/css/affilun-card.css'; $abs_path = dirname( $entry_file ) . '/' . $rel_path; $src = plugins_url( $rel_path, $entry_file ); $version = file_exists( $abs_path ) ? (string) filemtime( $abs_path ) : ( defined( 'AFFILUN_VERSION' ) ? (string) constant( 'AFFILUN_VERSION' ) : '0' ); wp_enqueue_style( 'affilun-card-style', $src, array(), $version, 'all' ); $this->enqueueProviderButtonColorStyles(); $this->enqueueClickTrackingScript( $version ); } /** * Enqueue provider button color overrides from plugin settings. */ private function enqueueProviderButtonColorStyles(): void { if ( ! function_exists( 'wp_add_inline_style' ) ) { return; } $colors = self::loadProviderButtonColors(); $textColors = self::loadProviderButtonTextColors(); $css = '.affilun-card {'; foreach ( $colors as $providerKey => $color ) { $token = self::providerCssToken( $providerKey ); $color = self::sanitizeCssHexColor( $color ); $preference = $textColors[ $providerKey ] ?? ProviderCredentials::BUTTON_TEXT_COLOR_AUTO; $css .= sprintf( '--affilun-color-%s:%s;--affilun-color-%s-text:%s;', $token, $color, $token, self::sanitizeCssHexColor( ProviderCredentials::resolveButtonTextColor( $color, $preference ) ) ); } $css .= '}'; wp_add_inline_style( 'affilun-card-style', $css ); } /** * Load provider button colors from the persisted settings option. * * @return array */ private static function loadProviderButtonColors(): array { $colors = ProviderCredentials::DEFAULT_BUTTON_COLORS; if ( ! function_exists( 'get_option' ) ) { return $colors; } $stored = get_option( OptionStoreSettingsRepository::OPTION_KEY, array() ); if ( ! is_array( $stored ) ) { return $colors; } $providers = $stored['provider_credentials'] ?? null; if ( ! is_array( $providers ) ) { return $colors; } foreach ( array_keys( $colors ) as $providerKey ) { $entry = $providers[ $providerKey ] ?? null; if ( ! is_array( $entry ) ) { continue; } $secrets = $entry['secrets'] ?? null; if ( ! is_array( $secrets ) ) { continue; } $value = isset( $secrets['button_color'] ) && is_string( $secrets['button_color'] ) ? $secrets['button_color'] : ''; $colors[ $providerKey ] = ProviderCredentials::normalizeButtonColor( $providerKey, $value ); } return $colors; } /** * Load provider button text color preferences from the persisted settings option. * * @return array */ private static function loadProviderButtonTextColors(): array { $textColors = array_fill_keys( array_keys( ProviderCredentials::DEFAULT_BUTTON_COLORS ), ProviderCredentials::BUTTON_TEXT_COLOR_AUTO ); if ( ! function_exists( 'get_option' ) ) { return $textColors; } $stored = get_option( OptionStoreSettingsRepository::OPTION_KEY, array() ); if ( ! is_array( $stored ) ) { return $textColors; } $providers = $stored['provider_credentials'] ?? null; if ( ! is_array( $providers ) ) { return $textColors; } foreach ( array_keys( $textColors ) as $providerKey ) { $entry = $providers[ $providerKey ] ?? null; if ( ! is_array( $entry ) ) { continue; } $secrets = $entry['secrets'] ?? null; if ( ! is_array( $secrets ) ) { continue; } $value = isset( $secrets['button_text_color'] ) && is_string( $secrets['button_text_color'] ) ? $secrets['button_text_color'] : ''; $textColors[ $providerKey ] = ProviderCredentials::normalizeButtonTextColor( $value ); } return $textColors; } /** * Convert provider keys to the CSS token names used in affilun-card.css. * * @param string $providerKey Provider key. */ private static function providerCssToken( string $providerKey ): string { $token = $providerKey === 'yahoo_shopping' ? 'yahoo' : str_replace( '_', '-', $providerKey ); if ( function_exists( 'sanitize_html_class' ) ) { return (string) sanitize_html_class( $token ); } return (string) preg_replace( '/[^a-z0-9_-]/i', '', $token ); } /** * Sanitize a CSS hex color declaration value. * * @param string $color Candidate color. */ private static function sanitizeCssHexColor( string $color ): string { if ( function_exists( 'sanitize_hex_color' ) ) { $sanitized = sanitize_hex_color( $color ); if ( is_string( $sanitized ) && $sanitized !== '' ) { return $sanitized; } } return preg_match( '/\A#[0-9a-fA-F]{6}\z/', $color ) === 1 ? $color : '#111827'; } /** * Enqueue the privacy-light click tracking helper. * * @param string $version Asset version. */ private function enqueueClickTrackingScript( string $version ): void { if ( ! function_exists( 'wp_register_script' ) || ! function_exists( 'wp_enqueue_script' ) ) { return; } $handle = 'affilun-click-tracking'; wp_register_script( $handle, '', array(), $version, true ); wp_enqueue_script( $handle ); if ( ! function_exists( 'wp_add_inline_script' ) ) { return; } $endpoint = function_exists( 'rest_url' ) ? (string) rest_url( 'affilun/v1/clicks' ) : '/wp-json/affilun/v1/clicks'; $encodedEndpoint = function_exists( 'wp_json_encode' ) ? wp_json_encode( $endpoint ) : '"' . addcslashes( $endpoint, "\\\"\n\r\t/" ) . '"'; if ( ! is_string( $encodedEndpoint ) ) { return; } $script = 'window.aflClickEndpoint=' . $encodedEndpoint . ';' . <<<'JS' (function () { if (!window.aflClickEndpoint || !window.document) { return; } function closestButton(target) { while (target && target !== document) { if (target.classList && target.classList.contains('affilun-button')) { return target; } target = target.parentNode; } return null; } function classToken(button, prefix) { var classes = button.className ? String(button.className).split(/\s+/) : []; for (var i = 0; i < classes.length; i++) { if (classes[i].indexOf(prefix) === 0) { return classes[i].slice(prefix.length); } } return 'unknown'; } function pagePostId() { var body = document.body; var className = body && body.className ? String(body.className) : ''; var match = className.match(/(?:^|\s)(?:postid|page-id)-(\d+)(?:\s|$)/); return match ? match[1] : null; } function storedVariant(key) { if (!window.localStorage) { return ''; } try { return window.localStorage.getItem(key) || ''; } catch (ignored) { return ''; } } function saveVariant(key, value) { if (!window.localStorage) { return; } try { window.localStorage.setItem(key, value); } catch (ignored) {} } function variantFor(root, productId) { var key = 'affilun_ab_' + (productId || root.getAttribute('data-affilun-ab-key') || 'global'); var variant = storedVariant(key); if (variant !== 'a' && variant !== 'b') { variant = Math.random() < 0.5 ? 'a' : 'b'; saveVariant(key, variant); } root.setAttribute('data-affilun-ab-variant', variant); return variant; } function ensureBadge(card, text) { if (!card || !text) { return; } var badge = card.querySelector('.affilun-badge-sale, .affilun-badge--sale'); if (badge) { badge.textContent = text; return; } var badges = card.querySelector('.affilun-card__badges'); if (!badges) { badges = document.createElement('div'); badges.className = 'affilun-card__badges'; card.appendChild(badges); } badge = document.createElement('span'); badge.className = 'affilun-badge affilun-badge--sale'; badge.textContent = text; badges.appendChild(badge); } function applyAbTesting() { var roots = document.querySelectorAll('[data-affilun-ab-enabled="1"]'); for (var i = 0; i < roots.length; i++) { var root = roots[i]; if (root.getAttribute('data-affilun-ab-applied') === '1') { continue; } root.setAttribute('data-affilun-ab-applied', '1'); var cards = root.matches && root.matches('.affilun-card') ? [root] : Array.prototype.slice.call(root.querySelectorAll('.affilun-card')); if (!cards.length) { cards = [root]; } for (var j = 0; j < cards.length; j++) { var card = cards[j]; var productId = card.getAttribute ? card.getAttribute('data-affilun-product-id') : null; var variant = variantFor(card, productId); if (card !== root) { card.setAttribute('data-affilun-ab-enabled', '1'); } if (variant !== 'b') { continue; } var label = root.getAttribute('data-affilun-ab-button-label-b') || ''; var microcopy = root.getAttribute('data-affilun-ab-microcopy-b') || ''; if (label) { var labels = card.querySelectorAll('.affilun-button__label'); for (var k = 0; k < labels.length; k++) { labels[k].textContent = label; } } ensureBadge(card, microcopy); } } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', applyAbTesting); } else { applyAbTesting(); } document.addEventListener('click', function (event) { var button = closestButton(event.target); if (!button || !button.href || !window.fetch) { return; } var provider = classToken(button, 'affilun-button-'); var card = button.closest ? button.closest('.affilun-card') : null; var productId = card && card.getAttribute ? card.getAttribute('data-affilun-product-id') : null; var targetUrl = button.getAttribute ? (button.getAttribute('href') || button.href) : button.href; var clickToken = button.getAttribute ? button.getAttribute('data-affilun-click-token') : null; if (!clickToken || !targetUrl) { return; } var abRoot = card && card.closest ? card.closest('[data-affilun-ab-enabled="1"]') : null; var payload = { provider: provider || 'unknown', button_key: provider || 'unknown', target_url: targetUrl, product_id: productId || null, click_token: clickToken, post_id: pagePostId(), variant: abRoot && abRoot.getAttribute ? abRoot.getAttribute('data-affilun-ab-variant') : null }; try { window.fetch(window.aflClickEndpoint, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), keepalive: true }); } catch (ignored) {} }, true); }()); JS; wp_add_inline_script( $handle, $script ); } /** * Resolve affiliate link router. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveAffiliateRouter( ServiceProviderRegistry $registry ): ?AffiliateLinkRouter { $key = PersistenceServiceProvider::BINDING_OPTION_STORE; if ( ! $registry->has( $key ) ) { return null; } $options = $registry->get( $key ); if ( ! $options instanceof OptionStore ) { return null; } return AffiliateLinkRouter::fromRepository( new OptionStoreAffiliateRoutingRepository( $options ) ); } /** * Resolve {@see ProductRepository} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see ProductRepository}. */ private function resolveProductRepository( ServiceProviderRegistry $registry ): ProductRepository { $key = PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY; if ( ! $registry->has( $key ) ) { $message = 'BlockServiceProvider requires PersistenceServiceProvider to register before it' . ' (missing ProductRepository binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $repository = $registry->get( $key ); if ( ! $repository instanceof ProductRepository ) { $message = sprintf( 'BlockServiceProvider expected ProductRepository under "%s"; got %s.', $key, is_object( $repository ) ? get_class( $repository ) : gettype( $repository ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $repository; } /** * Resolve {@see ProductCardViewModelFactory} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see ProductCardViewModelFactory}. */ private function resolveViewModelFactory( ServiceProviderRegistry $registry ): ProductCardViewModelFactory { $key = RenderServiceProvider::BINDING_PRODUCT_CARD_VIEW_MODEL_FACTORY; if ( ! $registry->has( $key ) ) { $message = 'BlockServiceProvider requires RenderServiceProvider to register before it' . ' (missing ProductCardViewModelFactory binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $factory = $registry->get( $key ); if ( ! $factory instanceof ProductCardViewModelFactory ) { $message = sprintf( 'BlockServiceProvider expected ProductCardViewModelFactory under "%s"; got %s.', $key, is_object( $factory ) ? get_class( $factory ) : gettype( $factory ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $factory; } /** * Resolve {@see ProductCardRenderer} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see ProductCardRenderer}. */ private function resolveProductCardRenderer( ServiceProviderRegistry $registry ): ProductCardRenderer { $key = RenderServiceProvider::BINDING_PRODUCT_CARD_RENDERER; if ( ! $registry->has( $key ) ) { $message = 'BlockServiceProvider requires RenderServiceProvider to register before it' . ' (missing ProductCardRenderer binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $renderer = $registry->get( $key ); if ( ! $renderer instanceof ProductCardRenderer ) { $message = sprintf( 'BlockServiceProvider expected ProductCardRenderer under "%s"; got %s.', $key, is_object( $renderer ) ? get_class( $renderer ) : gettype( $renderer ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $renderer; } /** * Resolve {@see ViewModelArrayDumper} from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. * * @throws LogicException When the binding is missing or the * bound value is not a * {@see ViewModelArrayDumper}. */ private function resolveArrayDumper( ServiceProviderRegistry $registry ): ViewModelArrayDumper { $key = RenderServiceProvider::BINDING_VIEW_MODEL_ARRAY_DUMPER; if ( ! $registry->has( $key ) ) { $message = 'BlockServiceProvider requires RenderServiceProvider to register before it' . ' (missing ViewModelArrayDumper binding).'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $dumper = $registry->get( $key ); if ( ! $dumper instanceof ViewModelArrayDumper ) { $message = sprintf( 'BlockServiceProvider expected ViewModelArrayDumper under "%s"; got %s.', $key, is_object( $dumper ) ? get_class( $dumper ) : gettype( $dumper ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } return $dumper; } /** * Resolve the optional §4 {@see RenderCache} binding published * by {@see CacheServiceProvider}, returning null when the * cache provider has not been registered upstream so the block * renderers fall back on the Phase 9 MVP "render every request" * behavior (KI-007 fallback path). * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveOptionalRenderCache( ServiceProviderRegistry $registry ): ?RenderCache { $key = CacheServiceProvider::BINDING_RENDER_CACHE; if ( ! $registry->has( $key ) ) { return null; } $value = $registry->get( $key ); return $value instanceof RenderCache ? $value : null; } /** * Resolve optional provider registry from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveOptionalProviderRegistry( ServiceProviderRegistry $registry ): ?ProviderRegistry { $key = ProviderServiceProvider::BINDING_PROVIDER_REGISTRY; if ( ! $registry->has( $key ) ) { return null; } $providerRegistry = $registry->get( $key ); return $providerRegistry instanceof ProviderRegistry ? $providerRegistry : null; } /** * Resolve optional product URL repository from the registry. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveOptionalProductUrlRepository( ServiceProviderRegistry $registry ): ?ProductUrlRepository { $key = PersistenceServiceProvider::BINDING_PRODUCT_URL_REPOSITORY; if ( ! $registry->has( $key ) ) { return null; } $repository = $registry->get( $key ); return $repository instanceof ProductUrlRepository ? $repository : null; } /** * Resolve settings repository from the shared option store. * * Block registration happens before SettingsServiceProvider publishes its * own binding, so the block renderer constructs the same repository shape * directly from PersistenceServiceProvider's OptionStore binding. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveSettingsRepository( ServiceProviderRegistry $registry ): ?SettingsRepository { $key = PersistenceServiceProvider::BINDING_OPTION_STORE; if ( ! $registry->has( $key ) ) { return null; } $optionStore = $registry->get( $key ); if ( ! $optionStore instanceof OptionStore ) { return null; } return new OptionStoreSettingsRepository( $optionStore ); } /** * Resolve the optional §6.3 {@see CacheKeyFactory} binding * published by {@see CacheServiceProvider}, returning null * when the cache provider has not been registered upstream. * * @param ServiceProviderRegistry $registry Bootstrap registry. */ private function resolveOptionalCacheKeyFactory( ServiceProviderRegistry $registry ): ?CacheKeyFactory { $key = CacheServiceProvider::BINDING_CACHE_KEY_FACTORY; if ( ! $registry->has( $key ) ) { return null; } $value = $registry->get( $key ); return $value instanceof CacheKeyFactory ? $value : null; } }