WP maple cart and page subtitle plugin upload

This commit is contained in:
Rodolfo Martinez 2025-12-12 18:30:26 -05:00
parent b3e87772ec
commit c85895d306
18 changed files with 5741 additions and 0 deletions

View file

@ -0,0 +1,962 @@
<?php
/**
* Admin interface for Maple Carts.
*
* @package MapleCarts
*/
defined( 'ABSPATH' ) || exit;
/**
* Admin class.
*/
class Maple_Carts_Admin {
/**
* Single instance.
*
* @var Maple_Carts_Admin
*/
private static $instance = null;
/**
* Get instance.
*
* @return Maple_Carts_Admin
*/
public static function instance() {
if ( is_null( self::$instance ) ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
private function __construct() {
add_action( 'admin_menu', [ $this, 'add_menu' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
add_action( 'admin_init', [ $this, 'handle_actions' ] );
// AJAX handlers.
add_action( 'wp_ajax_maple_carts_delete_cart', [ $this, 'ajax_delete_cart' ] );
add_action( 'wp_ajax_maple_carts_save_template', [ $this, 'ajax_save_template' ] );
add_action( 'wp_ajax_maple_carts_delete_template', [ $this, 'ajax_delete_template' ] );
add_action( 'wp_ajax_maple_carts_toggle_template', [ $this, 'ajax_toggle_template' ] );
}
/**
* Add admin menu.
*/
public function add_menu() {
add_menu_page(
__( 'Maple Carts', 'maple-carts' ),
__( 'Maple Carts', 'maple-carts' ),
'manage_woocommerce',
'maple-carts',
[ $this, 'render_dashboard' ],
'dashicons-cart',
56
);
add_submenu_page(
'maple-carts',
__( 'Dashboard', 'maple-carts' ),
__( 'Dashboard', 'maple-carts' ),
'manage_woocommerce',
'maple-carts',
[ $this, 'render_dashboard' ]
);
add_submenu_page(
'maple-carts',
__( 'Abandoned Carts', 'maple-carts' ),
__( 'Abandoned Carts', 'maple-carts' ),
'manage_woocommerce',
'maple-carts-abandoned',
[ $this, 'render_abandoned_carts' ]
);
add_submenu_page(
'maple-carts',
__( 'Recovered Carts', 'maple-carts' ),
__( 'Recovered Carts', 'maple-carts' ),
'manage_woocommerce',
'maple-carts-recovered',
[ $this, 'render_recovered_carts' ]
);
add_submenu_page(
'maple-carts',
__( 'Email Templates', 'maple-carts' ),
__( 'Email Templates', 'maple-carts' ),
'manage_woocommerce',
'maple-carts-emails',
[ $this, 'render_email_templates' ]
);
add_submenu_page(
'maple-carts',
__( 'Settings', 'maple-carts' ),
__( 'Settings', 'maple-carts' ),
'manage_woocommerce',
'maple-carts-settings',
[ $this, 'render_settings' ]
);
}
/**
* Enqueue admin scripts and styles.
*
* @param string $hook Current admin page hook.
*/
public function enqueue_scripts( $hook ) {
if ( strpos( $hook, 'maple-carts' ) === false ) {
return;
}
wp_enqueue_style(
'maple-carts-admin',
MAPLE_CARTS_URL . 'assets/css/admin.css',
[],
MAPLE_CARTS_VERSION
);
wp_enqueue_script(
'maple-carts-admin',
MAPLE_CARTS_URL . 'assets/js/admin.js',
[ 'jquery' ],
MAPLE_CARTS_VERSION,
true
);
wp_localize_script( 'maple-carts-admin', 'mapleCartsAdmin', [
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'maple_carts_admin' ),
'confirmDelete' => __( 'Are you sure you want to delete this?', 'maple-carts' ),
'saving' => __( 'Saving...', 'maple-carts' ),
'saved' => __( 'Saved!', 'maple-carts' ),
] );
// Enqueue WP editor for email templates.
if ( strpos( $hook, 'maple-carts-emails' ) !== false ) {
wp_enqueue_editor();
}
}
/**
* Handle form actions.
*/
public function handle_actions() {
// Save settings.
if ( isset( $_POST['maple_carts_save_settings'] ) && check_admin_referer( 'maple_carts_settings' ) ) {
$this->save_settings();
}
}
/**
* Save settings.
*/
private function save_settings() {
$settings = [
'enabled' => isset( $_POST['enabled'] ) ? 'yes' : 'no',
'cart_cutoff_time' => absint( $_POST['cart_cutoff_time'] ?? 15 ),
'delete_after_days' => absint( $_POST['delete_after_days'] ?? 90 ),
'email_mode' => isset( $_POST['email_mode'] ) && 'mailjet' === $_POST['email_mode'] ? 'mailjet' : 'builtin',
'mailjet_api_key' => sanitize_text_field( wp_unslash( $_POST['mailjet_api_key'] ?? '' ) ),
'mailjet_secret_key'=> sanitize_text_field( wp_unslash( $_POST['mailjet_secret_key'] ?? '' ) ),
'from_name' => sanitize_text_field( wp_unslash( $_POST['from_name'] ?? '' ) ),
'from_email' => sanitize_email( wp_unslash( $_POST['from_email'] ?? '' ) ),
'reply_to' => sanitize_email( wp_unslash( $_POST['reply_to'] ?? '' ) ),
'notify_admin' => isset( $_POST['notify_admin'] ) ? 'yes' : 'no',
'admin_email' => sanitize_email( wp_unslash( $_POST['admin_email'] ?? '' ) ),
'require_consent' => isset( $_POST['require_consent'] ) ? 'yes' : 'no',
'consent_text' => sanitize_text_field( wp_unslash( $_POST['consent_text'] ?? '' ) ),
'show_delete_link' => isset( $_POST['show_delete_link'] ) ? 'yes' : 'no',
'gdpr_notice' => sanitize_textarea_field( wp_unslash( $_POST['gdpr_notice'] ?? '' ) ),
'exclude_roles' => isset( $_POST['exclude_roles'] ) ? array_map( 'sanitize_text_field', $_POST['exclude_roles'] ) : [],
];
update_option( 'maple_carts_settings', $settings );
add_settings_error( 'maple_carts', 'settings_saved', __( 'Settings saved.', 'maple-carts' ), 'success' );
}
/**
* Render dashboard page.
*/
public function render_dashboard() {
$period = isset( $_GET['period'] ) ? sanitize_text_field( wp_unslash( $_GET['period'] ) ) : '30days';
$stats = Maple_Carts_DB::get_stats( $period );
?>
<div class="wrap maple-carts-wrap">
<h1><?php esc_html_e( 'Maple Carts Dashboard', 'maple-carts' ); ?></h1>
<div class="maple-carts-period-selector">
<a href="<?php echo esc_url( add_query_arg( 'period', '7days' ) ); ?>"
class="<?php echo '7days' === $period ? 'active' : ''; ?>">
<?php esc_html_e( 'Last 7 Days', 'maple-carts' ); ?>
</a>
<a href="<?php echo esc_url( add_query_arg( 'period', '30days' ) ); ?>"
class="<?php echo '30days' === $period ? 'active' : ''; ?>">
<?php esc_html_e( 'Last 30 Days', 'maple-carts' ); ?>
</a>
<a href="<?php echo esc_url( add_query_arg( 'period', 'all' ) ); ?>"
class="<?php echo 'all' === $period ? 'active' : ''; ?>">
<?php esc_html_e( 'All Time', 'maple-carts' ); ?>
</a>
</div>
<div class="maple-carts-stats-grid">
<div class="maple-carts-stat-box">
<span class="stat-number"><?php echo esc_html( $stats['abandoned'] ); ?></span>
<span class="stat-label"><?php esc_html_e( 'Abandoned Carts', 'maple-carts' ); ?></span>
<span class="stat-value"><?php echo wc_price( $stats['abandoned_value'] ); ?></span>
</div>
<div class="maple-carts-stat-box stat-success">
<span class="stat-number"><?php echo esc_html( $stats['recovered'] ); ?></span>
<span class="stat-label"><?php esc_html_e( 'Recovered', 'maple-carts' ); ?></span>
<span class="stat-value"><?php echo wc_price( $stats['recovered_value'] ); ?></span>
</div>
<div class="maple-carts-stat-box">
<span class="stat-number"><?php echo esc_html( $stats['recovery_rate'] ); ?>%</span>
<span class="stat-label"><?php esc_html_e( 'Recovery Rate', 'maple-carts' ); ?></span>
</div>
<div class="maple-carts-stat-box stat-muted">
<span class="stat-number"><?php echo esc_html( $stats['lost'] ); ?></span>
<span class="stat-label"><?php esc_html_e( 'Lost Carts', 'maple-carts' ); ?></span>
</div>
</div>
<div class="maple-carts-quick-links">
<h2><?php esc_html_e( 'Quick Links', 'maple-carts' ); ?></h2>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=maple-carts-abandoned' ) ); ?>" class="button">
<?php esc_html_e( 'View Abandoned Carts', 'maple-carts' ); ?>
</a>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=maple-carts-emails' ) ); ?>" class="button">
<?php esc_html_e( 'Email Templates', 'maple-carts' ); ?>
</a>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=maple-carts-settings' ) ); ?>" class="button">
<?php esc_html_e( 'Settings', 'maple-carts' ); ?>
</a>
</div>
</div>
<?php
}
/**
* Render abandoned carts page.
*/
public function render_abandoned_carts() {
$this->render_carts_table( 'abandoned', __( 'Abandoned Carts', 'maple-carts' ) );
}
/**
* Render recovered carts page.
*/
public function render_recovered_carts() {
$this->render_carts_table( 'recovered', __( 'Recovered Carts', 'maple-carts' ) );
}
/**
* Render carts table.
*
* @param string $status Page status.
* @param string $title Page title.
*/
private function render_carts_table( $status, $title ) {
$page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1;
$limit = 20;
$offset = ( $page - 1 ) * $limit;
$carts = Maple_Carts_DB::get_carts_by_status( $status, $limit, $offset );
$total = Maple_Carts_DB::count_carts_by_status( $status );
$pages = ceil( $total / $limit );
?>
<div class="wrap maple-carts-wrap">
<h1><?php echo esc_html( $title ); ?></h1>
<?php if ( empty( $carts ) ) : ?>
<p><?php esc_html_e( 'No carts found.', 'maple-carts' ); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e( 'Email', 'maple-carts' ); ?></th>
<th><?php esc_html_e( 'Customer', 'maple-carts' ); ?></th>
<th><?php esc_html_e( 'Cart Total', 'maple-carts' ); ?></th>
<th><?php esc_html_e( 'Products', 'maple-carts' ); ?></th>
<th><?php esc_html_e( 'Date', 'maple-carts' ); ?></th>
<th><?php esc_html_e( 'Actions', 'maple-carts' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $carts as $cart ) :
$cart_contents = maybe_unserialize( $cart->cart_contents );
$product_count = is_array( $cart_contents ) ? count( $cart_contents ) : 0;
?>
<tr data-cart-id="<?php echo esc_attr( $cart->id ); ?>">
<td>
<strong><?php echo esc_html( $cart->email ); ?></strong>
<?php if ( $cart->unsubscribed ) : ?>
<span class="maple-carts-badge badge-muted"><?php esc_html_e( 'Unsubscribed', 'maple-carts' ); ?></span>
<?php endif; ?>
</td>
<td><?php echo esc_html( $cart->customer_name ?: '—' ); ?></td>
<td><?php echo wc_price( $cart->cart_total, [ 'currency' => $cart->currency ] ); ?></td>
<td>
<?php
printf(
/* translators: %d: number of products */
esc_html( _n( '%d product', '%d products', $product_count, 'maple-carts' ) ),
$product_count
);
?>
<button type="button" class="button-link maple-carts-view-products" data-cart="<?php echo esc_attr( wp_json_encode( $cart_contents ) ); ?>">
<?php esc_html_e( 'View', 'maple-carts' ); ?>
</button>
</td>
<td>
<?php
$date = 'recovered' === $status && $cart->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 ) ) );
?>
</td>
<td>
<button type="button" class="button-link maple-carts-delete-cart" data-id="<?php echo esc_attr( $cart->id ); ?>">
<?php esc_html_e( 'Delete', 'maple-carts' ); ?>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ( $pages > 1 ) : ?>
<div class="tablenav bottom">
<div class="tablenav-pages">
<?php
echo paginate_links( [
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
'total' => $pages,
'current' => $page,
] );
?>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<!-- Products modal -->
<div id="maple-carts-products-modal" class="maple-carts-modal" style="display:none;">
<div class="maple-carts-modal-content">
<span class="maple-carts-modal-close">&times;</span>
<h3><?php esc_html_e( 'Cart Contents', 'maple-carts' ); ?></h3>
<div id="maple-carts-products-list"></div>
</div>
</div>
<?php
}
/**
* Render email templates page.
*/
public function render_email_templates() {
$templates = Maple_Carts_DB::get_email_templates();
$editing = isset( $_GET['edit'] ) ? absint( $_GET['edit'] ) : 0;
$template = $editing ? Maple_Carts_DB::get_email_template( $editing ) : null;
?>
<div class="wrap maple-carts-wrap">
<h1>
<?php esc_html_e( 'Email Templates', 'maple-carts' ); ?>
<a href="<?php echo esc_url( add_query_arg( 'edit', 'new' ) ); ?>" class="page-title-action">
<?php esc_html_e( 'Add New', 'maple-carts' ); ?>
</a>
</h1>
<?php if ( $editing || isset( $_GET['edit'] ) ) : ?>
<!-- Template Editor -->
<div class="maple-carts-template-editor">
<form id="maple-carts-template-form">
<input type="hidden" name="id" value="<?php echo esc_attr( $template ? $template->id : '' ); ?>">
<table class="form-table">
<tr>
<th><label for="template-name"><?php esc_html_e( 'Template Name', 'maple-carts' ); ?></label></th>
<td>
<input type="text" id="template-name" name="name" class="regular-text"
value="<?php echo esc_attr( $template ? $template->name : '' ); ?>" required>
</td>
</tr>
<tr>
<th><label for="template-subject"><?php esc_html_e( 'Email Subject', 'maple-carts' ); ?></label></th>
<td>
<input type="text" id="template-subject" name="subject" class="large-text"
value="<?php echo esc_attr( $template ? $template->subject : '' ); ?>" required>
<p class="description"><?php esc_html_e( 'Available: {{customer_name}}, {{cart_total}}, {{coupon_code}}, {{coupon_amount}}', 'maple-carts' ); ?></p>
</td>
</tr>
<tr>
<th><label for="template-body"><?php esc_html_e( 'Email Body', 'maple-carts' ); ?></label></th>
<td>
<?php
wp_editor(
$template ? $template->body : '',
'template-body',
[
'textarea_name' => 'body',
'textarea_rows' => 15,
'media_buttons' => false,
]
);
?>
<p class="description">
<?php esc_html_e( 'Available placeholders:', 'maple-carts' ); ?><br>
<code>{{customer_name}}</code>, <code>{{customer_email}}</code>, <code>{{cart_contents}}</code>, <code>{{cart_total}}</code>, <code>{{product_names}}</code>, <code>{{recovery_url}}</code>, <code>{{coupon_code}}</code>, <code>{{coupon_amount}}</code>, <code>{{coupon_expires}}</code>, <code>{{site_name}}</code>, <code>{{unsubscribe_link}}</code>, <code>{{delete_data_link}}</code>
</p>
</td>
</tr>
<tr>
<th><label><?php esc_html_e( 'Send After', 'maple-carts' ); ?></label></th>
<td>
<input type="number" name="delay_value" min="1" class="small-text"
value="<?php echo esc_attr( $template ? $template->delay_value : 1 ); ?>">
<select name="delay_unit">
<option value="minutes" <?php selected( $template && $template->delay_unit, 'minutes' ); ?>><?php esc_html_e( 'Minutes', 'maple-carts' ); ?></option>
<option value="hours" <?php selected( ! $template || $template->delay_unit === 'hours', true ); ?>><?php esc_html_e( 'Hours', 'maple-carts' ); ?></option>
<option value="days" <?php selected( $template && $template->delay_unit, 'days' ); ?>><?php esc_html_e( 'Days', 'maple-carts' ); ?></option>
</select>
<p class="description"><?php esc_html_e( 'Time after cart is abandoned before sending this email.', 'maple-carts' ); ?></p>
</td>
</tr>
<tr>
<th><label><?php esc_html_e( 'Include Coupon', 'maple-carts' ); ?></label></th>
<td>
<label>
<input type="checkbox" name="include_coupon" value="1"
<?php checked( $template && $template->include_coupon ); ?>>
<?php esc_html_e( 'Generate a unique coupon code for this email', 'maple-carts' ); ?>
</label>
</td>
</tr>
<tr class="coupon-settings" <?php echo ( ! $template || ! $template->include_coupon ) ? 'style="display:none;"' : ''; ?>>
<th><label><?php esc_html_e( 'Coupon Type', 'maple-carts' ); ?></label></th>
<td>
<select name="coupon_type">
<option value="percent" <?php selected( ! $template || $template->coupon_type === 'percent', true ); ?>><?php esc_html_e( 'Percentage Discount', 'maple-carts' ); ?></option>
<option value="fixed_cart" <?php selected( $template && $template->coupon_type, 'fixed_cart' ); ?>><?php esc_html_e( 'Fixed Cart Discount', 'maple-carts' ); ?></option>
</select>
</td>
</tr>
<tr class="coupon-settings" <?php echo ( ! $template || ! $template->include_coupon ) ? 'style="display:none;"' : ''; ?>>
<th><label><?php esc_html_e( 'Coupon Amount', 'maple-carts' ); ?></label></th>
<td>
<input type="number" name="coupon_amount" min="0" step="0.01" class="small-text"
value="<?php echo esc_attr( $template ? $template->coupon_amount : 10 ); ?>">
</td>
</tr>
<tr class="coupon-settings" <?php echo ( ! $template || ! $template->include_coupon ) ? 'style="display:none;"' : ''; ?>>
<th><label><?php esc_html_e( 'Coupon Expires', 'maple-carts' ); ?></label></th>
<td>
<input type="number" name="coupon_expires_days" min="0" class="small-text"
value="<?php echo esc_attr( $template ? $template->coupon_expires_days : 7 ); ?>">
<?php esc_html_e( 'days after email is sent (0 = no expiry)', 'maple-carts' ); ?>
</td>
</tr>
<tr>
<th><label><?php esc_html_e( 'Status', 'maple-carts' ); ?></label></th>
<td>
<label>
<input type="checkbox" name="is_active" value="1"
<?php checked( $template && $template->is_active ); ?>>
<?php esc_html_e( 'Active', 'maple-carts' ); ?>
</label>
</td>
</tr>
</table>
<p class="submit">
<button type="submit" class="button button-primary"><?php esc_html_e( 'Save Template', 'maple-carts' ); ?></button>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=maple-carts-emails' ) ); ?>" class="button"><?php esc_html_e( 'Cancel', 'maple-carts' ); ?></a>
<button type="button" class="button maple-carts-preview-email"><?php esc_html_e( 'Preview', 'maple-carts' ); ?></button>
<button type="button" class="button maple-carts-test-email"><?php esc_html_e( 'Send Test Email', 'maple-carts' ); ?></button>
</p>
</form>
</div>
<?php else : ?>
<!-- Templates List -->
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e( 'Name', 'maple-carts' ); ?></th>
<th><?php esc_html_e( 'Subject', 'maple-carts' ); ?></th>
<th><?php esc_html_e( 'Send After', 'maple-carts' ); ?></th>
<th><?php esc_html_e( 'Coupon', 'maple-carts' ); ?></th>
<th><?php esc_html_e( 'Status', 'maple-carts' ); ?></th>
<th><?php esc_html_e( 'Actions', 'maple-carts' ); ?></th>
</tr>
</thead>
<tbody>
<?php if ( empty( $templates ) ) : ?>
<tr>
<td colspan="6"><?php esc_html_e( 'No email templates found.', 'maple-carts' ); ?></td>
</tr>
<?php else : ?>
<?php foreach ( $templates as $tmpl ) : ?>
<tr>
<td><strong><?php echo esc_html( $tmpl->name ); ?></strong></td>
<td><?php echo esc_html( $tmpl->subject ); ?></td>
<td><?php echo esc_html( $tmpl->delay_value . ' ' . $tmpl->delay_unit ); ?></td>
<td>
<?php if ( $tmpl->include_coupon ) : ?>
<?php
echo $tmpl->coupon_type === 'percent'
? esc_html( $tmpl->coupon_amount . '%' )
: wc_price( $tmpl->coupon_amount );
?>
<?php else : ?>
<?php endif; ?>
</td>
<td>
<button type="button" class="maple-carts-toggle-template button-link"
data-id="<?php echo esc_attr( $tmpl->id ); ?>"
data-active="<?php echo esc_attr( $tmpl->is_active ); ?>">
<?php if ( $tmpl->is_active ) : ?>
<span class="maple-carts-badge badge-success"><?php esc_html_e( 'Active', 'maple-carts' ); ?></span>
<?php else : ?>
<span class="maple-carts-badge badge-muted"><?php esc_html_e( 'Inactive', 'maple-carts' ); ?></span>
<?php endif; ?>
</button>
</td>
<td>
<a href="<?php echo esc_url( add_query_arg( 'edit', $tmpl->id ) ); ?>" class="button-link">
<?php esc_html_e( 'Edit', 'maple-carts' ); ?>
</a>
|
<button type="button" class="button-link maple-carts-delete-template" data-id="<?php echo esc_attr( $tmpl->id ); ?>">
<?php esc_html_e( 'Delete', 'maple-carts' ); ?>
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Preview modal -->
<div id="maple-carts-preview-modal" class="maple-carts-modal" style="display:none;">
<div class="maple-carts-modal-content maple-carts-modal-large">
<span class="maple-carts-modal-close">&times;</span>
<h3><?php esc_html_e( 'Email Preview', 'maple-carts' ); ?></h3>
<div class="maple-carts-preview-subject"></div>
<iframe id="maple-carts-preview-frame" style="width:100%;height:500px;border:1px solid #ddd;"></iframe>
</div>
</div>
<!-- Test email modal -->
<div id="maple-carts-test-modal" class="maple-carts-modal" style="display:none;">
<div class="maple-carts-modal-content">
<span class="maple-carts-modal-close">&times;</span>
<h3><?php esc_html_e( 'Send Test Email', 'maple-carts' ); ?></h3>
<p>
<label for="test-email-address"><?php esc_html_e( 'Send to:', 'maple-carts' ); ?></label>
<input type="email" id="test-email-address" class="regular-text"
value="<?php echo esc_attr( get_option( 'admin_email' ) ); ?>">
</p>
<p>
<button type="button" class="button button-primary maple-carts-send-test">
<?php esc_html_e( 'Send Test', 'maple-carts' ); ?>
</button>
</p>
<div class="maple-carts-test-result"></div>
</div>
</div>
<?php
}
/**
* Render settings page.
*/
public function render_settings() {
settings_errors( 'maple_carts' );
?>
<div class="wrap maple-carts-wrap">
<h1><?php esc_html_e( 'Maple Carts Settings', 'maple-carts' ); ?></h1>
<form method="post" action="">
<?php wp_nonce_field( 'maple_carts_settings' ); ?>
<table class="form-table">
<tr>
<th><?php esc_html_e( 'Enable Cart Tracking', 'maple-carts' ); ?></th>
<td>
<label>
<input type="checkbox" name="enabled" value="yes"
<?php checked( Maple_Carts::get_option( 'enabled', 'yes' ), 'yes' ); ?>>
<?php esc_html_e( 'Track abandoned carts and send recovery emails', 'maple-carts' ); ?>
</label>
</td>
</tr>
<tr>
<th><label for="cart_cutoff_time"><?php esc_html_e( 'Cart Cutoff Time', 'maple-carts' ); ?></label></th>
<td>
<input type="number" id="cart_cutoff_time" name="cart_cutoff_time" min="5" class="small-text"
value="<?php echo esc_attr( Maple_Carts::get_option( 'cart_cutoff_time', 15 ) ); ?>">
<?php esc_html_e( 'minutes', 'maple-carts' ); ?>
<p class="description"><?php esc_html_e( 'Time after last activity before a cart is considered abandoned.', 'maple-carts' ); ?></p>
</td>
</tr>
<tr>
<th><label for="delete_after_days"><?php esc_html_e( 'Data Retention', 'maple-carts' ); ?></label></th>
<td>
<input type="number" id="delete_after_days" name="delete_after_days" min="7" class="small-text"
value="<?php echo esc_attr( Maple_Carts::get_option( 'delete_after_days', 90 ) ); ?>">
<?php esc_html_e( 'days', 'maple-carts' ); ?>
<p class="description"><?php esc_html_e( 'Automatically delete cart data older than this.', 'maple-carts' ); ?></p>
</td>
</tr>
</table>
<h2><?php esc_html_e( 'Email Settings', 'maple-carts' ); ?></h2>
<table class="form-table">
<tr>
<th><?php esc_html_e( 'Email Mode', 'maple-carts' ); ?></th>
<td>
<fieldset>
<label>
<input type="radio" name="email_mode" value="builtin"
<?php checked( Maple_Carts::get_option( 'email_mode', 'builtin' ), 'builtin' ); ?>>
<?php esc_html_e( 'Built-in Emails', 'maple-carts' ); ?>
<span class="description"><?php esc_html_e( '— Send timed emails directly via WordPress/SMTP', 'maple-carts' ); ?></span>
</label>
<br><br>
<label>
<input type="radio" name="email_mode" value="mailjet"
<?php checked( Maple_Carts::get_option( 'email_mode', 'builtin' ), 'mailjet' ); ?>>
<?php esc_html_e( 'Mailjet Sync', 'maple-carts' ); ?>
<span class="description"><?php esc_html_e( '— Sync cart data to Mailjet for use in automations', 'maple-carts' ); ?></span>
</label>
</fieldset>
</td>
</tr>
</table>
<!-- Mailjet Settings (shown when mailjet mode selected) -->
<div id="mailjet-settings" style="<?php echo 'mailjet' !== Maple_Carts::get_option( 'email_mode', 'builtin' ) ? 'display:none;' : ''; ?>">
<h3><?php esc_html_e( 'Mailjet Configuration', 'maple-carts' ); ?></h3>
<p class="description">
<?php
printf(
/* translators: %s: link to Mailjet */
esc_html__( 'Get your API credentials from %s', 'maple-carts' ),
'<a href="https://app.mailjet.com/account/apikeys" target="_blank">Mailjet API Keys</a>'
);
?>
</p>
<table class="form-table">
<tr>
<th><label for="mailjet_api_key"><?php esc_html_e( 'API Key', 'maple-carts' ); ?></label></th>
<td>
<input type="text" id="mailjet_api_key" name="mailjet_api_key" class="regular-text"
value="<?php echo esc_attr( Maple_Carts::get_option( 'mailjet_api_key', '' ) ); ?>"
autocomplete="off">
</td>
</tr>
<tr>
<th><label for="mailjet_secret_key"><?php esc_html_e( 'Secret Key', 'maple-carts' ); ?></label></th>
<td>
<input type="password" id="mailjet_secret_key" name="mailjet_secret_key" class="regular-text"
value="<?php echo esc_attr( Maple_Carts::get_option( 'mailjet_secret_key', '' ) ); ?>"
autocomplete="off">
</td>
</tr>
<tr>
<th><?php esc_html_e( 'Connection', 'maple-carts' ); ?></th>
<td>
<button type="button" class="button" id="test-mailjet-connection">
<?php esc_html_e( 'Test Connection', 'maple-carts' ); ?>
</button>
<button type="button" class="button" id="setup-mailjet-properties">
<?php esc_html_e( 'Setup Contact Properties', 'maple-carts' ); ?>
</button>
<span id="mailjet-status" style="margin-left:10px;"></span>
</td>
</tr>
<tr>
<th><?php esc_html_e( 'Contact Properties', 'maple-carts' ); ?></th>
<td>
<p class="description"><?php esc_html_e( 'These properties will be synced to Mailjet for each abandoned cart:', 'maple-carts' ); ?></p>
<ul style="list-style:disc;margin-left:20px;margin-top:10px;">
<?php foreach ( Maple_Carts_Mailjet::instance()->get_available_properties() as $prop => $desc ) : ?>
<li><code><?php echo esc_html( $prop ); ?></code> — <?php echo esc_html( $desc ); ?></li>
<?php endforeach; ?>
</ul>
<p class="description" style="margin-top:10px;">
<?php esc_html_e( 'Use these in Mailjet automation conditions, e.g.:', 'maple-carts' ); ?>
<code>maple_has_abandoned_cart equals true</code>
</p>
</td>
</tr>
</table>
</div>
<!-- Built-in Email Settings (shown when builtin mode selected) -->
<div id="builtin-email-settings" style="<?php echo 'mailjet' === Maple_Carts::get_option( 'email_mode', 'builtin' ) ? 'display:none;' : ''; ?>">
<h3><?php esc_html_e( 'Email Configuration', 'maple-carts' ); ?></h3>
<table class="form-table">
<tr>
<th><label for="from_name"><?php esc_html_e( 'From Name', 'maple-carts' ); ?></label></th>
<td>
<input type="text" id="from_name" name="from_name" class="regular-text"
value="<?php echo esc_attr( Maple_Carts::get_option( 'from_name', get_bloginfo( 'name' ) ) ); ?>">
</td>
</tr>
<tr>
<th><label for="from_email"><?php esc_html_e( 'From Email', 'maple-carts' ); ?></label></th>
<td>
<input type="email" id="from_email" name="from_email" class="regular-text"
value="<?php echo esc_attr( Maple_Carts::get_option( 'from_email', get_option( 'admin_email' ) ) ); ?>">
</td>
</tr>
<tr>
<th><label for="reply_to"><?php esc_html_e( 'Reply-To Email', 'maple-carts' ); ?></label></th>
<td>
<input type="email" id="reply_to" name="reply_to" class="regular-text"
value="<?php echo esc_attr( Maple_Carts::get_option( 'reply_to', get_option( 'admin_email' ) ) ); ?>">
</td>
</tr>
</table>
<h2><?php esc_html_e( 'Notifications', 'maple-carts' ); ?></h2>
<table class="form-table">
<tr>
<th><?php esc_html_e( 'Recovery Notifications', 'maple-carts' ); ?></th>
<td>
<label>
<input type="checkbox" name="notify_admin" value="yes"
<?php checked( Maple_Carts::get_option( 'notify_admin', 'yes' ), 'yes' ); ?>>
<?php esc_html_e( 'Email admin when an abandoned cart is recovered', 'maple-carts' ); ?>
</label>
</td>
</tr>
<tr>
<th><label for="admin_email"><?php esc_html_e( 'Admin Email', 'maple-carts' ); ?></label></th>
<td>
<input type="email" id="admin_email" name="admin_email" class="regular-text"
value="<?php echo esc_attr( Maple_Carts::get_option( 'admin_email', get_option( 'admin_email' ) ) ); ?>">
</td>
</tr>
</table>
</div><!-- /builtin-email-settings -->
<h2><?php esc_html_e( 'Privacy', 'maple-carts' ); ?></h2>
<p class="description">
<?php
printf(
/* translators: %s: link to privacy policy page */
esc_html__( 'Maple Carts integrates with WordPress Privacy Tools. Suggested privacy policy text has been added to your %s.', 'maple-carts' ),
'<a href="' . esc_url( admin_url( 'options-privacy.php' ) ) . '">' . esc_html__( 'Privacy Settings', 'maple-carts' ) . '</a>'
);
?>
</p>
<table class="form-table">
<tr>
<th><?php esc_html_e( 'Require Consent', 'maple-carts' ); ?></th>
<td>
<label>
<input type="checkbox" name="require_consent" value="yes"
<?php checked( Maple_Carts::get_option( 'require_consent', 'no' ), 'yes' ); ?>>
<?php esc_html_e( 'Show consent checkbox on checkout (recommended for GDPR)', 'maple-carts' ); ?>
</label>
<p class="description"><?php esc_html_e( 'When enabled, cart tracking only occurs if the customer explicitly consents.', 'maple-carts' ); ?></p>
</td>
</tr>
<tr>
<th><label for="consent_text"><?php esc_html_e( 'Consent Checkbox Text', 'maple-carts' ); ?></label></th>
<td>
<input type="text" id="consent_text" name="consent_text" class="large-text"
value="<?php echo esc_attr( Maple_Carts::get_option( 'consent_text', __( 'I agree to receive cart reminder emails if I don\'t complete my purchase.', 'maple-carts' ) ) ); ?>">
</td>
</tr>
<tr>
<th><?php esc_html_e( 'Delete Data Link', 'maple-carts' ); ?></th>
<td>
<label>
<input type="checkbox" name="show_delete_link" value="yes"
<?php checked( Maple_Carts::get_option( 'show_delete_link', 'no' ), 'yes' ); ?>>
<?php esc_html_e( 'Include "Delete my data" link in emails (required for GDPR/EU)', 'maple-carts' ); ?>
</label>
<p class="description"><?php esc_html_e( 'When enabled, adds a one-click data deletion link to all recovery emails. Enable this if you have customers in the EU.', 'maple-carts' ); ?></p>
</td>
</tr>
<tr>
<th><label for="gdpr_notice"><?php esc_html_e( 'GDPR Notice', 'maple-carts' ); ?></label></th>
<td>
<textarea id="gdpr_notice" name="gdpr_notice" class="large-text" rows="3"><?php echo esc_textarea( Maple_Carts::get_option( 'gdpr_notice', '' ) ); ?></textarea>
<p class="description"><?php esc_html_e( 'Optional notice displayed on checkout about cart data collection. Leave empty to disable.', 'maple-carts' ); ?></p>
</td>
</tr>
<tr>
<th><label><?php esc_html_e( 'Exclude User Roles', 'maple-carts' ); ?></label></th>
<td>
<?php
$exclude_roles = Maple_Carts::get_option( 'exclude_roles', [] );
foreach ( wp_roles()->roles as $role_key => $role ) :
?>
<label style="display:block;margin-bottom:5px;">
<input type="checkbox" name="exclude_roles[]" value="<?php echo esc_attr( $role_key ); ?>"
<?php checked( in_array( $role_key, $exclude_roles, true ) ); ?>>
<?php echo esc_html( $role['name'] ); ?>
</label>
<?php endforeach; ?>
<p class="description"><?php esc_html_e( 'Do not track carts for users with these roles.', 'maple-carts' ); ?></p>
</td>
</tr>
</table>
<p class="submit">
<input type="submit" name="maple_carts_save_settings" class="button button-primary"
value="<?php esc_attr_e( 'Save Settings', 'maple-carts' ); ?>">
</p>
</form>
</div>
<?php
}
/**
* AJAX: Delete cart.
*/
public function ajax_delete_cart() {
check_ajax_referer( 'maple_carts_admin', 'nonce' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( 'Permission denied' );
}
$cart_id = isset( $_POST['cart_id'] ) ? absint( $_POST['cart_id'] ) : 0;
if ( ! $cart_id ) {
wp_send_json_error( 'Invalid cart ID' );
}
if ( Maple_Carts_DB::delete_cart( $cart_id ) ) {
wp_send_json_success();
} else {
wp_send_json_error( 'Failed to delete cart' );
}
}
/**
* AJAX: Save template.
*/
public function ajax_save_template() {
check_ajax_referer( 'maple_carts_admin', 'nonce' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( 'Permission denied' );
}
// Validate enum values.
$delay_unit = sanitize_text_field( wp_unslash( $_POST['delay_unit'] ?? 'hours' ) );
if ( ! in_array( $delay_unit, [ 'minutes', 'hours', 'days' ], true ) ) {
$delay_unit = 'hours';
}
$coupon_type = sanitize_text_field( wp_unslash( $_POST['coupon_type'] ?? 'percent' ) );
if ( ! in_array( $coupon_type, [ 'percent', 'fixed_cart' ], true ) ) {
$coupon_type = 'percent';
}
$data = [
'name' => sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) ),
'subject' => sanitize_text_field( wp_unslash( $_POST['subject'] ?? '' ) ),
'body' => wp_kses_post( wp_unslash( $_POST['body'] ?? '' ) ),
'delay_value' => absint( $_POST['delay_value'] ?? 1 ),
'delay_unit' => $delay_unit,
'is_active' => isset( $_POST['is_active'] ) ? 1 : 0,
'include_coupon' => isset( $_POST['include_coupon'] ) ? 1 : 0,
'coupon_type' => $coupon_type,
'coupon_amount' => floatval( $_POST['coupon_amount'] ?? 10 ),
'coupon_expires_days'=> absint( $_POST['coupon_expires_days'] ?? 7 ),
];
if ( ! empty( $_POST['id'] ) ) {
$data['id'] = absint( $_POST['id'] );
}
$id = Maple_Carts_DB::save_email_template( $data );
if ( $id ) {
wp_send_json_success( [ 'id' => $id ] );
} else {
wp_send_json_error( 'Failed to save template' );
}
}
/**
* AJAX: Delete template.
*/
public function ajax_delete_template() {
check_ajax_referer( 'maple_carts_admin', 'nonce' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( 'Permission denied' );
}
$id = isset( $_POST['template_id'] ) ? absint( $_POST['template_id'] ) : 0;
if ( ! $id ) {
wp_send_json_error( 'Invalid template ID' );
}
if ( Maple_Carts_DB::delete_email_template( $id ) ) {
wp_send_json_success();
} else {
wp_send_json_error( 'Failed to delete template' );
}
}
/**
* AJAX: Toggle template active status.
*/
public function ajax_toggle_template() {
check_ajax_referer( 'maple_carts_admin', 'nonce' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( 'Permission denied' );
}
$id = isset( $_POST['template_id'] ) ? absint( $_POST['template_id'] ) : 0;
$active = isset( $_POST['active'] ) ? absint( $_POST['active'] ) : 0;
if ( ! $id ) {
wp_send_json_error( 'Invalid template ID' );
}
global $wpdb;
$result = $wpdb->update(
$wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE,
[ 'is_active' => $active ? 0 : 1 ],
[ 'id' => $id ]
);
if ( false !== $result ) {
wp_send_json_success( [ 'is_active' => $active ? 0 : 1 ] );
} else {
wp_send_json_error( 'Failed to update template' );
}
}
}

View file

@ -0,0 +1,795 @@
<?php
/**
* Database handler for Maple Carts.
*
* @package MapleCarts
*/
defined( 'ABSPATH' ) || exit;
/**
* Database class.
*/
class Maple_Carts_DB {
/**
* Create database tables on activation.
*/
public static function activate() {
self::create_tables();
self::seed_default_emails();
self::set_default_options();
// Schedule cron events.
if ( ! wp_next_scheduled( 'maple_carts_send_emails' ) ) {
wp_schedule_event( time(), 'every_five_minutes', 'maple_carts_send_emails' );
}
if ( ! wp_next_scheduled( 'maple_carts_cleanup' ) ) {
wp_schedule_event( time(), 'daily', 'maple_carts_cleanup' );
}
// Add custom cron interval.
add_filter( 'cron_schedules', [ __CLASS__, 'add_cron_interval' ] );
update_option( 'maple_carts_version', MAPLE_CARTS_VERSION );
}
/**
* Add custom cron interval.
*
* @param array $schedules Existing schedules.
* @return array
*/
public static function add_cron_interval( $schedules ) {
$schedules['every_five_minutes'] = [
'interval' => 300,
'display' => __( 'Every Five Minutes', 'maple-carts' ),
];
return $schedules;
}
/**
* Create database tables.
*/
public static function create_tables() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$carts_table = $wpdb->prefix . MAPLE_CARTS_TABLE;
$emails_table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE;
$log_table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
// Abandoned carts table.
$sql = "CREATE TABLE {$carts_table} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
session_id VARCHAR(60) NOT NULL,
email VARCHAR(200) DEFAULT NULL,
cart_contents LONGTEXT,
cart_total DECIMAL(10,2) DEFAULT 0,
items_count INT DEFAULT 0,
currency VARCHAR(10) DEFAULT 'USD',
customer_name VARCHAR(200) DEFAULT NULL,
customer_phone VARCHAR(50) DEFAULT NULL,
billing_data LONGTEXT,
status ENUM('active','abandoned','recovered','lost','converted') DEFAULT 'active',
abandonment_type ENUM('browsing','checkout','payment') DEFAULT NULL,
recovery_url VARCHAR(255) DEFAULT NULL,
coupon_code VARCHAR(50) DEFAULT NULL,
recovery_source VARCHAR(50) DEFAULT NULL,
unsubscribed TINYINT(1) DEFAULT 0,
ip_address VARCHAR(45) DEFAULT NULL,
user_agent TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
first_seen_at DATETIME DEFAULT NULL,
last_tracked_at DATETIME DEFAULT NULL,
abandoned_at DATETIME DEFAULT NULL,
recovered_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY session_id (session_id),
KEY email (email),
KEY status (status),
KEY created_at (created_at),
KEY updated_at (updated_at),
KEY last_tracked_at (last_tracked_at),
KEY status_tracked (status, last_tracked_at)
) {$charset_collate};";
dbDelta( $sql );
// Email templates table.
$sql = "CREATE TABLE {$emails_table} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(200) NOT NULL,
subject VARCHAR(500) NOT NULL,
body LONGTEXT NOT NULL,
delay_value INT(11) DEFAULT 1,
delay_unit ENUM('minutes','hours','days') DEFAULT 'hours',
is_active TINYINT(1) DEFAULT 0,
include_coupon TINYINT(1) DEFAULT 0,
coupon_type ENUM('percent','fixed_cart') DEFAULT 'percent',
coupon_amount DECIMAL(10,2) DEFAULT 10,
coupon_expires_days INT(11) DEFAULT 7,
sort_order INT(11) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY is_active (is_active),
KEY sort_order (sort_order)
) {$charset_collate};";
dbDelta( $sql );
// Email log table.
$sql = "CREATE TABLE {$log_table} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
cart_id BIGINT(20) UNSIGNED NOT NULL,
email_template_id BIGINT(20) UNSIGNED NOT NULL,
scheduled_at DATETIME NOT NULL,
sent_at DATETIME DEFAULT NULL,
status ENUM('pending','sent','failed','cancelled') DEFAULT 'pending',
coupon_code VARCHAR(50) DEFAULT NULL,
error_message TEXT,
PRIMARY KEY (id),
KEY cart_id (cart_id),
KEY email_template_id (email_template_id),
KEY status (status),
KEY scheduled_at (scheduled_at)
) {$charset_collate};";
dbDelta( $sql );
}
/**
* Set default options.
*/
public static function set_default_options() {
$defaults = [
'enabled' => 'yes',
'cart_cutoff_time' => 15, // Minutes before cart is considered abandoned.
'delete_after_days' => 90, // Days to keep abandoned cart data.
'email_mode' => 'builtin', // 'builtin' or 'mailjet'.
'mailjet_api_key' => '',
'mailjet_secret_key'=> '',
'from_name' => get_bloginfo( 'name' ),
'from_email' => get_option( 'admin_email' ),
'reply_to' => get_option( 'admin_email' ),
'notify_admin' => 'yes',
'admin_email' => get_option( 'admin_email' ),
'require_consent' => 'no',
'consent_text' => __( 'I agree to receive cart reminder emails if I don\'t complete my purchase.', 'maple-carts' ),
'show_delete_link' => 'no',
'exclude_roles' => [],
'gdpr_notice' => '',
];
$existing = get_option( 'maple_carts_settings', [] );
$merged = array_merge( $defaults, $existing );
update_option( 'maple_carts_settings', $merged );
}
/**
* Seed default email templates.
*/
public static function seed_default_emails() {
global $wpdb;
$table = $wpdb->prefix . MAPLE_CARTS_EMAILS_TABLE;
$count = $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
if ( $count > 0 ) {
return;
}
$templates = [
[
'name' => __( 'First Reminder', 'maple-carts' ),
'subject' => __( '{{customer_name}}, you left something behind!', 'maple-carts' ),
'body' => self::get_default_email_body( 1 ),
'delay_value' => 1,
'delay_unit' => 'hours',
'is_active' => 0,
'include_coupon' => 0,
'sort_order' => 1,
],
[
'name' => __( 'Second Reminder', 'maple-carts' ),
'subject' => __( 'Your cart is waiting, {{customer_name}}', 'maple-carts' ),
'body' => self::get_default_email_body( 2 ),
'delay_value' => 24,
'delay_unit' => 'hours',
'is_active' => 0,
'include_coupon' => 0,
'sort_order' => 2,
],
[
'name' => __( 'Final Reminder with Discount', 'maple-carts' ),
'subject' => __( 'Last chance! {{coupon_amount}} off your cart', 'maple-carts' ),
'body' => self::get_default_email_body( 3 ),
'delay_value' => 3,
'delay_unit' => 'days',
'is_active' => 0,
'include_coupon' => 1,
'coupon_type' => 'percent',
'coupon_amount' => 10,
'sort_order' => 3,
],
];
foreach ( $templates as $template ) {
$wpdb->insert( $table, $template );
}
}
/**
* Get default email body.
*
* @param int $template_num Template number.
* @return string
*/
private static function get_default_email_body( $template_num ) {
$bodies = [
1 => '<p>Hi {{customer_name}},</p>
<p>We noticed you left some items in your cart at {{site_name}}. No worries your cart is saved and ready when you are!</p>
<p>{{cart_contents}}</p>
<p><a href="{{recovery_url}}" style="background-color:#0073aa;color:#ffffff;padding:12px 24px;text-decoration:none;border-radius:4px;display:inline-block;">Complete Your Order</a></p>
<p>If you have any questions, just reply to this email.</p>
<p>Thanks,<br>{{site_name}}</p>
<p style="font-size:12px;color:#666;">{{unsubscribe_link}}{{delete_data_link}}</p>',
2 => '<p>Hi {{customer_name}},</p>
<p>Just a friendly reminder that your cart at {{site_name}} is still waiting for you.</p>
<p>{{cart_contents}}</p>
<p><strong>Cart Total: {{cart_total}}</strong></p>
<p><a href="{{recovery_url}}" style="background-color:#0073aa;color:#ffffff;padding:12px 24px;text-decoration:none;border-radius:4px;display:inline-block;">Return to Your Cart</a></p>
<p>Best,<br>{{site_name}}</p>
<p style="font-size:12px;color:#666;">{{unsubscribe_link}}{{delete_data_link}}</p>',
3 => '<p>Hi {{customer_name}},</p>
<p>This is your last reminder about the items in your cart. To help you complete your purchase, we\'re offering you an exclusive discount!</p>
<p>{{cart_contents}}</p>
<p><strong>Use code <span style="background:#f0f0f0;padding:4px 8px;font-family:monospace;">{{coupon_code}}</span> for {{coupon_amount}} off!</strong></p>
<p><a href="{{recovery_url}}" style="background-color:#0073aa;color:#ffffff;padding:12px 24px;text-decoration:none;border-radius:4px;display:inline-block;">Claim Your Discount</a></p>
<p>This offer expires in {{coupon_expires}} days.</p>
<p>Thanks,<br>{{site_name}}</p>
<p style="font-size:12px;color:#666;">{{unsubscribe_link}}{{delete_data_link}}</p>',
];
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' ] );

View file

@ -0,0 +1,633 @@
<?php
/**
* Email handling for Maple Carts.
*
* @package MapleCarts
*/
defined( 'ABSPATH' ) || exit;
/**
* Emails class.
*/
class Maple_Carts_Emails {
/**
* Single instance.
*
* @var Maple_Carts_Emails
*/
private static $instance = null;
/**
* Get instance.
*
* @return Maple_Carts_Emails
*/
public static function instance() {
if ( is_null( self::$instance ) ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
private function __construct() {
// Cron hook for sending emails.
add_action( 'maple_carts_send_emails', [ $this, 'process_pending_emails' ] );
// AJAX for preview email.
add_action( 'wp_ajax_maple_carts_preview_email', [ $this, 'ajax_preview_email' ] );
add_action( 'wp_ajax_maple_carts_send_test_email', [ $this, 'ajax_send_test_email' ] );
}
/**
* Process pending emails.
* Includes lock to prevent overlapping cron runs.
*/
public function process_pending_emails() {
// Skip if Mailjet mode is enabled - emails are handled by Mailjet automations.
if ( 'mailjet' === Maple_Carts::get_option( 'email_mode', 'builtin' ) ) {
return;
}
// Prevent overlapping cron runs.
if ( get_transient( 'maple_carts_sending_emails' ) ) {
return;
}
set_transient( 'maple_carts_sending_emails', 1, 300 ); // 5 minute lock.
$pending = Maple_Carts_DB::get_pending_emails();
foreach ( $pending as $email_data ) {
$this->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(
'<a href="%s" style="color:#999;">%s</a>',
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(
' | <a href="%s" style="color:#999;">%s</a>',
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 = '<table style="width:100%;border-collapse:collapse;margin:20px 0;">';
$html .= '<thead><tr style="background:#f7f7f7;">';
$html .= '<th style="padding:12px;text-align:left;border-bottom:2px solid #ddd;">' . __( 'Product', 'maple-carts' ) . '</th>';
$html .= '<th style="padding:12px;text-align:center;border-bottom:2px solid #ddd;">' . __( 'Qty', 'maple-carts' ) . '</th>';
$html .= '<th style="padding:12px;text-align:right;border-bottom:2px solid #ddd;">' . __( 'Price', 'maple-carts' ) . '</th>';
$html .= '</tr></thead><tbody>';
$total = 0;
foreach ( $cart_contents as $item ) {
$line_total = $item['price'] * $item['quantity'];
$total += $line_total;
$html .= '<tr>';
$html .= '<td style="padding:12px;border-bottom:1px solid #eee;">';
if ( ! empty( $item['image'] ) ) {
$html .= '<img src="' . esc_url( $item['image'] ) . '" alt="" style="width:50px;height:50px;object-fit:cover;margin-right:10px;vertical-align:middle;">';
}
$html .= '<span style="vertical-align:middle;">' . esc_html( $item['name'] ) . '</span>';
$html .= '</td>';
$html .= '<td style="padding:12px;text-align:center;border-bottom:1px solid #eee;">' . esc_html( $item['quantity'] ) . '</td>';
$html .= '<td style="padding:12px;text-align:right;border-bottom:1px solid #eee;">' . wc_price( $line_total, [ 'currency' => $currency ] ) . '</td>';
$html .= '</tr>';
}
$html .= '</tbody></table>';
return $html;
}
/**
* Check if cart has out of stock products.
*
* @param array $cart_contents Cart contents.
* @return bool
*/
private function cart_has_out_of_stock( $cart_contents ) {
if ( empty( $cart_contents ) || ! is_array( $cart_contents ) ) {
return false;
}
foreach ( $cart_contents as $item ) {
$product_id = ! empty( $item['variation_id'] ) ? $item['variation_id'] : $item['product_id'];
$product = wc_get_product( $product_id );
if ( ! $product || ! $product->is_in_stock() ) {
return true;
}
}
return false;
}
/**
* Generate a coupon code.
*
* @param string $type Coupon type (percent, fixed_cart).
* @param float $amount Discount amount.
* @param int $expires_days Days until expiry.
* @param int $cart_id Cart ID for reference.
* @return string
*/
public function generate_coupon( $type, $amount, $expires_days, $cart_id ) {
// Check if cart already has a coupon (prevents double creation on cron misfire).
$existing_coupon = $this->get_cart_coupon( $cart_id );
if ( $existing_coupon ) {
return $existing_coupon;
}
$coupon_code = 'MAPLE-' . strtoupper( wp_generate_password( 8, false, false ) );
$coupon = new WC_Coupon();
$coupon->set_code( $coupon_code );
$coupon->set_discount_type( $type );
$coupon->set_amount( $amount );
$coupon->set_individual_use( true );
$coupon->set_usage_limit( 1 );
$coupon->set_usage_limit_per_user( 1 );
if ( $expires_days > 0 ) {
$expiry = strtotime( "+{$expires_days} days" );
$coupon->set_date_expires( $expiry );
}
$coupon->save();
// Mark as Maple Carts coupon for cleanup with creation timestamp.
update_post_meta( $coupon->get_id(), '_maple_carts_coupon', '1' );
update_post_meta( $coupon->get_id(), '_maple_carts_cart_id', $cart_id );
update_post_meta( $coupon->get_id(), '_maple_carts_created_at', time() );
return $coupon_code;
}
/**
* Get existing coupon for a cart (prevents double creation).
*
* @param int $cart_id Cart ID.
* @return string|null Coupon code or null.
*/
private function get_cart_coupon( $cart_id ) {
global $wpdb;
// Check email log for existing coupon.
$log_table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE;
$coupon = $wpdb->get_var( $wpdb->prepare(
"SELECT coupon_code FROM {$log_table}
WHERE cart_id = %d AND coupon_code IS NOT NULL AND coupon_code != ''
LIMIT 1",
$cart_id
) );
if ( $coupon ) {
// Verify coupon still exists in WooCommerce.
$coupon_id = wc_get_coupon_id_by_code( $coupon );
if ( $coupon_id ) {
return $coupon;
}
}
return null;
}
/**
* Dispatch email using wp_mail (works with any SMTP plugin).
*
* @param string $to Recipient email.
* @param string $subject Email subject.
* @param string $body Email body (HTML).
* @return bool
*/
private function dispatch_email( $to, $subject, $body ) {
// Sanitize header values to prevent header injection.
$from_name = $this->sanitize_header_value( Maple_Carts::get_option( 'from_name', get_bloginfo( 'name' ) ) );
$from_email = $this->sanitize_header_value( Maple_Carts::get_option( 'from_email', get_option( 'admin_email' ) ) );
$reply_to = $this->sanitize_header_value( Maple_Carts::get_option( 'reply_to', get_option( 'admin_email' ) ) );
// Validate email addresses.
if ( ! is_email( $from_email ) ) {
$from_email = get_option( 'admin_email' );
}
if ( ! is_email( $reply_to ) ) {
$reply_to = get_option( 'admin_email' );
}
// Wrap in basic HTML template.
$html_body = $this->wrap_email_html( $body );
// Generate plain text alternative for better spam scores.
$plain_body = $this->generate_plain_text( $body );
// Create multipart boundary.
$boundary = 'maple_carts_' . md5( uniqid( time() ) );
$headers = [
"From: {$from_name} <{$from_email}>",
"Reply-To: {$reply_to}",
"Content-Type: multipart/alternative; boundary=\"{$boundary}\"",
];
// Build multipart message.
$message = "--{$boundary}\r\n";
$message .= "Content-Type: text/plain; charset=UTF-8\r\n";
$message .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
$message .= $plain_body . "\r\n\r\n";
$message .= "--{$boundary}\r\n";
$message .= "Content-Type: text/html; charset=UTF-8\r\n";
$message .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
$message .= $html_body . "\r\n\r\n";
$message .= "--{$boundary}--";
/**
* Filter the email before sending.
*
* @param array $email Email data.
*/
$email = apply_filters( 'maple_carts_before_send_email', [
'to' => $to,
'subject' => $subject,
'body' => $message,
'headers' => $headers,
] );
// Use wp_mail which integrates with SMTP plugins.
$sent = wp_mail( $email['to'], $email['subject'], $email['body'], $email['headers'] );
// Retry once on failure.
if ( ! $sent ) {
sleep( 1 );
$sent = wp_mail( $email['to'], $email['subject'], $email['body'], $email['headers'] );
}
return $sent;
}
/**
* Generate plain text version of HTML email.
*
* @param string $html HTML content.
* @return string Plain text content.
*/
private function generate_plain_text( $html ) {
// Convert common HTML elements.
$text = $html;
// Convert links to text format.
$text = preg_replace( '/<a[^>]+href=["\']([^"\']+)["\'][^>]*>([^<]+)<\/a>/i', '$2 ($1)', $text );
// Convert line breaks and paragraphs.
$text = preg_replace( '/<br\s*\/?>/i', "\n", $text );
$text = preg_replace( '/<\/p>/i', "\n\n", $text );
$text = preg_replace( '/<\/div>/i', "\n", $text );
$text = preg_replace( '/<\/h[1-6]>/i', "\n\n", $text );
// Convert list items.
$text = preg_replace( '/<li[^>]*>/i', "", $text );
$text = preg_replace( '/<\/li>/i', "\n", $text );
// Strip remaining tags.
$text = wp_strip_all_tags( $text );
// Decode HTML entities.
$text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' );
// Clean up whitespace.
$text = preg_replace( '/[ \t]+/', ' ', $text );
$text = preg_replace( '/\n{3,}/', "\n\n", $text );
$text = trim( $text );
return $text;
}
/**
* Sanitize a value for use in email headers.
* Prevents header injection attacks by removing newlines and control characters.
*
* @param string $value The value to sanitize.
* @return string
*/
private function sanitize_header_value( $value ) {
// Remove any newlines, carriage returns, and null bytes.
$value = preg_replace( '/[\r\n\0]/', '', $value );
// Remove any remaining control characters.
$value = preg_replace( '/[\x00-\x1F\x7F]/', '', $value );
return trim( $value );
}
/**
* Wrap email content in HTML template.
*
* @param string $content Email content.
* @return string
*/
private function wrap_email_html( $content ) {
$template = '<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f4f4f4;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Oxygen-Sans,Ubuntu,Cantarell,\'Helvetica Neue\',sans-serif;">
<table role="presentation" style="width:100%;border-collapse:collapse;">
<tr>
<td style="padding:40px 20px;">
<table role="presentation" style="max-width:600px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.05);">
<tr>
<td style="padding:40px;">
{{content}}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>';
return str_replace( '{{content}}', wpautop( $content ), $template );
}
/**
* AJAX handler for email preview.
*/
public function ajax_preview_email() {
check_ajax_referer( 'maple_carts_admin', 'nonce' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( 'Permission denied' );
}
$template_id = isset( $_POST['template_id'] ) ? absint( $_POST['template_id'] ) : 0;
$subject = isset( $_POST['subject'] ) ? sanitize_text_field( wp_unslash( $_POST['subject'] ) ) : '';
$body = isset( $_POST['body'] ) ? wp_kses_post( wp_unslash( $_POST['body'] ) ) : '';
// Create dummy data for preview.
$dummy_data = (object) [
'email' => 'customer@example.com',
'customer_name' => 'John Doe',
'cart_contents' => maybe_serialize( [
[
'product_id' => 1,
'name' => 'Sample Product',
'price' => 29.99,
'quantity' => 2,
'image' => '',
],
] ),
'cart_total' => 59.98,
'currency' => get_woocommerce_currency(),
'session_id' => 'preview-session',
'include_coupon' => true,
'coupon_type' => 'percent',
'coupon_amount' => 10,
'coupon_expires_days' => 7,
];
$parsed_subject = $this->parse_template( $subject, $dummy_data, 'PREVIEW10', '#' );
$parsed_body = $this->parse_template( $body, $dummy_data, 'PREVIEW10', '#' );
$html_body = $this->wrap_email_html( $parsed_body );
wp_send_json_success( [
'subject' => $parsed_subject,
'body' => $html_body,
] );
}
/**
* AJAX handler for sending test email.
*/
public function ajax_send_test_email() {
check_ajax_referer( 'maple_carts_admin', 'nonce' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( 'Permission denied' );
}
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
$subject = isset( $_POST['subject'] ) ? sanitize_text_field( wp_unslash( $_POST['subject'] ) ) : '';
$body = isset( $_POST['body'] ) ? wp_kses_post( wp_unslash( $_POST['body'] ) ) : '';
if ( ! is_email( $email ) ) {
wp_send_json_error( __( 'Invalid email address', 'maple-carts' ) );
}
// Create dummy data.
$dummy_data = (object) [
'email' => $email,
'customer_name' => 'Test Customer',
'cart_contents' => maybe_serialize( [
[
'product_id' => 1,
'name' => 'Test Product',
'price' => 49.99,
'quantity' => 1,
'image' => '',
],
] ),
'cart_total' => 49.99,
'currency' => get_woocommerce_currency(),
'session_id' => 'test-session',
'include_coupon' => true,
'coupon_type' => 'percent',
'coupon_amount' => 10,
'coupon_expires_days' => 7,
];
$parsed_subject = $this->parse_template( $subject, $dummy_data, 'TEST10OFF', '#' );
$parsed_body = $this->parse_template( $body, $dummy_data, 'TEST10OFF', '#' );
$sent = $this->dispatch_email( $email, '[TEST] ' . $parsed_subject, $parsed_body );
if ( $sent ) {
wp_send_json_success( __( 'Test email sent successfully!', 'maple-carts' ) );
} else {
wp_send_json_error( __( 'Failed to send test email. Check your email configuration.', 'maple-carts' ) );
}
}
}

View file

@ -0,0 +1,534 @@
<?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' ),
];
}
}

View file

@ -0,0 +1,502 @@
<?php
/**
* GDPR Privacy Handler for Maple Carts.
*
* Integrates with WordPress and WooCommerce privacy tools.
*
* @package MapleCarts
*/
defined( 'ABSPATH' ) || exit;
/**
* Privacy class.
*/
class Maple_Carts_Privacy {
/**
* Single instance.
*
* @var Maple_Carts_Privacy
*/
private static $instance = null;
/**
* Get instance.
*
* @return Maple_Carts_Privacy
*/
public static function instance() {
if ( is_null( self::$instance ) ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
private function __construct() {
// WordPress Privacy hooks.
add_action( 'admin_init', [ $this, 'add_privacy_policy_content' ] );
add_filter( 'wp_privacy_personal_data_exporters', [ $this, 'register_data_exporter' ] );
add_filter( 'wp_privacy_personal_data_erasers', [ $this, 'register_data_eraser' ] );
// Checkout consent checkbox.
if ( 'yes' === Maple_Carts::get_option( 'require_consent', 'no' ) ) {
add_action( 'woocommerce_review_order_before_submit', [ $this, 'add_consent_checkbox' ] );
add_action( 'woocommerce_checkout_process', [ $this, 'validate_consent_checkbox' ] );
}
// Data deletion request from unsubscribe.
add_action( 'wp', [ $this, 'handle_data_deletion_request' ] );
}
/**
* Add privacy policy suggested content.
*/
public function add_privacy_policy_content() {
if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
return;
}
$content = $this->get_privacy_policy_content();
wp_add_privacy_policy_content( 'Maple Carts', $content );
}
/**
* Get privacy policy suggested content.
*
* @return string
*/
private function get_privacy_policy_content() {
$retention_days = Maple_Carts::get_option( 'delete_after_days', 90 );
return sprintf(
'<h2>%s</h2>
<p>%s</p>
<h3>%s</h3>
<p>%s</p>
<ul>
<li>%s</li>
<li>%s</li>
<li>%s</li>
<li>%s</li>
<li>%s</li>
<li>%s</li>
</ul>
<h3>%s</h3>
<p>%s</p>
<h3>%s</h3>
<p>%s</p>
<h3>%s</h3>
<p>%s</p>
<h3>%s</h3>
<p>%s</p>',
__( '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 ]
);
}
}

View file

@ -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 );
}
}

View file

@ -0,0 +1,10 @@
<?php
/**
* Silence is golden.
*
* @package MapleCarts
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}