' + response.data + '
'); + } else { + $('.maple-carts-test-result').html('' + response.data + '
'); + } + }); + }, + + closeModal: function() { + $('.maple-carts-modal').hide(); + } + }; + + $(document).ready(function() { + MapleCartsAdmin.init(); + + // Email mode toggle. + $('input[name="email_mode"]').on('change', function() { + var mode = $(this).val(); + if (mode === 'mailjet') { + $('#mailjet-settings').show(); + $('#builtin-email-settings').hide(); + } else { + $('#mailjet-settings').hide(); + $('#builtin-email-settings').show(); + } + }); + + // Test Mailjet connection. + $('#test-mailjet-connection').on('click', function() { + var $btn = $(this); + var $status = $('#mailjet-status'); + var apiKey = $('#mailjet_api_key').val(); + var secretKey = $('#mailjet_secret_key').val(); + + if (!apiKey || !secretKey) { + $status.html('Please enter API credentials first.'); + return; + } + + $btn.prop('disabled', true); + $status.html('Testing connection...'); + + $.post(mapleCartsAdmin.ajaxUrl, { + action: 'maple_carts_test_mailjet', + nonce: mapleCartsAdmin.nonce, + api_key: apiKey, + secret_key: secretKey + }, function(response) { + $btn.prop('disabled', false); + if (response.success) { + $status.html('✓ ' + response.data.message + ''); + } else { + $status.html('✗ ' + response.data + ''); + } + }).fail(function() { + $btn.prop('disabled', false); + $status.html('✗ Connection failed'); + }); + }); + + // Setup Mailjet properties. + $('#setup-mailjet-properties').on('click', function() { + var $btn = $(this); + var $status = $('#mailjet-status'); + + $btn.prop('disabled', true); + $status.html('Setting up contact properties...'); + + $.post(mapleCartsAdmin.ajaxUrl, { + action: 'maple_carts_setup_mailjet_properties', + nonce: mapleCartsAdmin.nonce + }, function(response) { + $btn.prop('disabled', false); + if (response.success) { + $status.html('✓ ' + response.data.message + ''); + } else { + $status.html('✗ ' + response.data + ''); + } + }).fail(function() { + $btn.prop('disabled', false); + $status.html('✗ Setup failed'); + }); + }); + }); + +})(jQuery); diff --git a/native/wordpress/maple-carts-wp/assets/js/index.php b/native/wordpress/maple-carts-wp/assets/js/index.php new file mode 100644 index 0000000..84d32c8 --- /dev/null +++ b/native/wordpress/maple-carts-wp/assets/js/index.php @@ -0,0 +1,10 @@ + 0) { + var notice = $('| + | + | + | + | + | + |
|---|---|---|---|---|---|
| + email ); ?> + unsubscribed ) : ?> + + + | +customer_name ?: '—' ); ?> | +cart_total, [ 'currency' => $cart->currency ] ); ?> | ++ + + | ++ recovered_at + ? $cart->recovered_at + : ( $cart->abandoned_at ?: $cart->created_at ); + echo esc_html( date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $date ) ) ); + ?> + | ++ + | +
| + | + | + | + | + | + |
|---|---|---|---|---|---|
| + | |||||
| name ); ?> | +subject ); ?> | +delay_value . ' ' . $tmpl->delay_unit ); ?> | ++ include_coupon ) : ?> + coupon_type === 'percent' + ? esc_html( $tmpl->coupon_amount . '%' ) + : wc_price( $tmpl->coupon_amount ); + ?> + + — + + | ++ + | ++ + + + | + + | +
Hi {{customer_name}},
+We noticed you left some items in your cart at {{site_name}}. No worries — your cart is saved and ready when you are!
+{{cart_contents}}
+ +If you have any questions, just reply to this email.
+Thanks,
{{site_name}}
{{unsubscribe_link}}{{delete_data_link}}
', + + 2 => 'Hi {{customer_name}},
+Just a friendly reminder that your cart at {{site_name}} is still waiting for you.
+{{cart_contents}}
+Cart Total: {{cart_total}}
+ +Best,
{{site_name}}
{{unsubscribe_link}}{{delete_data_link}}
', + + 3 => 'Hi {{customer_name}},
+This is your last reminder about the items in your cart. To help you complete your purchase, we\'re offering you an exclusive discount!
+{{cart_contents}}
+Use code {{coupon_code}} for {{coupon_amount}} off!
+ +This offer expires in {{coupon_expires}} days.
+Thanks,
{{site_name}}
{{unsubscribe_link}}{{delete_data_link}}
', + ]; + + return $bodies[ $template_num ] ?? $bodies[1]; + } + + /** + * Get a cart by session ID. + * + * @param string $session_id Session ID. + * @return object|null + */ + public static function get_cart_by_session( $session_id ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_TABLE; + return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE session_id = %s", $session_id ) ); + } + + /** + * Get a cart by email. + * + * @param string $email Email address. + * @param array $statuses Statuses to filter by. + * @return object|null + */ + public static function get_cart_by_email( $email, $statuses = [ 'active', 'abandoned' ] ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_TABLE; + $placeholders = implode( ',', array_fill( 0, count( $statuses ), '%s' ) ); + $params = array_merge( [ $email ], $statuses ); + + return $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE email = %s AND status IN ({$placeholders}) ORDER BY created_at DESC LIMIT 1", + $params + ) + ); + } + + /** + * Insert or update a cart. + * + * @param array $data Cart data. + * @return int|false Cart ID or false on failure. + */ + public static function save_cart( $data ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_TABLE; + + // Always update last_tracked_at when saving. + $data['last_tracked_at'] = current_time( 'mysql' ); + + $existing = null; + if ( ! empty( $data['session_id'] ) ) { + $existing = self::get_cart_by_session( $data['session_id'] ); + } + + if ( $existing ) { + $wpdb->update( $table, $data, [ 'id' => $existing->id ] ); + $cart_id = $existing->id; + } else { + // Set first_seen_at for new carts. + $data['first_seen_at'] = current_time( 'mysql' ); + $wpdb->insert( $table, $data ); + $cart_id = $wpdb->insert_id; + } + + // Fire webhook for integrations (Facebook CAPI, etc.). + if ( $cart_id ) { + $cart = self::get_cart_by_id( $cart_id ); + if ( $cart ) { + /** + * Fires when a cart is created or updated. + * Useful for Facebook CAPI, retargeting pixels, etc. + * + * @param object $cart Cart object. + */ + do_action( 'maple_carts_cart_updated', $cart ); + } + } + + return $cart_id; + } + + /** + * Update cart status. + * + * @param int $cart_id Cart ID. + * @param string $status New status. + * @param array $extra Extra data to update. + */ + public static function update_cart_status( $cart_id, $status, $extra = [] ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_TABLE; + + $data = array_merge( [ 'status' => $status ], $extra ); + + if ( 'abandoned' === $status ) { + $data['abandoned_at'] = current_time( 'mysql' ); + } elseif ( 'recovered' === $status ) { + $data['recovered_at'] = current_time( 'mysql' ); + } + + $wpdb->update( $table, $data, [ 'id' => $cart_id ] ); + + // Fire hooks for integrations (Mailjet, etc.). + $cart = self::get_cart_by_id( $cart_id ); + if ( $cart ) { + if ( 'abandoned' === $status ) { + /** + * Fires when a cart is marked as abandoned. + * + * @param int $cart_id Cart ID. + * @param object $cart Cart object. + */ + do_action( 'maple_carts_cart_abandoned', $cart_id, $cart ); + } elseif ( 'recovered' === $status ) { + /** + * Fires when a cart is recovered. + * + * @param int $cart_id Cart ID. + * @param object $cart Cart object. + */ + do_action( 'maple_carts_cart_recovered', $cart_id, $cart ); + } elseif ( 'converted' === $status ) { + /** + * Fires when a cart is converted (order placed). + * + * @param int $cart_id Cart ID. + * @param object $cart Cart object. + */ + do_action( 'maple_carts_cart_converted', $cart_id, $cart ); + } + } + } + + /** + * Get cart by ID. + * + * @param int $cart_id Cart ID. + * @return object|null + */ + public static function get_cart_by_id( $cart_id ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_TABLE; + + return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $cart_id ) ); + } + + /** + * Get carts by status. + * + * @param string $status Status. + * @param int $limit Limit. + * @param int $offset Offset. + * @return array + */ + public static function get_carts_by_status( $status, $limit = 50, $offset = 0 ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_TABLE; + + return $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE status = %s ORDER BY created_at DESC LIMIT %d OFFSET %d", + $status, + $limit, + $offset + ) + ); + } + + /** + * Count carts by status. + * + * @param string $status Status. + * @return int + */ + public static function count_carts_by_status( $status ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_TABLE; + + return (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", $status ) + ); + } + + /** + * Get all email templates. + * + * @param bool $active_only Only active templates. + * @return array + */ + public static function get_email_templates( $active_only = false ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE; + + $where = $active_only ? 'WHERE is_active = 1' : ''; + + return $wpdb->get_results( "SELECT * FROM {$table} {$where} ORDER BY sort_order ASC" ); + } + + /** + * Get email template by ID. + * + * @param int $id Template ID. + * @return object|null + */ + public static function get_email_template( $id ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE; + + return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) ); + } + + /** + * Save email template. + * + * @param array $data Template data. + * @return int|false Template ID or false. + */ + public static function save_email_template( $data ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE; + + if ( ! empty( $data['id'] ) ) { + $id = $data['id']; + unset( $data['id'] ); + $wpdb->update( $table, $data, [ 'id' => $id ] ); + return $id; + } else { + $wpdb->insert( $table, $data ); + return $wpdb->insert_id; + } + } + + /** + * Delete email template. + * + * @param int $id Template ID. + * @return bool + */ + public static function delete_email_template( $id ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE; + + return $wpdb->delete( $table, [ 'id' => $id ] ) !== false; + } + + /** + * Schedule emails for a cart. + * + * @param int $cart_id Cart ID. + */ + public static function schedule_emails( $cart_id ) { + global $wpdb; + + $log_table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE; + $cart = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}" . MAPLE_CARTS_TABLE . " WHERE id = %d", $cart_id ) ); + + if ( ! $cart || $cart->unsubscribed ) { + return; + } + + $templates = self::get_email_templates( true ); + if ( empty( $templates ) ) { + return; + } + + $base_time = $cart->abandoned_at ? $cart->abandoned_at : current_time( 'mysql' ); + + // Get all existing scheduled emails for this cart in one query. + $existing_template_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT email_template_id FROM {$log_table} WHERE cart_id = %d", + $cart_id + ) + ); + $existing_set = array_flip( $existing_template_ids ); + + foreach ( $templates as $template ) { + // Skip if already scheduled. + if ( isset( $existing_set[ $template->id ] ) ) { + continue; + } + + // Calculate scheduled time. + $delay_seconds = self::get_delay_seconds( $template->delay_value, $template->delay_unit ); + $scheduled_at = gmdate( 'Y-m-d H:i:s', strtotime( $base_time ) + $delay_seconds ); + + $wpdb->insert( + $log_table, + [ + 'cart_id' => $cart_id, + 'email_template_id' => $template->id, + 'scheduled_at' => $scheduled_at, + 'status' => 'pending', + ] + ); + } + } + + /** + * Get delay in seconds. + * + * @param int $value Delay value. + * @param string $unit Delay unit. + * @return int + */ + private static function get_delay_seconds( $value, $unit ) { + switch ( $unit ) { + case 'minutes': + return $value * 60; + case 'hours': + return $value * 3600; + case 'days': + return $value * 86400; + default: + return $value * 3600; + } + } + + /** + * Get pending emails to send. + * + * @return array + */ + public static function get_pending_emails() { + global $wpdb; + + $log_table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE; + $carts_table = $wpdb->prefix . MAPLE_CARTS_TABLE; + $email_table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE; + $now = current_time( 'mysql' ); + + return $wpdb->get_results( + $wpdb->prepare( + "SELECT l.*, c.email, c.customer_name, c.cart_contents, c.cart_total, c.currency, + c.session_id, c.coupon_code as cart_coupon, c.billing_data, + e.subject, e.body, e.include_coupon, e.coupon_type, e.coupon_amount, e.coupon_expires_days + FROM {$log_table} l + JOIN {$carts_table} c ON l.cart_id = c.id + JOIN {$email_table} e ON l.email_template_id = e.id + WHERE l.status = 'pending' + AND l.scheduled_at <= %s + AND c.status = 'abandoned' + AND c.unsubscribed = 0 + ORDER BY l.scheduled_at ASC + LIMIT 50", + $now + ) + ); + } + + /** + * Update email log status. + * + * @param int $id Log ID. + * @param string $status Status. + * @param array $extra Extra data. + */ + public static function update_email_log( $id, $status, $extra = [] ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE; + + $data = array_merge( [ 'status' => $status ], $extra ); + + if ( 'sent' === $status ) { + $data['sent_at'] = current_time( 'mysql' ); + } + + $wpdb->update( $table, $data, [ 'id' => $id ] ); + } + + /** + * Cancel pending emails for a cart. + * + * @param int $cart_id Cart ID. + */ + public static function cancel_pending_emails( $cart_id ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE; + + $wpdb->update( + $table, + [ 'status' => 'cancelled' ], + [ 'cart_id' => $cart_id, 'status' => 'pending' ] + ); + } + + /** + * Get cart statistics. + * + * @param string $period Period (7days, 30days, all). + * @return array + */ + public static function get_stats( $period = '30days' ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_TABLE; + + $date_filter = ''; + if ( '7days' === $period ) { + $date_filter = "AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)"; + } elseif ( '30days' === $period ) { + $date_filter = "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"; + } + + $stats = [ + 'abandoned' => 0, + 'recovered' => 0, + 'lost' => 0, + 'abandoned_value' => 0, + 'recovered_value' => 0, + 'recovery_rate' => 0, + ]; + + $results = $wpdb->get_results( + "SELECT status, COUNT(*) as count, SUM(cart_total) as total + FROM {$table} + WHERE 1=1 {$date_filter} + GROUP BY status" + ); + + foreach ( $results as $row ) { + if ( 'abandoned' === $row->status ) { + $stats['abandoned'] = (int) $row->count; + $stats['abandoned_value'] = (float) $row->total; + } elseif ( 'recovered' === $row->status ) { + $stats['recovered'] = (int) $row->count; + $stats['recovered_value'] = (float) $row->total; + } elseif ( 'lost' === $row->status ) { + $stats['lost'] = (int) $row->count; + } + } + + $total_actionable = $stats['abandoned'] + $stats['recovered'] + $stats['lost']; + if ( $total_actionable > 0 ) { + $stats['recovery_rate'] = round( ( $stats['recovered'] / $total_actionable ) * 100, 1 ); + } + + return $stats; + } + + /** + * Cleanup old data. + * Runs daily, processes in batches to prevent memory issues. + */ + public static function cleanup_old_data() { + global $wpdb; + + // Prevent overlapping runs. + if ( get_transient( 'maple_carts_cleanup_running' ) ) { + return; + } + set_transient( 'maple_carts_cleanup_running', 1, 3600 ); // 1 hour lock. + + $days = (int) Maple_Carts::get_option( 'delete_after_days', 90 ); + $carts_table = $wpdb->prefix . MAPLE_CARTS_TABLE; + $log_table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE; + $batch_size = 500; + + // Delete old carts in batches. + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$carts_table} WHERE created_at < DATE_SUB(NOW(), INTERVAL %d DAY) LIMIT %d", + $days, + $batch_size + ) + ); + + // Delete orphaned email logs in batches. + $wpdb->query( + "DELETE l FROM {$log_table} l + LEFT JOIN {$carts_table} c ON l.cart_id = c.id + WHERE c.id IS NULL + LIMIT {$batch_size}" + ); + + // Delete expired coupons created by this plugin (batched). + $expired_coupons = $wpdb->get_col( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} + WHERE post_type = 'shop_coupon' + AND ID IN ( + SELECT post_id FROM {$wpdb->postmeta} + WHERE meta_key = '_maple_carts_coupon' AND meta_value = '1' + ) + AND ID IN ( + SELECT post_id FROM {$wpdb->postmeta} + WHERE meta_key = 'date_expires' AND meta_value < UNIX_TIMESTAMP() + ) + LIMIT %d", + $batch_size + ) + ); + + foreach ( $expired_coupons as $coupon_id ) { + wp_delete_post( $coupon_id, true ); + } + + // Delete unused coupons older than 30 days (never used, cart may have converted another way). + $unused_old_coupons = $wpdb->get_col( + $wpdb->prepare( + "SELECT p.ID FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm_maple ON p.ID = pm_maple.post_id + AND pm_maple.meta_key = '_maple_carts_coupon' AND pm_maple.meta_value = '1' + INNER JOIN {$wpdb->postmeta} pm_created ON p.ID = pm_created.post_id + AND pm_created.meta_key = '_maple_carts_created_at' AND pm_created.meta_value < %d + INNER JOIN {$wpdb->postmeta} pm_usage ON p.ID = pm_usage.post_id + AND pm_usage.meta_key = 'usage_count' AND pm_usage.meta_value = '0' + WHERE p.post_type = 'shop_coupon' + LIMIT %d", + strtotime( '-30 days' ), + $batch_size + ) + ); + + foreach ( $unused_old_coupons as $coupon_id ) { + wp_delete_post( $coupon_id, true ); + } + + delete_transient( 'maple_carts_cleanup_running' ); + } + + /** + * Delete a cart. + * + * @param int $cart_id Cart ID. + * @return bool + */ + public static function delete_cart( $cart_id ) { + global $wpdb; + + // Delete email logs first. + $wpdb->delete( $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE, [ 'cart_id' => $cart_id ] ); + + // Delete cart. + return $wpdb->delete( $wpdb->prefix . MAPLE_CARTS_TABLE, [ 'id' => $cart_id ] ) !== false; + } +} + +// Register cron interval early. +add_filter( 'cron_schedules', [ 'Maple_Carts_DB', 'add_cron_interval' ] ); diff --git a/native/wordpress/maple-carts-wp/includes/class-maple-carts-emails.php b/native/wordpress/maple-carts-wp/includes/class-maple-carts-emails.php new file mode 100644 index 0000000..3bac2e9 --- /dev/null +++ b/native/wordpress/maple-carts-wp/includes/class-maple-carts-emails.php @@ -0,0 +1,633 @@ +send_email( $email_data ); + } + + delete_transient( 'maple_carts_sending_emails' ); + } + + /** + * Send a recovery email. + * + * @param object $email_data Email data from database. + * @return bool + */ + public function send_email( $email_data ) { + // Validate email address. + if ( ! is_email( $email_data->email ) ) { + Maple_Carts_DB::update_email_log( $email_data->id, 'failed', [ 'error_message' => 'Invalid email address' ] ); + return false; + } + + // Check if cart still qualifies. + $cart = Maple_Carts_DB::get_cart_by_session( $email_data->session_id ); + if ( ! $cart || 'abandoned' !== $cart->status || $cart->unsubscribed ) { + Maple_Carts_DB::update_email_log( $email_data->id, 'cancelled' ); + return false; + } + + // Check for out-of-stock products. + $cart_contents = maybe_unserialize( $email_data->cart_contents ); + if ( $this->cart_has_out_of_stock( $cart_contents ) ) { + Maple_Carts_DB::update_email_log( $email_data->id, 'cancelled', [ 'error_message' => 'Products out of stock' ] ); + return false; + } + + // Generate coupon if needed. + $coupon_code = ''; + if ( $email_data->include_coupon ) { + $coupon_code = $this->generate_coupon( + $email_data->coupon_type, + $email_data->coupon_amount, + $email_data->coupon_expires_days, + $email_data->cart_id + ); + + // Store coupon code in log. + global $wpdb; + $wpdb->update( + $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE, + [ 'coupon_code' => $coupon_code ], + [ 'id' => $email_data->id ] + ); + } + + // Generate recovery URL with coupon. + $recovery_url = Maple_Carts_Tracking::instance()->generate_recovery_url( + $email_data->session_id, + $coupon_code + ); + + // Add UTM parameters for analytics attribution. + $recovery_url = add_query_arg( [ + 'utm_source' => 'maple_carts', + 'utm_medium' => 'email', + 'utm_campaign' => 'cart_recovery', + 'utm_content' => 'email_' . $email_data->email_template_id, + ], $recovery_url ); + + // Parse email content. + $subject = $this->parse_template( $email_data->subject, $email_data, $coupon_code, $recovery_url ); + $body = $this->parse_template( $email_data->body, $email_data, $coupon_code, $recovery_url ); + + // Send email. + $sent = $this->dispatch_email( $email_data->email, $subject, $body ); + + if ( $sent ) { + Maple_Carts_DB::update_email_log( $email_data->id, 'sent' ); + + /** + * Fires after a recovery email is sent. + * + * @param object $email_data Email data. + * @param string $subject Email subject. + */ + do_action( 'maple_carts_email_sent', $email_data, $subject ); + + return true; + } else { + Maple_Carts_DB::update_email_log( $email_data->id, 'failed', [ 'error_message' => 'Failed to send email' ] ); + return false; + } + } + + /** + * Parse email template with placeholders. + * + * @param string $template Template string. + * @param object $email_data Email data. + * @param string $coupon_code Coupon code. + * @param string $recovery_url Recovery URL. + * @return string + */ + private function parse_template( $template, $email_data, $coupon_code, $recovery_url ) { + $cart_contents = maybe_unserialize( $email_data->cart_contents ); + + // Customer name fallback. + $customer_name = $email_data->customer_name; + if ( empty( $customer_name ) ) { + $customer_name = __( 'there', 'maple-carts' ); + } + + // Generate cart contents HTML. + $cart_html = $this->generate_cart_html( $cart_contents, $email_data->currency ); + + // Generate product names list. + $product_names = []; + if ( is_array( $cart_contents ) ) { + foreach ( $cart_contents as $item ) { + $product_names[] = $item['name']; + } + } + + // Coupon display amount. + $coupon_display = ''; + if ( ! empty( $coupon_code ) ) { + if ( 'percent' === $email_data->coupon_type ) { + $coupon_display = $email_data->coupon_amount . '%'; + } else { + $coupon_display = wc_price( $email_data->coupon_amount ); + } + } + + // Unsubscribe link (using signed URL from Tracking class). + $unsubscribe_url = Maple_Carts_Tracking::instance()->generate_unsubscribe_url( $email_data->session_id ); + $unsubscribe_link = sprintf( + '%s', + esc_url( $unsubscribe_url ), + __( 'Unsubscribe from these emails', 'maple-carts' ) + ); + + // Delete data link for GDPR compliance (optional). + $delete_data_link = ''; + if ( 'yes' === Maple_Carts::get_option( 'show_delete_link', 'no' ) ) { + $delete_data_url = Maple_Carts_Privacy::generate_deletion_url( $email_data->session_id ); + $delete_data_link = sprintf( + ' | %s', + esc_url( $delete_data_url ), + __( 'Delete my data', 'maple-carts' ) + ); + } + + // Replacements. + $replacements = [ + '{{customer_name}}' => esc_html( $customer_name ), + '{{customer_email}}' => esc_html( $email_data->email ), + '{{cart_contents}}' => $cart_html, + '{{cart_total}}' => wc_price( $email_data->cart_total, [ 'currency' => $email_data->currency ] ), + '{{product_names}}' => esc_html( implode( ', ', $product_names ) ), + '{{recovery_url}}' => esc_url( $recovery_url ), + '{{coupon_code}}' => esc_html( $coupon_code ), + '{{coupon_amount}}' => $coupon_display, + '{{coupon_expires}}' => $email_data->coupon_expires_days, + '{{site_name}}' => esc_html( get_bloginfo( 'name' ) ), + '{{site_url}}' => esc_url( home_url() ), + '{{unsubscribe_link}}' => $unsubscribe_link, + '{{delete_data_link}}' => $delete_data_link, + '{{current_date}}' => date_i18n( get_option( 'date_format' ) ), + ]; + + /** + * Filter email template replacements. + * + * @param array $replacements Placeholder replacements. + * @param object $email_data Email data. + */ + $replacements = apply_filters( 'maple_carts_email_replacements', $replacements, $email_data ); + + return str_replace( array_keys( $replacements ), array_values( $replacements ), $template ); + } + + /** + * Generate cart contents HTML. + * + * @param array $cart_contents Cart contents. + * @param string $currency Currency code. + * @return string + */ + private function generate_cart_html( $cart_contents, $currency = 'USD' ) { + if ( empty( $cart_contents ) || ! is_array( $cart_contents ) ) { + return ''; + } + + $html = '| ' . __( 'Product', 'maple-carts' ) . ' | '; + $html .= '' . __( 'Qty', 'maple-carts' ) . ' | '; + $html .= '' . __( 'Price', 'maple-carts' ) . ' | '; + $html .= '
|---|---|---|
| ';
+
+ if ( ! empty( $item['image'] ) ) {
+ $html .= ' | ';
+ $html .= '' . esc_html( $item['quantity'] ) . ' | '; + $html .= '' . wc_price( $line_total, [ 'currency' => $currency ] ) . ' | '; + $html .= '
| + + | +
%s
+%s
+%s
+%s
+%s
+%s
', + __( 'Abandoned Cart Recovery', 'maple-carts' ), + __( 'We use abandoned cart recovery technology to help remind you about items left in your shopping cart. This section explains what data we collect and how we use it.', 'maple-carts' ), + __( 'What data we collect', 'maple-carts' ), + __( 'When you begin the checkout process, we may collect:', 'maple-carts' ), + __( 'Email address', 'maple-carts' ), + __( 'Name and phone number', 'maple-carts' ), + __( 'Billing address', 'maple-carts' ), + __( 'Cart contents and total', 'maple-carts' ), + __( 'IP address', 'maple-carts' ), + __( 'Browser information', 'maple-carts' ), + __( 'Why we collect this data', 'maple-carts' ), + __( 'We collect this data to send you reminder emails if you leave items in your cart without completing your purchase. These emails may include a discount code to encourage you to complete your order.', 'maple-carts' ), + __( 'How long we retain your data', 'maple-carts' ), + sprintf( + /* translators: %d: number of days */ + __( 'Abandoned cart data is automatically deleted after %d days. You can request immediate deletion at any time.', 'maple-carts' ), + $retention_days + ), + __( 'Your rights', 'maple-carts' ), + __( 'You can unsubscribe from abandoned cart emails at any time by clicking the unsubscribe link in any email. You can also request a copy of your data or request deletion through our privacy tools.', 'maple-carts' ), + __( 'Third-party sharing', 'maple-carts' ), + __( 'We do not share your abandoned cart data with any third parties. Emails are sent through our own email system or your configured SMTP provider.', 'maple-carts' ) + ); + } + + /** + * Register data exporter. + * + * @param array $exporters Existing exporters. + * @return array + */ + public function register_data_exporter( $exporters ) { + $exporters['maple-carts'] = [ + 'exporter_friendly_name' => __( 'Maple Carts Abandoned Cart Data', 'maple-carts' ), + 'callback' => [ $this, 'export_personal_data' ], + ]; + return $exporters; + } + + /** + * Register data eraser. + * + * @param array $erasers Existing erasers. + * @return array + */ + public function register_data_eraser( $erasers ) { + $erasers['maple-carts'] = [ + 'eraser_friendly_name' => __( 'Maple Carts Abandoned Cart Data', 'maple-carts' ), + 'callback' => [ $this, 'erase_personal_data' ], + ]; + return $erasers; + } + + /** + * Export personal data for a user. + * + * @param string $email_address Email address. + * @param int $page Page number. + * @return array + */ + public function export_personal_data( $email_address, $page = 1 ) { + global $wpdb; + + $export_items = []; + $table = $wpdb->prefix . MAPLE_CARTS_TABLE; + $per_page = 10; + $offset = ( $page - 1 ) * $per_page; + + $carts = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE email = %s LIMIT %d OFFSET %d", + $email_address, + $per_page, + $offset + ) + ); + + foreach ( $carts as $cart ) { + $cart_contents = maybe_unserialize( $cart->cart_contents ); + $product_names = []; + + if ( is_array( $cart_contents ) ) { + foreach ( $cart_contents as $item ) { + $product_names[] = $item['name'] ?? 'Unknown Product'; + } + } + + $data = [ + [ + 'name' => __( 'Email', 'maple-carts' ), + 'value' => $cart->email, + ], + [ + 'name' => __( 'Name', 'maple-carts' ), + 'value' => $cart->customer_name ?: __( 'Not provided', 'maple-carts' ), + ], + [ + 'name' => __( 'Phone', 'maple-carts' ), + 'value' => $cart->customer_phone ?: __( 'Not provided', 'maple-carts' ), + ], + [ + 'name' => __( 'Cart Contents', 'maple-carts' ), + 'value' => implode( ', ', $product_names ), + ], + [ + 'name' => __( 'Cart Total', 'maple-carts' ), + 'value' => wc_price( $cart->cart_total, [ 'currency' => $cart->currency ] ), + ], + [ + 'name' => __( 'Status', 'maple-carts' ), + 'value' => ucfirst( $cart->status ), + ], + [ + 'name' => __( 'IP Address', 'maple-carts' ), + 'value' => $cart->ip_address ?: __( 'Not recorded', 'maple-carts' ), + ], + [ + 'name' => __( 'Created', 'maple-carts' ), + 'value' => $cart->created_at, + ], + [ + 'name' => __( 'Unsubscribed', 'maple-carts' ), + 'value' => $cart->unsubscribed ? __( 'Yes', 'maple-carts' ) : __( 'No', 'maple-carts' ), + ], + ]; + + // Add billing data if present. + $billing_data = maybe_unserialize( $cart->billing_data ); + if ( ! empty( $billing_data ) ) { + $address_parts = array_filter( [ + $billing_data['address_1'] ?? '', + $billing_data['address_2'] ?? '', + $billing_data['city'] ?? '', + $billing_data['state'] ?? '', + $billing_data['postcode'] ?? '', + $billing_data['country'] ?? '', + ] ); + + if ( ! empty( $address_parts ) ) { + $data[] = [ + 'name' => __( 'Billing Address', 'maple-carts' ), + 'value' => implode( ', ', $address_parts ), + ]; + } + } + + $export_items[] = [ + 'group_id' => 'maple-carts', + 'group_label' => __( 'Abandoned Cart Data', 'maple-carts' ), + 'group_description' => __( 'Cart data collected during checkout.', 'maple-carts' ), + 'item_id' => 'cart-' . $cart->id, + 'data' => $data, + ]; + } + + // Check if there are more records. + $total = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE email = %s", + $email_address + ) + ); + + $done = ( $page * $per_page ) >= $total; + + return [ + 'data' => $export_items, + 'done' => $done, + ]; + } + + /** + * Erase personal data for a user. + * + * @param string $email_address Email address. + * @param int $page Page number. + * @return array + */ + public function erase_personal_data( $email_address, $page = 1 ) { + global $wpdb; + + $table = $wpdb->prefix . MAPLE_CARTS_TABLE; + $log_table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE; + $per_page = 50; + $items_removed = 0; + $items_retained = 0; + + // Get carts to delete. + $carts = $wpdb->get_results( + $wpdb->prepare( + "SELECT id FROM {$table} WHERE email = %s LIMIT %d", + $email_address, + $per_page + ) + ); + + foreach ( $carts as $cart ) { + // Delete email logs first. + $wpdb->delete( $log_table, [ 'cart_id' => $cart->id ] ); + + // Delete cart. + $deleted = $wpdb->delete( $table, [ 'id' => $cart->id ] ); + + if ( $deleted ) { + $items_removed++; + } else { + $items_retained++; + } + } + + // Check if there are more records. + $remaining = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE email = %s", + $email_address + ) + ); + + $done = ( $remaining == 0 ); + + return [ + 'items_removed' => $items_removed, + 'items_retained' => $items_retained, + 'messages' => [], + 'done' => $done, + ]; + } + + /** + * Add consent checkbox to checkout. + */ + public function add_consent_checkbox() { + $consent_text = Maple_Carts::get_option( + 'consent_text', + __( 'I agree to receive cart reminder emails if I don\'t complete my purchase.', 'maple-carts' ) + ); + + woocommerce_form_field( + 'maple_carts_consent', + [ + 'type' => 'checkbox', + 'class' => [ 'form-row maple-carts-consent' ], + 'label' => esc_html( $consent_text ), + 'required' => false, + ] + ); + } + + /** + * Validate consent checkbox. + */ + public function validate_consent_checkbox() { + // If consent is required and not given, we don't track the cart. + // This is handled in the tracking class. + } + + /** + * Check if user has given consent. + * + * @return bool + */ + public static function has_consent() { + // If consent is not required, always return true. + if ( 'yes' !== Maple_Carts::get_option( 'require_consent', 'no' ) ) { + return true; + } + + // Check if consent checkbox was checked. + if ( isset( $_POST['maple_carts_consent'] ) ) { + return true; + } + + // Check session for previous consent. + if ( WC()->session && WC()->session->get( 'maple_carts_consent' ) ) { + return true; + } + + return false; + } + + /** + * Record consent. + */ + public static function record_consent() { + if ( WC()->session ) { + WC()->session->set( 'maple_carts_consent', true ); + WC()->session->set( 'maple_carts_consent_time', current_time( 'mysql' ) ); + } + } + + /** + * Handle data deletion request from unsubscribe page. + */ + public function handle_data_deletion_request() { + if ( ! isset( $_GET['maple_delete_data'] ) ) { + return; + } + + $token = sanitize_text_field( wp_unslash( $_GET['maple_delete_data'] ) ); + + // Verify signed token (same as unsubscribe). + $tracking = Maple_Carts_Tracking::instance(); + $data = $this->verify_deletion_token( $token ); + + if ( ! $data || empty( $data['sid'] ) ) { + wc_add_notice( __( 'This data deletion 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 ) { + // Delete all data for this cart. + Maple_Carts_DB::delete_cart( $cart->id ); + + wc_add_notice( __( 'Your cart data has been permanently deleted.', 'maple-carts' ), 'success' ); + } + + wp_safe_redirect( wc_get_page_permalink( 'shop' ) ); + exit; + } + + /** + * Verify deletion token (reuses tracking token verification). + * + * @param string $token Token to verify. + * @return array|false + */ + private function verify_deletion_token( $token ) { + $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. + $secret = wp_salt( 'auth' ) . 'maple_carts'; + $expected_signature = hash_hmac( 'sha256', $payload, $secret ); + + if ( ! hash_equals( $expected_signature, $signature ) ) { + return false; + } + + $data = json_decode( $payload, true ); + if ( ! $data ) { + return false; + } + + // Token valid for 90 days. + if ( isset( $data['t'] ) && ( time() - $data['t'] ) > ( 90 * DAY_IN_SECONDS ) ) { + return false; + } + + return $data; + } + + /** + * Generate data deletion URL. + * + * @param string $session_id Session ID. + * @return string + */ + public static function generate_deletion_url( $session_id ) { + $data = [ + 'sid' => $session_id, + 't' => time(), + ]; + + $payload = wp_json_encode( $data ); + $secret = wp_salt( 'auth' ) . 'maple_carts'; + $signature = hash_hmac( 'sha256', $payload, $secret ); + $token = base64_encode( $payload . '|' . $signature ); + + return add_query_arg( 'maple_delete_data', $token, home_url() ); + } + + /** + * Get anonymized data (for when full deletion isn't required). + * + * @param int $cart_id Cart ID. + */ + public static function anonymize_cart( $cart_id ) { + global $wpdb; + $table = $wpdb->prefix . MAPLE_CARTS_TABLE; + + $wpdb->update( + $table, + [ + 'email' => 'deleted@anonymized.invalid', + 'customer_name' => 'Anonymized', + 'customer_phone' => null, + 'billing_data' => null, + 'ip_address' => '0.0.0.0', + 'user_agent' => null, + ], + [ 'id' => $cart_id ] + ); + } +} diff --git a/native/wordpress/maple-carts-wp/includes/class-maple-carts-tracking.php b/native/wordpress/maple-carts-wp/includes/class-maple-carts-tracking.php new file mode 100644 index 0000000..e53cacb --- /dev/null +++ b/native/wordpress/maple-carts-wp/includes/class-maple-carts-tracking.php @@ -0,0 +1,767 @@ +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 ); + } +} diff --git a/native/wordpress/maple-carts-wp/includes/index.php b/native/wordpress/maple-carts-wp/includes/index.php new file mode 100644 index 0000000..84d32c8 --- /dev/null +++ b/native/wordpress/maple-carts-wp/includes/index.php @@ -0,0 +1,10 @@ +includes(); + $this->init_hooks(); + } + + /** + * Include required files. + */ + private function includes() { + require_once MAPLE_CARTS_PATH . 'includes/class-maple-carts-db.php'; + require_once MAPLE_CARTS_PATH . 'includes/class-maple-carts-tracking.php'; + require_once MAPLE_CARTS_PATH . 'includes/class-maple-carts-emails.php'; + require_once MAPLE_CARTS_PATH . 'includes/class-maple-carts-privacy.php'; + require_once MAPLE_CARTS_PATH . 'includes/class-maple-carts-mailjet.php'; + + if ( is_admin() ) { + require_once MAPLE_CARTS_PATH . 'includes/class-maple-carts-admin.php'; + } + } + + /** + * Initialize hooks. + */ + private function init_hooks() { + register_activation_hook( MAPLE_CARTS_FILE, [ 'Maple_Carts_DB', 'activate' ] ); + register_deactivation_hook( MAPLE_CARTS_FILE, [ $this, 'deactivate' ] ); + + add_action( 'plugins_loaded', [ $this, 'on_plugins_loaded' ] ); + add_action( 'before_woocommerce_init', [ $this, 'declare_hpos_compatibility' ] ); + } + + /** + * On plugins loaded. + */ + public function on_plugins_loaded() { + if ( ! class_exists( 'WooCommerce' ) ) { + add_action( 'admin_notices', [ $this, 'woocommerce_missing_notice' ] ); + return; + } + + // Initialize components. + Maple_Carts_Tracking::instance(); + Maple_Carts_Emails::instance(); + Maple_Carts_Privacy::instance(); + Maple_Carts_Mailjet::instance(); + + if ( is_admin() ) { + Maple_Carts_Admin::instance(); + } + + // Load textdomain. + load_plugin_textdomain( 'maple-carts', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); + } + + /** + * Deactivation. + */ + public function deactivate() { + wp_clear_scheduled_hook( 'maple_carts_send_emails' ); + wp_clear_scheduled_hook( 'maple_carts_cleanup' ); + } + + /** + * HPOS compatibility. + */ + public function declare_hpos_compatibility() { + if ( class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) ) { + \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', MAPLE_CARTS_FILE, true ); + } + } + + /** + * WooCommerce missing notice. + */ + public function woocommerce_missing_notice() { + ?> +