monorepo/native/wordpress/maple-carts-wp/includes/class-maple-carts-mailjet.php

534 lines
18 KiB
PHP

<?php
/**
* Mailjet Integration for Maple Carts.
*
* Syncs abandoned cart data to Mailjet contact properties
* for use in Mailjet automations.
*
* @package MapleCarts
*/
defined( 'ABSPATH' ) || exit;
/**
* Mailjet integration class.
*/
class Maple_Carts_Mailjet {
/**
* Single instance.
*
* @var Maple_Carts_Mailjet
*/
private static $instance = null;
/**
* Mailjet API base URL.
*
* @var string
*/
private $api_url = 'https://api.mailjet.com/v3/REST';
/**
* Contact properties to sync.
*
* @var array
*/
private $contact_properties = [
'maple_has_abandoned_cart' => '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' ),
];
}
}