WP maple cart and page subtitle plugin upload
This commit is contained in:
parent
b3e87772ec
commit
c85895d306
18 changed files with 5741 additions and 0 deletions
|
|
@ -0,0 +1,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' => '«',
|
||||
'next_text' => '»',
|
||||
'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">×</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">×</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">×</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' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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' ] );
|
||||
|
|
@ -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' ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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' ),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
10
native/wordpress/maple-carts-wp/includes/index.php
Normal file
10
native/wordpress/maple-carts-wp/includes/index.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
/**
|
||||
* Silence is golden.
|
||||
*
|
||||
* @package MapleCarts
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue