diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ed675..538d4c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- Added WooCommerce integration with product and cart synchronization + ### Changed - Changed plugin to sync subscribers using background tasks avoiding errors if API isn't available diff --git a/css/style.css b/css/style.css index 8b4910a..733efe4 100644 --- a/css/style.css +++ b/css/style.css @@ -69,3 +69,39 @@ padding: 16px 8px; } } + +/* Sync Status Box Styling */ +.mailrelay-sync-status { + margin: 20px 0; + display: flex; + flex-wrap: wrap; +} + +.mailrelay-sync-box { + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + padding: 20px; + text-align: center; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); + min-width: 180px; + margin-bottom: 20px; + transition: all 0.3s ease; +} + +.mailrelay-sync-box:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border-color: #0073aa; +} + +.mailrelay-sync-count { + font-size: 32px; + font-weight: bold; + color: #23282d; + margin: 5px 0; +} + +.mailrelay-sync-label { + color: #646970; + font-size: 14px; +} diff --git a/inc/class-mailrelay-api-request-exception.php b/inc/class-mailrelay-api-request-exception.php new file mode 100644 index 0000000..1726aeb --- /dev/null +++ b/inc/class-mailrelay-api-request-exception.php @@ -0,0 +1,8 @@ +process_save_settings(); } elseif ( 'mailrelay_manual_sync' === $_POST['action'] ) { $response = $this->process_manual_sync(); + } elseif ( 'mailrelay_save_woocommerce_sync_settings' === $_POST['action'] ) { + $response = $this->process_save_woocommerce_sync_settings(); + } elseif ( 'mailrelay_manual_woocommerce_sync' === $_POST['action'] ) { + $response = $this->process_manual_woocommerce_sync(); + } elseif ( 'mailrelay_connect_woocommerce_store' === $_POST['action'] ) { + $response = $this->process_connect_woocommerce_store(); } if ( isset( $response ) ) { @@ -79,6 +85,8 @@ public function render_admin_page() { $authenticated = false; } + $display_woocommerce_tab = ! $disconnected && mailrelay_woo_commerce_installed(); + $tab = isset( $_GET['tab'] ) ? wp_unslash( $_GET['tab'] ) : $default_tab; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized ?> @@ -102,6 +110,9 @@ public function render_admin_page() { + + +
@@ -122,6 +133,11 @@ public function render_admin_page() { $link = admin_url( '/admin.php?page=mailrelay&tab=Authentication' ); include_once __DIR__ . '/partials/tab-settings.php'; break; + + case 'WooCommerce': + $this->setup_woocommerce_page_fields(); + include_once __DIR__ . '/partials/tab-woocommerce.php'; + break; endswitch; ?>
@@ -490,4 +506,139 @@ public function process_manual_sync() { 'message' => $message, ); } + + public function setup_woocommerce_page_fields() { + add_settings_section( + 'mailrelay_woocommerce_sync_section', // id + '', // title + function () { }, // callback + 'mailrelay-woocommerce-sync-page' // page + ); + + $store_id = MailrelayWoocommerce::instance()->get_store_id(); + + if ( $store_id ) { + add_settings_field( + 'woocommerce_auto_sync', // id + __( 'Automatically sync WooCommerce with Mailrelay', 'mailrelay' ), // title + array( $this, 'woocommerce_auto_sync_callback' ), // callback + 'mailrelay-woocommerce-sync-page', // page + 'mailrelay_woocommerce_sync_section'// section + ); + + add_settings_field( + 'action', // id + '', // title + array( $this, 'action_woocommerce_settings_callback' ), // callback + 'mailrelay-woocommerce-sync-page', // page + 'mailrelay_woocommerce_sync_section', // section + array( + 'class' => 'hidden', + ) + ); + } + } + + public function action_woocommerce_settings_callback() { + printf( + '' + ); + } + + public function woocommerce_auto_sync_callback() { + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Reason: Nonce verification happens at render_admin_page. + $value = isset( $_POST['mailrelay_woocommerce_auto_sync'] ) ? filter_var( wp_unslash( $_POST['mailrelay_woocommerce_auto_sync'] ), FILTER_SANITIZE_NUMBER_INT ) : get_option( 'mailrelay_woocommerce_auto_sync' ); + ?> + /> +

+ enqueue_sync_all_products(); + } + + return array( + 'valid' => true, + 'message' => __( 'WooCommerce Sync settings saved successfully.', 'mailrelay' ), + ); + } + + public function process_connect_woocommerce_store() { + // First, try to find a store with the same URL as the current site + $site_url = home_url(); + + $query_parameters = array( + 'q' => array( + 'url_eq' => $site_url, + ), + ); + + $response = mailrelay_api_request( 'GET', 'ecommerce/stores?' . http_build_query( $query_parameters ) ); + + if ( $response['wp_error'] || 200 !== $response['code'] ) { + return array( + 'valid' => false, + 'message' => __( 'Failed to connect to Mailrelay API. Please try again.', 'mailrelay' ), + ); + } + + $existing_store = $response['body'][0]; + + if ( $existing_store ) { + // Store found, save the ID + update_option( 'mailrelay_woocommerce_store_id', $existing_store['id'] ); + return array( + 'valid' => true, + 'message' => __( 'Successfully connected to existing store in Mailrelay.', 'mailrelay' ), + ); + } else { + // Create a new store + $store_data = array( + 'name' => get_bloginfo( 'name' ) . ' - WooCommerce', + 'url' => $site_url, + 'currency' => get_woocommerce_currency(), + ); + + $response = mailrelay_api_request( + 'POST', + 'ecommerce/stores', + array( + 'body' => wp_json_encode( $store_data ), + 'headers' => array( 'content-type' => 'application/json' ), + ) + ); + + if ( $response['wp_error'] || ( 200 !== $response['code'] && 201 !== $response['code'] ) ) { + return array( + 'valid' => false, + 'message' => __( 'Failed to create store in Mailrelay. Please try again.', 'mailrelay' ), + ); + } + + update_option( 'mailrelay_woocommerce_store_id', $response['body']['id'] ); + update_option( 'mailrelay_woocommerce_auto_sync', 1 ); + + MailrelayWoocommerce::instance()->enqueue_sync_all_products(); + + return array( + 'valid' => true, + 'message' => __( 'Successfully created and connected to a new store in Mailrelay.', 'mailrelay' ), + ); + } + } + + public function process_manual_woocommerce_sync() { + MailrelayWoocommerce::instance()->enqueue_sync_all_products(); + + return array( + 'valid' => true, + 'message' => __( 'Products are being synced to Mailrelay. It may take a few minutes to complete.', 'mailrelay' ), + ); + } } diff --git a/inc/class-mailrelay-woocommerce-all-products.php b/inc/class-mailrelay-woocommerce-all-products.php new file mode 100644 index 0000000..99d995a --- /dev/null +++ b/inc/class-mailrelay-woocommerce-all-products.php @@ -0,0 +1,52 @@ + 'product', + 'post_status' => 'publish', + 'posts_per_page' => self::BATCH_SIZE, + 'paged' => $page, + 'fields' => 'ids', + 'no_found_rows' => false, // We need total count for pagination + ); + + $query = new WP_Query( $args ); + $products = $query->posts; + + // Queue individual product sync jobs + foreach ( $products as $product_id ) { + as_enqueue_async_action( + 'mailrelay_sync_product_background', + array( $product_id ), + 'mailrelay' + ); + } + + // If there are more pages, schedule the next batch + if ( $page < $query->max_num_pages ) { + as_enqueue_async_action( + 'mailrelay_sync_all_products_background', + array( $page + 1 ), + 'mailrelay' + ); + } + + // Clean up memory + wp_reset_postdata(); + } +} diff --git a/inc/class-mailrelay-woocommerce-cart.php b/inc/class-mailrelay-woocommerce-cart.php new file mode 100644 index 0000000..9aa0462 --- /dev/null +++ b/inc/class-mailrelay-woocommerce-cart.php @@ -0,0 +1,85 @@ +transform( $cart_data ); + + $store_id = MailrelayWoocommerce::instance()->get_store_id(); + $endpoint = "ecommerce/stores/{$store_id}/carts/sync"; + + $response = mailrelay_api_request( + 'POST', + $endpoint, + array( + 'body' => wp_json_encode( $data ), + 'headers' => array( 'content-type' => 'application/json' ), + ) + ); + + if ( $response['wp_error'] || ( isset( $response['code'] ) && $response['code'] >= 500 ) ) { + throw new MailrelayApiRequestException( 'Mailrelay API request failed, will retry.' ); + } + + return $response; + } + + public function set_completed( $cart_id, $email ) { + $store_id = MailrelayWoocommerce::instance()->get_store_id(); + $endpoint = "ecommerce/stores/{$store_id}/carts/{$cart_id}"; + + $data = array( + 'email' => $email, + 'status' => 'completed', + ); + + $response = mailrelay_api_request( + 'PATCH', + $endpoint, + array( + 'body' => wp_json_encode( $data ), + 'headers' => array( 'content-type' => 'application/json' ), + ) + ); + + if ( $response['wp_error'] || ( isset( $response['code'] ) && $response['code'] >= 500 ) ) { + throw new MailrelayApiRequestException( 'Mailrelay API request failed, will retry.' ); + } + + return $response; + } + + public function transform( array $cart_data ) { + $cart_items_attributes = array(); + if ( ! empty( $cart_data['cart_items'] ) ) { + foreach ( $cart_data['cart_items'] as $cart_item_key => $cart_item ) { + $cart_items_attributes[] = array( + 'cart_item_id' => $cart_item_key, + 'product_variant_id' => (string) ( ! empty( $cart_item['variation_id'] ) ? $cart_item['variation_id'] : $cart_item['product_id'] ), + 'quantity' => $cart_item['quantity'], + 'price' => MailrelayWoocommerceHelper::transform_price( $cart_item['product_price'] ), + ); + } + } + + return array( + 'cart_id' => $cart_data['cart_id'], + 'status' => 'pending', + 'checkout_url' => $cart_data['checkout_url'], + 'total' => MailrelayWoocommerceHelper::transform_price( $cart_data['cart_total'] ), + 'tax_total' => MailrelayWoocommerceHelper::transform_price( $cart_data['cart_tax_total'] ), + 'email' => $cart_data['email'], + 'cart_items_attributes' => $cart_items_attributes, + ); + } +} diff --git a/inc/class-mailrelay-woocommerce-helper.php b/inc/class-mailrelay-woocommerce-helper.php new file mode 100644 index 0000000..00e0e70 --- /dev/null +++ b/inc/class-mailrelay-woocommerce-helper.php @@ -0,0 +1,10 @@ +transform( $product ); + + $store_id = MailrelayWoocommerce::instance()->get_store_id(); + $endpoint = "ecommerce/stores/{$store_id}/products/sync"; + + $response = mailrelay_api_request( + 'POST', + $endpoint, + array( + 'body' => wp_json_encode($data), + 'headers' => array( 'content-type' => 'application/json' ), + ) + ); + + if ( $response['wp_error'] || ( isset( $response['code'] ) && $response['code'] >= 500 ) ) { + throw new MailrelayApiRequestException( 'Mailrelay API request failed, will retry.' ); + } + + // Clear cache after successful sync since product count may have changed + MailrelayWoocommerce::instance()->clear_synced_products_count_cache(); + + return $response; + } + + public function transform( WC_Product $product ) { + if ( ! $product ) { + return array(); + } + + $product_image = $this->get_product_image($product); + + $variants = array(); + if ( $product->is_type('variable') ) { + foreach ( $product->get_children() as $child_id ) { + $variant = wc_get_product($child_id); + if ( $variant ) { + $variant_image = $this->get_product_image($variant); + + $variants[] = array( + 'product_variant_id' => (string) $variant->get_id(), + 'name' => $variant->get_name(), + 'sku' => $variant->get_sku(), + 'price' => MailrelayWoocommerceHelper::transform_price( $variant->get_price() ), + 'url' => get_permalink($variant->get_id()), + 'image_url' => $variant_image ? $variant_image : $product_image, + ); + } + } + } + if ( empty($variants) ) { + // No variants, add the product itself as a single variant + $variants[] = array( + 'product_variant_id' => (string) $product->get_id(), + 'name' => $product->get_name(), + 'sku' => $product->get_sku(), + 'price' => MailrelayWoocommerceHelper::transform_price( $product->get_price() ), + 'url' => get_permalink($product->get_id()), + 'image_url' => $product_image, + ); + } + return array( + 'product_id' => (string) $product->get_id(), + 'name' => $product->get_name(), + 'url' => get_permalink($product->get_id()), + 'image_url' => $product_image, + 'product_variants_attributes' => $variants, + ); + } + + public function get_product_image( $product ) { + $id = is_a($product, 'WC_Product') ? $product->get_id() : $product->ID; + $meta = get_post_meta($id); + $key = '_thumbnail_id'; + $image_key = $this->get_product_image_key(); + if ( $meta && is_array($meta) && array_key_exists($key, $meta) && isset($meta[ $key ][0]) ) { + $img = wp_get_attachment_image_src($meta[ $key ][0], $image_key); + if ( ! empty($img[0]) ) { + if ( substr($img[0], 0, 4) !== 'http' ) { + return rtrim(home_url(), '/') . '/' . ltrim($img[0], '/'); + } + return $img[0]; + } + } + $url = get_the_post_thumbnail_url($id, $image_key); + if ( $url ) { + return $url; + } else { + return null; + } + } + + public function get_product_image_key() { + return 'medium'; + } +} diff --git a/inc/class-mailrelay-woocommerce.php b/inc/class-mailrelay-woocommerce.php new file mode 100644 index 0000000..936c24b --- /dev/null +++ b/inc/class-mailrelay-woocommerce.php @@ -0,0 +1,320 @@ +get_store_id() && $this->mailrelay_configured(); + } + + public function mailrelay_configured() { + return (bool) mailrelay_data(); + } + + public function setup_hooks() { + if ( ! mailrelay_woo_commerce_installed() ) { + return; + } + + add_action( 'woocommerce_new_product', array( $this, 'handle_product_created' ), 10, 1 ); + add_action( 'woocommerce_update_product', array( $this, 'handle_product_updated' ), 10, 1 ); + + // Cart hooks. + add_action( 'woocommerce_add_to_cart', array( $this, 'handle_cart_event' ), 20, 99 ); + add_action( 'woocommerce_cart_updated', array( $this, 'handle_cart_event' ), 20, 99 ); + add_action( 'woocommerce_remove_cart_item', array( $this, 'handle_cart_event' ), 20, 99 ); + add_action( 'woocommerce_cart_emptied', array( $this, 'handle_cart_event' ), 20, 99 ); + add_action( 'woocommerce_checkout_update_order_review', array( $this, 'capture_guest_email_from_checkout' ), 10, 1 ); + add_action( 'woocommerce_new_order', array( $this, 'handle_new_order' ), 10, 2 ); + + // User session hooks. + add_action( 'wp_login', array( $this, 'handle_user_login' ), 10, 2 ); + add_action( 'wp_logout', array( $this, 'handle_user_logout' ), 10, 1 ); + + add_action( + 'mailrelay_sync_product_background', + function ( int $post_ID ) { + $service = new MailrelayWoocommerceProduct(); + $service->sync( $post_ID ); + }, + 10, + 1 + ); + + add_action( + 'mailrelay_sync_all_products_background', + function ( $page = 1 ) { + $service = new MailrelayWoocommerceAllProducts(); + $service->sync( $page ); + }, + 10, + 1 + ); + + add_action( + 'mailrelay_sync_cart_background', + function ( $cart_data ) { + $service = new MailrelayWoocommerceCart(); + $service->sync( $cart_data ); + }, + 10, + 1 + ); + + add_action( + 'mailrelay_sync_cart_completed_background', + function ( $data ) { + $service = new MailrelayWoocommerceCart(); + $service->set_completed( $data['cart_id'], $data['email'] ); + }, + 10, + 1 + ); + } + + public function enqueue_sync_all_products() { + as_enqueue_async_action( + 'mailrelay_sync_all_products_background', + array(), + 'mailrelay', + true + ); + } + + /** + * Handle product save. + * + * @param int $post_ID Product ID. + */ + public function handle_product_created( int $post_ID ) { + if ( 'product' !== get_post_type($post_ID) ) { + return; + } + + if ( ! $this->auto_sync_enabled() ) { + return; + } + + as_enqueue_async_action( + 'mailrelay_sync_product_background', + array( $post_ID ), + 'mailrelay' + ); + } + + /** + * Handle product update. + * + * @param int $post_ID Product ID. + */ + public function handle_product_updated( int $post_ID ) { + if ( 'product' !== get_post_type($post_ID) ) { + return; + } + + if ( ! $this->auto_sync_enabled() ) { + return; + } + + if ( in_array(get_post_status($post_ID), array( 'trash', 'auto-draft' ), true) ) { + return; + } + + as_enqueue_async_action( + 'mailrelay_sync_product_background', + array( $post_ID ), + 'mailrelay' + ); + } + + public function handle_new_order( $order_id, $order ) { + if ( ! $this->auto_sync_enabled() ) { + return; + } + + if ( ! $order ) { + return; + } + + $email = $order->get_billing_email(); + // The cart_id is the user session. + $cart_id = WC()->session->get_customer_id(); + + if ( empty( $email ) || empty( $cart_id ) ) { + return; + } + + as_enqueue_async_action( + 'mailrelay_sync_cart_completed_background', + array( + 'cart_id' => $cart_id, + 'email' => $email, + ), + 'mailrelay' + ); + } + + public function capture_guest_email_from_checkout() { + // phpcs:disable WordPress.Security.NonceVerification.Missing + if ( ! is_user_logged_in() && isset( $_POST['billing_email'] ) && is_email( wp_unslash( $_POST['billing_email'] ) ) ) { + $email = sanitize_email( wp_unslash( $_POST['billing_email'] ) ); + if ( WC()->session ) { + WC()->session->set( 'mailrelay_cart_tracking_email', $email ); + } + } + // phpcs:enable WordPress.Security.NonceVerification.Missing + } + + private function get_tracking_email() { + if ( is_user_logged_in() ) { + $user = wp_get_current_user(); + return $user->user_email; + } + + if ( WC()->customer && WC()->customer->get_billing_email() ) { + return WC()->customer->get_billing_email(); + } + + return null; + } + + public function handle_cart_event() { + if ( ! $this->auto_sync_enabled() ) { + return; + } + + $email = $this->get_tracking_email(); + + if ( empty( $email ) ) { + return; + } + + $cart = WC()->cart; + + if ( is_null( $cart ) || $cart->is_empty() ) { + return; + } + + // Prevent duplicate jobs using a transient based on cart hash. + $transient_name = 'mailrelay_cart_sync_' . $cart->get_cart_hash(); + if ( get_transient( $transient_name ) ) { + return; + } + set_transient( $transient_name, true, MINUTE_IN_SECONDS * 5 ); + + $cart_contents_for_queue = array(); + foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { + $product = $cart_item['data']; + if ( ! $product ) { + continue; + } + + $cart_contents_for_queue[ $cart_item_key ] = array( + 'product_id' => $cart_item['product_id'], + 'variation_id' => $cart_item['variation_id'], + 'quantity' => $cart_item['quantity'], + 'product_price' => $product->get_price(), + ); + } + + $cart_data = array( + 'email' => $email, + 'user_id' => get_current_user_id(), + 'cart_id' => WC()->session->get_customer_id(), + 'cart_total' => $cart->get_total( 'edit' ), + 'cart_tax_total' => $cart->get_total_tax(), + 'cart_items' => $cart_contents_for_queue, + 'checkout_url' => wc_get_checkout_url(), + 'is_empty' => $cart->is_empty(), + ); + + as_enqueue_async_action( + 'mailrelay_sync_cart_background', + array( 'cart_data' => $cart_data ), + 'mailrelay' + ); + } + + public function handle_user_login( $user_login, $user ) { + if ( WC()->session ) { + WC()->session->set( 'mailrelay_cart_tracking_email', $user->user_email ); + } + $this->handle_cart_event(); + } + + public function handle_user_logout() { + if ( WC()->session ) { + WC()->session->set( 'mailrelay_cart_tracking_email', null ); + } + } + + public function get_synced_products_count() { + $store_id = $this->get_store_id(); + + if ( ! $store_id ) { + return 0; + } + + // Check cache first + $cache_key = 'mailrelay_synced_products_count_' . $store_id; + $cached_count = get_transient( $cache_key ); + + if ( false !== $cached_count ) { + return (int) $cached_count; + } + + $response = mailrelay_api_request( 'GET', "ecommerce/stores/{$store_id}/products?" . http_build_query( array( 'per_page' => 1 ) ) ); + + if ( $response['wp_error'] || 200 !== $response['code'] ) { + return 0; + } + + $count = (int) $response['headers']['total']; + + // Cache for 5 minutes + set_transient( $cache_key, $count, 5 * MINUTE_IN_SECONDS ); + + return $count; + } + + /** + * Clear the synced products count cache + */ + public function clear_synced_products_count_cache() { + $store_id = $this->get_store_id(); + if ( $store_id ) { + $cache_key = 'mailrelay_synced_products_count_' . $store_id; + delete_transient( $cache_key ); + } + } +} diff --git a/inc/common.php b/inc/common.php index dd1a1a1..9eba61a 100644 --- a/inc/common.php +++ b/inc/common.php @@ -31,11 +31,14 @@ function mailrelay_api_request( $method, $url, $args = array(), $mailrelay_data $response = wp_remote_request( $url, $args ); + $headers = wp_remote_retrieve_headers( $response ); + if ( is_wp_error( $response ) ) { return array( 'wp_error' => true, 'error_message' => $response->get_error_message(), 'response' => $response, + 'headers' => $headers, ); } @@ -48,6 +51,7 @@ function mailrelay_api_request( $method, $url, $args = array(), $mailrelay_data 'code' => $code, 'body' => $body, 'response' => $response, + 'headers' => $headers, ); } } diff --git a/inc/global-hooks.php b/inc/global-hooks.php index 5eeca97..be4a510 100644 --- a/inc/global-hooks.php +++ b/inc/global-hooks.php @@ -14,8 +14,7 @@ function mailrelay_user_register_hook( $user_id ) { as_enqueue_async_action( 'mailrelay_sync_user_background', array( $user_id ), - 'mailrelay', - true + 'mailrelay' ); } add_action( 'user_register', 'mailrelay_user_register_hook' ); diff --git a/inc/partials/tab-woocommerce.php b/inc/partials/tab-woocommerce.php new file mode 100644 index 0000000..75ac55b --- /dev/null +++ b/inc/partials/tab-woocommerce.php @@ -0,0 +1,64 @@ +get_store_id(); +?> + + + get_synced_products_count(); + ?> + +

+ +
+
+
+
+
+
+ +
+ +

+ +
+ +
+ +
+ +

+

+ +
+ + + +
+ +

+ +

+ +
+ + + +
+ \ No newline at end of file diff --git a/mailrelay.php b/mailrelay.php index 200c814..f4e2b47 100644 --- a/mailrelay.php +++ b/mailrelay.php @@ -34,7 +34,18 @@ } -function mailrelay_wpforms() { - require_once __DIR__ . '/inc/class-mailrelay-wpforms.php'; -} -add_action( 'wpforms_loaded', 'mailrelay_wpforms' ); + +add_action( + 'wpforms_loaded', + function () { + require_once __DIR__ . '/inc/class-mailrelay-wpforms.php'; + } +); + +require_once __DIR__ . '/inc/class-mailrelay-woocommerce.php'; +add_action( + 'plugins_loaded', + function () { + MailrelayWoocommerce::instance()->setup_hooks(); + } +);