'', 'prefix_shop' => '/shop', 'prefix_category' => '/shop/category', 'prefix_brand' => '/shop/brand', 'prefix_collection' => '/shop/collection', 'prefix_budget' => '/shop/budget', 'path_delivery' => '/shipping-info', 'mode' => 'preview', // 'preview' | 'live' — new installs start safe. 'color_primary' => '', 'color_button' => '', 'radius' => '', 'keep_data' => 0, // W6 uninstall. 'admins_see_orders' => 1, // 0.4.2: administrators see the orders window (class-roles.php). ); } public function all() { if ( null === $this->cache ) { $stored = get_option( self::OPTION, array() ); $this->cache = wp_parse_args( is_array( $stored ) ? $stored : array(), self::defaults() ); } return $this->cache; } public function get( $key ) { $all = $this->all(); return isset( $all[ $key ] ) ? $all[ $key ] : null; } public function update( array $values ) { $next = wp_parse_args( $values, $this->all() ); foreach ( self::REMOVED_KEYS as $k ) { unset( $next[ $k ] ); } update_option( self::OPTION, $next, false ); $this->cache = null; $this->collisions = null; } /** * aoo address — fixed for shop owners. Developers: `define( 'AOOSHOP_API_BASE', 'http://localhost:3000' )` * in wp-config.php, or the `aooshop_api_base` filter. A value saved by an older version is never used. */ public function api_base() { $base = ( defined( 'AOOSHOP_API_BASE' ) && is_string( AOOSHOP_API_BASE ) && '' !== trim( AOOSHOP_API_BASE ) ) ? trim( AOOSHOP_API_BASE ) : self::DEFAULT_API_BASE; $base = (string) apply_filters( 'aooshop_api_base', $base ); if ( ! preg_match( '#^https?://#i', $base ) ) { $base = self::DEFAULT_API_BASE; } return untrailingslashit( $base ); } /** True when a developer points the plugin somewhere other than the production aoo. */ public function api_base_overridden() { return self::DEFAULT_API_BASE !== $this->api_base(); } // ── site moved (0.6) ───────────────────────────────────────────────── /** Hostnames that only exist on a developer machine. */ public static function is_local_host( $host ) { $host = strtolower( trim( (string) $host, '[]' ) ); if ( '' === $host ) { return false; } return in_array( $host, array( 'localhost', '127.0.0.1', '::1', '0.0.0.0' ), true ) || (bool) preg_match( '/\.(local|localhost|test|internal)$/', $host ) || 0 === strpos( $host, '127.' ) || false === strpos( $host, '.' ); } /** This WordPress runs on a developer machine (Local, wp-env …). */ public function site_is_local() { if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) { return true; } return self::is_local_host( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); } /** The aoo address points at a developer machine. */ public function api_base_is_local() { return self::is_local_host( (string) wp_parse_url( $this->api_base(), PHP_URL_HOST ) ); } /** `https://www.Shop.com/` and `http://www.shop.com` are the same site (scheme / case / trailing slash ignored). */ public static function normalize_home( $url ) { $url = strtolower( untrailingslashit( trim( (string) $url ) ) ); return (string) preg_replace( '#^https?://#', '', $url ); } public function connected_home() { return (string) get_option( self::HOME_OPTION, '' ); } /** Remember this site's address as the connected one (pairing · manual test · first good ping). */ public function remember_home() { update_option( self::HOME_OPTION, home_url(), false ); } /** * Why this copy of the site looks moved: '' (fine) · 'local_api' (aoo address = a developer machine but the site * is public — every aoo call would time out) · 'home' (connected from another address — a clone / migrated copy). */ public function moved_reason() { if ( $this->api_base_is_local() && ! $this->site_is_local() ) { return 'local_api'; } $home = $this->connected_home(); if ( '' !== $home && self::normalize_home( $home ) !== self::normalize_home( home_url() ) ) { return 'home'; } return ''; } /** Thai explanation for moved_reason(). */ public function moved_message() { $reason = $this->moved_reason(); if ( 'local_api' === $reason ) { return sprintf( /* translators: %s: aoo address */ __( 'This site was moved from another server. The AOO address still points to %s (a developer machine). Remove AOOSHOP_API_BASE from wp-config.php, then reconnect.', 'aoo-shop' ), $this->api_base() ); } if ( 'home' === $reason ) { return sprintf( /* translators: 1: old address 2: this site */ __( 'This site was moved from another server (connected from %1$s, now %2$s). Please reconnect.', 'aoo-shop' ), $this->connected_home(), home_url() ); } return ''; } /** aoo screen where the owner edits the WordPress link settings ("แก้ที่ AOO"). */ public function aoo_settings_url() { return $this->api_base() . '/settings/storefront/wordpress'; } public function api_key() { if ( defined( 'AOOSHOP_API_KEY' ) && AOOSHOP_API_KEY ) { return (string) AOOSHOP_API_KEY; } return (string) $this->get( 'api_key' ); } public function key_from_constant() { return defined( 'AOOSHOP_API_KEY' ) && AOOSHOP_API_KEY; } public function is_configured() { return '' !== $this->api_base() && '' !== $this->api_key(); } /** `aoo_wp_ab12cd34_••••` — safe to show in admin. */ public function masked_key() { $key = $this->api_key(); if ( '' === $key ) { return ''; } return substr( $key, 0, 16 ) . str_repeat( '•', 8 ); } // ── prefixes ───────────────────────────────────────────────────────── /** * '/shop/' · 'shop' · ' /Shop//x ' → '/shop' · '/shop/x' — ASCII path only, no trailing slash. * Returns '' when unusable (empty, root, reserved first segment). */ public static function sanitize_path( $value ) { $value = strtolower( trim( (string) $value ) ); $value = preg_replace( '#[^a-z0-9_\-/]#', '', $value ); $value = preg_replace( '#/+#', '/', '/' . $value ); $value = rtrim( $value, '/' ); if ( '' === $value ) { return ''; } $first = explode( '/', ltrim( $value, '/' ) ); if ( in_array( $first[0], self::RESERVED, true ) ) { return ''; } return $value; } /** Prefixes as configured (what the live site uses) — aoo's values from the last good ping, stored by adopt_remote_paths(). */ public function configured_paths() { $d = self::defaults(); $out = array(); foreach ( self::PATH_KEYS as $k ) { $v = self::sanitize_path( $this->get( $k ) ); $out[ $k ] = '' !== $v ? $v : $d[ $k ]; } return $out; } /** * Prefixes actually routed right now — live = configured · preview = everything under /aoo-preview * (category/brand keep their part below the shop prefix, e.g. /shop/category → /aoo-preview/category). */ public function paths() { $paths = $this->configured_paths(); if ( ! $this->is_preview() ) { return $paths; } $shop = $paths['prefix_shop']; $out = array( 'prefix_shop' => self::PREVIEW_BASE ); foreach ( array_diff( self::PATH_KEYS, array( 'prefix_shop' ) ) as $k ) { $p = $paths[ $k ]; if ( 0 === strpos( $p . '/', $shop . '/' ) && $p !== $shop ) { $rel = substr( $p, strlen( $shop ) ); } else { $rel = '/' . basename( $p ); } $out[ $k ] = self::PREVIEW_BASE . $rel; } return $out; } // ── mode ───────────────────────────────────────────────────────────── /** * Things on this site that already own one of our live paths — each one forces preview mode * (e.g. WooCommerce's /shop/ page while Woo is still running before cutover). * * @return string[] Thai, human-readable reasons. */ public function collisions() { if ( null !== $this->collisions ) { return $this->collisions; } $reasons = array(); $paths = $this->configured_paths(); // Every template page (central + per-type) is ours — never a collision. $tpl_ids = ( new AooShop_Template_Page() )->ids(); foreach ( array( 'prefix_shop', 'path_delivery' ) as $k ) { $path = ltrim( $paths[ $k ], '/' ); $page = get_page_by_path( $path, OBJECT, array( 'page', 'post' ) ); if ( $page && ! in_array( (int) $page->ID, $tpl_ids, true ) && 'publish' === $page->post_status ) { $reasons[] = sprintf( /* translators: 1: path 2: page title */ __( 'A WordPress page already exists at %1$s ("%2$s")', 'aoo-shop' ), '/' . $path . '/', $page->post_title ); } } if ( class_exists( 'WooCommerce' ) ) { $shop_page = function_exists( 'wc_get_page_id' ) ? (int) wc_get_page_id( 'shop' ) : 0; if ( $shop_page > 0 ) { $uri = get_page_uri( $shop_page ); if ( $uri && '/' . trim( $uri, '/' ) === $paths['prefix_shop'] ) { $reasons[] = sprintf( /* translators: %s: path */ __( 'WooCommerce is still active and uses the shop page %s', 'aoo-shop' ), $paths['prefix_shop'] . '/' ); } } $wc_perma = (array) get_option( 'woocommerce_permalinks', array() ); foreach ( array( 'product_base', 'category_base', 'tag_base' ) as $base_key ) { if ( empty( $wc_perma[ $base_key ] ) ) { continue; } $base = '/' . trim( preg_replace( '#%[^%]+%#', '', (string) $wc_perma[ $base_key ] ), '/' ); $first = '/' . strtok( ltrim( $base, '/' ), '/' ); if ( '/' !== $first && $first === $paths['prefix_shop'] ) { $reasons[] = sprintf( /* translators: %s: path */ __( 'WooCommerce product/category links start with %s', 'aoo-shop' ), $first . '/' ); } } } $this->collisions = array_values( array_unique( (array) apply_filters( 'aooshop_collisions', $reasons, $paths ) ) ); return $this->collisions; } public function requested_mode() { return 'live' === $this->get( 'mode' ) ? 'live' : 'preview'; } public function is_forced_preview() { return 'live' === $this->requested_mode() && ! empty( $this->collisions() ); } public function is_preview() { return 'preview' === $this->requested_mode() || ! empty( $this->collisions() ); } public function mode() { return $this->is_preview() ? 'preview' : 'live'; } // ── what aoo told us (ping) ────────────────────────────────────────── /** * @return array{ok:bool, at:int, company?:array, wordpress?:array, accepting_orders?:bool, error?:string} */ public function remote() { $r = get_option( self::REMOTE_OPTION, array() ); return is_array( $r ) ? $r : array(); } public function save_remote( array $data ) { update_option( self::REMOTE_OPTION, $data, false ); } /** * Price buckets of the shop (ping `price_buckets`) — the only `?price=` values / budget page slugs aoo serves. * * @return array */ public function price_buckets() { $remote = $this->remote(); $out = array(); foreach ( isset( $remote['price_buckets'] ) ? (array) $remote['price_buckets'] : array() as $b ) { if ( ! is_array( $b ) || ! isset( $b['slug'] ) || ! is_scalar( $b['slug'] ) || ! preg_match( '/^\d+-\d*$/', (string) $b['slug'] ) ) { continue; } $out[] = array( 'slug' => (string) $b['slug'], 'label' => isset( $b['label'] ) ? sanitize_text_field( (string) $b['label'] ) : (string) $b['slug'], 'title' => isset( $b['title'] ) ? sanitize_text_field( (string) $b['title'] ) : '', 'min' => isset( $b['min'] ) && is_numeric( $b['min'] ) ? (float) $b['min'] : 0.0, 'max' => isset( $b['max'] ) && is_numeric( $b['max'] ) ? (float) $b['max'] : null, ); } return $out; } /** A `?price=` value that equals one of the shop's bucket slugs exactly — else ''. */ public function valid_price( $raw ) { $v = trim( rawurldecode( (string) $raw ), "/ \t\n\r\0\x0B" ); if ( '' === $v || strlen( $v ) > 24 ) { return ''; } foreach ( $this->price_buckets() as $b ) { if ( $b['slug'] === $v ) { return $v; } } return ''; } /** A `?sort=` value from the whitelist — 'manual' only on collection pages · else ''. */ public static function valid_sort( $raw, $type ) { $v = is_scalar( $raw ) ? strtolower( trim( (string) $raw ) ) : ''; if ( ! in_array( $v, self::SORTS, true ) ) { return ''; } return ( 'manual' === $v && 'collection' !== $type ) ? '' : $v; } /** * Ping → take every route prefix from aoo (aoo is the single source — the owner sets the links there once). * Values aoo sends that this site can't route (empty · reserved first segment) keep the current value; the * delivery page may never sit on the shop prefix itself. The router re-flushes when the rules hash changes. * * @param array $wp `wordpress` block of the ping. */ public function adopt_remote_paths( array $wp ) { $current = $this->configured_paths(); $next = $current; foreach ( self::PATH_KEYS as $k ) { if ( empty( $wp[ $k ] ) || ! is_scalar( $wp[ $k ] ) ) { continue; } $remote = self::sanitize_path( $wp[ $k ] ); if ( '' !== $remote ) { $next[ $k ] = $remote; } } if ( $next['path_delivery'] === $next['prefix_shop'] ) { $next['path_delivery'] = $current['path_delivery']; } $changed = array_diff_assoc( $next, $current ); $stored = $this->all(); foreach ( self::PATH_KEYS as $k ) { // Also write keys that were never stored (defaults) so the fallback survives a later default change. if ( ! isset( $stored[ $k ] ) || (string) $stored[ $k ] !== $next[ $k ] ) { $changed[ $k ] = $next[ $k ]; } } if ( $changed ) { $this->update( $changed ); } } /** Did aoo ever send its link settings (a ping that succeeded with a `wordpress` block)? */ public function paths_from_aoo() { $remote = $this->remote(); return ! empty( $remote['ok'] ) && ! empty( $remote['wordpress']['prefix_shop'] ); } /** Owner picked a color in the plugin (then the automatic "never black" guard in aoo-shop.js stays off). */ public function has_color_override() { return (bool) ( sanitize_hex_color( (string) $this->get( 'color_primary' ) ) || sanitize_hex_color( (string) $this->get( 'color_button' ) ) ); } /** Theme-bridge CSS variables set by the owner — only non-empty ones. */ public function theme_vars() { $vars = array(); $color = sanitize_hex_color( (string) $this->get( 'color_primary' ) ); if ( $color ) { $vars['--aoo-primary'] = $color; } $color = sanitize_hex_color( (string) $this->get( 'color_button' ) ); if ( $color ) { $vars['--aoo-button'] = $color; } $radius = (string) $this->get( 'radius' ); if ( '' !== $radius && preg_match( '/^\d{1,3}(px|rem|em)?$/', $radius ) ) { $vars['--aoo-radius'] = is_numeric( $radius ) ? $radius . 'px' : $radius; } return $vars; } /** * Escape a CSS rule for wp_add_inline_style() at output time: every custom property name and value is checked * against a strict allow-list (names `--aoo-*`, values hex colors / plain lengths) and anything else is dropped. * * @param string $selector Fixed selector from plugin code. * @param array $vars name => value pairs, e.g. theme_vars(). * @return string Escaped rule, or '' when nothing is left. */ public static function esc_css_vars_rule( $selector, $vars ) { $decl = ''; foreach ( (array) $vars as $name => $value ) { $name = (string) $name; $value = (string) $value; if ( ! preg_match( '/^--aoo-[a-z0-9-]+$/', $name ) ) { continue; } if ( ! sanitize_hex_color( $value ) && ! preg_match( '/^\d{1,4}(?:\.\d+)?(?:px|rem|em|%)?$/', $value ) ) { continue; } $decl .= $name . ':' . $value . ';'; } return '' === $decl ? '' : wp_strip_all_tags( $selector . '{' . $decl . '}' ); } }