settings = $settings; $this->renderer = $renderer; } public function hooks() { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue' ), 20 ); add_filter( 'wp_resource_hints', array( $this, 'resource_hints' ), 10, 2 ); add_action( self::CSS_CACHE_HOOK, array( $this, 'cache_css' ) ); add_action( 'aooshop_pinged', array( $this, 'pinged' ), 20, 2 ); } /** Every good ping (daily cron · test · connect) makes sure the current CSS version has a local copy. */ public function pinged( $data = array(), $ok = false ) { $remote = (string) get_option( self::CSS_OPTION, '' ); if ( $ok && '' !== $remote && '' === $this->local_css_url( $remote ) ) { $this->schedule_css_cache( $remote ); } } // ── self-hosted embed CSS (0.6.2) ──────────────────────────────────── // // storefront-embed.css from app.aoocommerce.com was render-blocking on mobile (DNS + TLS to another domain ≈ 1.2 s // in PageSpeed). The file is versioned (`?v=` from aoo), so a copy per version is safe forever: // wp-content/uploads/aoo-shop/storefront-embed-.css, served from the shop's own domain. A new version → // cron copies it (first view of the new version still uses the remote URL) → older copies are deleted. // Copy failed / uploads not writable → remote URL as before (retry after an hour). /** `` of the remote CSS URL (`?v=` from aoo · md5 of the URL when there is none). */ public static function css_version( $url ) { $query = array(); wp_parse_str( (string) wp_parse_url( (string) $url, PHP_URL_QUERY ), $query ); $v = isset( $query['v'] ) && is_string( $query['v'] ) ? strtolower( $query['v'] ) : ''; $v = preg_replace( '/[^a-z0-9]/', '', $v ); return '' !== $v ? substr( $v, 0, 32 ) : substr( md5( (string) $url ), 0, 12 ); } /** @return array{dir:string, url:string}|null uploads/aoo-shop (null when uploads is unavailable). */ private static function css_dir() { $up = wp_upload_dir( null, false ); if ( ! empty( $up['error'] ) || empty( $up['basedir'] ) ) { return null; } return array( 'dir' => trailingslashit( $up['basedir'] ) . self::CSS_DIR, 'url' => trailingslashit( set_url_scheme( $up['baseurl'] ) ) . self::CSS_DIR, ); } /** Local URL of the cached copy of `$remote` — '' when not cached yet. */ public function local_css_url( $remote ) { $d = self::css_dir(); if ( ! $d ) { return ''; } $file = 'storefront-embed-' . self::css_version( $remote ) . '.css'; return is_readable( $d['dir'] . '/' . $file ) ? $d['url'] . '/' . $file : ''; } /** Queue a copy of `$remote` (once — the cron event carries the URL). */ private function schedule_css_cache( $remote ) { if ( get_transient( self::CSS_FAIL_KEY ) || wp_next_scheduled( self::CSS_CACHE_HOOK, array( $remote ) ) ) { return; } wp_schedule_single_event( time(), self::CSS_CACHE_HOOK, array( $remote ) ); } /** * Cron: download the CSS from aoo and store it under uploads/aoo-shop · delete older versions. * * @param string $remote Versioned URL on the aoo host. * @return bool Stored. */ public function cache_css( $remote ) { $remote = esc_url_raw( (string) $remote ); $d = self::css_dir(); if ( '' === $remote || ! $d ) { return false; } $fail = function () { set_transient( self::CSS_FAIL_KEY, 1, HOUR_IN_SECONDS ); return false; }; $res = wp_remote_get( $remote, array( 'timeout' => 15, 'redirection' => 2 ) ); if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { return $fail(); } $css = (string) wp_remote_retrieve_body( $res ); $ctype = (string) wp_remote_retrieve_header( $res, 'content-type' ); // Must really be our stylesheet (not an HTML error page from a proxy). if ( strlen( $css ) < 1000 || false === strpos( $css, '.sf-' ) || ( '' !== $ctype && false === stripos( $ctype, 'css' ) ) ) { return $fail(); } if ( ! wp_mkdir_p( $d['dir'] ) ) { return $fail(); } $fs = self::filesystem(); if ( ! $fs ) { return $fail(); } $name = 'storefront-embed-' . self::css_version( $remote ) . '.css'; $tmp = $d['dir'] . '/.' . $name . '.' . wp_generate_password( 6, false ) . '.tmp'; // Write + move so a visitor never gets a half-written file. if ( ! $fs->put_contents( $tmp, $css, FS_CHMOD_FILE ) || ! $fs->move( $tmp, $d['dir'] . '/' . $name, true ) ) { wp_delete_file( $tmp ); return $fail(); } foreach ( (array) glob( $d['dir'] . '/storefront-embed-*.css' ) as $old ) { if ( basename( $old ) !== $name ) { wp_delete_file( $old ); } } delete_transient( self::CSS_FAIL_KEY ); return true; } /** Remove the cached copies (uninstall · "ล้างแคช"). */ public static function clear_css_cache() { $d = self::css_dir(); if ( ! $d ) { return; } foreach ( (array) glob( $d['dir'] . '/storefront-embed-*.css' ) as $f ) { wp_delete_file( $f ); } delete_transient( self::CSS_FAIL_KEY ); } /** * WP_Filesystem — only when it can write directly. This runs in cron / on the front end, where WordPress cannot * ask for FTP credentials; any other method = keep loading the stylesheet from AooCommerce (null). * * @return WP_Filesystem_Base|null */ private static function filesystem() { global $wp_filesystem; if ( ! function_exists( 'get_filesystem_method' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $up = wp_upload_dir( null, false ); if ( ! empty( $up['error'] ) || 'direct' !== get_filesystem_method( array(), $up['basedir'] ) ) { return null; } if ( ! WP_Filesystem( false, $up['basedir'] ) || ! $wp_filesystem ) { return null; } return $wp_filesystem; } /** * `` (0.6.1 · LCP): the hosts the shop's images come from — aoo (`/api/thumb` card * thumbnails) + the first image hosts in the page HTML (product photos on Supabase storage / marketplace CDNs). * Only on aoo routes and pages that printed embed CSS (article cards). Max 3 hosts. * * @param array $urls Hints so far. * @param string $relation_type preconnect | dns-prefetch | … * @return array */ public function resource_hints( $urls, $relation_type ) { if ( 'preconnect' !== $relation_type || ( ! $this->renderer->active() && ! wp_style_is( self::HANDLE, 'enqueued' ) ) ) { return $urls; } $own = (string) wp_parse_url( home_url(), PHP_URL_HOST ); $hosts = array( $this->origin( $this->settings->api_base() ) ); $payload = $this->renderer->payload(); if ( $payload && ! empty( $payload['html'] ) && preg_match_all( '#]+src="(https?://[^/"]+)/#i', (string) $payload['html'], $m ) ) { foreach ( $m[1] as $origin ) { $hosts[] = $this->origin( $origin ); } } $out = array(); foreach ( array_unique( array_filter( $hosts ) ) as $origin ) { if ( count( $out ) >= 3 || (string) wp_parse_url( $origin, PHP_URL_HOST ) === $own ) { continue; } $out[] = $origin; } return array_merge( (array) $urls, $out ); } /** `https://host[:port]` of a URL ('' when not http(s)). */ private function origin( $url ) { $p = wp_parse_url( (string) $url ); if ( empty( $p['scheme'] ) || empty( $p['host'] ) || ! in_array( strtolower( $p['scheme'] ), array( 'http', 'https' ), true ) ) { return ''; } return strtolower( $p['scheme'] ) . '://' . strtolower( $p['host'] ) . ( ! empty( $p['port'] ) ? ':' . (int) $p['port'] : '' ); } /** Last CSS URL aoo sent — reused by cards (W4) and when a page comes from an older cache entry. */ public function css_url() { $payload = $this->renderer->payload(); $url = ( $payload && ! empty( $payload['assets']['css'] ) ) ? $this->on_api_host( (string) $payload['assets']['css'] ) : ''; if ( $url ) { if ( get_option( self::CSS_OPTION ) !== $url ) { update_option( self::CSS_OPTION, $url, true ); } return $url; } $stored = (string) get_option( self::CSS_OPTION, '' ); return $stored ? $stored : $this->settings->api_base() . '/storefront-embed.css'; } /** * Serve the CSS from the aoo host this site is connected to (settings api_base), keeping aoo's path + ?v= hash. * aoo builds `assets.css` from its own public URL env, which on a dev/preview deployment points at production * (where the file may not exist yet) — the API host is always the one that actually rendered the HTML. */ private function on_api_host( $url ) { $path = (string) wp_parse_url( $url, PHP_URL_PATH ); if ( '' === $path ) { return $url; } $query = (string) wp_parse_url( $url, PHP_URL_QUERY ); return $this->settings->api_base() . $path . ( '' !== $query ? '?' . $query : '' ); } public function enqueue_embed_css() { if ( wp_style_is( self::HANDLE, 'enqueued' ) ) { return; } $remote = $this->css_url(); $local = $this->local_css_url( $remote ); if ( '' === $local ) { $this->schedule_css_cache( $remote ); } // Own domain when the copy exists (no extra DNS/TLS before first paint) · aoo host meanwhile / as fallback. $url = esc_url_raw( (string) apply_filters( 'aooshop_css_url', '' !== $local ? $local : $remote, $remote ) ); if ( ! $url ) { return; } // Version is already in the URL (?v=) — pass null so WP adds nothing. wp_enqueue_style( self::HANDLE, $url, array(), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion wp_add_inline_style( self::HANDLE, $this->theme_bridge_css() ); } public function enqueue() { if ( ! $this->renderer->active() ) { return; } $this->enqueue_embed_css(); // Generic fallback for themes without an adapter: hide the theme's page title (aoo html has the h1). wp_add_inline_style( self::HANDLE, 'body.aoo-shop :is(.entry-title,.page-title,.wp-block-post-title):not(#aoo-shop *){display:none!important}' // Astra plain (unboxed) container has no top/bottom spacing on #primary — keep the shop off the header. . 'body.aoo-shop.ast-plain-container #primary{padding-top:clamp(20px,3vw,40px);padding-bottom:clamp(24px,4vw,56px)}' ); } /** * Owner variables (only the ones set) + the order-button chain. aoo's embed CSS falls back to Elementor's * "accent" for the buy buttons, which many kits use as a near-black text color — the plugin pins the buttons to * the primary chain instead (owner button color → owner primary → theme primary → the shop's color in aoo). * aoo-shop.js ensureCtaColor() handles themes whose primary itself is near-black. */ private function theme_bridge_css() { return AooShop_Settings::esc_css_vars_rule( ':root', $this->settings->theme_vars() ) . ':is(#aoo-shop,.aoo-embed){--sf-cta:var(--aoo-button,var(--sf-primary))}'; } }