in cron/unprivileged contexts). * * Source of truth: the merchant API public endpoint * GET {merchant-api}/merchant/content-pages/public/ → { pages: [...] } */ defined( 'ABSPATH' ) || exit; class Xpay_Content_Pages { const IDS_OPTION = 'xpay_wc_content_page_ids'; // map: contentKey => postId const MANAGED_META = '_xpay_content_page'; // = contentKey (marks our posts) const HASH_META = '_xpay_content_hash'; // skip no-op updates const SCHEMA_META = '_xpay_content_schema'; // JSON-LD blob for wp_head const RECONCILE_HOOK = 'xpay_content_pages_reconcile'; const CACHE_KEY = 'xpay_wc_content_pages'; const THROTTLE_KEY = 'xpay_wc_content_pages_reconciled'; const POWERED_BY_URL = 'https://www.xpay.sh/agentic-commerce/'; const ENABLE_OPTION = 'xpay_wc_content_engine_enabled'; private static $instance = null; public static function instance() { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } private function __construct() { // Reconcile: admin-only + throttled (cheap), a cron tick, and on init for cron. add_action( 'admin_init', array( $this, 'maybe_reconcile' ) ); add_action( self::RECONCILE_HOOK, array( $this, 'reconcile' ) ); add_action( 'init', array( $this, 'ensure_cron' ) ); // Manual "Sync now" from the settings screen. add_action( 'admin_post_xpay_content_pages_sync', array( $this, 'handle_manual_sync' ) ); add_action( 'admin_notices', array( $this, 'admin_notice' ) ); // React immediately when the local entitlement toggle flips (bust the // cached entitlement first so reconcile sees the new state, not the TTL). add_action( 'update_option_' . self::ENABLE_OPTION, array( $this, 'on_toggle' ) ); // Emit stored JSON-LD on our managed pages. add_action( 'wp_head', array( $this, 'emit_schema' ), 20 ); } public function ensure_cron() { if ( ! wp_next_scheduled( self::RECONCILE_HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::RECONCILE_HOOK ); } } /** Local enable toggled — drop the cached entitlement, then reconcile. */ public function on_toggle() { if ( method_exists( 'Xpay_Plugin', 'clear_content_engine_entitlement_cache' ) ) { Xpay_Plugin::clear_content_engine_entitlement_cache(); } $this->reconcile(); } public static function clear_cron() { $ts = wp_next_scheduled( self::RECONCILE_HOOK ); if ( $ts ) { wp_unschedule_event( $ts, self::RECONCILE_HOOK ); } } /** Admin-load reconcile, throttled to once per hour via a transient. */ public function maybe_reconcile() { if ( get_transient( self::THROTTLE_KEY ) ) { return; } set_transient( self::THROTTLE_KEY, 1, HOUR_IN_SECONDS ); $this->reconcile(); } public function handle_manual_sync() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Not allowed.', 'agentic-commerce-for-woocommerce' ) ); } check_admin_referer( 'xpay_content_pages_sync' ); delete_transient( self::CACHE_KEY ); // force a fresh pull $this->reconcile(); wp_safe_redirect( admin_url( 'options-general.php?page=agentic-commerce-for-woocommerce&xpay_content_pages=synced' ) ); exit; } /** * Idempotently make published content pages match the remote list. * Not connected / not entitled → draft everything we manage. */ public function reconcile() { $slug = Xpay_Plugin::merchant_slug(); $map = $this->id_map(); if ( '' === $slug || ! Xpay_Plugin::is_connected() || ! Xpay_Plugin::is_content_engine_entitled() ) { $this->draft_all( $map ); return; } $pages = $this->fetch_pages( $slug ); if ( ! is_array( $pages ) ) { return; // transient fetch failure — leave existing pages untouched. } $seen = array(); foreach ( $pages as $p ) { $key = $this->page_key( $p ); if ( '' === $key || empty( $p['title'] ) ) { continue; // malformed entry — skip, don't let it disturb the set. } $seen[ $key ] = true; $this->upsert( $key, $p, $map ); } // Draft any page we manage that the remote list no longer includes. foreach ( $map as $key => $post_id ) { if ( empty( $seen[ $key ] ) ) { $this->draft( (int) $post_id, (string) $key ); } } update_option( self::IDS_OPTION, $map, false ); } /** Create or update the WP post for one remote content page. */ private function upsert( $key, $p, &$map ) { $desired_status = ( isset( $p['status'] ) && 'draft' === $p['status'] ) ? 'draft' : 'publish'; $desired_type = ( isset( $p['type'] ) && 'post' === $p['type'] ) ? 'post' : 'page'; $desired_title = wp_strip_all_tags( (string) $p['title'] ); $desired_slug = $this->sanitize_slug( isset( $p['slug'] ) ? $p['slug'] : $key ); $desired_content = $this->build_content( $p ); $schema_json = $this->schema_json( $p ); $hash = md5( $desired_type . '|' . $desired_status . '|' . $desired_title . '|' . $desired_slug . '|' . $desired_content . '|' . $schema_json ); $post = $this->managed_post( (int) ( $map[ $key ] ?? 0 ), $key ); if ( $post ) { if ( (string) get_post_meta( $post->ID, self::HASH_META, true ) !== $hash ) { wp_update_post( array( 'ID' => $post->ID, 'post_status' => $desired_status, 'post_title' => $desired_title, 'post_name' => $desired_slug, 'post_content' => $desired_content, ) ); update_post_meta( $post->ID, self::HASH_META, $hash ); update_post_meta( $post->ID, self::SCHEMA_META, $schema_json ); } $map[ $key ] = (int) $post->ID; return; } $id = wp_insert_post( array( 'post_type' => $desired_type, 'post_status' => $desired_status, 'post_title' => $desired_title, 'post_name' => $desired_slug, 'post_content' => $desired_content, 'comment_status' => 'closed', 'ping_status' => 'closed', 'meta_input' => array( self::MANAGED_META => $key, self::HASH_META => $hash, self::SCHEMA_META => $schema_json, ), ), true ); if ( ! is_wp_error( $id ) && $id ) { $map[ $key ] = (int) $id; } } /** * Body HTML: KSES-sanitised merchant-safe markup + a server-side "Powered by * xpay" backlink (real link equity). JSON-LD is NOT inlined here (KSES strips * \n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } } /** Draft every page we manage (unentitled / disconnected). */ private function draft_all( $map ) { foreach ( $map as $key => $post_id ) { $this->draft( (int) $post_id, (string) $key ); } } private function draft( $post_id, $key ) { $post = $this->managed_post( $post_id, $key ); if ( $post && 'draft' !== $post->post_status ) { wp_update_post( array( 'ID' => $post->ID, 'post_status' => 'draft' ) ); } } /** * Return the post for ($post_id, $key) ONLY if it's one of ours (carries our * managed meta for this key) and not trashed — so we never touch merchant * content even if an id collides. null otherwise. */ private function managed_post( $post_id, $key ) { if ( ! $post_id ) { return null; } $post = get_post( $post_id ); if ( ! $post || 'trash' === $post->post_status || ! in_array( $post->post_type, array( 'page', 'post' ), true ) ) { return null; } if ( (string) get_post_meta( $post->ID, self::MANAGED_META, true ) !== (string) $key ) { return null; } return $post; } private function id_map() { $map = get_option( self::IDS_OPTION, array() ); return is_array( $map ) ? $map : array(); } private function page_key( $p ) { $raw = isset( $p['key'] ) ? $p['key'] : ( isset( $p['slug'] ) ? $p['slug'] : '' ); return sanitize_key( (string) $raw ); } private function sanitize_slug( $raw ) { $slug = sanitize_title( (string) $raw ); return '' !== $slug ? $slug : 'xpay-page'; } /** * Fetch the merchant's content-page list (public endpoint), cached 1h. * Returns an array of pages, or null on failure (so reconcile leaves the * existing set untouched rather than drafting everything on a blip). */ private function fetch_pages( $slug ) { $cached = get_transient( self::CACHE_KEY ); if ( is_array( $cached ) ) { return $cached; } $base = defined( 'XPAY_WC_MERCHANT_API' ) ? XPAY_WC_MERCHANT_API : 'https://nehl6uu58j.execute-api.us-east-1.amazonaws.com'; $url = trailingslashit( $base ) . 'merchant/content-pages/public/' . rawurlencode( $slug ); $res = wp_remote_get( $url, array( 'timeout' => 5 ) ); if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { return null; } $decoded = json_decode( (string) wp_remote_retrieve_body( $res ), true ); $pages = ( is_array( $decoded ) && isset( $decoded['pages'] ) && is_array( $decoded['pages'] ) ) ? $decoded['pages'] : array(); set_transient( self::CACHE_KEY, $pages, HOUR_IN_SECONDS ); return $pages; } /** Settings-screen notice: how many content pages are live + a Re-sync button. */ public function admin_notice() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || false === strpos( (string) $screen->id, 'agentic-commerce-for-woocommerce' ) ) { return; } if ( ! Xpay_Plugin::is_connected() || ! Xpay_Plugin::is_content_engine_entitled() ) { return; } $flag = isset( $_GET['xpay_content_pages'] ) ? sanitize_key( wp_unslash( $_GET['xpay_content_pages'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( 'synced' === $flag ) { echo '

'; esc_html_e( 'Content pages synced. Clear your page cache if you use one.', 'agentic-commerce-for-woocommerce' ); echo '

'; } $live = 0; foreach ( $this->id_map() as $key => $post_id ) { $post = $this->managed_post( (int) $post_id, (string) $key ); if ( $post && 'publish' === $post->post_status ) { $live++; } } echo '

'; esc_html_e( 'Content Engine', 'agentic-commerce-for-woocommerce' ); echo ' — '; printf( /* translators: %d: number of live content pages. */ esc_html( _n( '%d published content page on your store.', '%d published content pages on your store.', $live, 'agentic-commerce-for-woocommerce' ) ), (int) $live ); echo ' '; esc_html_e( 'Sync now', 'agentic-commerce-for-woocommerce' ); echo '

'; } }