Initial commit: WooCommerce Email Discount Manager plugin

This commit is contained in:
kristianwind
2026-08-23 15:38:14 +02:00
commit e4a40e53a0
16 changed files with 4391 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
<?php
/**
* Audit log admin view.
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Displays recorded REST API activity.
*/
class WC_EDM_Admin_Audit {
/**
* Entries shown per page.
*/
const PER_PAGE = 50;
/**
* Handle the clear-log action. Runs on admin_init.
*/
public function handle_actions() {
if ( ! isset( $_GET['action'] ) || 'clear_audit_log' !== $_GET['action'] ) {
return;
}
check_admin_referer( 'wc_edm_clear_audit_log' );
WC_EDM_DB::clear_audit_log();
wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'audit', 'message' => 'deleted' ) ) );
exit;
}
/**
* Render the audit log tab.
*/
public function render() {
$outcome = isset( $_GET['outcome'] ) ? sanitize_key( wp_unslash( $_GET['outcome'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$outcome = in_array( $outcome, array( 'allowed', 'denied', 'rate_limited' ), true ) ? $outcome : '';
$paged = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$offset = ( $paged - 1 ) * self::PER_PAGE;
$entries = WC_EDM_DB::get_audit_entries( self::PER_PAGE, $offset, $outcome );
$total = WC_EDM_DB::count_audit_entries( $outcome );
$counts = array(
'' => WC_EDM_DB::count_audit_entries(),
'allowed' => WC_EDM_DB::count_audit_entries( 'allowed' ),
'denied' => WC_EDM_DB::count_audit_entries( 'denied' ),
'rate_limited' => WC_EDM_DB::count_audit_entries( 'rate_limited' ),
);
$labels = array(
'' => __( 'All', 'wc-email-discount' ),
'allowed' => __( 'Changes', 'wc-email-discount' ),
'denied' => __( 'Rejected', 'wc-email-discount' ),
'rate_limited' => __( 'Rate limited', 'wc-email-discount' ),
);
$clear_url = wp_nonce_url(
WC_EDM_Helpers::admin_url( array( 'tab' => 'audit', 'action' => 'clear_audit_log' ) ),
'wc_edm_clear_audit_log'
);
?>
<div class="wc-edm-header-row">
<h2><?php esc_html_e( 'API Audit Log', 'wc-email-discount' ); ?></h2>
<?php if ( $counts[''] > 0 ) : ?>
<a class="button wc-edm-delete" href="<?php echo esc_url( $clear_url ); ?>">
<?php esc_html_e( 'Clear Log', 'wc-email-discount' ); ?>
</a>
<?php endif; ?>
</div>
<p class="description">
<?php
printf(
/* translators: %d: retention in days */
esc_html__( 'Records every change made through the REST API, plus rejected and rate limited requests. Entries older than %d days are removed automatically.', 'wc-email-discount' ),
(int) WC_EDM_Audit_Log::retention_days()
);
?>
</p>
<ul class="subsubsub">
<?php $first = true; ?>
<?php foreach ( $labels as $key => $label ) : ?>
<li>
<?php echo $first ? '' : ' | '; ?>
<a href="<?php echo esc_url( WC_EDM_Helpers::admin_url( array_filter( array( 'tab' => 'audit', 'outcome' => $key ) ) ) ); ?>"
class="<?php echo $outcome === $key ? 'current' : ''; ?>">
<?php echo esc_html( $label ); ?>
<span class="count">(<?php echo esc_html( $counts[ $key ] ); ?>)</span>
</a>
</li>
<?php $first = false; ?>
<?php endforeach; ?>
</ul>
<div class="clear"></div>
<?php if ( empty( $entries ) ) : ?>
<p><?php esc_html_e( 'No activity recorded yet.', 'wc-email-discount' ); ?></p>
<?php return; ?>
<?php endif; ?>
<table class="widefat striped wc-edm-table wc-edm-audit-table">
<thead>
<tr>
<th><?php esc_html_e( 'When', 'wc-email-discount' ); ?></th>
<th><?php esc_html_e( 'User', 'wc-email-discount' ); ?></th>
<th><?php esc_html_e( 'IP', 'wc-email-discount' ); ?></th>
<th><?php esc_html_e( 'Request', 'wc-email-discount' ); ?></th>
<th><?php esc_html_e( 'Result', 'wc-email-discount' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $entries as $entry ) : ?>
<?php
$details = $entry->details ? json_decode( $entry->details, true ) : array();
$badge = array(
'allowed' => 'wc-edm-badge-group',
'denied' => 'wc-edm-badge-denied',
'rate_limited' => 'wc-edm-badge-coupon',
);
?>
<tr>
<td><?php echo esc_html( mysql2date( get_option( 'date_format' ) . ' H:i:s', $entry->created_at ) ); ?></td>
<td>
<?php if ( $entry->user_login ) : ?>
<?php echo esc_html( $entry->user_login ); ?>
<?php else : ?>
<em><?php esc_html_e( 'Not signed in', 'wc-email-discount' ); ?></em>
<?php endif; ?>
</td>
<td><code><?php echo esc_html( $entry->ip ? $entry->ip : '—' ); ?></code></td>
<td>
<code><?php echo esc_html( $entry->method ); ?></code>
<span class="wc-edm-item-meta"><?php echo esc_html( $entry->route ); ?></span>
</td>
<td>
<span class="wc-edm-badge <?php echo esc_attr( $badge[ $entry->outcome ] ?? '' ); ?>">
<?php echo esc_html( $labels[ $entry->outcome ] ?? $entry->outcome ); ?>
</span>
<span class="wc-edm-item-meta">
<?php echo esc_html( $entry->status ); ?>
<?php echo ! empty( $details['code'] ) ? esc_html( ' · ' . $details['code'] ) : ''; ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
$pages = (int) ceil( $total / self::PER_PAGE );
if ( $pages > 1 ) :
?>
<div class="tablenav"><div class="tablenav-pages">
<?php
echo wp_kses_post(
paginate_links(
array(
'base' => esc_url_raw( add_query_arg( 'paged', '%#%' ) ),
'format' => '',
'current' => $paged,
'total' => $pages,
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
)
)
);
?>
</div></div>
<?php
endif;
}
}
+345
View File
@@ -0,0 +1,345 @@
<?php
/**
* Email assignments admin (CRUD).
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* List, create, edit and delete email assignments.
*/
class WC_EDM_Admin_Emails {
/**
* Handle save/delete/toggle actions for the emails tab. Runs on admin_init.
*/
public function handle_actions() {
// Save (create or update).
if ( isset( $_POST['wc_edm_action'] ) && 'save_email' === $_POST['wc_edm_action'] ) {
check_admin_referer( 'wc_edm_save_email' );
$id = isset( $_POST['email_id'] ) ? absint( $_POST['email_id'] ) : 0;
$pattern = isset( $_POST['email_pattern'] ) ? strtolower( sanitize_text_field( wp_unslash( $_POST['email_pattern'] ) ) ) : '';
$type = isset( $_POST['assignment_type'] ) ? sanitize_key( wp_unslash( $_POST['assignment_type'] ) ) : 'group';
if ( ! in_array( $type, array( 'group', 'coupon', 'custom' ), true ) ) {
$type = 'group';
}
if ( '' === $pattern ) {
wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'message' => 'error' ) ) );
exit;
}
$data = array(
'email_pattern' => $pattern,
'assignment_type' => $type,
'group_id' => null,
'coupon_code' => null,
'custom_discount' => null,
'is_active' => isset( $_POST['is_active'] ) ? 1 : 0,
'free_shipping' => isset( $_POST['free_shipping'] ) ? 1 : 0,
);
// Only the fields for the chosen type are stored.
switch ( $type ) {
case 'group':
$data['group_id'] = isset( $_POST['group_id'] ) ? absint( $_POST['group_id'] ) : 0;
if ( ! $data['group_id'] ) {
$data['group_id'] = null;
}
break;
case 'coupon':
$data['coupon_code'] = isset( $_POST['coupon_code'] )
? wc_format_coupon_code( wp_unslash( $_POST['coupon_code'] ) )
: '';
break;
case 'custom':
$data['custom_discount'] = isset( $_POST['custom_discount'] )
? WC_EDM_Helpers::sanitize_percent( $_POST['custom_discount'] )
: 0;
break;
}
if ( $id ) {
WC_EDM_DB::update_email( $id, $data );
} else {
$id = WC_EDM_DB::insert_email( $data );
}
wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'message' => $id ? 'saved' : 'error' ) ) );
exit;
}
// Delete.
if ( isset( $_GET['action'], $_GET['id'] ) && 'delete' === $_GET['action'] ) {
$id = absint( $_GET['id'] );
check_admin_referer( 'wc_edm_delete_email_' . $id );
WC_EDM_DB::delete_email( $id );
wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'message' => 'deleted' ) ) );
exit;
}
// Toggle active.
if ( isset( $_GET['action'], $_GET['id'] ) && 'toggle' === $_GET['action'] ) {
$id = absint( $_GET['id'] );
check_admin_referer( 'wc_edm_toggle_email_' . $id );
$assignment = WC_EDM_DB::get_email( $id );
if ( $assignment ) {
WC_EDM_DB::set_email_active( $id, $assignment->is_active ? 0 : 1 );
}
wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'message' => 'saved' ) ) );
exit;
}
}
/**
* Render the emails tab (list or edit form).
*/
public function render() {
$action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( 'new' === $action || 'edit' === $action ) {
$id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$this->render_form( $id );
return;
}
$this->render_list();
}
/**
* Render the list of email assignments.
*/
private function render_list() {
$assignments = WC_EDM_DB::get_emails();
?>
<div class="wc-edm-header-row">
<h2><?php esc_html_e( 'Email Assignments', 'wc-email-discount' ); ?></h2>
<a class="button button-primary" href="<?php echo esc_url( WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'action' => 'new' ) ) ); ?>">
<?php esc_html_e( 'Add New Assignment', 'wc-email-discount' ); ?>
</a>
</div>
<p class="description">
<?php esc_html_e( 'Patterns: full email (john@example.com), domain (@company.com) or partial match (john).', 'wc-email-discount' ); ?>
</p>
<?php if ( empty( $assignments ) ) : ?>
<p><?php esc_html_e( 'No email assignments found.', 'wc-email-discount' ); ?></p>
<?php return; ?>
<?php endif; ?>
<table class="widefat striped wc-edm-table">
<thead>
<tr>
<th><?php esc_html_e( 'Email Pattern', 'wc-email-discount' ); ?></th>
<th><?php esc_html_e( 'Type', 'wc-email-discount' ); ?></th>
<th><?php esc_html_e( 'Assignment', 'wc-email-discount' ); ?></th>
<th><?php esc_html_e( 'Status', 'wc-email-discount' ); ?></th>
<th class="wc-edm-actions-col"><?php esc_html_e( 'Actions', 'wc-email-discount' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $assignments as $assignment ) : ?>
<?php
$id = (int) $assignment->id;
$edit_url = WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'action' => 'edit', 'id' => $id ) );
$delete_url = wp_nonce_url(
WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'action' => 'delete', 'id' => $id ) ),
'wc_edm_delete_email_' . $id
);
$toggle_url = wp_nonce_url(
WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'action' => 'toggle', 'id' => $id ) ),
'wc_edm_toggle_email_' . $id
);
$type_labels = array(
'group' => __( 'Group', 'wc-email-discount' ),
'coupon' => __( 'Coupon', 'wc-email-discount' ),
'custom' => __( 'Custom', 'wc-email-discount' ),
);
?>
<tr>
<td><a href="<?php echo esc_url( $edit_url ); ?>"><strong><?php echo esc_html( $assignment->email_pattern ); ?></strong></a></td>
<td>
<span class="wc-edm-badge wc-edm-badge-<?php echo esc_attr( $assignment->assignment_type ); ?>">
<?php echo esc_html( $type_labels[ $assignment->assignment_type ] ?? $assignment->assignment_type ); ?>
</span>
</td>
<td><?php echo esc_html( WC_EDM_Helpers::describe_assignment( $assignment ) ); ?></td>
<td>
<a href="<?php echo esc_url( $toggle_url ); ?>" class="wc-edm-status <?php echo $assignment->is_active ? 'is-active' : 'is-inactive'; ?>">
<?php echo $assignment->is_active ? esc_html__( 'Active', 'wc-email-discount' ) : esc_html__( 'Inactive', 'wc-email-discount' ); ?>
</a>
</td>
<td class="wc-edm-actions-col">
<a class="button button-small" href="<?php echo esc_url( $edit_url ); ?>"><?php esc_html_e( 'Edit', 'wc-email-discount' ); ?></a>
<a class="button button-small wc-edm-delete" href="<?php echo esc_url( $delete_url ); ?>"><?php esc_html_e( 'Delete', 'wc-email-discount' ); ?></a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
}
/**
* Render the create/edit form.
*
* @param int $id Assignment ID (0 for new).
*/
private function render_form( $id ) {
$assignment = $id ? WC_EDM_DB::get_email( $id ) : null;
$groups = WC_EDM_DB::get_groups();
if ( $id && ! $assignment ) {
echo '<p>' . esc_html__( 'Assignment not found.', 'wc-email-discount' ) . '</p>';
return;
}
$type = $assignment ? $assignment->assignment_type : 'group';
$is_active = $assignment ? (int) $assignment->is_active : 1;
$free_shipping = $assignment ? (int) ! empty( $assignment->free_shipping ) : 0;
$coupon_codes = WC_EDM_Helpers::get_coupon_codes();
$current_coupon = $assignment && $assignment->coupon_code ? $assignment->coupon_code : '';
// Keep a stored code selectable even if the coupon was since deleted.
if ( $current_coupon && ! in_array( $current_coupon, $coupon_codes, true ) ) {
array_unshift( $coupon_codes, $current_coupon );
}
?>
<h2><?php echo $assignment ? esc_html__( 'Edit Email Assignment', 'wc-email-discount' ) : esc_html__( 'Add Email Assignment', 'wc-email-discount' ); ?></h2>
<form method="post" action="<?php echo esc_url( WC_EDM_Helpers::admin_url( array( 'tab' => 'emails' ) ) ); ?>">
<?php wp_nonce_field( 'wc_edm_save_email' ); ?>
<input type="hidden" name="wc_edm_action" value="save_email">
<input type="hidden" name="email_id" value="<?php echo esc_attr( $assignment ? (int) $assignment->id : 0 ); ?>">
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="wc-edm-email-pattern"><?php esc_html_e( 'Email Pattern', 'wc-email-discount' ); ?></label>
</th>
<td>
<input type="text" id="wc-edm-email-pattern" name="email_pattern" class="regular-text" required
value="<?php echo esc_attr( $assignment ? $assignment->email_pattern : '' ); ?>"
placeholder="john@example.com">
<p class="description">
<?php esc_html_e( 'Full email (john@example.com), domain (@company.com) or partial match (john).', 'wc-email-discount' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Assignment Type', 'wc-email-discount' ); ?></th>
<td>
<fieldset id="wc-edm-assignment-type">
<label>
<input type="radio" name="assignment_type" value="group" <?php checked( $type, 'group' ); ?>>
<?php esc_html_e( 'Discount Group', 'wc-email-discount' ); ?>
</label><br>
<label>
<input type="radio" name="assignment_type" value="coupon" <?php checked( $type, 'coupon' ); ?>>
<?php esc_html_e( 'Coupon Code', 'wc-email-discount' ); ?>
</label><br>
<label>
<input type="radio" name="assignment_type" value="custom" <?php checked( $type, 'custom' ); ?>>
<?php esc_html_e( 'Custom Discount %', 'wc-email-discount' ); ?>
</label>
</fieldset>
</td>
</tr>
<tr class="wc-edm-type-fields" data-type="group">
<th scope="row">
<label for="wc-edm-email-group"><?php esc_html_e( 'Discount Group', 'wc-email-discount' ); ?></label>
</th>
<td>
<select id="wc-edm-email-group" name="group_id">
<option value=""><?php esc_html_e( '— Select a group —', 'wc-email-discount' ); ?></option>
<?php foreach ( $groups as $group ) : ?>
<option value="<?php echo esc_attr( (int) $group->id ); ?>"
<?php selected( $assignment && (int) $assignment->group_id === (int) $group->id ); ?>>
<?php echo esc_html( $group->name . ' (' . wc_format_localized_decimal( $group->discount_percent ) . '%)' ); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description"><?php esc_html_e( 'Inherits the group\'s discount percent and categories.', 'wc-email-discount' ); ?></p>
</td>
</tr>
<tr class="wc-edm-type-fields" data-type="coupon">
<th scope="row">
<label for="wc-edm-email-coupon"><?php esc_html_e( 'Coupon Code', 'wc-email-discount' ); ?></label>
</th>
<td>
<?php if ( empty( $coupon_codes ) ) : ?>
<input type="text" id="wc-edm-email-coupon" name="coupon_code" class="regular-text" value="">
<p class="description"><?php esc_html_e( 'No WooCommerce coupons found — enter a coupon code manually, or create one under Marketing → Coupons.', 'wc-email-discount' ); ?></p>
<?php else : ?>
<select id="wc-edm-email-coupon" name="coupon_code">
<option value=""><?php esc_html_e( '— Select a coupon —', 'wc-email-discount' ); ?></option>
<?php foreach ( $coupon_codes as $code ) : ?>
<option value="<?php echo esc_attr( $code ); ?>" <?php selected( $current_coupon, $code ); ?>>
<?php echo esc_html( $code ); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description"><?php esc_html_e( 'Your existing WooCommerce coupons. The selected coupon is auto-applied at checkout.', 'wc-email-discount' ); ?></p>
<?php endif; ?>
</td>
</tr>
<tr class="wc-edm-type-fields" data-type="custom">
<th scope="row">
<label for="wc-edm-email-custom"><?php esc_html_e( 'Custom Discount %', 'wc-email-discount' ); ?></label>
</th>
<td>
<input type="number" id="wc-edm-email-custom" name="custom_discount" min="0" max="100" step="0.01"
value="<?php echo esc_attr( $assignment && null !== $assignment->custom_discount ? $assignment->custom_discount : '' ); ?>"> %
<p class="description"><?php esc_html_e( 'Applied to the entire cart.', 'wc-email-discount' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Free Shipping', 'wc-email-discount' ); ?></th>
<td>
<label>
<input type="checkbox" name="free_shipping" value="1" <?php checked( $free_shipping, 1 ); ?>>
<?php esc_html_e( 'Grant free shipping', 'wc-email-discount' ); ?>
</label>
<p class="description"><?php esc_html_e( 'All shipping methods become free at checkout for matching emails. Works with every assignment type.', 'wc-email-discount' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Status', 'wc-email-discount' ); ?></th>
<td>
<label>
<input type="checkbox" name="is_active" value="1" <?php checked( $is_active, 1 ); ?>>
<?php esc_html_e( 'Active', 'wc-email-discount' ); ?>
</label>
</td>
</tr>
</table>
<p class="submit">
<button type="submit" class="button button-primary"><?php esc_html_e( 'Save Assignment', 'wc-email-discount' ); ?></button>
<a class="button" href="<?php echo esc_url( WC_EDM_Helpers::admin_url( array( 'tab' => 'emails' ) ) ); ?>">
<?php esc_html_e( 'Cancel', 'wc-email-discount' ); ?>
</a>
</p>
</form>
<?php
}
}
+240
View File
@@ -0,0 +1,240 @@
<?php
/**
* Discount groups admin (CRUD).
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* List, create, edit and delete discount groups.
*/
class WC_EDM_Admin_Groups {
/**
* Handle save/delete actions for the groups tab. Runs on admin_init.
*/
public function handle_actions() {
// Save (create or update).
if ( isset( $_POST['wc_edm_action'] ) && 'save_group' === $_POST['wc_edm_action'] ) {
check_admin_referer( 'wc_edm_save_group' );
$id = isset( $_POST['group_id'] ) ? absint( $_POST['group_id'] ) : 0;
$name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '';
$percent = isset( $_POST['discount_percent'] ) ? WC_EDM_Helpers::sanitize_percent( $_POST['discount_percent'] ) : 0;
$category_ids = isset( $_POST['category_ids'] ) && is_array( $_POST['category_ids'] )
? array_map( 'absint', wp_unslash( $_POST['category_ids'] ) )
: array();
if ( '' === $name ) {
wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'groups', 'message' => 'error' ) ) );
exit;
}
$data = array(
'name' => $name,
'discount_percent' => $percent,
);
if ( $id ) {
WC_EDM_DB::update_group( $id, $data );
} else {
$id = WC_EDM_DB::insert_group( $data );
}
if ( $id ) {
WC_EDM_DB::set_group_categories( $id, $category_ids );
}
wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'groups', 'message' => $id ? 'saved' : 'error' ) ) );
exit;
}
// Delete.
if ( isset( $_GET['action'], $_GET['id'] ) && 'delete' === $_GET['action'] ) {
$id = absint( $_GET['id'] );
check_admin_referer( 'wc_edm_delete_group_' . $id );
WC_EDM_DB::delete_group( $id );
wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'groups', 'message' => 'deleted' ) ) );
exit;
}
}
/**
* Render the groups tab (list or edit form).
*/
public function render() {
$action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( 'new' === $action || 'edit' === $action ) {
$id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$this->render_form( $id );
return;
}
$this->render_list();
}
/**
* Render the list of groups.
*/
private function render_list() {
$groups = WC_EDM_DB::get_groups();
?>
<div class="wc-edm-header-row">
<h2><?php esc_html_e( 'Discount Groups', 'wc-email-discount' ); ?></h2>
<a class="button button-primary" href="<?php echo esc_url( WC_EDM_Helpers::admin_url( array( 'tab' => 'groups', 'action' => 'new' ) ) ); ?>">
<?php esc_html_e( 'Add New Group', 'wc-email-discount' ); ?>
</a>
</div>
<?php if ( empty( $groups ) ) : ?>
<p><?php esc_html_e( 'No discount groups found.', 'wc-email-discount' ); ?></p>
<?php return; ?>
<?php endif; ?>
<table class="widefat striped wc-edm-table">
<thead>
<tr>
<th><?php esc_html_e( 'Name', 'wc-email-discount' ); ?></th>
<th><?php esc_html_e( 'Discount', 'wc-email-discount' ); ?></th>
<th><?php esc_html_e( 'Categories', 'wc-email-discount' ); ?></th>
<th class="wc-edm-actions-col"><?php esc_html_e( 'Actions', 'wc-email-discount' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $groups as $group ) : ?>
<?php
$category_ids = WC_EDM_DB::get_group_category_ids( (int) $group->id );
$names = array();
foreach ( $category_ids as $category_id ) {
$term = get_term( $category_id, 'product_cat' );
if ( $term && ! is_wp_error( $term ) ) {
$names[] = $term->name;
}
}
$edit_url = WC_EDM_Helpers::admin_url( array( 'tab' => 'groups', 'action' => 'edit', 'id' => (int) $group->id ) );
$delete_url = wp_nonce_url(
WC_EDM_Helpers::admin_url( array( 'tab' => 'groups', 'action' => 'delete', 'id' => (int) $group->id ) ),
'wc_edm_delete_group_' . (int) $group->id
);
?>
<tr>
<td><a href="<?php echo esc_url( $edit_url ); ?>"><strong><?php echo esc_html( $group->name ); ?></strong></a></td>
<td><span class="wc-edm-badge wc-edm-badge-percent"><?php echo esc_html( wc_format_localized_decimal( $group->discount_percent ) ); ?>%</span></td>
<td><?php echo $names ? esc_html( implode( ', ', $names ) ) : '<em>' . esc_html__( 'None', 'wc-email-discount' ) . '</em>'; ?></td>
<td class="wc-edm-actions-col">
<a class="button button-small" href="<?php echo esc_url( $edit_url ); ?>"><?php esc_html_e( 'Edit', 'wc-email-discount' ); ?></a>
<a class="button button-small wc-edm-delete" href="<?php echo esc_url( $delete_url ); ?>"><?php esc_html_e( 'Delete', 'wc-email-discount' ); ?></a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
}
/**
* Render the create/edit form.
*
* @param int $id Group ID (0 for new).
*/
private function render_form( $id ) {
$group = $id ? WC_EDM_DB::get_group( $id ) : null;
$selected_ids = $id ? WC_EDM_DB::get_group_category_ids( $id ) : array();
$categories = WC_EDM_Helpers::get_product_categories();
$categories_by_id = array();
foreach ( $categories as $category ) {
$categories_by_id[ $category->term_id ] = $category;
}
if ( $id && ! $group ) {
echo '<p>' . esc_html__( 'Group not found.', 'wc-email-discount' ) . '</p>';
return;
}
?>
<h2><?php echo $group ? esc_html__( 'Edit Discount Group', 'wc-email-discount' ) : esc_html__( 'Add Discount Group', 'wc-email-discount' ); ?></h2>
<form method="post" action="<?php echo esc_url( WC_EDM_Helpers::admin_url( array( 'tab' => 'groups' ) ) ); ?>">
<?php wp_nonce_field( 'wc_edm_save_group' ); ?>
<input type="hidden" name="wc_edm_action" value="save_group">
<input type="hidden" name="group_id" value="<?php echo esc_attr( $group ? (int) $group->id : 0 ); ?>">
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="wc-edm-group-name"><?php esc_html_e( 'Group Name', 'wc-email-discount' ); ?></label>
</th>
<td>
<input type="text" id="wc-edm-group-name" name="name" class="regular-text" required
value="<?php echo esc_attr( $group ? $group->name : '' ); ?>">
</td>
</tr>
<tr>
<th scope="row">
<label for="wc-edm-group-percent"><?php esc_html_e( 'Discount %', 'wc-email-discount' ); ?></label>
</th>
<td>
<input type="number" id="wc-edm-group-percent" name="discount_percent" min="0" max="100" step="0.01" required
value="<?php echo esc_attr( $group ? $group->discount_percent : '' ); ?>"> %
<p class="description"><?php esc_html_e( 'Applies to all products in the categories below.', 'wc-email-discount' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Product Categories', 'wc-email-discount' ); ?></th>
<td>
<div class="wc-edm-category-picker">
<select id="wc-edm-category-select">
<option value=""><?php esc_html_e( '— Select a category —', 'wc-email-discount' ); ?></option>
<?php foreach ( $categories as $category ) : ?>
<option value="<?php echo esc_attr( $category->term_id ); ?>"
<?php disabled( in_array( $category->term_id, $selected_ids, true ) ); ?>>
<?php echo esc_html( $category->name ); ?>
</option>
<?php endforeach; ?>
</select>
<button type="button" class="button" id="wc-edm-add-category">
<?php esc_html_e( 'Add Category', 'wc-email-discount' ); ?>
</button>
</div>
<ul id="wc-edm-selected-categories" class="wc-edm-sortable">
<?php foreach ( $selected_ids as $category_id ) : ?>
<?php
if ( ! isset( $categories_by_id[ $category_id ] ) ) {
continue;
}
?>
<li data-id="<?php echo esc_attr( $category_id ); ?>">
<span class="dashicons dashicons-menu wc-edm-drag-handle"></span>
<span class="wc-edm-item-label"><?php echo esc_html( $categories_by_id[ $category_id ]->name ); ?></span>
<button type="button" class="wc-edm-remove-category" aria-label="<?php esc_attr_e( 'Remove', 'wc-email-discount' ); ?>">&times;</button>
<input type="hidden" name="category_ids[]" value="<?php echo esc_attr( $category_id ); ?>">
</li>
<?php endforeach; ?>
</ul>
<p class="description"><?php esc_html_e( 'Drag to reorder. The discount applies to all listed categories.', 'wc-email-discount' ); ?></p>
</td>
</tr>
</table>
<p class="submit">
<button type="submit" class="button button-primary"><?php esc_html_e( 'Save Group', 'wc-email-discount' ); ?></button>
<a class="button" href="<?php echo esc_url( WC_EDM_Helpers::admin_url( array( 'tab' => 'groups' ) ) ); ?>">
<?php esc_html_e( 'Cancel', 'wc-email-discount' ); ?>
</a>
</p>
</form>
<?php
}
}
+195
View File
@@ -0,0 +1,195 @@
<?php
/**
* Shipping rules admin.
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Configure per-group show/hide rules for shipping methods.
*/
class WC_EDM_Admin_Shipping {
/**
* Handle save actions for the shipping tab. Runs on admin_init.
*/
public function handle_actions() {
if ( ! isset( $_POST['wc_edm_action'] ) || 'save_shipping_rules' !== $_POST['wc_edm_action'] ) {
return;
}
check_admin_referer( 'wc_edm_save_shipping_rules' );
$group_id = isset( $_POST['group_id'] ) ? absint( $_POST['group_id'] ) : 0;
if ( ! $group_id || ! WC_EDM_DB::get_group( $group_id ) ) {
wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'shipping', 'message' => 'error' ) ) );
exit;
}
$order = isset( $_POST['method_order'] ) && is_array( $_POST['method_order'] )
? array_map( 'sanitize_text_field', wp_unslash( $_POST['method_order'] ) )
: array();
$raw_rules = isset( $_POST['rule'] ) && is_array( $_POST['rule'] )
? array_map( 'sanitize_text_field', wp_unslash( $_POST['rule'] ) )
: array();
$known_methods = WC_EDM_Helpers::get_all_shipping_methods();
// Rules are replaced wholesale: only methods with an explicit show/hide choice are stored.
$rules = array();
foreach ( $order as $index => $method_id ) {
if ( ! isset( $known_methods[ $method_id ] ) ) {
continue;
}
$action = isset( $raw_rules[ $method_id ] ) ? $raw_rules[ $method_id ] : '';
if ( ! in_array( $action, array( 'show', 'hide' ), true ) ) {
continue;
}
$rules[] = array(
'shipping_method_id' => $method_id,
'action' => $action,
'sort_order' => $index,
);
}
WC_EDM_DB::replace_shipping_rules( $group_id, $rules );
wp_safe_redirect(
WC_EDM_Helpers::admin_url(
array(
'tab' => 'shipping',
'group_id' => $group_id,
'message' => 'saved',
)
)
);
exit;
}
/**
* Render the shipping rules tab.
*/
public function render() {
$groups = WC_EDM_DB::get_groups();
?>
<h2><?php esc_html_e( 'Shipping Rules', 'wc-email-discount' ); ?></h2>
<p class="description">
<?php esc_html_e( 'Choose which shipping methods are shown or hidden for customers in a discount group. If a group has no rules, all shipping methods behave normally. If any method is set to "Show", only "Show" methods are offered.', 'wc-email-discount' ); ?>
</p>
<?php if ( empty( $groups ) ) : ?>
<p>
<?php esc_html_e( 'Create a discount group first to configure shipping rules.', 'wc-email-discount' ); ?>
<a href="<?php echo esc_url( WC_EDM_Helpers::admin_url( array( 'tab' => 'groups', 'action' => 'new' ) ) ); ?>">
<?php esc_html_e( 'Add Discount Group', 'wc-email-discount' ); ?>
</a>
</p>
<?php return; ?>
<?php endif; ?>
<?php
$group_id = isset( $_GET['group_id'] ) ? absint( $_GET['group_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
?>
<form method="get" action="<?php echo esc_url( admin_url( 'admin.php' ) ); ?>" class="wc-edm-group-selector">
<input type="hidden" name="page" value="wc-email-discounts">
<input type="hidden" name="tab" value="shipping">
<label for="wc-edm-shipping-group"><?php esc_html_e( 'Discount group:', 'wc-email-discount' ); ?></label>
<select id="wc-edm-shipping-group" name="group_id">
<option value=""><?php esc_html_e( '— Select a group —', 'wc-email-discount' ); ?></option>
<?php foreach ( $groups as $group ) : ?>
<option value="<?php echo esc_attr( (int) $group->id ); ?>" <?php selected( $group_id, (int) $group->id ); ?>>
<?php echo esc_html( $group->name ); ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="button"><?php esc_html_e( 'Select', 'wc-email-discount' ); ?></button>
</form>
<?php
if ( ! $group_id || ! WC_EDM_DB::get_group( $group_id ) ) {
return;
}
$this->render_rules_form( $group_id );
}
/**
* Render the rules form for a selected group.
*
* @param int $group_id Group ID.
*/
private function render_rules_form( $group_id ) {
$methods = WC_EDM_Helpers::get_all_shipping_methods();
$rules = WC_EDM_DB::get_shipping_rules( $group_id );
if ( empty( $methods ) ) {
echo '<p>' . esc_html__( 'No shipping methods found. Configure shipping zones in WooCommerce first.', 'wc-email-discount' ) . '</p>';
return;
}
// Existing rules by method id, and the display order: ruled methods first (by sort_order), then the rest.
$rules_by_method = array();
foreach ( $rules as $rule ) {
$rules_by_method[ $rule->shipping_method_id ] = $rule;
}
$ordered_keys = array_merge(
array_values( array_intersect( array_keys( $rules_by_method ), array_keys( $methods ) ) ),
array_values( array_diff( array_keys( $methods ), array_keys( $rules_by_method ) ) )
);
?>
<form method="post" action="<?php echo esc_url( WC_EDM_Helpers::admin_url( array( 'tab' => 'shipping' ) ) ); ?>">
<?php wp_nonce_field( 'wc_edm_save_shipping_rules' ); ?>
<input type="hidden" name="wc_edm_action" value="save_shipping_rules">
<input type="hidden" name="group_id" value="<?php echo esc_attr( $group_id ); ?>">
<ul id="wc-edm-shipping-methods" class="wc-edm-sortable wc-edm-shipping-list">
<?php foreach ( $ordered_keys as $method_id ) : ?>
<?php
$method = $methods[ $method_id ];
$action = isset( $rules_by_method[ $method_id ] ) ? $rules_by_method[ $method_id ]->action : '';
?>
<li>
<span class="dashicons dashicons-menu wc-edm-drag-handle"></span>
<span class="wc-edm-item-label">
<strong><?php echo esc_html( $method['label'] ); ?></strong>
<span class="wc-edm-item-meta"><?php echo esc_html( $method['zone'] ); ?> &middot; <code><?php echo esc_html( $method_id ); ?></code></span>
</span>
<span class="wc-edm-rule-options">
<label>
<input type="radio" name="rule[<?php echo esc_attr( $method_id ); ?>]" value="" <?php checked( $action, '' ); ?>>
<?php esc_html_e( 'Default', 'wc-email-discount' ); ?>
</label>
<label>
<input type="radio" name="rule[<?php echo esc_attr( $method_id ); ?>]" value="show" <?php checked( $action, 'show' ); ?>>
<?php esc_html_e( 'Show', 'wc-email-discount' ); ?>
</label>
<label>
<input type="radio" name="rule[<?php echo esc_attr( $method_id ); ?>]" value="hide" <?php checked( $action, 'hide' ); ?>>
<?php esc_html_e( 'Hide', 'wc-email-discount' ); ?>
</label>
</span>
<input type="hidden" name="method_order[]" value="<?php echo esc_attr( $method_id ); ?>">
</li>
<?php endforeach; ?>
</ul>
<p class="description"><?php esc_html_e( 'Drag to reorder — the order here controls the order shipping methods appear in at checkout.', 'wc-email-discount' ); ?></p>
<p class="submit">
<button type="submit" class="button button-primary"><?php esc_html_e( 'Save Shipping Rules', 'wc-email-discount' ); ?></button>
</p>
</form>
<?php
}
}
+328
View File
@@ -0,0 +1,328 @@
<?php
/**
* Admin menu, tabs and routing.
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registers the admin page under WooCommerce and routes tabs/actions.
*/
class WC_EDM_Admin {
/**
* Admin page slug.
*/
const PAGE_SLUG = 'wc-email-discounts';
/**
* Groups controller.
*
* @var WC_EDM_Admin_Groups
*/
private $groups;
/**
* Emails controller.
*
* @var WC_EDM_Admin_Emails
*/
private $emails;
/**
* Shipping controller.
*
* @var WC_EDM_Admin_Shipping
*/
private $shipping;
/**
* Audit log controller.
*
* @var WC_EDM_Admin_Audit
*/
private $audit;
/**
* Hook everything up.
*/
public function __construct() {
// Each sub-controller lives in its own file. Guarding here means an
// incomplete plugin folder costs a tab, not the whole admin screen.
$this->groups = class_exists( 'WC_EDM_Admin_Groups' ) ? new WC_EDM_Admin_Groups() : null;
$this->emails = class_exists( 'WC_EDM_Admin_Emails' ) ? new WC_EDM_Admin_Emails() : null;
$this->shipping = class_exists( 'WC_EDM_Admin_Shipping' ) ? new WC_EDM_Admin_Shipping() : null;
$this->audit = class_exists( 'WC_EDM_Admin_Audit' ) ? new WC_EDM_Admin_Audit() : null;
add_action( 'admin_menu', array( $this, 'register_menu' ) );
add_action( 'admin_init', array( $this, 'handle_actions' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
/**
* Register the submenu page under WooCommerce.
*/
public function register_menu() {
add_submenu_page(
'woocommerce',
__( 'Email Discounts', 'wc-email-discount' ),
__( 'Email Discounts', 'wc-email-discount' ),
'manage_woocommerce',
self::PAGE_SLUG,
array( $this, 'render_page' )
);
}
/**
* Enqueue admin CSS/JS on the plugin page only.
*
* @param string $hook Current admin page hook.
*/
public function enqueue_assets( $hook ) {
if ( false === strpos( $hook, self::PAGE_SLUG ) ) {
return;
}
wp_enqueue_style(
'wc-edm-admin',
WC_EDM_PLUGIN_URL . 'assets/css/admin.css',
array(),
WC_EDM_VERSION
);
wp_enqueue_script(
'wc-edm-admin',
WC_EDM_PLUGIN_URL . 'assets/js/admin.js',
array( 'jquery', 'jquery-ui-sortable' ),
WC_EDM_VERSION,
true
);
wp_localize_script(
'wc-edm-admin',
'wcEdmAdmin',
array(
'confirmDelete' => __( 'Are you sure you want to delete this item? This cannot be undone.', 'wc-email-discount' ),
)
);
}
/**
* Get the currently active tab.
*
* @return string
*/
public function current_tab() {
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'dashboard'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$tabs = array_keys( $this->get_tabs() );
return in_array( $tab, $tabs, true ) ? $tab : 'dashboard';
}
/**
* Tab definitions.
*
* @return array
*/
private function get_tabs() {
$tabs = array( 'dashboard' => __( 'Dashboard', 'wc-email-discount' ) );
// Only offer tabs whose controller actually loaded.
if ( $this->groups ) {
$tabs['groups'] = __( 'Discount Groups', 'wc-email-discount' );
}
if ( $this->emails ) {
$tabs['emails'] = __( 'Email Assignments', 'wc-email-discount' );
}
if ( $this->shipping ) {
$tabs['shipping'] = __( 'Shipping Rules', 'wc-email-discount' );
}
if ( $this->audit ) {
$tabs['audit'] = __( 'Audit Log', 'wc-email-discount' );
}
return $tabs;
}
/**
* Handle POST/GET actions before any output (allows redirects).
*/
public function handle_actions() {
if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
switch ( $this->current_tab() ) {
case 'groups':
$this->groups->handle_actions();
break;
case 'emails':
$this->emails->handle_actions();
break;
case 'shipping':
$this->shipping->handle_actions();
break;
case 'audit':
$this->audit->handle_actions();
break;
}
}
/**
* Render the admin page with tab navigation.
*/
public function render_page() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'wc-email-discount' ) );
}
$current = $this->current_tab();
?>
<div class="wrap wc-edm-wrap">
<h1><?php esc_html_e( 'Email Discounts', 'wc-email-discount' ); ?></h1>
<?php $this->render_notices(); ?>
<nav class="nav-tab-wrapper wc-edm-nav">
<?php foreach ( $this->get_tabs() as $slug => $label ) : ?>
<a href="<?php echo esc_url( WC_EDM_Helpers::admin_url( array( 'tab' => $slug ) ) ); ?>"
class="nav-tab <?php echo $current === $slug ? 'nav-tab-active' : ''; ?>">
<?php echo esc_html( $label ); ?>
</a>
<?php endforeach; ?>
</nav>
<div class="wc-edm-content">
<?php
switch ( $current ) {
case 'groups':
$this->groups->render();
break;
case 'emails':
$this->emails->render();
break;
case 'shipping':
$this->shipping->render();
break;
case 'audit':
$this->audit->render();
break;
default:
$this->render_dashboard();
break;
}
?>
</div>
</div>
<?php
}
/**
* Render success/error notices based on the ?message= query arg.
*/
private function render_notices() {
if ( ! isset( $_GET['message'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
$message = sanitize_key( wp_unslash( $_GET['message'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$messages = array(
'saved' => array( 'success', __( 'Saved successfully.', 'wc-email-discount' ) ),
'deleted' => array( 'success', __( 'Deleted successfully.', 'wc-email-discount' ) ),
'error' => array( 'error', __( 'Something went wrong. Please try again.', 'wc-email-discount' ) ),
);
if ( ! isset( $messages[ $message ] ) ) {
return;
}
list( $type, $text ) = $messages[ $message ];
printf(
'<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
esc_attr( $type ),
esc_html( $text )
);
}
/**
* Render the dashboard tab: stats cards, quick actions, recent groups.
*/
private function render_dashboard() {
$group_count = WC_EDM_DB::count_groups();
$email_count = WC_EDM_DB::count_emails();
$active_count = WC_EDM_DB::count_emails( true );
$recent = WC_EDM_DB::get_recent_groups( 5 );
?>
<div class="wc-edm-stats">
<div class="wc-edm-stat-card">
<span class="wc-edm-stat-number"><?php echo esc_html( $group_count ); ?></span>
<span class="wc-edm-stat-label"><?php esc_html_e( 'Discount Groups', 'wc-email-discount' ); ?></span>
</div>
<div class="wc-edm-stat-card">
<span class="wc-edm-stat-number"><?php echo esc_html( $email_count ); ?></span>
<span class="wc-edm-stat-label"><?php esc_html_e( 'Email Assignments', 'wc-email-discount' ); ?></span>
</div>
<div class="wc-edm-stat-card">
<span class="wc-edm-stat-number"><?php echo esc_html( $active_count ); ?></span>
<span class="wc-edm-stat-label"><?php esc_html_e( 'Active Assignments', 'wc-email-discount' ); ?></span>
</div>
</div>
<h2><?php esc_html_e( 'Quick Actions', 'wc-email-discount' ); ?></h2>
<p class="wc-edm-quick-actions">
<a class="button button-primary" href="<?php echo esc_url( WC_EDM_Helpers::admin_url( array( 'tab' => 'groups', 'action' => 'new' ) ) ); ?>">
<?php esc_html_e( 'Add Discount Group', 'wc-email-discount' ); ?>
</a>
<a class="button button-primary" href="<?php echo esc_url( WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'action' => 'new' ) ) ); ?>">
<?php esc_html_e( 'Add Email Assignment', 'wc-email-discount' ); ?>
</a>
<a class="button" href="<?php echo esc_url( WC_EDM_Helpers::admin_url( array( 'tab' => 'shipping' ) ) ); ?>">
<?php esc_html_e( 'Manage Shipping Rules', 'wc-email-discount' ); ?>
</a>
</p>
<h2><?php esc_html_e( 'Recent Discount Groups', 'wc-email-discount' ); ?></h2>
<?php if ( empty( $recent ) ) : ?>
<p><?php esc_html_e( 'No discount groups yet. Create your first one to get started.', 'wc-email-discount' ); ?></p>
<?php else : ?>
<table class="widefat striped wc-edm-table">
<thead>
<tr>
<th><?php esc_html_e( 'Name', 'wc-email-discount' ); ?></th>
<th><?php esc_html_e( 'Discount', 'wc-email-discount' ); ?></th>
<th><?php esc_html_e( 'Categories', 'wc-email-discount' ); ?></th>
<th><?php esc_html_e( 'Created', 'wc-email-discount' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $recent as $group ) : ?>
<tr>
<td>
<a href="<?php echo esc_url( WC_EDM_Helpers::admin_url( array( 'tab' => 'groups', 'action' => 'edit', 'id' => (int) $group->id ) ) ); ?>">
<strong><?php echo esc_html( $group->name ); ?></strong>
</a>
</td>
<td><?php echo esc_html( wc_format_localized_decimal( $group->discount_percent ) ); ?>%</td>
<td><?php echo esc_html( count( WC_EDM_DB::get_group_category_ids( (int) $group->id ) ) ); ?></td>
<td><?php echo esc_html( date_i18n( get_option( 'date_format' ), strtotime( $group->created_at ) ) ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
/**
* Audit log for REST API activity.
*
* Records every write to the wc-edm REST API and every rejected request,
* so unauthorized access attempts are visible after the fact.
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Writes and maintains the audit log.
*/
class WC_EDM_Audit_Log {
/**
* Cron hook used for pruning old entries.
*/
const CRON_HOOK = 'wc_edm_prune_audit_log';
/**
* Hook the daily prune job.
*/
public function __construct() {
add_action( self::CRON_HOOK, array( __CLASS__, 'prune' ) );
add_action( 'init', array( __CLASS__, 'schedule_prune' ) );
}
/**
* Make sure the daily prune job is scheduled.
*/
public static function schedule_prune() {
if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK );
}
}
/**
* Remove the scheduled prune job (used on deactivation).
*/
public static function unschedule_prune() {
$timestamp = wp_next_scheduled( self::CRON_HOOK );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, self::CRON_HOOK );
}
}
/**
* Whether audit logging is enabled.
*
* @return bool
*/
public static function is_enabled() {
/**
* Filter whether REST activity is written to the audit log.
*
* @param bool $enabled Default true.
*/
return (bool) apply_filters( 'wc_edm_audit_log_enabled', true );
}
/**
* How many days entries are kept.
*
* @return int
*/
public static function retention_days() {
/**
* Filter the audit log retention period in days.
*
* @param int $days Default 30.
*/
return max( 1, (int) apply_filters( 'wc_edm_audit_log_retention_days', 30 ) );
}
/**
* Resolve the client IP address.
*
* The shop may sit behind a reverse proxy (e.g. Cloudflare), where
* REMOTE_ADDR is the proxy rather than the visitor. Proxy headers are
* only trustworthy when the proxy is guaranteed to set them, so this is
* filterable for setups that need different handling.
*
* @return string
*/
public static function get_client_ip() {
$ip = '';
if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
} elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
}
$ip = filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
/**
* Filter the resolved client IP used for logging and rate limiting.
*
* @param string $ip Resolved IP address.
*/
return (string) apply_filters( 'wc_edm_client_ip', $ip );
}
/**
* Record an entry.
*
* @param string $outcome One of allowed|denied|rate_limited.
* @param string $method HTTP method.
* @param string $route REST route.
* @param int $status HTTP status code.
* @param array $details Optional extra context.
* @return void
*/
public static function record( $outcome, $method, $route, $status = 0, $details = array() ) {
if ( ! self::is_enabled() ) {
return;
}
if ( ! in_array( $outcome, array( 'allowed', 'denied', 'rate_limited' ), true ) ) {
$outcome = 'allowed';
}
$user = wp_get_current_user();
WC_EDM_DB::insert_audit_entry(
array(
'user_id' => $user ? (int) $user->ID : 0,
'user_login' => $user && $user->user_login ? $user->user_login : '',
'ip' => self::get_client_ip(),
'method' => strtoupper( substr( (string) $method, 0, 10 ) ),
'route' => substr( (string) $route, 0, 191 ),
'status' => (int) $status,
'outcome' => $outcome,
'details' => ! empty( $details ) ? wp_json_encode( $details ) : null,
)
);
}
/**
* Delete entries older than the retention period.
*
* @return void
*/
public static function prune() {
WC_EDM_DB::prune_audit_log( self::retention_days() );
}
}
+647
View File
@@ -0,0 +1,647 @@
<?php
/**
* Database tables and CRUD.
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles custom tables and all CRUD operations.
*/
class WC_EDM_DB {
/**
* Get a fully prefixed table name.
*
* @param string $name Short table name (groups, group_categories, emails, shipping_rules).
* @return string
*/
public static function table( $name ) {
global $wpdb;
return $wpdb->prefix . 'wc_edm_' . $name;
}
/**
* Create/upgrade the custom tables via dbDelta(), then add foreign keys.
*/
public static function create_tables() {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset_collate = $wpdb->get_charset_collate();
$groups = self::table( 'groups' );
$group_categories = self::table( 'group_categories' );
$emails = self::table( 'emails' );
$shipping_rules = self::table( 'shipping_rules' );
$audit_log = self::table( 'audit_log' );
$sql = "CREATE TABLE {$groups} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
name varchar(191) NOT NULL,
discount_percent decimal(5,2) NOT NULL DEFAULT 0.00,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
PRIMARY KEY (id)
) {$charset_collate};
CREATE TABLE {$group_categories} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
group_id bigint(20) unsigned NOT NULL,
category_id bigint(20) unsigned NOT NULL,
sort_order int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY group_category (group_id,category_id),
KEY group_id (group_id)
) {$charset_collate};
CREATE TABLE {$emails} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
email_pattern varchar(191) NOT NULL,
assignment_type enum('group','coupon','custom') NOT NULL DEFAULT 'group',
group_id bigint(20) unsigned DEFAULT NULL,
coupon_code varchar(191) DEFAULT NULL,
custom_discount decimal(5,2) DEFAULT NULL,
is_active tinyint(1) NOT NULL DEFAULT 1,
free_shipping tinyint(1) NOT NULL DEFAULT 0,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
PRIMARY KEY (id),
KEY email_pattern (email_pattern),
KEY group_id (group_id)
) {$charset_collate};
CREATE TABLE {$shipping_rules} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
group_id bigint(20) unsigned NOT NULL,
shipping_method_id varchar(191) NOT NULL,
action enum('show','hide') NOT NULL DEFAULT 'show',
sort_order int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY group_id (group_id)
) {$charset_collate};
CREATE TABLE {$audit_log} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
created_at datetime NOT NULL,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
user_login varchar(191) DEFAULT NULL,
ip varchar(45) DEFAULT NULL,
method varchar(10) NOT NULL DEFAULT '',
route varchar(191) NOT NULL DEFAULT '',
status smallint(5) unsigned NOT NULL DEFAULT 0,
outcome enum('allowed','denied','rate_limited') NOT NULL DEFAULT 'allowed',
details text DEFAULT NULL,
PRIMARY KEY (id),
KEY created_at (created_at),
KEY user_id (user_id),
KEY outcome (outcome)
) {$charset_collate};";
dbDelta( $sql );
self::add_foreign_keys();
update_option( 'wc_edm_db_version', WC_EDM_VERSION );
}
/**
* Run create_tables() again when the plugin was updated without
* re-activation (dbDelta adds any missing columns).
*/
public static function maybe_upgrade() {
if ( get_option( 'wc_edm_db_version' ) !== WC_EDM_VERSION ) {
self::create_tables();
}
}
/**
* Add foreign key constraints (dbDelta cannot handle these).
*/
private static function add_foreign_keys() {
$groups = self::table( 'groups' );
self::maybe_add_foreign_key(
self::table( 'group_categories' ),
'fk_wc_edm_group_categories_group',
"FOREIGN KEY (group_id) REFERENCES {$groups} (id) ON DELETE CASCADE"
);
self::maybe_add_foreign_key(
self::table( 'emails' ),
'fk_wc_edm_emails_group',
"FOREIGN KEY (group_id) REFERENCES {$groups} (id) ON DELETE SET NULL"
);
self::maybe_add_foreign_key(
self::table( 'shipping_rules' ),
'fk_wc_edm_shipping_rules_group',
"FOREIGN KEY (group_id) REFERENCES {$groups} (id) ON DELETE CASCADE"
);
}
/**
* Add a foreign key if it does not already exist.
*
* @param string $table Table name.
* @param string $constraint Constraint name.
* @param string $definition FOREIGN KEY definition SQL.
*/
private static function maybe_add_foreign_key( $table, $constraint, $definition ) {
global $wpdb;
$exists = $wpdb->get_var(
$wpdb->prepare(
'SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = %s AND TABLE_NAME = %s AND CONSTRAINT_NAME = %s AND CONSTRAINT_TYPE = %s',
DB_NAME,
$table,
$constraint,
'FOREIGN KEY'
)
);
if ( ! $exists ) {
// Table/constraint names are internal, not user input.
$wpdb->query( "ALTER TABLE {$table} ADD CONSTRAINT {$constraint} {$definition}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}
/*
|--------------------------------------------------------------------------
| Discount groups
|--------------------------------------------------------------------------
*/
/**
* Get all discount groups.
*
* @return array Array of row objects.
*/
public static function get_groups() {
global $wpdb;
$table = self::table( 'groups' );
return $wpdb->get_results( "SELECT * FROM {$table} ORDER BY name ASC" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Count discount groups.
*
* @return int
*/
public static function count_groups() {
global $wpdb;
$table = self::table( 'groups' );
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Get the most recently created groups.
*
* @param int $limit Number of groups.
* @return array
*/
public static function get_recent_groups( $limit = 5 ) {
global $wpdb;
$table = self::table( 'groups' );
return $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d", $limit ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
}
/**
* Get a single group.
*
* @param int $id Group ID.
* @return object|null
*/
public static function get_group( $id ) {
global $wpdb;
$table = self::table( 'groups' );
return $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
}
/**
* Insert a group.
*
* @param array $data { name, discount_percent }.
* @return int New group ID (0 on failure).
*/
public static function insert_group( $data ) {
global $wpdb;
$now = current_time( 'mysql' );
$wpdb->insert(
self::table( 'groups' ),
array(
'name' => $data['name'],
'discount_percent' => $data['discount_percent'],
'created_at' => $now,
'updated_at' => $now,
),
array( '%s', '%f', '%s', '%s' )
);
return (int) $wpdb->insert_id;
}
/**
* Update a group.
*
* @param int $id Group ID.
* @param array $data { name, discount_percent }.
* @return bool
*/
public static function update_group( $id, $data ) {
global $wpdb;
return false !== $wpdb->update(
self::table( 'groups' ),
array(
'name' => $data['name'],
'discount_percent' => $data['discount_percent'],
'updated_at' => current_time( 'mysql' ),
),
array( 'id' => $id ),
array( '%s', '%f', '%s' ),
array( '%d' )
);
}
/**
* Delete a group and its related rows.
*
* @param int $id Group ID.
* @return bool
*/
public static function delete_group( $id ) {
global $wpdb;
// Manual cleanup in case foreign keys are unsupported (e.g. MyISAM).
$wpdb->delete( self::table( 'group_categories' ), array( 'group_id' => $id ), array( '%d' ) );
$wpdb->delete( self::table( 'shipping_rules' ), array( 'group_id' => $id ), array( '%d' ) );
$wpdb->update( self::table( 'emails' ), array( 'group_id' => null ), array( 'group_id' => $id ), array( '%d' ), array( '%d' ) );
return false !== $wpdb->delete( self::table( 'groups' ), array( 'id' => $id ), array( '%d' ) );
}
/*
|--------------------------------------------------------------------------
| Group categories (M2M)
|--------------------------------------------------------------------------
*/
/**
* Get category IDs for a group, ordered by sort_order.
*
* @param int $group_id Group ID.
* @return int[]
*/
public static function get_group_category_ids( $group_id ) {
global $wpdb;
$table = self::table( 'group_categories' );
return array_map(
'intval',
$wpdb->get_col(
$wpdb->prepare( "SELECT category_id FROM {$table} WHERE group_id = %d ORDER BY sort_order ASC, id ASC", $group_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
)
);
}
/**
* Replace the categories for a group. Sort order follows array order.
*
* @param int $group_id Group ID.
* @param int[] $category_ids Ordered category IDs.
*/
public static function set_group_categories( $group_id, $category_ids ) {
global $wpdb;
$wpdb->delete( self::table( 'group_categories' ), array( 'group_id' => $group_id ), array( '%d' ) );
$category_ids = array_values( array_unique( array_map( 'intval', $category_ids ) ) );
foreach ( $category_ids as $index => $category_id ) {
if ( $category_id <= 0 ) {
continue;
}
$wpdb->insert(
self::table( 'group_categories' ),
array(
'group_id' => $group_id,
'category_id' => $category_id,
'sort_order' => $index,
),
array( '%d', '%d', '%d' )
);
}
}
/*
|--------------------------------------------------------------------------
| Email assignments
|--------------------------------------------------------------------------
*/
/**
* Get all email assignments.
*
* @return array
*/
public static function get_emails() {
global $wpdb;
$table = self::table( 'emails' );
return $wpdb->get_results( "SELECT * FROM {$table} ORDER BY email_pattern ASC" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Get all active email assignments.
*
* @return array
*/
public static function get_active_emails() {
global $wpdb;
$table = self::table( 'emails' );
return $wpdb->get_results( "SELECT * FROM {$table} WHERE is_active = 1" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Count email assignments.
*
* @param bool $active_only Count only active assignments.
* @return int
*/
public static function count_emails( $active_only = false ) {
global $wpdb;
$table = self::table( 'emails' );
$sql = "SELECT COUNT(*) FROM {$table}";
if ( $active_only ) {
$sql .= ' WHERE is_active = 1';
}
return (int) $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Get a single email assignment.
*
* @param int $id Assignment ID.
* @return object|null
*/
public static function get_email( $id ) {
global $wpdb;
$table = self::table( 'emails' );
return $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
}
/**
* Insert an email assignment.
*
* @param array $data Assignment data.
* @return int New ID (0 on failure).
*/
public static function insert_email( $data ) {
global $wpdb;
$now = current_time( 'mysql' );
$wpdb->insert(
self::table( 'emails' ),
array(
'email_pattern' => $data['email_pattern'],
'assignment_type' => $data['assignment_type'],
'group_id' => $data['group_id'],
'coupon_code' => $data['coupon_code'],
'custom_discount' => $data['custom_discount'],
'is_active' => $data['is_active'],
'free_shipping' => $data['free_shipping'],
'created_at' => $now,
'updated_at' => $now,
)
);
return (int) $wpdb->insert_id;
}
/**
* Update an email assignment.
*
* @param int $id Assignment ID.
* @param array $data Assignment data.
* @return bool
*/
public static function update_email( $id, $data ) {
global $wpdb;
return false !== $wpdb->update(
self::table( 'emails' ),
array(
'email_pattern' => $data['email_pattern'],
'assignment_type' => $data['assignment_type'],
'group_id' => $data['group_id'],
'coupon_code' => $data['coupon_code'],
'custom_discount' => $data['custom_discount'],
'is_active' => $data['is_active'],
'free_shipping' => $data['free_shipping'],
'updated_at' => current_time( 'mysql' ),
),
array( 'id' => $id )
);
}
/**
* Toggle an email assignment's active flag.
*
* @param int $id Assignment ID.
* @param int $is_active 1 or 0.
* @return bool
*/
public static function set_email_active( $id, $is_active ) {
global $wpdb;
return false !== $wpdb->update(
self::table( 'emails' ),
array(
'is_active' => $is_active ? 1 : 0,
'updated_at' => current_time( 'mysql' ),
),
array( 'id' => $id ),
array( '%d', '%s' ),
array( '%d' )
);
}
/**
* Delete an email assignment.
*
* @param int $id Assignment ID.
* @return bool
*/
public static function delete_email( $id ) {
global $wpdb;
return false !== $wpdb->delete( self::table( 'emails' ), array( 'id' => $id ), array( '%d' ) );
}
/*
|--------------------------------------------------------------------------
| Shipping rules
|--------------------------------------------------------------------------
*/
/**
* Get shipping rules for a group, ordered by sort_order.
*
* @param int $group_id Group ID.
* @return array
*/
public static function get_shipping_rules( $group_id ) {
global $wpdb;
$table = self::table( 'shipping_rules' );
return $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$table} WHERE group_id = %d ORDER BY sort_order ASC, id ASC", $group_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
}
/**
* Replace all shipping rules for a group.
*
* @param int $group_id Group ID.
* @param array $rules Array of { shipping_method_id, action, sort_order }.
*/
public static function replace_shipping_rules( $group_id, $rules ) {
global $wpdb;
$wpdb->delete( self::table( 'shipping_rules' ), array( 'group_id' => $group_id ), array( '%d' ) );
foreach ( $rules as $rule ) {
if ( empty( $rule['shipping_method_id'] ) || ! in_array( $rule['action'], array( 'show', 'hide' ), true ) ) {
continue;
}
$wpdb->insert(
self::table( 'shipping_rules' ),
array(
'group_id' => $group_id,
'shipping_method_id' => $rule['shipping_method_id'],
'action' => $rule['action'],
'sort_order' => (int) $rule['sort_order'],
),
array( '%d', '%s', '%s', '%d' )
);
}
}
/*
|--------------------------------------------------------------------------
| Audit log
|--------------------------------------------------------------------------
*/
/**
* Insert an audit log entry.
*
* @param array $data Entry data.
* @return int New entry ID (0 on failure).
*/
public static function insert_audit_entry( $data ) {
global $wpdb;
$wpdb->insert(
self::table( 'audit_log' ),
array(
'created_at' => current_time( 'mysql' ),
'user_id' => (int) $data['user_id'],
'user_login' => $data['user_login'],
'ip' => $data['ip'],
'method' => $data['method'],
'route' => $data['route'],
'status' => (int) $data['status'],
'outcome' => $data['outcome'],
'details' => $data['details'],
),
array( '%s', '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s' )
);
return (int) $wpdb->insert_id;
}
/**
* Get audit log entries, newest first.
*
* @param int $limit Max entries.
* @param int $offset Offset.
* @param string $outcome Optional outcome filter (allowed/denied/rate_limited).
* @return array
*/
public static function get_audit_entries( $limit = 50, $offset = 0, $outcome = '' ) {
global $wpdb;
$table = self::table( 'audit_log' );
if ( in_array( $outcome, array( 'allowed', 'denied', 'rate_limited' ), true ) ) {
return $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE outcome = %s ORDER BY id DESC LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$outcome,
$limit,
$offset
)
);
}
return $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d OFFSET %d", $limit, $offset ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
}
/**
* Count audit log entries.
*
* @param string $outcome Optional outcome filter.
* @return int
*/
public static function count_audit_entries( $outcome = '' ) {
global $wpdb;
$table = self::table( 'audit_log' );
if ( in_array( $outcome, array( 'allowed', 'denied', 'rate_limited' ), true ) ) {
return (int) $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE outcome = %s", $outcome ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
}
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Delete audit entries older than the retention period.
*
* @param int $days Retention in days.
* @return int Rows deleted.
*/
public static function prune_audit_log( $days ) {
global $wpdb;
$table = self::table( 'audit_log' );
$cutoff = gmdate( 'Y-m-d H:i:s', strtotime( current_time( 'mysql' ) . ' -' . absint( $days ) . ' days' ) );
return (int) $wpdb->query(
$wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", $cutoff ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
}
/**
* Delete every audit log entry.
*
* @return int Rows deleted.
*/
public static function clear_audit_log() {
global $wpdb;
$table = self::table( 'audit_log' );
return (int) $wpdb->query( "DELETE FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}
+413
View File
@@ -0,0 +1,413 @@
<?php
/**
* Frontend / checkout integration.
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Applies discounts, auto-applies coupons and filters shipping methods on checkout.
*/
class WC_EDM_Frontend {
/**
* Session key holding the tracked guest email.
*/
const SESSION_EMAIL_KEY = 'wc_edm_email';
/**
* Guard so the coupon is only auto-applied once per request.
*
* @var bool
*/
private $coupon_applied = false;
/**
* Hook everything up.
*/
public function __construct() {
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
add_action( 'wp_ajax_wc_edm_track_email', array( $this, 'ajax_track_email' ) );
add_action( 'wp_ajax_nopriv_wc_edm_track_email', array( $this, 'ajax_track_email' ) );
add_action( 'woocommerce_cart_calculate_fees', array( $this, 'apply_discount_fees' ), 20 );
add_action( 'woocommerce_before_calculate_totals', array( $this, 'maybe_apply_coupon' ), 20 );
add_filter( 'woocommerce_package_rates', array( $this, 'filter_shipping_rates' ), 20, 2 );
}
/**
* Enqueue the checkout email tracking script.
*/
public function enqueue_scripts() {
if ( ! function_exists( 'is_checkout' ) || ( ! is_checkout() && ! is_cart() ) ) {
return;
}
$params = array(
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'wc-edm-track-email' ),
);
$script = '
jQuery( function( $ ) {
var wcEdm = ' . wp_json_encode( $params ) . ';
var timer = null;
var lastTracked = "";
function trackEmail( email ) {
if ( ! email || email.indexOf( "@" ) < 1 || email === lastTracked ) {
return;
}
lastTracked = email;
$.post( wcEdm.ajax_url, {
action: "wc_edm_track_email",
nonce: wcEdm.nonce,
email: email
}, function() {
$( document.body ).trigger( "update_checkout" );
} );
}
$( document.body ).on( "keyup change blur", "#billing_email", function() {
var email = $( this ).val();
clearTimeout( timer );
timer = setTimeout( function() {
trackEmail( email );
}, 600 );
} );
// Track a prefilled email on page load.
var initial = $( "#billing_email" ).val();
if ( initial ) {
trackEmail( initial );
}
} );';
wp_add_inline_script( 'jquery', $script );
}
/**
* AJAX: store the checkout email in the WooCommerce session so
* discounts apply when totals are recalculated.
*/
public function ajax_track_email() {
check_ajax_referer( 'wc-edm-track-email', 'nonce' );
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
if ( ! WC()->session ) {
wp_send_json_error( array( 'message' => 'No session.' ) );
}
if ( $email && is_email( $email ) ) {
WC()->session->set( self::SESSION_EMAIL_KEY, $email );
} else {
WC()->session->set( self::SESSION_EMAIL_KEY, null );
}
$this->clear_shipping_cache();
$match = $email ? WC_EDM_Helpers::find_email_match( $email ) : null;
wp_send_json_success(
array(
'matched' => (bool) $match,
)
);
}
/**
* Clear cached shipping rates so shipping rules re-evaluate.
*/
private function clear_shipping_cache() {
if ( ! WC()->cart || ! WC()->session ) {
return;
}
$packages = WC()->cart->get_shipping_packages();
foreach ( array_keys( $packages ) as $index ) {
WC()->session->set( 'shipping_for_package_' . $index, null );
}
}
/**
* Resolve the email address of the current customer.
*
* Logged-in users use their account email; guests use the tracked
* session email or the billing email WooCommerce already knows.
*
* @return string
*/
public function get_customer_email() {
if ( is_user_logged_in() ) {
$user = wp_get_current_user();
if ( $user && $user->user_email ) {
return $user->user_email;
}
}
if ( WC()->session ) {
$session_email = WC()->session->get( self::SESSION_EMAIL_KEY );
if ( $session_email ) {
return $session_email;
}
}
if ( WC()->customer ) {
$billing_email = WC()->customer->get_billing_email();
if ( $billing_email ) {
return $billing_email;
}
}
return '';
}
/**
* Get the current customer's email assignment match, if any.
*
* @return object|null
*/
private function get_current_match() {
$email = $this->get_customer_email();
if ( ! $email ) {
return null;
}
return WC_EDM_Helpers::find_email_match( $email );
}
/**
* Apply group/custom discounts as negative fees.
*
* @param WC_Cart $cart Cart object.
*/
public function apply_discount_fees( $cart ) {
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
return;
}
$match = $this->get_current_match();
if ( ! $match ) {
return;
}
if ( 'group' === $match->assignment_type ) {
$this->apply_group_fee( $cart, $match );
} elseif ( 'custom' === $match->assignment_type ) {
$this->apply_custom_fee( $cart, $match );
}
}
/**
* Group discount: percentage off cart items in the group's categories.
*
* @param WC_Cart $cart Cart object.
* @param object $match Email assignment row.
*/
private function apply_group_fee( $cart, $match ) {
if ( ! $match->group_id ) {
return;
}
$group = WC_EDM_DB::get_group( (int) $match->group_id );
if ( ! $group || (float) $group->discount_percent <= 0 ) {
return;
}
$category_ids = WC_EDM_DB::get_group_category_ids( (int) $group->id );
if ( empty( $category_ids ) ) {
return;
}
$eligible_total = 0;
foreach ( $cart->get_cart() as $cart_item ) {
$product_id = (int) $cart_item['product_id']; // Parent ID for variations.
if ( has_term( $category_ids, 'product_cat', $product_id ) ) {
$eligible_total += (float) $cart_item['line_total'];
}
}
if ( $eligible_total <= 0 ) {
return;
}
$discount = $eligible_total * ( (float) $group->discount_percent / 100 );
$cart->add_fee(
/* translators: %s: discount group name */
sprintf( __( 'Email discount: %s', 'wc-email-discount' ), $group->name ),
-$discount,
false
);
}
/**
* Custom discount: percentage off the entire cart contents.
*
* @param WC_Cart $cart Cart object.
* @param object $match Email assignment row.
*/
private function apply_custom_fee( $cart, $match ) {
$percent = (float) $match->custom_discount;
if ( $percent <= 0 ) {
return;
}
$cart_total = 0;
foreach ( $cart->get_cart() as $cart_item ) {
$cart_total += (float) $cart_item['line_total'];
}
if ( $cart_total <= 0 ) {
return;
}
$discount = $cart_total * ( $percent / 100 );
$cart->add_fee(
/* translators: %s: discount percent */
sprintf( __( 'Email discount (%s%%)', 'wc-email-discount' ), wc_format_localized_decimal( $percent ) ),
-$discount,
false
);
}
/**
* Auto-apply a coupon for coupon-type assignments.
*
* @param WC_Cart $cart Cart object.
*/
public function maybe_apply_coupon( $cart ) {
if ( $this->coupon_applied || ( is_admin() && ! defined( 'DOING_AJAX' ) ) ) {
return;
}
$match = $this->get_current_match();
if ( ! $match || 'coupon' !== $match->assignment_type || empty( $match->coupon_code ) ) {
return;
}
$code = wc_format_coupon_code( $match->coupon_code );
if ( ! wc_get_coupon_id_by_code( $code ) ) {
return;
}
if ( $cart->has_discount( $code ) ) {
return;
}
$this->coupon_applied = true;
$cart->apply_coupon( $code );
}
/**
* Filter shipping rates for the matched email assignment.
*
* Group assignments get their show/hide rules applied; any assignment
* with the free shipping flag gets all remaining rates at zero cost.
*
* @param array $rates Available shipping rates (rate_id => WC_Shipping_Rate).
* @param array $package Shipping package.
* @return array
*/
public function filter_shipping_rates( $rates, $package ) {
$match = $this->get_current_match();
if ( ! $match ) {
return $rates;
}
if ( 'group' === $match->assignment_type && $match->group_id ) {
$rates = $this->apply_group_shipping_rules( $rates, (int) $match->group_id );
}
if ( ! empty( $match->free_shipping ) ) {
foreach ( $rates as $rate ) {
$rate->set_cost( 0 );
$rate->set_taxes( array() );
}
}
return $rates;
}
/**
* Apply a group's show/hide shipping rules.
*
* If the group has no rules, all rates pass through untouched.
* "show" rules act as a whitelist; "hide" rules remove rates.
* Rates are reordered to follow the rules' sort order.
*
* @param array $rates Available shipping rates (rate_id => WC_Shipping_Rate).
* @param int $group_id Discount group ID.
* @return array
*/
private function apply_group_shipping_rules( $rates, $group_id ) {
$rules = WC_EDM_DB::get_shipping_rules( $group_id );
if ( empty( $rules ) ) {
return $rates;
}
$show = array();
$hide = array();
foreach ( $rules as $rule ) {
if ( 'show' === $rule->action ) {
$show[] = $rule->shipping_method_id;
} else {
$hide[] = $rule->shipping_method_id;
}
}
$filtered = array();
foreach ( $rates as $rate_id => $rate ) {
// Whitelist mode: only explicitly shown methods survive.
if ( ! empty( $show ) && ! in_array( $rate_id, $show, true ) ) {
continue;
}
if ( in_array( $rate_id, $hide, true ) ) {
continue;
}
$filtered[ $rate_id ] = $rate;
}
// Never leave the customer without shipping because of misconfigured rules.
if ( empty( $filtered ) ) {
return $rates;
}
// Reorder rates to follow the rules' sort order; unmatched rates keep their position at the end.
$rule_order = wp_list_pluck( $rules, 'shipping_method_id' );
$ordered = array();
foreach ( $rule_order as $method_id ) {
if ( isset( $filtered[ $method_id ] ) ) {
$ordered[ $method_id ] = $filtered[ $method_id ];
unset( $filtered[ $method_id ] );
}
}
return $ordered + $filtered;
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
/**
* Utility functions.
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Static helper methods shared between admin and frontend.
*/
class WC_EDM_Helpers {
/**
* Find the best matching email assignment for an email address.
*
* Match priority:
* 1. Exact email match (john@example.com)
* 2. Domain match (@company.com)
* 3. Partial match (john)
*
* @param string $email Email address.
* @return object|null Matching row from the emails table, or null.
*/
public static function find_email_match( $email ) {
$email = strtolower( trim( (string) $email ) );
if ( '' === $email || ! is_email( $email ) ) {
return null;
}
$assignments = WC_EDM_DB::get_active_emails();
if ( empty( $assignments ) ) {
return null;
}
$domain_match = null;
$partial_match = null;
foreach ( $assignments as $assignment ) {
$pattern = strtolower( trim( $assignment->email_pattern ) );
if ( '' === $pattern ) {
continue;
}
// 1. Exact match wins immediately.
if ( $pattern === $email ) {
return $assignment;
}
// 2. Domain match: pattern starts with '@'.
if ( null === $domain_match && 0 === strpos( $pattern, '@' ) ) {
if ( substr( $email, -strlen( $pattern ) ) === $pattern ) {
$domain_match = $assignment;
}
continue;
}
// 3. Partial match: pattern occurs anywhere in the email.
if ( null === $partial_match && false === strpos( $pattern, '@' ) && false !== strpos( $email, $pattern ) ) {
$partial_match = $assignment;
}
}
if ( $domain_match ) {
return $domain_match;
}
return $partial_match;
}
/**
* Get all product categories.
*
* @return WP_Term[]
*/
public static function get_product_categories() {
$terms = get_terms(
array(
'taxonomy' => 'product_cat',
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
)
);
return is_wp_error( $terms ) ? array() : $terms;
}
/**
* Get every shipping method instance across all zones (including "Rest of the world").
*
* @return array Map of method key ("flat_rate:3") => array{ label, zone, method }.
*/
public static function get_all_shipping_methods() {
$methods = array();
if ( ! class_exists( 'WC_Shipping_Zones' ) ) {
return $methods;
}
$zones = WC_Shipping_Zones::get_zones();
// Zone 0 = "Locations not covered by your other zones".
$rest_of_world = new WC_Shipping_Zone( 0 );
$zones[] = array(
'zone_name' => $rest_of_world->get_zone_name(),
'shipping_methods' => $rest_of_world->get_shipping_methods(),
);
foreach ( $zones as $zone ) {
$zone_name = $zone['zone_name'];
foreach ( $zone['shipping_methods'] as $method ) {
$key = $method->id . ':' . $method->get_instance_id();
$methods[ $key ] = array(
'key' => $key,
'label' => $method->get_title(),
'zone' => $zone_name,
'method' => $method->id,
);
}
}
return $methods;
}
/**
* Get all published WooCommerce coupon codes.
*
* @return string[]
*/
public static function get_coupon_codes() {
$posts = get_posts(
array(
'post_type' => 'shop_coupon',
'post_status' => 'publish',
'posts_per_page' => 500,
'orderby' => 'title',
'order' => 'ASC',
)
);
$codes = array();
foreach ( $posts as $post ) {
$codes[] = wc_format_coupon_code( $post->post_title );
}
return array_values( array_unique( $codes ) );
}
/**
* Get a human-readable description of an email assignment's target.
*
* @param object $assignment Row from the emails table.
* @return string
*/
public static function describe_assignment( $assignment ) {
$extra = ! empty( $assignment->free_shipping )
? ' + ' . __( 'free shipping', 'wc-email-discount' )
: '';
return self::describe_assignment_type( $assignment ) . $extra;
}
/**
* Describe the discount part of an assignment (without extras).
*
* @param object $assignment Row from the emails table.
* @return string
*/
private static function describe_assignment_type( $assignment ) {
switch ( $assignment->assignment_type ) {
case 'group':
$group = $assignment->group_id ? WC_EDM_DB::get_group( (int) $assignment->group_id ) : null;
if ( $group ) {
/* translators: 1: group name, 2: discount percent */
return sprintf( __( '%1$s (%2$s%%)', 'wc-email-discount' ), $group->name, wc_format_localized_decimal( $group->discount_percent ) );
}
return __( 'Group missing', 'wc-email-discount' );
case 'coupon':
/* translators: %s: coupon code */
return sprintf( __( 'Coupon: %s', 'wc-email-discount' ), $assignment->coupon_code );
case 'custom':
/* translators: %s: discount percent */
return sprintf( __( '%s%% off entire cart', 'wc-email-discount' ), wc_format_localized_decimal( $assignment->custom_discount ) );
}
return '';
}
/**
* Sanitize a discount percent value into the 0–100 range.
*
* @param mixed $value Raw value.
* @return float
*/
public static function sanitize_percent( $value ) {
$value = (float) wc_format_decimal( wp_unslash( $value ) );
return max( 0, min( 100, $value ) );
}
/**
* Build an admin URL for the plugin page.
*
* @param array $args Extra query args (tab, action, id, ...).
* @return string
*/
public static function admin_url( $args = array() ) {
return add_query_arg(
array_merge( array( 'page' => 'wc-email-discounts' ), $args ),
admin_url( 'admin.php' )
);
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
/**
* Rate limiting for the wc-edm REST API.
*
* Uses a transient counter per identity (user ID when authenticated,
* client IP otherwise) inside a rolling window.
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Simple transient-backed request throttle.
*/
class WC_EDM_Rate_Limiter {
/**
* Transient key prefix.
*/
const PREFIX = 'wc_edm_rl_';
/**
* Whether rate limiting is active.
*
* @return bool
*/
public static function is_enabled() {
/**
* Filter whether the REST API is rate limited.
*
* @param bool $enabled Default true.
*/
return (bool) apply_filters( 'wc_edm_rate_limit_enabled', true );
}
/**
* Maximum requests allowed per window.
*
* @param string $method HTTP method of the current request.
* @return int
*/
public static function max_requests( $method = 'GET' ) {
$default = self::is_write_method( $method ) ? 20 : 60;
/**
* Filter the request allowance per window.
*
* Writes get a tighter default than reads.
*
* @param int $default Allowed requests.
* @param string $method HTTP method.
*/
return max( 1, (int) apply_filters( 'wc_edm_rate_limit_max_requests', $default, $method ) );
}
/**
* Length of the rolling window in seconds.
*
* @param string $method HTTP method of the current request.
* @return int
*/
public static function window( $method = 'GET' ) {
/**
* Filter the rate limit window in seconds.
*
* @param int $seconds Default 60.
* @param string $method HTTP method.
*/
return max( 1, (int) apply_filters( 'wc_edm_rate_limit_window', MINUTE_IN_SECONDS, $method ) );
}
/**
* Whether a method mutates data.
*
* @param string $method HTTP method.
* @return bool
*/
public static function is_write_method( $method ) {
return in_array( strtoupper( (string) $method ), array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true );
}
/**
* Identity used to bucket requests.
*
* @return string
*/
private static function identity() {
$user_id = get_current_user_id();
if ( $user_id ) {
return 'u' . $user_id;
}
$ip = WC_EDM_Audit_Log::get_client_ip();
return 'i' . md5( $ip ? $ip : 'unknown' );
}
/**
* Whether this rate limit hit should be written to the audit log.
*
* A blocked caller that keeps hammering would otherwise write one log row
* per rejected request, turning the audit log into an amplifier. Only the
* first hit per identity per window is recorded.
*
* @return bool
*/
public static function should_log_limit_hit() {
$key = self::PREFIX . 'logged_' . self::identity();
if ( get_transient( $key ) ) {
return false;
}
set_transient( $key, 1, self::window() );
return true;
}
/**
* Register a request and report whether the caller is over the limit.
*
* @param string $method HTTP method.
* @return array{limited:bool,count:int,max:int,retry_after:int}
*/
public static function check( $method = 'GET' ) {
$max = self::max_requests( $method );
$window = self::window( $method );
if ( ! self::is_enabled() ) {
return array(
'limited' => false,
'count' => 0,
'max' => $max,
'retry_after' => 0,
);
}
// The key carries the window slice, so each window gets its own
// counter and expires on its own. Re-arming a single transient's TTL
// on every request would instead extend the block indefinitely.
$now = time();
$slice = (int) floor( $now / $window );
$retry_after = ( ( $slice + 1 ) * $window ) - $now;
$bucket = self::is_write_method( $method ) ? 'w' : 'r';
$key = self::PREFIX . $bucket . '_' . self::identity() . '_' . $slice;
$count = (int) get_transient( $key ) + 1;
set_transient( $key, $count, $window );
return array(
'limited' => $count > $max,
'count' => $count,
'max' => $max,
'retry_after' => max( 1, $retry_after ),
);
}
}
+796
View File
@@ -0,0 +1,796 @@
<?php
/**
* REST API for remote management.
*
* All routes require an authenticated user with the manage_woocommerce
* capability (e.g. via a WordPress Application Password over HTTPS).
* There is deliberately no token-in-code or unauthenticated access.
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registers wc-edm/v1 REST routes for groups, emails and shipping rules.
*/
class WC_EDM_REST_API {
/**
* REST namespace.
*/
const REST_NAMESPACE = 'wc-edm/v1';
/**
* Hook route registration.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
add_filter( 'rest_request_after_callbacks', array( $this, 'log_request' ), 10, 3 );
}
/**
* Permission check shared by all routes.
*
* Rate limiting runs before the capability check so that unauthenticated
* floods are throttled too.
*
* @param WP_REST_Request|null $request Current request.
* @return bool|WP_Error
*/
public function permission_check( $request = null ) {
$method = $request instanceof WP_REST_Request ? $request->get_method() : 'GET';
$rate = WC_EDM_Rate_Limiter::check( $method );
if ( $rate['limited'] ) {
return new WP_Error(
'wc_edm_rate_limited',
__( 'Too many requests. Please slow down.', 'wc-email-discount' ),
array(
'status' => 429,
'retry_after' => $rate['retry_after'],
)
);
}
if ( current_user_can( 'manage_woocommerce' ) ) {
return true;
}
return new WP_Error(
'wc_edm_forbidden',
__( 'You are not allowed to manage email discounts.', 'wc-email-discount' ),
array( 'status' => rest_authorization_required_code() )
);
}
/**
* Write an audit entry for wc-edm requests.
*
* Runs after the route callback (and also when a permission check failed),
* so both successful writes and rejected attempts are recorded. Successful
* reads are skipped to keep the log signal-heavy.
*
* @param WP_REST_Response|WP_HTTP_Response|WP_Error $response Result to send.
* @param array $handler Route handler.
* @param WP_REST_Request $request Current request.
* @return mixed The unmodified response.
*/
public function log_request( $response, $handler, $request ) {
if ( ! $request instanceof WP_REST_Request ) {
return $response;
}
$route = $request->get_route();
if ( 0 !== strpos( ltrim( $route, '/' ), self::REST_NAMESPACE ) ) {
return $response;
}
$method = $request->get_method();
$is_write = WC_EDM_Rate_Limiter::is_write_method( $method );
$details = array();
$outcome = 'allowed';
$status = 200;
if ( is_wp_error( $response ) ) {
$error_data = $response->get_error_data();
$status = is_array( $error_data ) && isset( $error_data['status'] ) ? (int) $error_data['status'] : 500;
$outcome = 'wc_edm_rate_limited' === $response->get_error_code() ? 'rate_limited' : 'denied';
$details['code'] = $response->get_error_code();
} elseif ( $response instanceof WP_HTTP_Response ) {
$status = $response->get_status();
if ( $status >= 400 ) {
$outcome = 'denied';
$data = $response->get_data();
$details['code'] = is_array( $data ) && isset( $data['code'] ) ? $data['code'] : 'error';
}
}
// Successful reads are noise; everything else is worth keeping.
if ( 'allowed' === $outcome && ! $is_write ) {
return $response;
}
// Don't let a blocked caller flood the log with identical rows.
if ( 'rate_limited' === $outcome && ! WC_EDM_Rate_Limiter::should_log_limit_hit() ) {
return $response;
}
WC_EDM_Audit_Log::record( $outcome, $method, $route, $status, $details );
return $response;
}
/**
* Register all routes.
*/
public function register_routes() {
$perm = array( $this, 'permission_check' );
register_rest_route(
self::REST_NAMESPACE,
'/status',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_status' ),
'permission_callback' => $perm,
)
);
register_rest_route(
self::REST_NAMESPACE,
'/groups',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_groups' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_group' ),
'permission_callback' => $perm,
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/groups/(?P<id>\d+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_group' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_group' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_group' ),
'permission_callback' => $perm,
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/emails',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_emails' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_email' ),
'permission_callback' => $perm,
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/emails/(?P<id>\d+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_email' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_email' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_email' ),
'permission_callback' => $perm,
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/shipping-methods',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_shipping_methods' ),
'permission_callback' => $perm,
)
);
register_rest_route(
self::REST_NAMESPACE,
'/groups/(?P<id>\d+)/shipping-rules',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_shipping_rules' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_shipping_rules' ),
'permission_callback' => $perm,
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/audit-log',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_audit_log' ),
'permission_callback' => $perm,
'args' => array(
'limit' => array(
'type' => 'integer',
'default' => 50,
),
'offset' => array(
'type' => 'integer',
'default' => 0,
),
'outcome' => array(
'type' => 'string',
'enum' => array( 'allowed', 'denied', 'rate_limited' ),
),
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/match',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'match_email' ),
'permission_callback' => $perm,
'args' => array(
'email' => array(
'required' => true,
'type' => 'string',
),
),
)
);
}
/*
|--------------------------------------------------------------------------
| Formatters
|--------------------------------------------------------------------------
*/
/**
* Format a group row for API output.
*
* @param object $group Group row.
* @return array
*/
private function format_group( $group ) {
return array(
'id' => (int) $group->id,
'name' => $group->name,
'discount_percent' => (float) $group->discount_percent,
'category_ids' => WC_EDM_DB::get_group_category_ids( (int) $group->id ),
'created_at' => $group->created_at,
'updated_at' => $group->updated_at,
);
}
/**
* Format an email assignment row for API output.
*
* @param object $assignment Email row.
* @return array
*/
private function format_email( $assignment ) {
return array(
'id' => (int) $assignment->id,
'email_pattern' => $assignment->email_pattern,
'assignment_type' => $assignment->assignment_type,
'group_id' => $assignment->group_id ? (int) $assignment->group_id : null,
'coupon_code' => $assignment->coupon_code,
'custom_discount' => null !== $assignment->custom_discount ? (float) $assignment->custom_discount : null,
'is_active' => (bool) $assignment->is_active,
'free_shipping' => ! empty( $assignment->free_shipping ),
'created_at' => $assignment->created_at,
'updated_at' => $assignment->updated_at,
);
}
/**
* Standard 404 error.
*
* @return WP_Error
*/
private function not_found() {
return new WP_Error( 'wc_edm_not_found', __( 'Resource not found.', 'wc-email-discount' ), array( 'status' => 404 ) );
}
/*
|--------------------------------------------------------------------------
| Status
|--------------------------------------------------------------------------
*/
/**
* GET /status — connectivity test plus quick stats.
*
* @return WP_REST_Response
*/
public function get_status() {
return rest_ensure_response(
array(
'plugin' => 'woocommerce-email-discount-manager',
'version' => WC_EDM_VERSION,
'woocommerce' => defined( 'WC_VERSION' ) ? WC_VERSION : null,
'groups' => WC_EDM_DB::count_groups(),
'email_assignments' => WC_EDM_DB::count_emails(),
'active_assignments' => WC_EDM_DB::count_emails( true ),
)
);
}
/*
|--------------------------------------------------------------------------
| Groups
|--------------------------------------------------------------------------
*/
/**
* GET /groups
*
* @return WP_REST_Response
*/
public function get_groups() {
return rest_ensure_response( array_map( array( $this, 'format_group' ), WC_EDM_DB::get_groups() ) );
}
/**
* GET /groups/{id}
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function get_group( $request ) {
$group = WC_EDM_DB::get_group( (int) $request['id'] );
return $group ? rest_ensure_response( $this->format_group( $group ) ) : $this->not_found();
}
/**
* POST /groups
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function create_group( $request ) {
$name = sanitize_text_field( (string) $request['name'] );
if ( '' === $name ) {
return new WP_Error( 'wc_edm_invalid', __( 'A group name is required.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
$id = WC_EDM_DB::insert_group(
array(
'name' => $name,
'discount_percent' => WC_EDM_Helpers::sanitize_percent( $request['discount_percent'] ?? 0 ),
)
);
if ( ! $id ) {
return new WP_Error( 'wc_edm_db_error', __( 'Could not create the group.', 'wc-email-discount' ), array( 'status' => 500 ) );
}
if ( is_array( $request['category_ids'] ) ) {
WC_EDM_DB::set_group_categories( $id, array_map( 'absint', $request['category_ids'] ) );
}
return rest_ensure_response( $this->format_group( WC_EDM_DB::get_group( $id ) ) );
}
/**
* PUT/PATCH /groups/{id}
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function update_group( $request ) {
$id = (int) $request['id'];
$group = WC_EDM_DB::get_group( $id );
if ( ! $group ) {
return $this->not_found();
}
$name = null !== $request['name'] ? sanitize_text_field( (string) $request['name'] ) : $group->name;
$percent = null !== $request['discount_percent'] ? WC_EDM_Helpers::sanitize_percent( $request['discount_percent'] ) : (float) $group->discount_percent;
if ( '' === $name ) {
return new WP_Error( 'wc_edm_invalid', __( 'A group name is required.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
WC_EDM_DB::update_group(
$id,
array(
'name' => $name,
'discount_percent' => $percent,
)
);
if ( is_array( $request['category_ids'] ) ) {
WC_EDM_DB::set_group_categories( $id, array_map( 'absint', $request['category_ids'] ) );
}
return rest_ensure_response( $this->format_group( WC_EDM_DB::get_group( $id ) ) );
}
/**
* DELETE /groups/{id}
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function delete_group( $request ) {
$id = (int) $request['id'];
if ( ! WC_EDM_DB::get_group( $id ) ) {
return $this->not_found();
}
WC_EDM_DB::delete_group( $id );
return rest_ensure_response( array( 'deleted' => true, 'id' => $id ) );
}
/*
|--------------------------------------------------------------------------
| Email assignments
|--------------------------------------------------------------------------
*/
/**
* GET /emails
*
* @return WP_REST_Response
*/
public function get_emails() {
return rest_ensure_response( array_map( array( $this, 'format_email' ), WC_EDM_DB::get_emails() ) );
}
/**
* GET /emails/{id}
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function get_email( $request ) {
$assignment = WC_EDM_DB::get_email( (int) $request['id'] );
return $assignment ? rest_ensure_response( $this->format_email( $assignment ) ) : $this->not_found();
}
/**
* Validate and normalize email assignment input.
*
* @param WP_REST_Request $request Request.
* @param object|null $existing Existing row when updating.
* @return array|WP_Error
*/
private function prepare_email_data( $request, $existing = null ) {
$pattern = null !== $request['email_pattern']
? strtolower( sanitize_text_field( (string) $request['email_pattern'] ) )
: ( $existing ? $existing->email_pattern : '' );
$type = null !== $request['assignment_type']
? sanitize_key( (string) $request['assignment_type'] )
: ( $existing ? $existing->assignment_type : 'group' );
if ( '' === $pattern ) {
return new WP_Error( 'wc_edm_invalid', __( 'An email pattern is required.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
if ( ! in_array( $type, array( 'group', 'coupon', 'custom' ), true ) ) {
return new WP_Error( 'wc_edm_invalid', __( 'assignment_type must be one of: group, coupon, custom.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
$data = array(
'email_pattern' => $pattern,
'assignment_type' => $type,
'group_id' => null,
'coupon_code' => null,
'custom_discount' => null,
'is_active' => null !== $request['is_active']
? ( rest_sanitize_boolean( $request['is_active'] ) ? 1 : 0 )
: ( $existing ? (int) $existing->is_active : 1 ),
'free_shipping' => null !== $request['free_shipping']
? ( rest_sanitize_boolean( $request['free_shipping'] ) ? 1 : 0 )
: ( $existing ? (int) ! empty( $existing->free_shipping ) : 0 ),
);
switch ( $type ) {
case 'group':
$group_id = null !== $request['group_id']
? absint( $request['group_id'] )
: ( $existing && $existing->group_id ? (int) $existing->group_id : 0 );
if ( ! $group_id || ! WC_EDM_DB::get_group( $group_id ) ) {
return new WP_Error( 'wc_edm_invalid', __( 'A valid group_id is required for group assignments.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
$data['group_id'] = $group_id;
break;
case 'coupon':
$coupon = null !== $request['coupon_code']
? wc_format_coupon_code( (string) $request['coupon_code'] )
: ( $existing ? (string) $existing->coupon_code : '' );
if ( '' === $coupon ) {
return new WP_Error( 'wc_edm_invalid', __( 'A coupon_code is required for coupon assignments.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
$data['coupon_code'] = $coupon;
break;
case 'custom':
$data['custom_discount'] = null !== $request['custom_discount']
? WC_EDM_Helpers::sanitize_percent( $request['custom_discount'] )
: ( $existing && null !== $existing->custom_discount ? (float) $existing->custom_discount : 0 );
break;
}
return $data;
}
/**
* POST /emails
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function create_email( $request ) {
$data = $this->prepare_email_data( $request );
if ( is_wp_error( $data ) ) {
return $data;
}
$id = WC_EDM_DB::insert_email( $data );
if ( ! $id ) {
return new WP_Error( 'wc_edm_db_error', __( 'Could not create the assignment.', 'wc-email-discount' ), array( 'status' => 500 ) );
}
return rest_ensure_response( $this->format_email( WC_EDM_DB::get_email( $id ) ) );
}
/**
* PUT/PATCH /emails/{id}
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function update_email( $request ) {
$id = (int) $request['id'];
$existing = WC_EDM_DB::get_email( $id );
if ( ! $existing ) {
return $this->not_found();
}
$data = $this->prepare_email_data( $request, $existing );
if ( is_wp_error( $data ) ) {
return $data;
}
WC_EDM_DB::update_email( $id, $data );
return rest_ensure_response( $this->format_email( WC_EDM_DB::get_email( $id ) ) );
}
/**
* DELETE /emails/{id}
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function delete_email( $request ) {
$id = (int) $request['id'];
if ( ! WC_EDM_DB::get_email( $id ) ) {
return $this->not_found();
}
WC_EDM_DB::delete_email( $id );
return rest_ensure_response( array( 'deleted' => true, 'id' => $id ) );
}
/*
|--------------------------------------------------------------------------
| Shipping
|--------------------------------------------------------------------------
*/
/**
* GET /shipping-methods — all shipping method instances across zones.
*
* @return WP_REST_Response
*/
public function get_shipping_methods() {
return rest_ensure_response( array_values( WC_EDM_Helpers::get_all_shipping_methods() ) );
}
/**
* GET /groups/{id}/shipping-rules
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function get_shipping_rules( $request ) {
$group_id = (int) $request['id'];
if ( ! WC_EDM_DB::get_group( $group_id ) ) {
return $this->not_found();
}
$rules = array();
foreach ( WC_EDM_DB::get_shipping_rules( $group_id ) as $rule ) {
$rules[] = array(
'shipping_method_id' => $rule->shipping_method_id,
'action' => $rule->action,
'sort_order' => (int) $rule->sort_order,
);
}
return rest_ensure_response( $rules );
}
/**
* PUT /groups/{id}/shipping-rules — replace all rules for the group.
*
* Body: { "rules": [ { "shipping_method_id": "flat_rate:3", "action": "show" }, ... ] }
* Sort order follows array order. An empty rules array clears all rules.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function update_shipping_rules( $request ) {
$group_id = (int) $request['id'];
if ( ! WC_EDM_DB::get_group( $group_id ) ) {
return $this->not_found();
}
$raw = $request['rules'];
if ( ! is_array( $raw ) ) {
return new WP_Error( 'wc_edm_invalid', __( 'A rules array is required.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
$rules = array();
foreach ( array_values( $raw ) as $index => $rule ) {
$method_id = isset( $rule['shipping_method_id'] ) ? sanitize_text_field( (string) $rule['shipping_method_id'] ) : '';
$action = isset( $rule['action'] ) ? sanitize_key( (string) $rule['action'] ) : '';
if ( '' === $method_id || ! in_array( $action, array( 'show', 'hide' ), true ) ) {
return new WP_Error(
'wc_edm_invalid',
__( 'Each rule needs a shipping_method_id and an action of "show" or "hide".', 'wc-email-discount' ),
array( 'status' => 400 )
);
}
$rules[] = array(
'shipping_method_id' => $method_id,
'action' => $action,
'sort_order' => $index,
);
}
WC_EDM_DB::replace_shipping_rules( $group_id, $rules );
return $this->get_shipping_rules( $request );
}
/*
|--------------------------------------------------------------------------
| Audit log
|--------------------------------------------------------------------------
*/
/**
* GET /audit-log — recent REST activity, newest first.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response
*/
public function get_audit_log( $request ) {
$limit = min( 200, max( 1, (int) $request['limit'] ) );
$offset = max( 0, (int) $request['offset'] );
$outcome = $request['outcome'] ? sanitize_key( (string) $request['outcome'] ) : '';
$entries = array();
foreach ( WC_EDM_DB::get_audit_entries( $limit, $offset, $outcome ) as $entry ) {
$entries[] = array(
'id' => (int) $entry->id,
'created_at' => $entry->created_at,
'user_id' => (int) $entry->user_id,
'user_login' => $entry->user_login,
'ip' => $entry->ip,
'method' => $entry->method,
'route' => $entry->route,
'status' => (int) $entry->status,
'outcome' => $entry->outcome,
'details' => $entry->details ? json_decode( $entry->details, true ) : null,
);
}
$response = rest_ensure_response( $entries );
$response->header( 'X-WP-Total', (string) WC_EDM_DB::count_audit_entries( $outcome ) );
return $response;
}
/*
|--------------------------------------------------------------------------
| Match test
|--------------------------------------------------------------------------
*/
/**
* GET /match?email=... — test which assignment an email would get.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response
*/
public function match_email( $request ) {
$email = sanitize_email( (string) $request['email'] );
$match = $email ? WC_EDM_Helpers::find_email_match( $email ) : null;
return rest_ensure_response(
array(
'email' => $email,
'matched' => (bool) $match,
'match' => $match ? $this->format_email( $match ) : null,
)
);
}
}