'bool', 'maple_cart_total' => 'float', 'maple_cart_currency' => 'str', 'maple_cart_product_count' => 'int', 'maple_cart_product_names' => 'str', 'maple_cart_categories' => 'str', 'maple_cart_value_level' => 'str', 'maple_abandonment_type' => 'str', 'maple_cart_recovery_url' => 'str', 'maple_cart_first_seen_at' => 'datetime', 'maple_cart_abandoned_at' => 'datetime', 'maple_cart_coupon_code' => 'str', 'maple_customer_first_name' => 'str', 'maple_customer_last_name' => 'str', 'maple_recovered_via' => 'str', ]; /** * Get instance. * * @return Maple_Carts_Mailjet */ public static function instance() { if ( is_null( self::$instance ) ) { self::$instance = new self(); } return self::$instance; } /** * Constructor. */ private function __construct() { // Admin AJAX hooks - always register so settings page works. add_action( 'wp_ajax_maple_carts_test_mailjet', [ $this, 'ajax_test_connection' ] ); add_action( 'wp_ajax_maple_carts_setup_mailjet_properties', [ $this, 'ajax_setup_properties' ] ); // Only initialize sync hooks if Mailjet mode is enabled. if ( 'mailjet' !== Maple_Carts::get_option( 'email_mode', 'builtin' ) ) { return; } // Hook into cart status changes. add_action( 'maple_carts_cart_abandoned', [ $this, 'sync_abandoned_cart' ], 10, 2 ); add_action( 'maple_carts_cart_recovered', [ $this, 'clear_abandoned_cart' ], 10, 2 ); add_action( 'maple_carts_cart_converted', [ $this, 'clear_abandoned_cart' ], 10, 2 ); } /** * Check if Mailjet is configured. * * @return bool */ public function is_configured() { $api_key = Maple_Carts::get_option( 'mailjet_api_key', '' ); $secret_key = Maple_Carts::get_option( 'mailjet_secret_key', '' ); return ! empty( $api_key ) && ! empty( $secret_key ); } /** * Make API request to Mailjet. * * @param string $endpoint API endpoint. * @param string $method HTTP method. * @param array $data Request data. * @return array|WP_Error */ private function api_request( $endpoint, $method = 'GET', $data = [] ) { $api_key = Maple_Carts::get_option( 'mailjet_api_key', '' ); $secret_key = Maple_Carts::get_option( 'mailjet_secret_key', '' ); if ( empty( $api_key ) || empty( $secret_key ) ) { return new WP_Error( 'not_configured', __( 'Mailjet API credentials not configured.', 'maple-carts' ) ); } $url = $this->api_url . $endpoint; $args = [ 'method' => $method, 'headers' => [ 'Authorization' => 'Basic ' . base64_encode( $api_key . ':' . $secret_key ), 'Content-Type' => 'application/json', ], 'timeout' => 30, ]; if ( ! empty( $data ) && in_array( $method, [ 'POST', 'PUT' ], true ) ) { $args['body'] = wp_json_encode( $data ); } $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( $code >= 400 ) { $error_message = isset( $data['ErrorMessage'] ) ? $data['ErrorMessage'] : __( 'Unknown API error', 'maple-carts' ); return new WP_Error( 'api_error', $error_message, [ 'status' => $code ] ); } return $data; } /** * Setup contact properties in Mailjet. * Should be run once during initial setup. * * @return array|WP_Error */ public function setup_contact_properties() { $results = []; foreach ( $this->contact_properties as $name => $type ) { // Check if property already exists. $existing = $this->api_request( '/contactmetadata/' . $name ); if ( ! is_wp_error( $existing ) && isset( $existing['Data'][0] ) ) { $results[ $name ] = 'exists'; continue; } // Create the property. $datatype = $this->get_mailjet_datatype( $type ); $response = $this->api_request( '/contactmetadata', 'POST', [ 'Name' => $name, 'Datatype' => $datatype, 'NameSpace' => 'static', ] ); if ( is_wp_error( $response ) ) { $results[ $name ] = 'error: ' . $response->get_error_message(); } else { $results[ $name ] = 'created'; } } return $results; } /** * Get Mailjet datatype from our type. * * @param string $type Our type. * @return string Mailjet datatype. */ private function get_mailjet_datatype( $type ) { $map = [ 'bool' => 'bool', 'int' => 'int', 'float' => 'float', 'str' => 'str', 'datetime' => 'datetime', ]; return isset( $map[ $type ] ) ? $map[ $type ] : 'str'; } /** * Sync abandoned cart data to Mailjet. * * @param int $cart_id Cart ID. * @param object $cart Cart object. */ public function sync_abandoned_cart( $cart_id, $cart ) { if ( empty( $cart->email ) ) { return; } // Parse cart contents for product names and categories. $cart_contents = maybe_unserialize( $cart->cart_contents ); $product_names = []; $categories = []; if ( is_array( $cart_contents ) ) { foreach ( $cart_contents as $item ) { $product_names[] = $item['name'] ?? 'Product'; // Get product categories. $product_id = $item['product_id'] ?? 0; if ( $product_id ) { $terms = get_the_terms( $product_id, 'product_cat' ); if ( $terms && ! is_wp_error( $terms ) ) { foreach ( $terms as $term ) { $categories[ $term->name ] = true; // Use as keys to dedupe. } } } } } // Parse billing data for name. $billing_data = maybe_unserialize( $cart->billing_data ); $first_name = $billing_data['first_name'] ?? ''; $last_name = $billing_data['last_name'] ?? ''; // Calculate value level for segmentation. $cart_total = (float) $cart->cart_total; $value_level = $this->get_cart_value_level( $cart_total ); // Generate recovery URL with coupon and UTM source for attribution. $recovery_url = Maple_Carts_Tracking::instance()->generate_recovery_url( $cart->session_id, $cart->coupon_code ?? '' ); // Add UTM source for recovery attribution. $recovery_url = add_query_arg( 'utm_source', 'mailjet', $recovery_url ); // Prepare contact data using items_count from database (no JSON parsing needed). $contact_data = [ 'maple_has_abandoned_cart' => true, 'maple_cart_total' => $cart_total, 'maple_cart_currency' => $cart->currency ?? 'USD', 'maple_cart_product_count' => (int) ( $cart->items_count ?? 0 ), 'maple_cart_product_names' => implode( ', ', array_slice( $product_names, 0, 5 ) ), // Limit to 5. 'maple_cart_categories' => implode( ', ', array_slice( array_keys( $categories ), 0, 5 ) ), // Limit to 5. 'maple_cart_value_level' => $value_level, 'maple_abandonment_type' => $cart->abandonment_type ?? 'checkout', 'maple_cart_recovery_url' => $recovery_url, 'maple_cart_first_seen_at' => $cart->first_seen_at ?? '', 'maple_cart_abandoned_at' => $cart->abandoned_at ?? current_time( 'c' ), 'maple_cart_coupon_code' => $cart->coupon_code ?? '', 'maple_customer_first_name' => $first_name, 'maple_customer_last_name' => $last_name, 'maple_recovered_via' => '', // Clear any previous recovery. ]; $this->update_contact( $cart->email, $contact_data ); } /** * Get cart value level for segmentation. * * @param float $total Cart total. * @return string Value level: low, medium, or high. */ private function get_cart_value_level( $total ) { if ( $total < 20 ) { return 'low'; } elseif ( $total < 80 ) { return 'medium'; } else { return 'high'; } } /** * Clear abandoned cart data from Mailjet contact. * * @param int $cart_id Cart ID. * @param object $cart Cart object. */ public function clear_abandoned_cart( $cart_id, $cart ) { if ( empty( $cart->email ) ) { return; } // Map recovery_source to Mailjet-friendly value. $recovered_via = ''; if ( 'recovered' === $cart->status && ! empty( $cart->recovery_source ) ) { $recovered_via = $cart->recovery_source; // mailjet, builtin_email, email_link, direct. } elseif ( 'converted' === $cart->status ) { $recovered_via = 'purchase'; // Direct purchase without abandonment. } // Clear the abandoned cart flag and data, but record recovery attribution. $contact_data = [ 'maple_has_abandoned_cart' => false, 'maple_cart_total' => 0, 'maple_cart_currency' => '', 'maple_cart_product_count' => 0, 'maple_cart_product_names' => '', 'maple_cart_categories' => '', 'maple_cart_value_level' => '', 'maple_abandonment_type' => '', 'maple_cart_recovery_url' => '', 'maple_cart_first_seen_at' => '', 'maple_cart_abandoned_at' => '', 'maple_cart_coupon_code' => '', 'maple_customer_first_name' => '', 'maple_customer_last_name' => '', 'maple_recovered_via' => $recovered_via, ]; $this->update_contact( $cart->email, $contact_data ); } /** * Update or create contact in Mailjet. * * @param string $email Contact email. * @param array $contact_data Contact properties. * @return array|WP_Error */ private function update_contact( $email, $contact_data ) { // First, ensure contact exists. $contact = $this->get_or_create_contact( $email ); if ( is_wp_error( $contact ) ) { $this->log_error( 'Failed to get/create contact: ' . $contact->get_error_message() ); return $contact; } // Format data for Mailjet. $formatted_data = []; foreach ( $contact_data as $name => $value ) { $formatted_data[] = [ 'Name' => $name, 'Value' => $value, ]; } // Update contact data. $response = $this->api_request( '/contactdata/' . $contact['ID'], 'PUT', [ 'Data' => $formatted_data ] ); if ( is_wp_error( $response ) ) { $this->log_error( 'Failed to update contact data: ' . $response->get_error_message() ); } return $response; } /** * Get or create contact in Mailjet. * * @param string $email Contact email. * @return array|WP_Error */ private function get_or_create_contact( $email ) { // Try to get existing contact. $response = $this->api_request( '/contact/' . urlencode( $email ) ); if ( ! is_wp_error( $response ) && isset( $response['Data'][0] ) ) { return $response['Data'][0]; } // Create new contact. $response = $this->api_request( '/contact', 'POST', [ 'Email' => $email, ] ); if ( is_wp_error( $response ) ) { return $response; } if ( isset( $response['Data'][0] ) ) { return $response['Data'][0]; } return new WP_Error( 'create_failed', __( 'Failed to create contact', 'maple-carts' ) ); } /** * Test Mailjet connection. * * @return array|WP_Error */ public function test_connection() { return $this->api_request( '/user' ); } /** * AJAX handler for testing Mailjet connection. */ public function ajax_test_connection() { check_ajax_referer( 'maple_carts_admin', 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( 'Unauthorized' ); } // Temporarily set credentials from POST for testing. $api_key = isset( $_POST['api_key'] ) ? sanitize_text_field( wp_unslash( $_POST['api_key'] ) ) : ''; $secret_key = isset( $_POST['secret_key'] ) ? sanitize_text_field( wp_unslash( $_POST['secret_key'] ) ) : ''; if ( empty( $api_key ) || empty( $secret_key ) ) { wp_send_json_error( __( 'Please enter both API Key and Secret Key.', 'maple-carts' ) ); } // Make test request. $url = $this->api_url . '/user'; $response = wp_remote_get( $url, [ 'headers' => [ 'Authorization' => 'Basic ' . base64_encode( $api_key . ':' . $secret_key ), ], 'timeout' => 15, ] ); if ( is_wp_error( $response ) ) { wp_send_json_error( $response->get_error_message() ); } $code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 === $code && isset( $body['Data'][0] ) ) { $user = $body['Data'][0]; wp_send_json_success( [ 'message' => sprintf( /* translators: %s: Mailjet username */ __( 'Connected successfully! Account: %s', 'maple-carts' ), $user['Username'] ?? $user['Email'] ?? 'Unknown' ), ] ); } else { wp_send_json_error( __( 'Authentication failed. Please check your API credentials.', 'maple-carts' ) ); } } /** * AJAX handler for setting up Mailjet contact properties. */ public function ajax_setup_properties() { check_ajax_referer( 'maple_carts_admin', 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( 'Unauthorized' ); } $results = $this->setup_contact_properties(); if ( is_wp_error( $results ) ) { wp_send_json_error( $results->get_error_message() ); } $created = 0; $existed = 0; $errors = 0; foreach ( $results as $status ) { if ( 'created' === $status ) { $created++; } elseif ( 'exists' === $status ) { $existed++; } else { $errors++; } } wp_send_json_success( [ 'message' => sprintf( /* translators: %1$d: created count, %2$d: existed count, %3$d: error count */ __( 'Properties setup complete. Created: %1$d, Already existed: %2$d, Errors: %3$d', 'maple-carts' ), $created, $existed, $errors ), 'details' => $results, ] ); } /** * Log error for debugging. * * @param string $message Error message. */ private function log_error( $message ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'Maple Carts Mailjet: ' . $message ); } } /** * Get available contact properties for display. * * @return array */ public function get_available_properties() { return [ 'maple_has_abandoned_cart' => __( 'Has abandoned cart (true/false)', 'maple-carts' ), 'maple_cart_total' => __( 'Cart total value', 'maple-carts' ), 'maple_cart_currency' => __( 'Cart currency code', 'maple-carts' ), 'maple_cart_product_count' => __( 'Number of items in cart', 'maple-carts' ), 'maple_cart_product_names' => __( 'Product names (comma separated)', 'maple-carts' ), 'maple_cart_categories' => __( 'Product categories (comma separated)', 'maple-carts' ), 'maple_cart_value_level' => __( 'Cart value: low (<$20), medium ($20-80), high ($80+)', 'maple-carts' ), 'maple_abandonment_type' => __( 'Abandonment stage: checkout or payment', 'maple-carts' ), 'maple_cart_recovery_url' => __( 'Recovery URL with coupon', 'maple-carts' ), 'maple_cart_first_seen_at' => __( 'First seen date/time (funnel entry)', 'maple-carts' ), 'maple_cart_abandoned_at' => __( 'Abandonment date/time', 'maple-carts' ), 'maple_cart_coupon_code' => __( 'Generated coupon code', 'maple-carts' ), 'maple_customer_first_name' => __( 'Customer first name', 'maple-carts' ), 'maple_customer_last_name' => __( 'Customer last name', 'maple-carts' ), 'maple_recovered_via' => __( 'Recovery attribution: mailjet, builtin_email, email_link, direct, purchase', 'maple-carts' ), ]; } }