get_header( 'Nonce' ); if ( empty( $nonce ) ) { $nonce = $request->get_header( 'X-WC-Store-API-Nonce' ); } if ( empty( $nonce ) ) { $nonce = $request->get_param( 'store_api_nonce' ); } $nonce = is_string( $nonce ) ? sanitize_text_field( $nonce ) : ''; if ( '' === $nonce || ! wp_verify_nonce( $nonce, 'wc_store_api' ) ) { return new WP_Error( 'pressnative_cart_forbidden', __( 'Valid cart nonce required.', 'pressnative-apps' ), array( 'status' => 403 ) ); } return true; } /** * Create a short-lived checkout transfer token for the given cart items. * * @param array $items List of array{ product_id: int, quantity: int }. * @return string Token string. */ public static function create_checkout_token( array $items ) { $token = wp_generate_password( 32, false, false ); $clean = array(); foreach ( $items as $item ) { $product_id = isset( $item['product_id'] ) ? absint( $item['product_id'] ) : 0; $quantity = isset( $item['quantity'] ) ? absint( $item['quantity'] ) : 0; if ( $product_id > 0 && $quantity > 0 ) { $clean[] = array( 'product_id' => $product_id, 'quantity' => $quantity, ); } } set_transient( 'pressnative_co_' . $token, $clean, 10 * MINUTE_IN_SECONDS ); return $token; } /** * Handle checkout from the native app via a short-lived signed token. * * The app requests POST /cart/checkout-token (with Store API nonce), then opens: * https://site.com/?pressnative_checkout_token=TOKEN */ public static function handle_app_checkout() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- one-time transfer token looked up server-side if ( empty( $_GET['pressnative_checkout_token'] ) ) { return; } if ( ! self::is_active() ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- validated via transient lookup $token = sanitize_text_field( wp_unslash( $_GET['pressnative_checkout_token'] ) ); // Marker value used after redirect (legacy); real tokens are 32+ chars from wp_generate_password. if ( '' === $token || strlen( $token ) < 16 || strlen( $token ) > 64 ) { return; } $key = 'pressnative_co_' . $token; $items = get_transient( $key ); delete_transient( $key ); if ( ! is_array( $items ) ) { wp_die( esc_html__( 'This checkout link has expired. Please return to the app and try again.', 'pressnative-apps' ), esc_html__( 'Checkout expired', 'pressnative-apps' ), array( 'response' => 403 ) ); } wc_load_cart(); WC()->cart->empty_cart(); foreach ( $items as $item ) { $product_id = isset( $item['product_id'] ) ? absint( $item['product_id'] ) : 0; $quantity = isset( $item['quantity'] ) ? absint( $item['quantity'] ) : 0; if ( $product_id > 0 && $quantity > 0 ) { WC()->cart->add_to_cart( $product_id, $quantity ); } } $checkout_url = add_query_arg( 'pressnative_app_checkout', '1', wc_get_checkout_url() ); wp_safe_redirect( $checkout_url ); exit; } /** * REST: create a checkout transfer URL from cart items (requires Store API nonce). * * @param WP_REST_Request $request Request with optional items[]; otherwise uses WC cart. * @return WP_REST_Response|WP_Error */ public static function handle_create_checkout_token( $request ) { if ( ! self::is_active() || ! function_exists( 'WC' ) ) { return new WP_Error( 'woocommerce_unavailable', __( 'WooCommerce not available', 'pressnative-apps' ), array( 'status' => 503 ) ); } $items = $request->get_param( 'items' ); if ( ! is_array( $items ) || empty( $items ) ) { if ( ! WC()->cart ) { wc_load_cart(); if ( method_exists( WC()->cart, 'get_cart_from_session' ) ) { WC()->cart->get_cart_from_session(); } } $items = array(); if ( WC()->cart ) { foreach ( WC()->cart->get_cart() as $cart_item ) { $items[] = array( 'product_id' => (int) $cart_item['product_id'], 'quantity' => (int) $cart_item['quantity'], ); } } } if ( empty( $items ) ) { return new WP_Error( 'empty_cart', __( 'No cart items to check out.', 'pressnative-apps' ), array( 'status' => 400 ) ); } $token = self::create_checkout_token( $items ); $url = add_query_arg( 'pressnative_checkout_token', $token, home_url( '/' ) ); return rest_ensure_response( array( 'ok' => true, 'checkout_url' => $url, 'expires_in' => 600, ) ); } /** * Register WooCommerce REST API routes. */ public static function register_rest_routes() { if ( ! class_exists( 'WooCommerce' ) ) { return; } register_rest_route( 'pressnative/v1', '/cart/checkout-token', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( __CLASS__, 'handle_create_checkout_token' ), 'permission_callback' => array( __CLASS__, 'permission_check_cart_mutate' ), 'args' => array( 'items' => array( 'required' => false, 'type' => 'array', ), ), ) ); register_rest_route( 'pressnative/v1', '/shop/nonce', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'handle_shop_nonce' ), // Public: issues a WooCommerce Store API nonce for the caller's own session. 'permission_callback' => '__return_true', ) ); } /** * Fresh Store API nonce so clients can retry cart mutations after 403. * * @return WP_REST_Response */ public static function handle_shop_nonce() { return rest_ensure_response( array( 'store_api_nonce' => self::generate_cart_session_nonce(), 'session_token' => self::get_session_token(), ) ); } /** * Whether WooCommerce is active. * * @return bool */ public static function is_active() { return class_exists( 'WooCommerce' ); } /** * Generate nonce for WooCommerce Store API (cart add, checkout). * Native app sends this as Nonce header when calling Store API or PressNative cart proxy. * * @return string Nonce string, or empty if WooCommerce inactive. */ public static function generate_cart_session_nonce() { if ( ! self::is_active() ) { return ''; } return wp_create_nonce( 'wc_store_api' ); } /** * Get a session/cart token for cookie bridge (checkout WebView). * Uses current customer session so checkout can pre-fill user data. * * @return string Token or empty. */ public static function get_session_token() { if ( ! self::is_active() ) { return ''; } if ( function_exists( 'WC' ) && isset( WC()->session ) && is_object( WC()->session ) ) { $session = WC()->session; if ( method_exists( $session, 'get_customer_id' ) ) { return (string) $session->get_customer_id(); } if ( isset( $session->_customer_id ) ) { return (string) $session->_customer_id; } } return ''; } /** * Get cart URL for the site. * * @return string */ public static function get_cart_url() { if ( ! self::is_active() ) { return ''; } $page_id = wc_get_page_id( 'cart' ); return $page_id > 0 ? (string) get_permalink( $page_id ) : trailingslashit( home_url( 'cart' ) ); } /** * Get checkout URL for the site. * Apps should obtain a transfer URL via POST /cart/checkout-token (requires Store API nonce). * * @return string */ public static function get_checkout_url() { if ( ! self::is_active() ) { return ''; } $page_id = wc_get_page_id( 'checkout' ); return $page_id > 0 ? (string) get_permalink( $page_id ) : trailingslashit( home_url( 'checkout' ) ); } /** * Get shop_config for layout responses (cart_url, checkout_url, nonce, session, currency). * * @return array */ public static function get_shop_config() { if ( ! self::is_active() ) { return array(); } $currency = get_woocommerce_currency(); $symbol = get_woocommerce_currency_symbol(); return array( 'cart_url' => self::get_cart_url(), 'checkout_url' => self::get_checkout_url(), 'store_api_nonce' => self::generate_cart_session_nonce(), 'session_token' => self::get_session_token(), 'currency' => $currency, 'currency_symbol' => $symbol, 'supports_variations' => true, 'cart_recovery_enabled' => class_exists( 'PressNative_Cart_Recovery' ) && PressNative_Cart_Recovery::is_enabled(), 'product_display_preferences' => self::get_product_display_preferences(), ); } /** * Get product display preferences for different contexts. * * @return array */ public static function get_product_display_preferences() { $in_post_style = get_option( 'pressnative_product_in_post_style', 'compact_row' ); $grid_style = get_option( 'pressnative_product_grid_style', 'card' ); return array( 'in_post_style' => $in_post_style, 'grid_style' => $grid_style, 'available_styles' => array( 'compact_row' => array( 'name' => __( 'Compact Row', 'pressnative-apps' ), 'description' => __( 'Image on left, details on right - perfect for in-post products', 'pressnative-apps' ), ), 'card' => array( 'name' => __( 'Card', 'pressnative-apps' ), 'description' => __( 'Full-width card with large image - great for product grids', 'pressnative-apps' ), ), 'mini_card' => array( 'name' => __( 'Mini Card', 'pressnative-apps' ), 'description' => __( 'Smaller card with centered image - subtle in-post display', 'pressnative-apps' ), ), ), ); } /** * Get products for layout (grid, carousel). * * @param array $args Same as wc_get_products (limit, category, featured, etc.). * @return array List of product data for contract (id, title, price, image_url, etc.). */ public static function get_products( $args = array() ) { if ( ! self::is_active() || ! function_exists( 'wc_get_products' ) ) { return array(); } $defaults = array( 'status' => 'publish', 'limit' => 12, 'orderby' => 'date', 'order' => 'DESC', 'return' => 'ids', ); $ids = wc_get_products( array_merge( $defaults, $args ) ); if ( ! is_array( $ids ) ) { return array(); } $out = array(); foreach ( $ids as $id ) { $product = wc_get_product( $id ); if ( ! $product || ! $product->is_visible() ) { continue; } $out[] = self::product_to_contract_item( $product ); } return $out; } /** * Convert WC_Product to contract-style product item. * * @param WC_Product $product Product object. * @return array */ public static function product_to_contract_item( $product ) { $id = $product->get_id(); $title = $product->get_name(); $price = self::strip_price_html( $product->get_price_html() ); $image = ''; if ( $product->get_image_id() ) { $image = wp_get_attachment_image_url( $product->get_image_id(), 'medium' ); } if ( empty( $image ) ) { $image = wc_placeholder_img_src( 'medium' ); } $image = $image ? (string) $image : ''; return array( 'product_id' => (string) $id, 'title' => $title, 'price' => $price, 'price_raw' => $product->get_price(), 'image_url' => $image, 'action' => array( 'type' => 'open_product', 'payload' => array( 'product_id' => (string) $id ), ), 'add_to_cart_action' => array( 'type' => 'add_to_cart', 'payload' => array( 'product_id' => (string) $id ), ), ); } /** * Get product categories for ProductCategoryList. * * @param array $args get_terms args (hide_empty, parent, include, etc.). * @return array List of category items for contract. */ public static function get_product_categories( $args = array() ) { if ( ! self::is_active() ) { return array(); } $defaults = array( 'taxonomy' => 'product_cat', 'hide_empty' => false, 'parent' => 0, ); $terms = get_terms( array_merge( $defaults, $args ) ); if ( is_wp_error( $terms ) || empty( $terms ) ) { return array(); } $out = array(); foreach ( $terms as $term ) { $icon = ''; $meta = get_term_meta( $term->term_id, 'thumbnail_id', true ); if ( $meta ) { $url = wp_get_attachment_image_url( (int) $meta, 'medium' ); if ( $url ) { $icon = $url; } } if ( empty( $icon ) ) { $icon = get_site_icon_url( 512 ) ?: ''; } $out[] = array( 'product_category_id' => (string) $term->term_id, 'name' => $term->name, 'icon_url' => $icon, 'action' => array( 'type' => 'open_product_category', 'payload' => array( 'product_category_id' => (string) $term->term_id ), ), ); } return $out; } /** * Get single product for ProductDetail layout. * * @param int $product_id Product ID. * @return array|null Contract product detail or null if not found. */ public static function get_product( $product_id ) { if ( ! self::is_active() ) { return null; } $product = wc_get_product( $product_id ); if ( ! $product || ! $product->is_visible() ) { return null; } $id = $product->get_id(); $image = ''; if ( $product->get_image_id() ) { $image = wp_get_attachment_image_url( $product->get_image_id(), 'large' ); } if ( empty( $image ) ) { $image = wc_placeholder_img_src( 'large' ); } $image = $image ? (string) $image : ''; // Gallery: featured first, then gallery IDs, deduplicated URLs. $image_ids = array(); $feat_id = $product->get_image_id(); if ( $feat_id ) { $image_ids[] = $feat_id; } if ( method_exists( $product, 'get_gallery_image_ids' ) ) { $gallery_ids = $product->get_gallery_image_ids(); if ( is_array( $gallery_ids ) ) { foreach ( $gallery_ids as $aid ) { if ( $aid && ! in_array( $aid, $image_ids, true ) ) { $image_ids[] = $aid; } } } } $images = array(); $seen = array(); foreach ( $image_ids as $aid ) { $url = wp_get_attachment_image_url( (int) $aid, 'large' ); if ( $url && ! in_array( $url, $seen, true ) ) { $images[] = (string) $url; $seen[] = $url; } } if ( empty( $images ) && $image ) { $images[] = $image; } if ( empty( $images ) && function_exists( 'wc_placeholder_img_src' ) ) { $images[] = (string) wc_placeholder_img_src( 'large' ); } $desc = $product->get_short_description(); if ( empty( $desc ) ) { $desc = $product->get_description(); } $variations = self::variations_to_contract( $product ); // Collect category names. $category_names = array(); $terms = get_the_terms( $id, 'product_cat' ); if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) { foreach ( $terms as $term ) { $category_names[] = $term->name; } } return array( 'product_id' => (string) $id, 'title' => $product->get_name(), 'price' => self::strip_price_html( $product->get_price_html() ), 'price_raw' => $product->get_price(), 'description' => $desc, 'image_url' => $image, 'images' => $images, 'categories' => $category_names, 'add_to_cart_action' => array( 'type' => 'add_to_cart', 'payload' => array( 'product_id' => (string) $id ), ), 'variations' => $variations, ); } /** * Normalize WooCommerce variations into a client-friendly contract shape. * * @param WC_Product $product Product object. * @return array */ private static function variations_to_contract( $product ) { if ( ! $product->is_type( 'variable' ) || ! method_exists( $product, 'get_available_variations' ) ) { return array(); } $raw = $product->get_available_variations(); if ( ! is_array( $raw ) ) { return array(); } $out = array(); foreach ( $raw as $variation ) { if ( ! is_array( $variation ) ) { continue; } $attributes = array(); if ( ! empty( $variation['attributes'] ) && is_array( $variation['attributes'] ) ) { foreach ( $variation['attributes'] as $key => $value ) { $attr_name = str_replace( 'attribute_', '', (string) $key ); $label = function_exists( 'wc_attribute_label' ) ? wc_attribute_label( $attr_name ) : $attr_name; $attributes[ $label ] = (string) $value; } } $image_url = ''; if ( ! empty( $variation['image']['src'] ) ) { $image_url = (string) $variation['image']['src']; } elseif ( ! empty( $variation['image']['url'] ) ) { $image_url = (string) $variation['image']['url']; } $display_price = isset( $variation['display_price'] ) ? $variation['display_price'] : ( isset( $variation['display_regular_price'] ) ? $variation['display_regular_price'] : 0 ); $price_html = isset( $variation['price_html'] ) ? $variation['price_html'] : ''; $price = $price_html ? self::strip_price_html( $price_html ) : ( function_exists( 'wc_price' ) ? self::strip_price_html( wc_price( $display_price ) ) : (string) $display_price ); $out[] = array( 'variation_id' => isset( $variation['variation_id'] ) ? (int) $variation['variation_id'] : 0, 'attributes' => $attributes, 'price' => $price, 'price_raw' => is_numeric( $display_price ) ? (float) $display_price : 0, 'in_stock' => ! empty( $variation['is_in_stock'] ), 'image_url' => $image_url, ); } return $out; } /** * Convert WooCommerce price HTML to plain text (e.g. "$44.00"). * * @param string $html Price HTML from get_price_html(). * @return string Plain text price. */ private static function strip_price_html( $html ) { $text = wp_strip_all_tags( $html ); $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ); return trim( $text ); } }