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
+5
View File
@@ -0,0 +1,5 @@
.DS_Store
node_modules/
vendor/
*.zip
wc-credentials.json
+115
View File
@@ -0,0 +1,115 @@
=== WooCommerce Email Discount Manager ===
Contributors: wedsmp
Tags: woocommerce, discount, email, shipping, coupon
Requires at least: 5.8
Tested up to: 6.7
Requires PHP: 7.4
Stable tag: 1.3.1
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Apply discounts and show/hide shipping methods based on the customer's email address.
== Description ==
WooCommerce Email Discount Manager lets you give discounts and filter available
shipping methods based on the email address a customer logs in or checks out with.
= Discount Groups =
* Create discount groups with a name and a discount percent.
* Attach any number of WooCommerce product categories to a group (drag & drop ordering).
* The group's discount percent applies to all products in the attached categories.
= Email Assignments =
Assign an email pattern to exactly one of three discount types:
* **Group** — the customer inherits a discount group's percent and categories.
* **Coupon** — an existing WooCommerce coupon is auto-applied at checkout.
* **Custom** — a custom percent applied to the entire cart.
Email patterns can be:
* A full email address: `john@example.com`
* A domain: `@company.com` (matches everyone on that domain)
* A partial match: `john` (matches any email containing "john")
Matching priority: exact email first, then domain, then partial match.
Assignments can be toggled active/inactive and work for both logged-in users
and guests (via the checkout billing email field, tracked with AJAX).
Every assignment can additionally grant **free shipping** — all shipping
methods become free at checkout for matching emails, regardless of the
assignment type. Coupon assignments pick from your existing WooCommerce
coupons in a dropdown.
= Shipping Rules =
* Per discount group, choose which shipping methods are shown or hidden.
* No rules for a group means all shipping methods behave normally.
* If any method is set to "Show", only "Show" methods are offered (whitelist).
* Drag & drop reordering controls the order methods appear in at checkout.
= How discounts are applied =
* Group and custom discounts are applied as negative fees on the cart.
* Coupons are applied via the standard WooCommerce coupon system.
== Installation ==
1. Upload the `woocommerce-email-discount-manager` folder to `/wp-content/plugins/`.
2. Make sure WooCommerce is installed and active.
3. Activate the plugin through the Plugins screen in WordPress.
4. Go to WooCommerce → Email Discounts to configure groups, assignments and shipping rules.
= REST API =
The plugin exposes a `wc-edm/v1` namespace for remote management (groups,
email assignments, shipping rules, audit log). Every route requires an
authenticated user with the `manage_woocommerce` capability — use a WordPress
Application Password over HTTPS.
Requests are rate limited per user (or per IP when unauthenticated) and all
changes are written to the audit log. Both are adjustable with filters:
* `wc_edm_rate_limit_enabled` (bool)
* `wc_edm_rate_limit_max_requests` (int, $method)
* `wc_edm_rate_limit_window` (int seconds, $method)
* `wc_edm_audit_log_enabled` (bool)
* `wc_edm_audit_log_retention_days` (int)
* `wc_edm_client_ip` (string) — override IP resolution behind a proxy/CDN
== Frequently Asked Questions ==
= Does it work for guests? =
Yes. When a guest types their email in the checkout billing email field, the
plugin stores it in the WooCommerce session via AJAX and recalculates the cart,
so discounts and shipping rules apply immediately.
= What happens if several patterns match one email? =
The most specific match wins: exact email, then domain, then partial match.
== Changelog ==
= 1.3.1 =
* Hardened loading: a class file that cannot be read is now recorded and reported in an admin notice instead of causing a fatal error. During a plugin upgrade WordPress replaces files one at a time, so a request landing mid-copy could previously take down wp-admin.
* Admin tabs are only shown when their controller loaded, so an incomplete install costs a tab rather than the whole screen.
= 1.3.0 =
* Added an audit log: every change made through the REST API is recorded with user, IP, endpoint and time, along with rejected and rate limited requests. Viewable under WooCommerce → Email Discounts → Audit Log, and via GET /wp-json/wc-edm/v1/audit-log.
* Added rate limiting on the REST API (default 60 reads and 20 writes per minute per user or IP), returning HTTP 429. Limits are filterable.
* Entries are pruned automatically after 30 days (filterable).
= 1.2.0 =
* Added a free shipping toggle on email assignments — matching customers get all shipping methods at zero cost, works with every assignment type.
* Coupon assignments now pick from the shop's existing WooCommerce coupons in a dropdown instead of a free-text field.
* Database schema upgrades automatically after plugin updates (no re-activation needed).
= 1.1.0 =
* Added REST API (namespace wc-edm/v1) for remote management of groups, email assignments and shipping rules. All routes require an authenticated user with the manage_woocommerce capability (use an Application Password over HTTPS).
= 1.0.0 =
* Initial release: discount groups, email assignments (group/coupon/custom), shipping rules, dashboard.
+236
View File
@@ -0,0 +1,236 @@
/* WooCommerce Email Discount Manager — admin styles */
.wc-edm-wrap .wc-edm-content {
margin-top: 20px;
}
/* Header row with title + primary action */
.wc-edm-header-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
.wc-edm-header-row h2 {
margin: 0;
}
/* Dashboard stat cards */
.wc-edm-stats {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin: 8px 0 24px;
}
.wc-edm-stat-card {
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
padding: 20px 28px;
min-width: 180px;
display: flex;
flex-direction: column;
gap: 4px;
}
.wc-edm-stat-number {
font-size: 32px;
font-weight: 600;
line-height: 1.2;
color: #1d2327;
}
.wc-edm-stat-label {
color: #646970;
font-size: 13px;
}
.wc-edm-quick-actions .button {
margin-right: 8px;
}
/* Tables */
.wc-edm-table {
max-width: 1100px;
}
.wc-edm-table .wc-edm-actions-col {
width: 150px;
text-align: right;
}
.wc-edm-table .wc-edm-actions-col .button {
margin-left: 4px;
}
/* Badges */
.wc-edm-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
line-height: 1.6;
background: #f0f0f1;
color: #3c434a;
}
.wc-edm-badge-percent,
.wc-edm-badge-group {
background: #e5f1e8;
color: #00450c;
}
.wc-edm-badge-coupon {
background: #fcf0e4;
color: #8a4d00;
}
.wc-edm-badge-custom {
background: #e6f0fa;
color: #0a4b78;
}
.wc-edm-badge-denied {
background: #fcebeb;
color: #8a2424;
}
/* Audit log */
.wc-edm-audit-table code {
background: #f0f0f1;
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
}
.wc-edm-audit-table .wc-edm-item-meta {
display: block;
color: #646970;
font-size: 12px;
margin-top: 2px;
}
/* Active / inactive status */
.wc-edm-status {
text-decoration: none;
font-weight: 600;
}
.wc-edm-status.is-active {
color: #00a32a;
}
.wc-edm-status.is-inactive {
color: #d63638;
}
.wc-edm-status:hover {
text-decoration: underline;
}
/* Sortable lists (categories + shipping methods) */
.wc-edm-sortable {
margin: 12px 0 4px;
max-width: 700px;
}
.wc-edm-sortable li {
display: flex;
align-items: center;
gap: 10px;
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 6px;
cursor: default;
}
.wc-edm-sortable .wc-edm-drag-handle {
color: #787c82;
cursor: move;
flex: 0 0 auto;
}
.wc-edm-sortable .wc-edm-item-label {
flex: 1 1 auto;
}
.wc-edm-sortable .wc-edm-item-meta {
display: block;
color: #646970;
font-size: 12px;
}
.wc-edm-sortable-placeholder {
border: 1px dashed #8c8f94;
border-radius: 4px;
background: #f6f7f7;
margin-bottom: 6px;
visibility: visible !important;
}
/* Remove-category button */
.wc-edm-remove-category {
flex: 0 0 auto;
border: none;
background: none;
color: #d63638;
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.wc-edm-remove-category:hover {
color: #8a2424;
}
/* Category picker */
.wc-edm-category-picker {
display: flex;
align-items: center;
gap: 8px;
}
/* Shipping rules */
.wc-edm-group-selector {
margin: 16px 0;
display: flex;
align-items: center;
gap: 8px;
}
.wc-edm-shipping-list {
max-width: 900px;
}
.wc-edm-rule-options {
flex: 0 0 auto;
display: flex;
gap: 16px;
white-space: nowrap;
}
.wc-edm-rule-options label {
display: inline-flex;
align-items: center;
gap: 4px;
}
@media screen and (max-width: 782px) {
.wc-edm-sortable li {
flex-wrap: wrap;
}
.wc-edm-rule-options {
width: 100%;
padding-left: 30px;
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
* WooCommerce Email Discount Manager — admin scripts.
*/
( function( $ ) {
'use strict';
$( function() {
// ---------------------------------------------------------------
// Drag & drop sorting (categories + shipping methods).
// ---------------------------------------------------------------
$( '.wc-edm-sortable' ).sortable( {
handle: '.wc-edm-drag-handle',
axis: 'y',
placeholder: 'wc-edm-sortable-placeholder',
forcePlaceholderSize: true
} );
// ---------------------------------------------------------------
// Group edit: add / remove categories.
// ---------------------------------------------------------------
var $categorySelect = $( '#wc-edm-category-select' );
var $categoryList = $( '#wc-edm-selected-categories' );
$( '#wc-edm-add-category' ).on( 'click', function() {
var $option = $categorySelect.find( 'option:selected' );
var id = $option.val();
var name = $option.text().trim();
if ( ! id || $option.prop( 'disabled' ) ) {
return;
}
var $item = $( '<li>' )
.attr( 'data-id', id )
.append( $( '<span>' ).addClass( 'dashicons dashicons-menu wc-edm-drag-handle' ) )
.append( $( '<span>' ).addClass( 'wc-edm-item-label' ).text( name ) )
.append( $( '<button>' ).attr( 'type', 'button' ).addClass( 'wc-edm-remove-category' ).html( '&times;' ) )
.append( $( '<input>' ).attr( { type: 'hidden', name: 'category_ids[]' } ).val( id ) );
$categoryList.append( $item );
$option.prop( 'disabled', true );
$categorySelect.val( '' );
} );
$categoryList.on( 'click', '.wc-edm-remove-category', function() {
var $item = $( this ).closest( 'li' );
var id = String( $item.data( 'id' ) );
$categorySelect.find( 'option[value="' + id + '"]' ).prop( 'disabled', false );
$item.remove();
} );
// ---------------------------------------------------------------
// Email edit: toggle fields for the selected assignment type.
// ---------------------------------------------------------------
function toggleAssignmentFields() {
var type = $( '#wc-edm-assignment-type input[name="assignment_type"]:checked' ).val();
$( '.wc-edm-type-fields' ).each( function() {
$( this ).toggle( $( this ).data( 'type' ) === type );
} );
}
if ( $( '#wc-edm-assignment-type' ).length ) {
toggleAssignmentFields();
$( '#wc-edm-assignment-type' ).on( 'change', 'input[name="assignment_type"]', toggleAssignmentFields );
}
// ---------------------------------------------------------------
// Confirm before deleting.
// ---------------------------------------------------------------
$( document ).on( 'click', '.wc-edm-delete', function( e ) {
if ( ! window.confirm( wcEdmAdmin.confirmDelete ) ) {
e.preventDefault();
}
} );
// ---------------------------------------------------------------
// Shipping tab: auto-submit the group selector on change.
// ---------------------------------------------------------------
$( '#wc-edm-shipping-group' ).on( 'change', function() {
$( this ).closest( 'form' ).trigger( 'submit' );
} );
} );
}( jQuery ) );
+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,
)
);
}
}
+263
View File
@@ -0,0 +1,263 @@
<?php
/**
* Plugin Name: WooCommerce Email Discount Manager
* Plugin URI: https://example.com/
* Description: Apply discounts and show/hide shipping methods based on the customer's email address (full address, domain or partial match).
* Version: 1.3.1
* Author: WEDSMP
* License: GPL-2.0-or-later
* Text Domain: wc-email-discount
* Requires at least: 5.8
* Requires PHP: 7.4
* WC requires at least: 6.0
* Requires Plugins: woocommerce
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'WC_EDM_VERSION', '1.3.1' );
define( 'WC_EDM_PLUGIN_FILE', __FILE__ );
define( 'WC_EDM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'WC_EDM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
/**
* Main plugin class (singleton).
*/
final class WC_Email_Discount_Manager {
/**
* Singleton instance.
*
* @var WC_Email_Discount_Manager|null
*/
private static $instance = null;
/**
* Admin controller.
*
* @var WC_EDM_Admin|null
*/
public $admin = null;
/**
* Frontend controller.
*
* @var WC_EDM_Frontend|null
*/
public $frontend = null;
/**
* REST API controller.
*
* @var WC_EDM_REST_API|null
*/
public $rest_api = null;
/**
* Audit log controller.
*
* @var WC_EDM_Audit_Log|null
*/
public $audit_log = null;
/**
* Class files that could not be loaded.
*
* A plugin upgrade replaces files one by one, so a request landing
* mid-copy can see an incomplete plugin folder. Tracking what is missing
* lets the plugin degrade instead of taking the whole screen down.
*
* @var string[]
*/
private $missing_files = array();
/**
* Get the singleton instance.
*
* @return WC_Email_Discount_Manager
*/
public static function instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor. Private — use instance().
*/
private function __construct() {
add_action( 'plugins_loaded', array( $this, 'init' ) );
add_action( 'before_woocommerce_init', array( $this, 'declare_wc_compatibility' ) );
}
/**
* Prevent cloning.
*/
private function __clone() {}
/**
* Prevent unserializing.
*/
public function __wakeup() {
_doing_it_wrong( __FUNCTION__, 'Unserializing is not allowed.', '1.0.0' );
}
/**
* Bootstrap once all plugins are loaded.
*/
public function init() {
if ( ! class_exists( 'WooCommerce' ) ) {
add_action( 'admin_notices', array( $this, 'woocommerce_missing_notice' ) );
return;
}
load_plugin_textdomain( 'wc-email-discount', false, dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) ) . '/languages' );
$this->includes();
if ( ! empty( $this->missing_files ) ) {
add_action( 'admin_notices', array( $this, 'incomplete_install_notice' ) );
}
// Everything else builds on these two, so without them the plugin
// stays dormant and shows the notice instead of erroring out.
if ( ! class_exists( 'WC_EDM_DB' ) || ! class_exists( 'WC_EDM_Helpers' ) ) {
return;
}
// Upgrade the DB schema after plugin updates that skip re-activation.
add_action( 'init', array( 'WC_EDM_DB', 'maybe_upgrade' ), 5 );
if ( is_admin() && class_exists( 'WC_EDM_Admin' ) ) {
$this->admin = new WC_EDM_Admin();
}
if ( class_exists( 'WC_EDM_Frontend' ) ) {
$this->frontend = new WC_EDM_Frontend();
}
if ( class_exists( 'WC_EDM_Audit_Log' ) ) {
$this->audit_log = new WC_EDM_Audit_Log();
}
if ( class_exists( 'WC_EDM_REST_API' ) ) {
$this->rest_api = new WC_EDM_REST_API();
}
}
/**
* Warn that the plugin folder is incomplete.
*/
public function incomplete_install_notice() {
if ( ! current_user_can( 'activate_plugins' ) ) {
return;
}
echo '<div class="notice notice-error"><p>';
echo esc_html__( 'WooCommerce Email Discount Manager could not load all of its files, so some features are unavailable. If you just updated the plugin, reload this page — the upload may still have been in progress. If the warning persists, upload the plugin again.', 'wc-email-discount' );
echo '</p><p><code>';
echo implode( '</code>, <code>', array_map( 'esc_html', $this->missing_files ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '</code></p></div>';
}
/**
* Load class files.
*/
private function includes() {
$files = array(
'includes/class-db.php',
'includes/class-helpers.php',
'includes/class-frontend.php',
'includes/class-audit-log.php',
'includes/class-rate-limiter.php',
'includes/class-rest-api.php',
);
if ( is_admin() ) {
$files = array_merge(
$files,
array(
'includes/class-admin.php',
'includes/class-admin-groups.php',
'includes/class-admin-emails.php',
'includes/class-admin-shipping.php',
'includes/class-admin-audit.php',
)
);
}
foreach ( $files as $file ) {
$path = WC_EDM_PLUGIN_DIR . $file;
// A missing file is recorded rather than fatal, so a half-copied
// upgrade cannot take down wp-admin.
if ( is_readable( $path ) ) {
require_once $path;
} else {
$this->missing_files[] = $file;
}
}
}
/**
* Declare compatibility with WooCommerce HPOS.
*/
public function declare_wc_compatibility() {
if ( class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', WC_EDM_PLUGIN_FILE, true );
}
}
/**
* Admin notice shown when WooCommerce is not active.
*/
public function woocommerce_missing_notice() {
echo '<div class="notice notice-error"><p>';
esc_html_e( 'WooCommerce Email Discount Manager requires WooCommerce to be installed and active.', 'wc-email-discount' );
echo '</p></div>';
}
/**
* Plugin activation: verify WooCommerce and create database tables.
*/
public static function activate() {
$active_plugins = apply_filters( 'active_plugins', get_option( 'active_plugins', array() ) );
if ( ! class_exists( 'WooCommerce' ) && ! in_array( 'woocommerce/woocommerce.php', $active_plugins, true ) ) {
deactivate_plugins( plugin_basename( WC_EDM_PLUGIN_FILE ) );
wp_die(
esc_html__( 'WooCommerce Email Discount Manager requires WooCommerce to be installed and active. Please activate WooCommerce first.', 'wc-email-discount' ),
esc_html__( 'Plugin activation error', 'wc-email-discount' ),
array( 'back_link' => true )
);
}
require_once WC_EDM_PLUGIN_DIR . 'includes/class-db.php';
WC_EDM_DB::create_tables();
}
/**
* Plugin deactivation: clear scheduled jobs. Data is left untouched.
*/
public static function deactivate() {
require_once WC_EDM_PLUGIN_DIR . 'includes/class-audit-log.php';
WC_EDM_Audit_Log::unschedule_prune();
}
}
register_activation_hook( __FILE__, array( 'WC_Email_Discount_Manager', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'WC_Email_Discount_Manager', 'deactivate' ) );
/**
* Access the main plugin instance.
*
* @return WC_Email_Discount_Manager
*/
function wc_edm() {
return WC_Email_Discount_Manager::instance();
}
wc_edm();