WP maple cart and page subtitle plugin upload
This commit is contained in:
parent
b3e87772ec
commit
c85895d306
18 changed files with 5741 additions and 0 deletions
|
|
@ -0,0 +1,767 @@
|
|||
<?php
|
||||
/**
|
||||
* Cart tracking for Maple Carts.
|
||||
*
|
||||
* @package MapleCarts
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Tracking class.
|
||||
*/
|
||||
class Maple_Carts_Tracking {
|
||||
|
||||
/**
|
||||
* Single instance.
|
||||
*
|
||||
* @var Maple_Carts_Tracking
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Get instance.
|
||||
*
|
||||
* @return Maple_Carts_Tracking
|
||||
*/
|
||||
public static function instance() {
|
||||
if ( is_null( self::$instance ) ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {
|
||||
if ( 'yes' !== Maple_Carts::get_option( 'enabled', 'yes' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Frontend tracking.
|
||||
add_action( 'woocommerce_after_checkout_form', [ $this, 'tracking_script' ] );
|
||||
add_action( 'woocommerce_blocks_enqueue_checkout_block_scripts_after', [ $this, 'tracking_script' ] );
|
||||
|
||||
// AJAX handlers.
|
||||
add_action( 'wp_ajax_maple_carts_save', [ $this, 'ajax_save_cart' ] );
|
||||
add_action( 'wp_ajax_nopriv_maple_carts_save', [ $this, 'ajax_save_cart' ] );
|
||||
|
||||
// Cart recovery.
|
||||
add_action( 'wp', [ $this, 'handle_recovery' ] );
|
||||
add_action( 'wp', [ $this, 'handle_unsubscribe' ] );
|
||||
|
||||
// WooCommerce hooks.
|
||||
add_action( 'woocommerce_new_order', [ $this, 'handle_new_order' ] );
|
||||
add_action( 'woocommerce_thankyou', [ $this, 'handle_order_complete' ] );
|
||||
add_action( 'woocommerce_order_status_changed', [ $this, 'handle_order_status_change' ], 10, 3 );
|
||||
|
||||
// Mark carts as abandoned via cron.
|
||||
add_action( 'maple_carts_send_emails', [ $this, 'mark_abandoned_carts' ], 5 );
|
||||
|
||||
// Cleanup cron.
|
||||
add_action( 'maple_carts_cleanup', [ 'Maple_Carts_DB', 'cleanup_old_data' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue tracking script on checkout.
|
||||
*/
|
||||
public function tracking_script() {
|
||||
if ( $this->should_skip_tracking() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_script(
|
||||
'maple-carts-tracking',
|
||||
MAPLE_CARTS_URL . 'assets/js/tracking.js',
|
||||
[ 'jquery' ],
|
||||
MAPLE_CARTS_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
$session_id = $this->get_or_create_session_id();
|
||||
|
||||
wp_localize_script(
|
||||
'maple-carts-tracking',
|
||||
'mapleCartsData',
|
||||
[
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( 'maple_carts_nonce' ),
|
||||
'sessionId' => $session_id,
|
||||
'gdprNotice' => Maple_Carts::get_option( 'gdpr_notice', '' ),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tracking should be skipped.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function should_skip_tracking() {
|
||||
// Skip if disabled via cookie.
|
||||
if ( isset( $_COOKIE['maple_carts_skip'] ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip for excluded roles.
|
||||
if ( is_user_logged_in() ) {
|
||||
$user = wp_get_current_user();
|
||||
$exclude_roles = Maple_Carts::get_option( 'exclude_roles', [] );
|
||||
|
||||
if ( ! empty( $exclude_roles ) && array_intersect( $user->roles, $exclude_roles ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create session ID.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_or_create_session_id() {
|
||||
if ( WC()->session ) {
|
||||
$session_id = WC()->session->get( 'maple_carts_session_id' );
|
||||
|
||||
if ( ! $session_id ) {
|
||||
$session_id = wp_generate_uuid4();
|
||||
WC()->session->set( 'maple_carts_session_id', $session_id );
|
||||
}
|
||||
|
||||
return $session_id;
|
||||
}
|
||||
|
||||
// Fallback to cookie when WC session is unavailable.
|
||||
if ( isset( $_COOKIE['maple_carts_session'] ) ) {
|
||||
return sanitize_text_field( wp_unslash( $_COOKIE['maple_carts_session'] ) );
|
||||
}
|
||||
|
||||
// Generate new session ID and persist to cookie.
|
||||
$session_id = wp_generate_uuid4();
|
||||
$this->set_session_cookie( $session_id );
|
||||
|
||||
return $session_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set session cookie for fallback persistence.
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
*/
|
||||
private function set_session_cookie( $session_id ) {
|
||||
if ( headers_sent() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$secure = is_ssl();
|
||||
$httponly = true;
|
||||
$samesite = 'Lax';
|
||||
$expire = time() + YEAR_IN_SECONDS;
|
||||
|
||||
if ( PHP_VERSION_ID >= 70300 ) {
|
||||
setcookie( 'maple_carts_session', $session_id, [
|
||||
'expires' => $expire,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => $secure,
|
||||
'httponly' => $httponly,
|
||||
'samesite' => $samesite,
|
||||
] );
|
||||
} else {
|
||||
setcookie( 'maple_carts_session', $session_id, $expire, '/; SameSite=' . $samesite, '', $secure, $httponly );
|
||||
}
|
||||
|
||||
// Also set in $_COOKIE for immediate availability.
|
||||
$_COOKIE['maple_carts_session'] = $session_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler for saving cart data.
|
||||
* Includes rate limiting for security.
|
||||
*/
|
||||
public function ajax_save_cart() {
|
||||
check_ajax_referer( 'maple_carts_nonce', 'nonce' );
|
||||
|
||||
// Rate limiting: max 10 requests per minute per IP.
|
||||
$ip = $this->get_client_ip();
|
||||
$rate_key = 'maple_carts_rate_' . md5( $ip );
|
||||
$requests = (int) get_transient( $rate_key );
|
||||
|
||||
if ( $requests >= 10 ) {
|
||||
wp_send_json_error( 'Rate limit exceeded. Please try again later.' );
|
||||
}
|
||||
|
||||
set_transient( $rate_key, $requests + 1, MINUTE_IN_SECONDS );
|
||||
|
||||
// Check for consent if required.
|
||||
if ( ! Maple_Carts_Privacy::has_consent() ) {
|
||||
wp_send_json_error( 'Consent not given' );
|
||||
}
|
||||
|
||||
// Record consent.
|
||||
Maple_Carts_Privacy::record_consent();
|
||||
|
||||
$session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '';
|
||||
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
|
||||
|
||||
if ( empty( $session_id ) ) {
|
||||
wp_send_json_error( 'Missing session ID' );
|
||||
}
|
||||
|
||||
// Get cart data from WooCommerce.
|
||||
$cart = WC()->cart;
|
||||
if ( ! $cart || $cart->is_empty() ) {
|
||||
wp_send_json_error( 'Cart is empty' );
|
||||
}
|
||||
|
||||
$cart_contents = [];
|
||||
foreach ( $cart->get_cart() as $key => $item ) {
|
||||
$product = $item['data'];
|
||||
$cart_contents[] = [
|
||||
'product_id' => $item['product_id'],
|
||||
'variation_id' => $item['variation_id'] ?? 0,
|
||||
'quantity' => $item['quantity'],
|
||||
'name' => $product->get_name(),
|
||||
'price' => $product->get_price(),
|
||||
'image' => wp_get_attachment_image_url( $product->get_image_id(), 'thumbnail' ),
|
||||
'permalink' => $product->get_permalink(),
|
||||
];
|
||||
}
|
||||
|
||||
// Collect billing data.
|
||||
$billing_data = [];
|
||||
$billing_fields = [
|
||||
'first_name', 'last_name', 'phone', 'address_1', 'address_2',
|
||||
'city', 'state', 'postcode', 'country'
|
||||
];
|
||||
|
||||
foreach ( $billing_fields as $field ) {
|
||||
$key = 'billing_' . $field;
|
||||
if ( isset( $_POST[ $key ] ) ) {
|
||||
$billing_data[ $field ] = sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
|
||||
}
|
||||
}
|
||||
|
||||
$first_name = $billing_data['first_name'] ?? '';
|
||||
$last_name = $billing_data['last_name'] ?? '';
|
||||
$customer_name = trim( "$first_name $last_name" );
|
||||
|
||||
// Calculate items count.
|
||||
$items_count = 0;
|
||||
foreach ( $cart_contents as $item ) {
|
||||
$items_count += $item['quantity'];
|
||||
}
|
||||
|
||||
// Prepare cart data.
|
||||
$data = [
|
||||
'session_id' => $session_id,
|
||||
'email' => $email,
|
||||
'cart_contents' => maybe_serialize( $cart_contents ),
|
||||
'cart_total' => $cart->get_cart_contents_total(),
|
||||
'items_count' => $items_count,
|
||||
'currency' => get_woocommerce_currency(),
|
||||
'customer_name' => $customer_name ?: null,
|
||||
'customer_phone' => $billing_data['phone'] ?? null,
|
||||
'billing_data' => maybe_serialize( $billing_data ),
|
||||
'ip_address' => $this->get_client_ip(),
|
||||
'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null,
|
||||
];
|
||||
|
||||
// Generate recovery URL.
|
||||
$data['recovery_url'] = $this->generate_recovery_url( $session_id );
|
||||
|
||||
$cart_id = Maple_Carts_DB::save_cart( $data );
|
||||
|
||||
if ( $cart_id ) {
|
||||
wp_send_json_success( [ 'cart_id' => $cart_id ] );
|
||||
} else {
|
||||
wp_send_json_error( 'Failed to save cart' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_client_ip() {
|
||||
$ip_keys = [ 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ];
|
||||
|
||||
foreach ( $ip_keys as $key ) {
|
||||
if ( ! empty( $_SERVER[ $key ] ) ) {
|
||||
$ip = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
|
||||
// Handle comma-separated IPs.
|
||||
if ( strpos( $ip, ',' ) !== false ) {
|
||||
$ip = trim( explode( ',', $ip )[0] );
|
||||
}
|
||||
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate recovery URL with HMAC signature.
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @param string $coupon Optional coupon code.
|
||||
* @return string
|
||||
*/
|
||||
public function generate_recovery_url( $session_id, $coupon = '' ) {
|
||||
$data = [
|
||||
'sid' => $session_id,
|
||||
'c' => $coupon,
|
||||
't' => time(),
|
||||
];
|
||||
|
||||
// Sign the token with HMAC.
|
||||
$payload = wp_json_encode( $data );
|
||||
$signature = hash_hmac( 'sha256', $payload, $this->get_token_secret() );
|
||||
$token = base64_encode( $payload . '|' . $signature );
|
||||
|
||||
return add_query_arg( 'maple_recover', $token, wc_get_checkout_url() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unsubscribe URL with HMAC signature.
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @return string
|
||||
*/
|
||||
public function generate_unsubscribe_url( $session_id ) {
|
||||
$data = [
|
||||
'sid' => $session_id,
|
||||
't' => time(),
|
||||
];
|
||||
|
||||
$payload = wp_json_encode( $data );
|
||||
$signature = hash_hmac( 'sha256', $payload, $this->get_token_secret() );
|
||||
$token = base64_encode( $payload . '|' . $signature );
|
||||
|
||||
return add_query_arg( 'maple_unsubscribe', $token, home_url() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode a signed token.
|
||||
*
|
||||
* @param string $token The token to verify.
|
||||
* @param int $max_age Maximum age in seconds (0 = no expiry).
|
||||
* @return array|false Decoded data or false if invalid.
|
||||
*/
|
||||
private function verify_token( $token, $max_age = 0 ) {
|
||||
$decoded = base64_decode( $token, true );
|
||||
if ( ! $decoded || strpos( $decoded, '|' ) === false ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parts = explode( '|', $decoded, 2 );
|
||||
if ( count( $parts ) !== 2 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
list( $payload, $signature ) = $parts;
|
||||
|
||||
// Verify HMAC signature (timing-safe comparison).
|
||||
$expected_signature = hash_hmac( 'sha256', $payload, $this->get_token_secret() );
|
||||
if ( ! hash_equals( $expected_signature, $signature ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode( $payload, true );
|
||||
if ( ! $data ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check token age if max_age is set.
|
||||
if ( $max_age > 0 && isset( $data['t'] ) ) {
|
||||
if ( ( time() - $data['t'] ) > $max_age ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secret key for token signing.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_token_secret() {
|
||||
// Use WordPress auth key + salt for signing.
|
||||
return wp_salt( 'auth' ) . 'maple_carts';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cart recovery.
|
||||
*/
|
||||
public function handle_recovery() {
|
||||
if ( ! isset( $_GET['maple_recover'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = sanitize_text_field( wp_unslash( $_GET['maple_recover'] ) );
|
||||
|
||||
// Verify signed token (valid for 30 days).
|
||||
$data = $this->verify_token( $token, 30 * DAY_IN_SECONDS );
|
||||
|
||||
if ( ! $data || empty( $data['sid'] ) ) {
|
||||
wc_add_notice( __( 'This recovery link has expired or is invalid.', 'maple-carts' ), 'error' );
|
||||
wp_safe_redirect( wc_get_page_permalink( 'shop' ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
$session_id = sanitize_text_field( $data['sid'] );
|
||||
$cart = Maple_Carts_DB::get_cart_by_session( $session_id );
|
||||
|
||||
if ( ! $cart || ! in_array( $cart->status, [ 'abandoned', 'active' ], true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore cart contents.
|
||||
$cart_contents = maybe_unserialize( $cart->cart_contents );
|
||||
|
||||
if ( ! empty( $cart_contents ) && is_array( $cart_contents ) ) {
|
||||
WC()->cart->empty_cart();
|
||||
|
||||
foreach ( $cart_contents as $item ) {
|
||||
$product_id = $item['product_id'];
|
||||
$quantity = $item['quantity'];
|
||||
$variation_id = $item['variation_id'] ?? 0;
|
||||
|
||||
// Check if product exists and is in stock.
|
||||
$product = wc_get_product( $variation_id ? $variation_id : $product_id );
|
||||
if ( $product && $product->is_in_stock() ) {
|
||||
WC()->cart->add_to_cart( $product_id, $quantity, $variation_id );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fill billing data.
|
||||
$billing_data = maybe_unserialize( $cart->billing_data );
|
||||
if ( ! empty( $billing_data ) ) {
|
||||
WC()->session->set( 'maple_carts_billing', $billing_data );
|
||||
add_filter( 'woocommerce_checkout_get_value', [ $this, 'prefill_checkout_fields' ], 10, 2 );
|
||||
}
|
||||
|
||||
// Apply coupon if provided.
|
||||
$coupon = ! empty( $data['c'] ) ? sanitize_text_field( $data['c'] ) : '';
|
||||
if ( $coupon && ! WC()->cart->has_discount( $coupon ) ) {
|
||||
WC()->cart->apply_coupon( $coupon );
|
||||
}
|
||||
|
||||
// Store session ID for tracking.
|
||||
WC()->session->set( 'maple_carts_session_id', $session_id );
|
||||
WC()->session->set( 'maple_carts_recovering', true );
|
||||
|
||||
// Redirect to clean URL.
|
||||
wp_safe_redirect( wc_get_checkout_url() );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fill checkout fields.
|
||||
*
|
||||
* @param mixed $value Default value.
|
||||
* @param string $input Field name.
|
||||
* @return mixed
|
||||
*/
|
||||
public function prefill_checkout_fields( $value, $input ) {
|
||||
$billing_data = WC()->session->get( 'maple_carts_billing' );
|
||||
|
||||
if ( ! $billing_data ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$field = str_replace( 'billing_', '', $input );
|
||||
|
||||
if ( isset( $billing_data[ $field ] ) ) {
|
||||
return $billing_data[ $field ];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle unsubscribe.
|
||||
*/
|
||||
public function handle_unsubscribe() {
|
||||
if ( ! isset( $_GET['maple_unsubscribe'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = sanitize_text_field( wp_unslash( $_GET['maple_unsubscribe'] ) );
|
||||
|
||||
// Verify signed token (valid for 90 days).
|
||||
$data = $this->verify_token( $token, 90 * DAY_IN_SECONDS );
|
||||
|
||||
if ( ! $data || empty( $data['sid'] ) ) {
|
||||
wc_add_notice( __( 'This unsubscribe link has expired or is invalid.', 'maple-carts' ), 'error' );
|
||||
wp_safe_redirect( wc_get_page_permalink( 'shop' ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
$session_id = sanitize_text_field( $data['sid'] );
|
||||
$cart = Maple_Carts_DB::get_cart_by_session( $session_id );
|
||||
|
||||
if ( $cart ) {
|
||||
global $wpdb;
|
||||
$wpdb->update(
|
||||
$wpdb->prefix . MAPLE_CARTS_TABLE,
|
||||
[ 'unsubscribed' => 1 ],
|
||||
[ 'id' => $cart->id ]
|
||||
);
|
||||
|
||||
// Cancel pending emails.
|
||||
Maple_Carts_DB::cancel_pending_emails( $cart->id );
|
||||
|
||||
// Set cookie to skip future tracking (with security flags).
|
||||
$secure = is_ssl();
|
||||
$httponly = true;
|
||||
$samesite = 'Lax';
|
||||
|
||||
if ( PHP_VERSION_ID >= 70300 ) {
|
||||
setcookie( 'maple_carts_skip', '1', [
|
||||
'expires' => time() + YEAR_IN_SECONDS,
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
'httponly' => $httponly,
|
||||
'samesite' => $samesite,
|
||||
] );
|
||||
} else {
|
||||
setcookie( 'maple_carts_skip', '1', time() + YEAR_IN_SECONDS, '/; SameSite=' . $samesite, '', $secure, $httponly );
|
||||
}
|
||||
|
||||
wc_add_notice( __( 'You have been unsubscribed from cart reminder emails.', 'maple-carts' ), 'success' );
|
||||
}
|
||||
|
||||
wp_safe_redirect( wc_get_page_permalink( 'shop' ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new order creation.
|
||||
*
|
||||
* @param int $order_id Order ID.
|
||||
*/
|
||||
public function handle_new_order( $order_id ) {
|
||||
$this->process_order_completion( $order_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle order complete (thank you page).
|
||||
*
|
||||
* @param int $order_id Order ID.
|
||||
*/
|
||||
public function handle_order_complete( $order_id ) {
|
||||
$this->process_order_completion( $order_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Process order completion.
|
||||
*
|
||||
* @param int $order_id Order ID.
|
||||
*/
|
||||
private function process_order_completion( $order_id ) {
|
||||
$order = wc_get_order( $order_id );
|
||||
if ( ! $order ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$email = $order->get_billing_email();
|
||||
if ( ! $email ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check session first.
|
||||
$session_id = null;
|
||||
$is_recovery = false;
|
||||
if ( WC()->session ) {
|
||||
$session_id = WC()->session->get( 'maple_carts_session_id' );
|
||||
$is_recovery = WC()->session->get( 'maple_carts_recovering' );
|
||||
}
|
||||
|
||||
$cart = null;
|
||||
if ( $session_id ) {
|
||||
$cart = Maple_Carts_DB::get_cart_by_session( $session_id );
|
||||
}
|
||||
|
||||
if ( ! $cart ) {
|
||||
$cart = Maple_Carts_DB::get_cart_by_email( $email );
|
||||
}
|
||||
|
||||
if ( ! $cart ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine new status and recovery source.
|
||||
$new_status = 'converted';
|
||||
$recovery_source = null;
|
||||
|
||||
if ( in_array( $cart->status, [ 'abandoned' ], true ) ) {
|
||||
$new_status = 'recovered';
|
||||
|
||||
// Determine recovery source from UTM parameters.
|
||||
if ( $is_recovery ) {
|
||||
// Came via recovery link - check UTM source for attribution.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$utm_source = isset( $_GET['utm_source'] ) ? sanitize_text_field( wp_unslash( $_GET['utm_source'] ) ) : '';
|
||||
|
||||
switch ( $utm_source ) {
|
||||
case 'mailjet':
|
||||
$recovery_source = 'mailjet';
|
||||
break;
|
||||
case 'maple_carts':
|
||||
$recovery_source = 'builtin_email';
|
||||
break;
|
||||
default:
|
||||
// Generic recovery link (e.g., shared, bookmarked).
|
||||
$recovery_source = 'email_link';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Returned directly without clicking recovery link.
|
||||
$recovery_source = 'direct';
|
||||
}
|
||||
|
||||
// Notify admin of recovery.
|
||||
if ( 'yes' === Maple_Carts::get_option( 'notify_admin', 'yes' ) ) {
|
||||
$this->send_admin_recovery_notification( $order, $cart );
|
||||
}
|
||||
}
|
||||
|
||||
Maple_Carts_DB::update_cart_status( $cart->id, $new_status, [ 'recovery_source' => $recovery_source ] );
|
||||
Maple_Carts_DB::cancel_pending_emails( $cart->id );
|
||||
|
||||
// Clear session data.
|
||||
if ( WC()->session ) {
|
||||
WC()->session->__unset( 'maple_carts_session_id' );
|
||||
WC()->session->__unset( 'maple_carts_recovering' );
|
||||
WC()->session->__unset( 'maple_carts_billing' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle order status change.
|
||||
*
|
||||
* @param int $order_id Order ID.
|
||||
* @param string $old_status Old status.
|
||||
* @param string $new_status New status.
|
||||
*/
|
||||
public function handle_order_status_change( $order_id, $old_status, $new_status ) {
|
||||
// Handle completed/processing orders.
|
||||
$completed_statuses = [ 'completed', 'processing' ];
|
||||
if ( in_array( $new_status, $completed_statuses, true ) ) {
|
||||
$this->process_order_completion( $order_id );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark carts as abandoned.
|
||||
* Processes in batches to prevent memory issues.
|
||||
*/
|
||||
public function mark_abandoned_carts() {
|
||||
global $wpdb;
|
||||
|
||||
// Prevent overlapping cron runs.
|
||||
if ( get_transient( 'maple_carts_marking_abandoned' ) ) {
|
||||
return;
|
||||
}
|
||||
set_transient( 'maple_carts_marking_abandoned', 1, 300 ); // 5 minute lock.
|
||||
|
||||
$table = $wpdb->prefix . MAPLE_CARTS_TABLE;
|
||||
$cutoff_time = (int) Maple_Carts::get_option( 'cart_cutoff_time', 15 );
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$cutoff_time} minutes" ) );
|
||||
$batch_size = 100; // Process 100 at a time.
|
||||
|
||||
// Get active carts that should be marked as abandoned (limited batch).
|
||||
// Use COALESCE to handle existing carts that don't have last_tracked_at set yet.
|
||||
$carts = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, email, billing_data FROM {$table}
|
||||
WHERE status = 'active'
|
||||
AND email IS NOT NULL
|
||||
AND email != ''
|
||||
AND COALESCE(last_tracked_at, updated_at) < %s
|
||||
LIMIT %d",
|
||||
$cutoff,
|
||||
$batch_size
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( $carts as $cart ) {
|
||||
// Determine abandonment type.
|
||||
$abandonment_type = $this->detect_abandonment_type( $cart );
|
||||
|
||||
Maple_Carts_DB::update_cart_status( $cart->id, 'abandoned', [
|
||||
'abandonment_type' => $abandonment_type,
|
||||
] );
|
||||
Maple_Carts_DB::schedule_emails( $cart->id );
|
||||
}
|
||||
|
||||
delete_transient( 'maple_carts_marking_abandoned' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect abandonment type based on cart data.
|
||||
*
|
||||
* @param object $cart Cart object (minimal data from query).
|
||||
* @return string Abandonment type: browsing, checkout, or payment.
|
||||
*/
|
||||
private function detect_abandonment_type( $cart ) {
|
||||
// No email = browsing abandonment (but we don't track these, so this shouldn't happen).
|
||||
if ( empty( $cart->email ) ) {
|
||||
return 'browsing';
|
||||
}
|
||||
|
||||
// Check billing data for payment indicators.
|
||||
$billing_data = maybe_unserialize( $cart->billing_data );
|
||||
|
||||
// If we have address data, they got further into checkout.
|
||||
$has_address = ! empty( $billing_data['address_1'] ) ||
|
||||
! empty( $billing_data['city'] ) ||
|
||||
! empty( $billing_data['postcode'] );
|
||||
|
||||
if ( $has_address ) {
|
||||
// They filled out billing = likely payment abandonment.
|
||||
return 'payment';
|
||||
}
|
||||
|
||||
// Just email = checkout abandonment.
|
||||
return 'checkout';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send admin recovery notification.
|
||||
*
|
||||
* @param WC_Order $order Order object.
|
||||
* @param object $cart Cart object.
|
||||
*/
|
||||
private function send_admin_recovery_notification( $order, $cart ) {
|
||||
$admin_email = Maple_Carts::get_option( 'admin_email', get_option( 'admin_email' ) );
|
||||
$site_name = get_bloginfo( 'name' );
|
||||
|
||||
$subject = sprintf(
|
||||
/* translators: %1$s: Site name, %2$d: Order ID */
|
||||
__( '[%1$s] Recovered Order #%2$d', 'maple-carts' ),
|
||||
$site_name,
|
||||
$order->get_id()
|
||||
);
|
||||
|
||||
$message = sprintf(
|
||||
/* translators: %1$d: Order ID, %2$s: Cart total, %3$s: Order link */
|
||||
__( "Great news! An abandoned cart has been recovered.\n\nOrder #%1\$d\nRecovered Value: %2\$s\n\nView order: %3\$s", 'maple-carts' ),
|
||||
$order->get_id(),
|
||||
wc_price( $order->get_total() ),
|
||||
admin_url( 'post.php?post=' . $order->get_id() . '&action=edit' )
|
||||
);
|
||||
|
||||
wp_mail( $admin_email, $subject, $message );
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue