fix: resolve container width issues, spa redirects, and appearance settings overwrite. feat: enhance order/sub details and newsletter layout

This commit is contained in:
Dwindi Ramadhana
2026-02-05 00:09:40 +07:00
parent a0b5f8496d
commit 5f08c18ec7
77 changed files with 7027 additions and 4546 deletions

View File

@@ -1,41 +1,45 @@
<?php
namespace WooNooW\Admin;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
class AppearanceController {
class AppearanceController
{
const OPTION_KEY = 'woonoow_appearance_settings';
const API_NAMESPACE = 'woonoow/v1';
public static function init() {
public static function init()
{
add_action('rest_api_init', [__CLASS__, 'register_routes']);
}
public static function register_routes() {
public static function register_routes()
{
// Get all settings (public access for frontend)
register_rest_route(self::API_NAMESPACE, '/appearance/settings', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_settings'],
'permission_callback' => '__return_true',
]);
// Save general settings
register_rest_route(self::API_NAMESPACE, '/appearance/general', [
'methods' => 'POST',
'callback' => [__CLASS__, 'save_general'],
'permission_callback' => [__CLASS__, 'check_permission'],
]);
// Save header settings
register_rest_route(self::API_NAMESPACE, '/appearance/header', [
'methods' => 'POST',
'callback' => [__CLASS__, 'save_header'],
'permission_callback' => [__CLASS__, 'check_permission'],
]);
// Save footer settings
register_rest_route(self::API_NAMESPACE, '/appearance/footer', [
'methods' => 'POST',
@@ -49,7 +53,7 @@ class AppearanceController {
'callback' => [__CLASS__, 'save_menus'],
'permission_callback' => [__CLASS__, 'check_permission'],
]);
// Save page-specific settings
register_rest_route(self::API_NAMESPACE, '/appearance/pages/(?P<page>[a-zA-Z0-9_-]+)', [
'methods' => 'POST',
@@ -63,7 +67,7 @@ class AppearanceController {
],
],
]);
// Get all WordPress pages for page selector
register_rest_route(self::API_NAMESPACE, '/pages/list', [
'methods' => 'GET',
@@ -71,40 +75,45 @@ class AppearanceController {
'permission_callback' => [__CLASS__, 'check_permission'],
]);
}
public static function check_permission() {
public static function check_permission()
{
return current_user_can('manage_woocommerce');
}
/**
* Get all appearance settings
*/
public static function get_settings(WP_REST_Request $request) {
public static function get_settings(WP_REST_Request $request)
{
$stored = get_option(self::OPTION_KEY, []);
$defaults = self::get_default_settings();
// Merge stored with defaults to ensure all fields exist (recursive)
$settings = array_replace_recursive($defaults, $stored);
return new WP_REST_Response([
'success' => true,
'data' => $settings,
], 200);
}
/**
* Save general settings
*/
public static function save_general(WP_REST_Request $request) {
public static function save_general(WP_REST_Request $request)
{
$settings = get_option(self::OPTION_KEY, []);
$defaults = self::get_default_settings();
$settings = array_replace_recursive($defaults, $settings);
$colors = $request->get_param('colors') ?? [];
$general_data = [
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
'spa_page' => absint($request->get_param('spaPage') ?? 0),
'container_width' => sanitize_text_field($request->get_param('containerWidth') ?? 'boxed'),
'toast_position' => sanitize_text_field($request->get_param('toastPosition') ?? 'top-right'),
'typography' => [
'mode' => sanitize_text_field($request->get_param('typography')['mode'] ?? 'predefined'),
@@ -125,23 +134,25 @@ class AppearanceController {
'gradientEnd' => sanitize_hex_color($colors['gradientEnd'] ?? '#3b82f6'),
],
];
$settings['general'] = $general_data;
// Merge with existing general settings to preserve other keys (like spa_frontpage)
$settings['general'] = array_merge($settings['general'] ?? [], $general_data);
update_option(self::OPTION_KEY, $settings);
return new WP_REST_Response([
'success' => true,
'message' => 'General settings saved successfully',
'data' => $general_data,
], 200);
}
/**
* Save header settings
*/
public static function save_header(WP_REST_Request $request) {
public static function save_header(WP_REST_Request $request)
{
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
$header_data = [
'style' => sanitize_text_field($request->get_param('style')),
'sticky' => (bool) $request->get_param('sticky'),
@@ -159,23 +170,24 @@ class AppearanceController {
'wishlist' => (bool) ($request->get_param('elements')['wishlist'] ?? false),
],
];
$settings['header'] = $header_data;
update_option(self::OPTION_KEY, $settings);
return new WP_REST_Response([
'success' => true,
'message' => 'Header settings saved successfully',
'data' => $header_data,
], 200);
}
/**
* Save footer settings
*/
public static function save_footer(WP_REST_Request $request) {
public static function save_footer(WP_REST_Request $request)
{
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
$social_links = $request->get_param('socialLinks') ?? [];
$sanitized_links = [];
foreach ($social_links as $link) {
@@ -185,7 +197,7 @@ class AppearanceController {
'url' => esc_url_raw($link['url'] ?? ''),
];
}
// Sanitize contact data
$contact_data = $request->get_param('contactData');
$sanitized_contact = [
@@ -196,7 +208,7 @@ class AppearanceController {
'show_phone' => (bool) ($contact_data['show_phone'] ?? true),
'show_address' => (bool) ($contact_data['show_address'] ?? true),
];
// Sanitize labels
$labels = $request->get_param('labels');
$sanitized_labels = [
@@ -206,7 +218,7 @@ class AppearanceController {
'newsletter_title' => sanitize_text_field($labels['newsletter_title'] ?? 'Newsletter'),
'newsletter_description' => sanitize_text_field($labels['newsletter_description'] ?? 'Subscribe to get updates'),
];
// Sanitize custom sections
$sections = $request->get_param('sections') ?? [];
$sanitized_sections = [];
@@ -219,28 +231,44 @@ class AppearanceController {
'visible' => (bool) ($section['visible'] ?? true),
];
}
$footer_data = [
'payment' => [
'enabled' => (bool) ($request->get_param('payment')['enabled'] ?? true),
'title' => sanitize_text_field($request->get_param('payment')['title'] ?? 'We accept'),
'methods' => array_map(function ($method) {
return [
'id' => sanitize_text_field($method['id'] ?? uniqid()),
'url' => esc_url_raw($method['url'] ?? ''),
'label' => sanitize_text_field($method['label'] ?? ''),
'width' => sanitize_text_field($method['width'] ?? ''),
];
}, $request->get_param('payment')['methods'] ?? []),
],
'copyright' => [
'enabled' => (bool) ($request->get_param('copyright')['enabled'] ?? true),
'text' => wp_kses_post($request->get_param('copyright')['text'] ?? ''),
],
'columns' => sanitize_text_field($request->get_param('columns')),
'style' => sanitize_text_field($request->get_param('style')),
'copyright_text' => wp_kses_post($request->get_param('copyrightText')),
// 'copyright_text' => Deprecated, moved to copyright.text
// 'elements' => Deprecated, moved to individual sections (except menu/contact/social flags if needed)
'elements' => [
'newsletter' => (bool) ($request->get_param('elements')['newsletter'] ?? true),
'social' => (bool) ($request->get_param('elements')['social'] ?? true),
'payment' => (bool) ($request->get_param('elements')['payment'] ?? true),
'copyright' => (bool) ($request->get_param('elements')['copyright'] ?? true),
'menu' => (bool) ($request->get_param('elements')['menu'] ?? true),
'contact' => (bool) ($request->get_param('elements')['contact'] ?? true),
// Payment and Copyright moved
],
'social_links' => $sanitized_links,
'contact_data' => $sanitized_contact,
'labels' => $sanitized_labels,
'sections' => $sanitized_sections,
];
$settings['footer'] = $footer_data;
update_option(self::OPTION_KEY, $settings);
return new WP_REST_Response([
'success' => true,
'message' => 'Footer settings saved successfully',
@@ -251,11 +279,12 @@ class AppearanceController {
/**
* Save menu settings
*/
public static function save_menus(WP_REST_Request $request) {
public static function save_menus(WP_REST_Request $request)
{
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
$menus = $request->get_param('menus') ?? [];
// Sanitize menus
$sanitized_menus = [
'primary' => [],
@@ -265,7 +294,7 @@ class AppearanceController {
foreach (['primary', 'mobile'] as $location) {
if (isset($menus[$location]) && is_array($menus[$location])) {
foreach ($menus[$location] as $item) {
$sanitized_menus[$location][] = [
$sanitized_menus[$location][] = [
'id' => sanitize_text_field($item['id'] ?? uniqid()),
'label' => sanitize_text_field($item['label'] ?? ''),
'type' => sanitize_text_field($item['type'] ?? 'page'), // page, custom
@@ -275,46 +304,48 @@ class AppearanceController {
}
}
}
$settings['menus'] = $sanitized_menus;
update_option(self::OPTION_KEY, $settings);
return new WP_REST_Response([
'success' => true,
'message' => 'Menu settings saved successfully',
'data' => $sanitized_menus,
], 200);
}
/**
* Save page-specific settings
*/
public static function save_page_settings(WP_REST_Request $request) {
public static function save_page_settings(WP_REST_Request $request)
{
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
$page = $request->get_param('page');
// Get all parameters from request
$page_data = $request->get_json_params();
// Sanitize based on page type
$sanitized_data = self::sanitize_page_data($page, $page_data);
$settings['pages'][$page] = $sanitized_data;
update_option(self::OPTION_KEY, $settings);
return new WP_REST_Response([
'success' => true,
'message' => ucfirst($page) . ' page settings saved successfully',
'data' => $sanitized_data,
], 200);
}
/**
* Sanitize page-specific data
*/
private static function sanitize_page_data($page, $data) {
private static function sanitize_page_data($page, $data)
{
$sanitized = [];
switch ($page) {
case 'shop':
$sanitized = [
@@ -342,7 +373,7 @@ class AppearanceController {
],
];
break;
case 'product':
$sanitized = [
'layout' => [
@@ -366,7 +397,7 @@ class AppearanceController {
],
];
break;
case 'cart':
$sanitized = [
'layout' => [
@@ -381,7 +412,7 @@ class AppearanceController {
],
];
break;
case 'checkout':
$sanitized = [
'layout' => [
@@ -399,7 +430,7 @@ class AppearanceController {
],
];
break;
case 'thankyou':
$sanitized = [
'template' => sanitize_text_field($data['template'] ?? 'basic'),
@@ -414,7 +445,7 @@ class AppearanceController {
],
];
break;
case 'account':
$sanitized = [
'layout' => [
@@ -430,31 +461,32 @@ class AppearanceController {
];
break;
}
return $sanitized;
}
/**
* Get list of WordPress pages for page selector
*/
public static function get_pages_list(WP_REST_Request $request) {
public static function get_pages_list(WP_REST_Request $request)
{
$pages = get_pages([
'post_status' => 'publish',
'sort_column' => 'post_title',
'sort_order' => 'ASC',
]);
$store_pages = [
(int) get_option('woocommerce_shop_page_id'),
(int) get_option('woocommerce_cart_page_id'),
(int) get_option('woocommerce_checkout_page_id'),
(int) get_option('woocommerce_myaccount_page_id'),
];
$pages_list = array_map(function($page) use ($store_pages) {
$pages_list = array_map(function ($page) use ($store_pages) {
$is_woonoow = !empty(get_post_meta($page->ID, '_wn_page_structure', true));
$is_store = in_array((int)$page->ID, $store_pages, true);
return [
'id' => $page->ID,
'title' => $page->post_title,
@@ -463,21 +495,24 @@ class AppearanceController {
'is_store_page' => $is_store,
];
}, $pages);
return new WP_REST_Response([
'success' => true,
'data' => $pages_list,
], 200);
}
/**
* Get default settings structure
*/
public static function get_default_settings() {
public static function get_default_settings()
{
return [
'general' => [
'spa_mode' => 'full',
'spa_page' => 0,
'container_width' => 'boxed',
'toast_position' => 'top-right',
'typography' => [
'mode' => 'predefined',
@@ -516,12 +551,22 @@ class AppearanceController {
'footer' => [
'columns' => '4',
'style' => 'detailed',
'copyright_text' => '© 2024 WooNooW. All rights reserved.',
'payment' => [
'enabled' => true,
'title' => 'We accept',
'methods' => [
['id' => 'visa', 'url' => '', 'label' => 'Visa', 'default_icon' => 'visa'], // Placeholder logic for defaults
['id' => 'mastercard', 'url' => '', 'label' => 'Mastercard', 'default_icon' => 'mastercard'],
['id' => 'paypal', 'url' => '', 'label' => 'PayPal', 'default_icon' => 'paypal'],
],
],
'copyright' => [
'enabled' => true,
'text' => '© 2024 WooNooW. All rights reserved.',
],
'elements' => [
'newsletter' => true,
'social' => true,
'payment' => true,
'copyright' => true,
'menu' => true,
'contact' => true,
],

View File

@@ -1,4 +1,5 @@
<?php
/**
* Campaigns REST Controller
*
@@ -13,64 +14,67 @@ use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WooNooW\Core\Campaigns\CampaignManager;
use WooNooW\Core\ActivityLog\Logger;
class CampaignsController
{
class CampaignsController {
const API_NAMESPACE = 'woonoow/v1';
/**
* Register REST routes
*/
public static function register_routes() {
public static function register_routes()
{
// List campaigns
register_rest_route(self::API_NAMESPACE, '/campaigns', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_campaigns'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Create campaign
register_rest_route(self::API_NAMESPACE, '/campaigns', [
'methods' => 'POST',
'callback' => [__CLASS__, 'create_campaign'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Get single campaign
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_campaign'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Update campaign
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
'methods' => 'PUT',
'callback' => [__CLASS__, 'update_campaign'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Delete campaign
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
'methods' => 'DELETE',
'callback' => [__CLASS__, 'delete_campaign'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Send campaign
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/send', [
'methods' => 'POST',
'callback' => [__CLASS__, 'send_campaign'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Send test email
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/test', [
'methods' => 'POST',
'callback' => [__CLASS__, 'send_test_email'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Preview campaign
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/preview', [
'methods' => 'GET',
@@ -78,30 +82,33 @@ class CampaignsController {
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
}
/**
* Check admin permission
*/
public static function check_admin_permission() {
public static function check_admin_permission()
{
return current_user_can('manage_options');
}
/**
* Get all campaigns
*/
public static function get_campaigns(WP_REST_Request $request) {
public static function get_campaigns(WP_REST_Request $request)
{
$campaigns = CampaignManager::get_all();
return new WP_REST_Response([
'success' => true,
'data' => $campaigns,
]);
}
/**
* Create campaign
*/
public static function create_campaign(WP_REST_Request $request) {
public static function create_campaign(WP_REST_Request $request)
{
$data = [
'title' => $request->get_param('title'),
'subject' => $request->get_param('subject'),
@@ -109,52 +116,54 @@ class CampaignsController {
'status' => $request->get_param('status') ?: 'draft',
'scheduled_at' => $request->get_param('scheduled_at'),
];
$campaign_id = CampaignManager::create($data);
if (is_wp_error($campaign_id)) {
return new WP_REST_Response([
'success' => false,
'error' => $campaign_id->get_error_message(),
], 400);
}
$campaign = CampaignManager::get($campaign_id);
return new WP_REST_Response([
'success' => true,
'data' => $campaign,
], 201);
}
/**
* Get single campaign
*/
public static function get_campaign(WP_REST_Request $request) {
public static function get_campaign(WP_REST_Request $request)
{
$campaign_id = (int) $request->get_param('id');
$campaign = CampaignManager::get($campaign_id);
if (!$campaign) {
return new WP_REST_Response([
'success' => false,
'error' => __('Campaign not found', 'woonoow'),
], 404);
}
return new WP_REST_Response([
'success' => true,
'data' => $campaign,
]);
}
/**
* Update campaign
*/
public static function update_campaign(WP_REST_Request $request) {
public static function update_campaign(WP_REST_Request $request)
{
$campaign_id = (int) $request->get_param('id');
$data = [];
if ($request->has_param('title')) {
$data['title'] = $request->get_param('title');
}
@@ -170,60 +179,62 @@ class CampaignsController {
if ($request->has_param('scheduled_at')) {
$data['scheduled_at'] = $request->get_param('scheduled_at');
}
$result = CampaignManager::update($campaign_id, $data);
if (is_wp_error($result)) {
return new WP_REST_Response([
'success' => false,
'error' => $result->get_error_message(),
], 400);
}
$campaign = CampaignManager::get($campaign_id);
return new WP_REST_Response([
'success' => true,
'data' => $campaign,
]);
}
/**
* Delete campaign
*/
public static function delete_campaign(WP_REST_Request $request) {
public static function delete_campaign(WP_REST_Request $request)
{
$campaign_id = (int) $request->get_param('id');
$result = CampaignManager::delete($campaign_id);
if (!$result) {
return new WP_REST_Response([
'success' => false,
'error' => __('Failed to delete campaign', 'woonoow'),
], 400);
}
return new WP_REST_Response([
'success' => true,
'message' => __('Campaign deleted', 'woonoow'),
]);
}
/**
* Send campaign
*/
public static function send_campaign(WP_REST_Request $request) {
public static function send_campaign(WP_REST_Request $request)
{
$campaign_id = (int) $request->get_param('id');
$result = CampaignManager::send($campaign_id);
if (!$result['success']) {
return new WP_REST_Response([
'success' => false,
'error' => $result['error'],
], 400);
}
return new WP_REST_Response([
'success' => true,
'message' => sprintf(
@@ -236,63 +247,80 @@ class CampaignsController {
'total' => $result['total'],
]);
}
/**
* Send test email
*/
public static function send_test_email(WP_REST_Request $request) {
public static function send_test_email(WP_REST_Request $request)
{
$campaign_id = (int) $request->get_param('id');
$email = sanitize_email($request->get_param('email'));
if (!is_email($email)) {
return new WP_REST_Response([
'success' => false,
'error' => __('Invalid email address', 'woonoow'),
], 400);
}
$result = CampaignManager::send_test($campaign_id, $email);
if (!$result) {
return new WP_REST_Response([
'success' => false,
'error' => __('Failed to send test email', 'woonoow'),
], 400);
}
// Log to activity log
Logger::log(
'test_sent',
'campaign',
$campaign_id,
sprintf(__('Test email sent to %s', 'woonoow'), $email)
);
return new WP_REST_Response([
'success' => true,
'message' => sprintf(__('Test email sent to %s', 'woonoow'), $email),
]);
}
/**
* Preview campaign
*/
public static function preview_campaign(WP_REST_Request $request) {
public static function preview_campaign(WP_REST_Request $request)
{
$campaign_id = (int) $request->get_param('id');
$campaign = CampaignManager::get($campaign_id);
if (!$campaign) {
return new WP_REST_Response([
'success' => false,
'error' => __('Campaign not found', 'woonoow'),
], 404);
}
// Use reflection to call private render method or make it public
// For now, return a simple preview
$renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
$template = $renderer->get_template_settings('newsletter_campaign', 'customer');
$content = $campaign['content'];
$subject = $campaign['subject'] ?: $campaign['title'];
if ($template) {
// Use template subject if available
if (!empty($template['subject'])) {
$subject = $template['subject'];
}
$content = str_replace('{content}', $campaign['content'], $template['body']);
$content = str_replace('{campaign_title}', $campaign['title'], $content);
}
// Replace campaign_title in subject
$subject = str_replace('{campaign_title}', $campaign['title'], $subject);
// Replace placeholders
$site_name = get_bloginfo('name');
$content = str_replace(['{site_name}', '{store_name}'], $site_name, $content);
@@ -301,7 +329,10 @@ class CampaignsController {
$content = str_replace('{unsubscribe_url}', '#unsubscribe', $content);
$content = str_replace('{current_date}', date_i18n(get_option('date_format')), $content);
$content = str_replace('{current_year}', date('Y'), $content);
// Parse card shortcodes before rendering
$content = $renderer->parse_cards($content);
// Render with design template
$design_path = $renderer->get_design_template();
if (file_exists($design_path)) {
@@ -310,7 +341,7 @@ class CampaignsController {
'site_url' => home_url(),
]);
}
return new WP_REST_Response([
'success' => true,
'subject' => $subject,

View File

@@ -311,12 +311,27 @@ class CheckoutController
return ['error' => __('No items provided', 'woonoow')];
}
// Security: Rate limiting check
if (\WooNooW\Compat\SecuritySettingsProvider::is_rate_limited()) {
return ['error' => __('Too many orders. Please try again later.', 'woonoow')];
}
// Security: CAPTCHA validation
$captcha_token = $payload['captcha_token'] ?? '';
$captcha_result = \WooNooW\Compat\SecuritySettingsProvider::validate_captcha($captcha_token);
if (is_wp_error($captcha_result)) {
return ['error' => $captcha_result->get_error_message()];
}
// Create order
$order = wc_create_order();
if (is_wp_error($order)) {
return ['error' => $order->get_error_message()];
}
// Track if user was logged in during this request (for frontend page reload)
$user_logged_in = false;
// Set customer ID if user is logged in
if (is_user_logged_in()) {
$user_id = get_current_user_id();
@@ -358,8 +373,9 @@ class CheckoutController
$existing_user = get_user_by('email', $email);
if ($existing_user) {
// User exists - link order to them
// User exists - link order to them (but do NOT auto-login for security)
$order->set_customer_id($existing_user->ID);
// Note: user_logged_in stays false - existing users must authenticate separately
} else {
// Create new user account
$password = wp_generate_password(12, true, true);
@@ -387,6 +403,7 @@ class CheckoutController
// AUTO-LOGIN: Set authentication cookie so user is logged in after page reload
wp_set_auth_cookie($new_user_id, true);
wp_set_current_user($new_user_id);
$user_logged_in = true;
// Set WooCommerce customer billing data
$customer = new \WC_Customer($new_user_id);
@@ -509,6 +526,9 @@ class CheckoutController
WC()->cart->empty_cart();
}
// Record this order attempt for rate limiting
\WooNooW\Compat\SecuritySettingsProvider::record_order_attempt();
return [
'ok' => true,
'order_id' => $order->get_id(),
@@ -516,6 +536,7 @@ class CheckoutController
'status' => $order->get_status(),
'pay_url' => $order->get_checkout_payment_url(),
'thankyou_url' => $order->get_checkout_order_received_url(),
'user_logged_in' => $user_logged_in, // True if user was logged in during this request (requires page reload)
];
}

View File

@@ -1,15 +1,19 @@
<?php
namespace WooNooW\API;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WooNooW\Core\Validation;
use WooNooW\Database\SubscriberTable;
class NewsletterController {
class NewsletterController
{
const API_NAMESPACE = 'woonoow/v1';
public static function register_routes() {
public static function register_routes()
{
register_rest_route(self::API_NAMESPACE, '/newsletter/subscribe', [
'methods' => 'POST',
'callback' => [__CLASS__, 'subscribe'],
@@ -18,45 +22,45 @@ class NewsletterController {
'email' => [
'required' => true,
'type' => 'string',
'validate_callback' => function($param) {
'validate_callback' => function ($param) {
return is_email($param);
},
],
],
]);
register_rest_route(self::API_NAMESPACE, '/newsletter/subscribers', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_subscribers'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_options');
},
]);
register_rest_route(self::API_NAMESPACE, '/newsletter/subscribers/(?P<email>[^/]+)', [
'methods' => 'DELETE',
'callback' => [__CLASS__, 'delete_subscriber'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_options');
},
]);
register_rest_route(self::API_NAMESPACE, '/newsletter/template/(?P<template>[^/]+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_template'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_options');
},
]);
register_rest_route(self::API_NAMESPACE, '/newsletter/template/(?P<template>[^/]+)', [
'methods' => 'POST',
'callback' => [__CLASS__, 'save_template'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_options');
},
]);
// Public unsubscribe endpoint (no auth needed, uses token)
register_rest_route(self::API_NAMESPACE, '/newsletter/unsubscribe', [
'methods' => 'GET',
@@ -73,139 +77,381 @@ class NewsletterController {
],
],
]);
// Public confirm endpoint (double opt-in)
register_rest_route(self::API_NAMESPACE, '/newsletter/confirm', [
'methods' => 'GET',
'callback' => [__CLASS__, 'confirm'],
'permission_callback' => '__return_true',
'args' => [
'email' => [
'required' => true,
'type' => 'string',
],
'token' => [
'required' => true,
'type' => 'string',
],
],
]);
}
public static function get_template(WP_REST_Request $request) {
public static function get_template(WP_REST_Request $request)
{
$template = $request->get_param('template');
$option_key = "woonoow_newsletter_{$template}_template";
$data = get_option($option_key, [
'subject' => $template === 'welcome' ? 'Welcome to {site_name} Newsletter!' : 'Confirm your newsletter subscription',
'content' => $template === 'welcome'
'content' => $template === 'welcome'
? "Thank you for subscribing to our newsletter!\n\nYou'll receive updates about our latest products and offers.\n\nBest regards,\n{site_name}"
: "Please confirm your newsletter subscription by clicking the link below:\n\n{confirmation_url}\n\nBest regards,\n{site_name}",
]);
return new WP_REST_Response([
'success' => true,
'subject' => $data['subject'] ?? '',
'content' => $data['content'] ?? '',
], 200);
}
public static function save_template(WP_REST_Request $request) {
public static function save_template(WP_REST_Request $request)
{
$template = $request->get_param('template');
$subject = sanitize_text_field($request->get_param('subject'));
$content = wp_kses_post($request->get_param('content'));
$option_key = "woonoow_newsletter_{$template}_template";
update_option($option_key, [
'subject' => $subject,
'content' => $content,
]);
return new WP_REST_Response([
'success' => true,
'message' => 'Template saved successfully',
], 200);
}
public static function delete_subscriber(WP_REST_Request $request) {
$email = urldecode($request->get_param('email'));
public static function delete_subscriber(WP_REST_Request $request)
{
$email = sanitize_email(urldecode($request->get_param('email')));
if (self::use_custom_table()) {
$result = SubscriberTable::delete_by_email($email);
if ($result) {
return new WP_REST_Response([
'success' => true,
'message' => 'Subscriber removed successfully',
], 200);
}
return new WP_Error('not_found', 'Subscriber not found', ['status' => 404]);
}
// Legacy: wp_options storage
$subscribers = get_option('woonoow_newsletter_subscribers', []);
$subscribers = array_filter($subscribers, function($sub) use ($email) {
$subscribers = array_filter($subscribers, function ($sub) use ($email) {
return isset($sub['email']) && $sub['email'] !== $email;
});
update_option('woonoow_newsletter_subscribers', array_values($subscribers));
return new WP_REST_Response([
'success' => true,
'message' => 'Subscriber removed successfully',
], 200);
}
public static function subscribe(WP_REST_Request $request) {
/**
* Check if custom subscriber table should be used
*/
private static function use_custom_table()
{
return SubscriberTable::table_exists();
}
public static function subscribe(WP_REST_Request $request)
{
$email = sanitize_email($request->get_param('email'));
$consent = (bool) $request->get_param('consent');
// Rate limiting (5 requests per IP per hour)
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$rate_key = 'woonoow_newsletter_rate_' . md5($ip);
$attempts = (int) get_transient($rate_key);
if ($attempts >= 5) {
return new WP_Error('rate_limited', __('Too many requests. Please try again later.', 'woonoow'), ['status' => 429]);
}
set_transient($rate_key, $attempts + 1, HOUR_IN_SECONDS);
// Use centralized validation with extensible filter hooks
$validation = Validation::validate_email($email, 'newsletter_subscribe');
if (is_wp_error($validation)) {
return $validation;
}
// Get existing subscribers (now stored as objects with metadata)
$subscribers = get_option('woonoow_newsletter_subscribers', []);
// Check if already subscribed
$existing = array_filter($subscribers, function($sub) use ($email) {
return isset($sub['email']) && $sub['email'] === $email;
});
if (!empty($existing)) {
return new WP_REST_Response([
'success' => true,
'message' => 'You are already subscribed to our newsletter!',
], 200);
// Check GDPR consent requirement
$gdpr_required = get_option('woonoow_newsletter_gdpr_consent', false);
if ($gdpr_required && !$consent) {
return new WP_Error('consent_required', __('Please accept the terms to subscribe.', 'woonoow'), ['status' => 400]);
}
// Check if email belongs to a WP user
$user = get_user_by('email', $email);
$user_id = $user ? $user->ID : null;
// Add new subscriber with metadata
$subscribers[] = [
'email' => $email,
'user_id' => $user_id,
'status' => 'active',
'subscribed_at' => current_time('mysql'),
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
];
update_option('woonoow_newsletter_subscribers', $subscribers);
// Trigger notification events
// Check double opt-in setting
$double_opt_in = get_option('woonoow_newsletter_double_opt_in', true);
$status = $double_opt_in ? 'pending' : 'active';
if (self::use_custom_table()) {
// Use custom table
$existing = SubscriberTable::get_by_email($email);
if ($existing) {
if ($existing['status'] === 'active') {
return new WP_REST_Response([
'success' => true,
'message' => __('You are already subscribed to our newsletter!', 'woonoow'),
], 200);
}
if ($existing['status'] === 'pending') {
self::send_confirmation_email($email, $existing['user_id'] ?? null);
return new WP_REST_Response([
'success' => true,
'message' => __('Confirmation email resent. Please check your inbox.', 'woonoow'),
], 200);
}
// Resubscribe (was unsubscribed)
SubscriberTable::update_by_email($email, [
'status' => $status,
'consent' => $consent ? 1 : 0,
'subscribed_at' => current_time('mysql'),
'ip_address' => $ip,
]);
} else {
// New subscriber
SubscriberTable::add([
'email' => $email,
'user_id' => $user_id,
'status' => $status,
'consent' => $consent,
'subscribed_at' => current_time('mysql'),
'ip_address' => $ip,
]);
}
} else {
// Legacy: wp_options storage
$subscribers = get_option('woonoow_newsletter_subscribers', []);
// Check if already subscribed
$existing_key = null;
foreach ($subscribers as $key => $sub) {
if (isset($sub['email']) && $sub['email'] === $email) {
$existing_key = $key;
break;
}
}
if ($existing_key !== null) {
$existing = $subscribers[$existing_key];
if (($existing['status'] ?? 'active') === 'active') {
return new WP_REST_Response([
'success' => true,
'message' => __('You are already subscribed to our newsletter!', 'woonoow'),
], 200);
}
if (($existing['status'] ?? '') === 'pending') {
self::send_confirmation_email($email, $existing['user_id'] ?? null);
return new WP_REST_Response([
'success' => true,
'message' => __('Confirmation email resent. Please check your inbox.', 'woonoow'),
], 200);
}
}
$subscribers[] = [
'email' => $email,
'user_id' => $user_id,
'status' => $status,
'consent' => $consent,
'subscribed_at' => current_time('mysql'),
'ip_address' => $ip,
];
update_option('woonoow_newsletter_subscribers', $subscribers);
}
if ($double_opt_in) {
// Send confirmation email
self::send_confirmation_email($email, $user_id);
return new WP_REST_Response([
'success' => true,
'message' => __('Please check your email to confirm your subscription.', 'woonoow'),
], 200);
}
// Direct subscription (no double opt-in)
do_action('woonoow_newsletter_subscribed', $email, $user_id);
// Trigger notification system events (uses email builder)
do_action('woonoow/notification/event', 'newsletter_welcome', 'customer', [
'email' => $email,
'user_id' => $user_id,
'subscribed_at' => current_time('mysql'),
]);
do_action('woonoow/notification/event', 'newsletter_subscribed_admin', 'staff', [
'email' => $email,
'user_id' => $user_id,
'subscribed_at' => current_time('mysql'),
]);
return new WP_REST_Response([
'success' => true,
'message' => 'Successfully subscribed! Check your email for confirmation.',
'message' => __('Successfully subscribed to our newsletter!', 'woonoow'),
], 200);
}
private static function send_welcome_email($email) {
$site_name = get_bloginfo('name');
$template = get_option('woonoow_newsletter_welcome_template', '');
if (empty($template)) {
$template = "Thank you for subscribing to our newsletter!\n\nYou'll receive updates about our latest products and offers.\n\nBest regards,\n{site_name}";
}
$subject = sprintf('Welcome to %s Newsletter!', $site_name);
$message = str_replace('{site_name}', $site_name, $template);
wp_mail($email, $subject, $message);
/**
* Send confirmation email for double opt-in
*/
private static function send_confirmation_email($email, $user_id = null)
{
$confirmation_url = self::generate_confirmation_url($email);
do_action('woonoow/notification/event', 'newsletter_confirm', 'customer', [
'email' => $email,
'user_id' => $user_id,
'confirmation_url' => $confirmation_url,
]);
}
public static function get_subscribers(WP_REST_Request $request) {
/**
* Generate confirmation URL with secure token
*/
public static function generate_confirmation_url($email)
{
$token = self::generate_unsubscribe_token($email); // Reuse same token logic
$base_url = rest_url('woonoow/v1/newsletter/confirm');
return add_query_arg([
'email' => urlencode($email),
'token' => $token,
], $base_url);
}
/**
* Handle confirmation request (double opt-in)
*/
public static function confirm(WP_REST_Request $request)
{
$email = sanitize_email(urldecode($request->get_param('email')));
$token = sanitize_text_field($request->get_param('token'));
// Verify token
$expected_token = self::generate_unsubscribe_token($email);
if (!hash_equals($expected_token, $token)) {
return new WP_REST_Response([
'success' => false,
'message' => __('Invalid confirmation link', 'woonoow'),
], 400);
}
$found = false;
$user_id = null;
if (self::use_custom_table()) {
$existing = SubscriberTable::get_by_email($email);
if ($existing) {
if ($existing['status'] === 'active') {
$found = true;
} else {
SubscriberTable::update_by_email($email, [
'status' => 'active',
'confirmed_at' => current_time('mysql'),
]);
$user_id = $existing['user_id'] ?? null;
$found = true;
}
}
} else {
// Legacy: wp_options
$subscribers = get_option('woonoow_newsletter_subscribers', []);
foreach ($subscribers as &$sub) {
if (isset($sub['email']) && $sub['email'] === $email) {
if (($sub['status'] ?? '') === 'active') {
$found = true;
break;
}
$sub['status'] = 'active';
$sub['confirmed_at'] = current_time('mysql');
$user_id = $sub['user_id'] ?? null;
$found = true;
break;
}
}
if ($found) {
update_option('woonoow_newsletter_subscribers', $subscribers);
}
}
// Trigger subscription events
do_action('woonoow_newsletter_subscribed', $email, $user_id);
do_action('woonoow/notification/event', 'newsletter_welcome', 'customer', [
'email' => $email,
'user_id' => $user_id,
'subscribed_at' => current_time('mysql'),
]);
do_action('woonoow/notification/event', 'newsletter_subscribed_admin', 'staff', [
'email' => $email,
'user_id' => $user_id,
'subscribed_at' => current_time('mysql'),
]);
// Return HTML page for nice UX
$site_name = get_bloginfo('name');
$shop_url = wc_get_page_permalink('shop') ?: home_url();
$html = sprintf(
'<!DOCTYPE html><html><head><title>%s</title><style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5;}.box{background:white;padding:40px;border-radius:8px;text-align:center;box-shadow:0 2px 10px rgba(0,0,0,0.1);max-width:400px;}h1{color:#333;margin-bottom:16px;}p{color:#666;}a{display:inline-block;margin-top:20px;padding:12px 24px;background:#333;color:white;text-decoration:none;border-radius:6px;}</style></head><body><div class="box"><h1>✓ Confirmed!</h1><p>You are now subscribed to %s newsletter.</p><a href="%s">Continue Shopping</a></div></body></html>',
__('Subscription Confirmed', 'woonoow'),
esc_html($site_name),
esc_url($shop_url)
);
header('Content-Type: text/html; charset=utf-8');
echo $html;
exit;
}
// Dead code removed: send_welcome_email() - now handled via notification system
public static function get_subscribers(WP_REST_Request $request)
{
if (self::use_custom_table()) {
$result = SubscriberTable::get_all([
'per_page' => 100,
'page' => 1,
]);
return new WP_REST_Response([
'success' => true,
'data' => [
'subscribers' => $result['items'],
'count' => $result['total'],
],
], 200);
}
// Legacy: wp_options
$subscribers = get_option('woonoow_newsletter_subscribers', []);
return new WP_REST_Response([
'success' => true,
'data' => [
@@ -214,14 +460,15 @@ class NewsletterController {
],
], 200);
}
/**
* Handle unsubscribe request
*/
public static function unsubscribe(WP_REST_Request $request) {
public static function unsubscribe(WP_REST_Request $request)
{
$email = sanitize_email(urldecode($request->get_param('email')));
$token = sanitize_text_field($request->get_param('token'));
// Verify token
$expected_token = self::generate_unsubscribe_token($email);
if (!hash_equals($expected_token, $token)) {
@@ -230,31 +477,45 @@ class NewsletterController {
'message' => __('Invalid unsubscribe link', 'woonoow'),
], 400);
}
// Get subscribers
$subscribers = get_option('woonoow_newsletter_subscribers', []);
$found = false;
foreach ($subscribers as &$sub) {
if (isset($sub['email']) && $sub['email'] === $email) {
$sub['status'] = 'unsubscribed';
$sub['unsubscribed_at'] = current_time('mysql');
if (self::use_custom_table()) {
$existing = SubscriberTable::get_by_email($email);
if ($existing) {
SubscriberTable::update_by_email($email, [
'status' => 'unsubscribed',
'unsubscribed_at' => current_time('mysql'),
]);
$found = true;
break;
}
} else {
// Legacy: wp_options
$subscribers = get_option('woonoow_newsletter_subscribers', []);
foreach ($subscribers as &$sub) {
if (isset($sub['email']) && $sub['email'] === $email) {
$sub['status'] = 'unsubscribed';
$sub['unsubscribed_at'] = current_time('mysql');
$found = true;
break;
}
}
if ($found) {
update_option('woonoow_newsletter_subscribers', $subscribers);
}
}
if (!$found) {
return new WP_REST_Response([
'success' => false,
'message' => __('Email not found', 'woonoow'),
], 404);
}
update_option('woonoow_newsletter_subscribers', $subscribers);
do_action('woonoow_newsletter_unsubscribed', $email);
// Return HTML page for nice UX
$site_name = get_bloginfo('name');
$html = sprintf(
@@ -262,24 +523,26 @@ class NewsletterController {
__('Unsubscribed', 'woonoow'),
esc_html($site_name)
);
header('Content-Type: text/html; charset=utf-8');
echo $html;
exit;
}
/**
* Generate secure unsubscribe token
*/
private static function generate_unsubscribe_token($email) {
private static function generate_unsubscribe_token($email)
{
$secret = wp_salt('auth');
return hash_hmac('sha256', $email, $secret);
}
/**
* Generate unsubscribe URL for email templates
*/
public static function generate_unsubscribe_url($email) {
public static function generate_unsubscribe_url($email)
{
$token = self::generate_unsubscribe_token($email);
$base_url = rest_url('woonoow/v1/newsletter/unsubscribe');
return add_query_arg([
@@ -288,4 +551,3 @@ class NewsletterController {
], $base_url);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -371,13 +371,13 @@ class ProductsController
}
// Virtual and downloadable
if (isset($data['virtual'])) {
if (array_key_exists('virtual', $data)) {
$product->set_virtual((bool) $data['virtual']);
}
if (isset($data['downloadable'])) {
if (array_key_exists('downloadable', $data)) {
$product->set_downloadable((bool) $data['downloadable']);
}
if (isset($data['featured'])) {
if (array_key_exists('featured', $data)) {
$product->set_featured((bool) $data['featured']);
}
@@ -510,13 +510,13 @@ class ProductsController
if (isset($data['height'])) $product->set_height(self::sanitize_number($data['height']));
// Virtual and downloadable
if (isset($data['virtual'])) {
if (array_key_exists('virtual', $data)) {
$product->set_virtual((bool) $data['virtual']);
}
if (isset($data['downloadable'])) {
if (array_key_exists('downloadable', $data)) {
$product->set_downloadable((bool) $data['downloadable']);
}
if (isset($data['featured'])) {
if (array_key_exists('featured', $data)) {
$product->set_featured((bool) $data['featured']);
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Store REST API Controller
*
@@ -11,28 +12,31 @@ namespace WooNooW\API;
use WooNooW\Compat\StoreSettingsProvider;
use WooNooW\Compat\CustomerSettingsProvider;
use WooNooW\Compat\SecuritySettingsProvider;
use WP_REST_Controller;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
class StoreController extends WP_REST_Controller {
class StoreController extends WP_REST_Controller
{
/**
* Namespace
*/
protected $namespace = 'woonoow/v1';
/**
* Rest base
*/
protected $rest_base = 'store';
/**
* Register routes
*/
public function register_routes() {
public function register_routes()
{
// GET /woonoow/v1/store/branding (PUBLIC - for login page)
register_rest_route($this->namespace, '/' . $this->rest_base . '/branding', [
[
@@ -41,7 +45,7 @@ class StoreController extends WP_REST_Controller {
'permission_callback' => '__return_true', // Public endpoint
],
]);
// GET /woonoow/v1/store/settings
register_rest_route($this->namespace, '/' . $this->rest_base . '/settings', [
[
@@ -50,7 +54,7 @@ class StoreController extends WP_REST_Controller {
'permission_callback' => [$this, 'check_permission'],
],
]);
// POST /woonoow/v1/store/settings
register_rest_route($this->namespace, '/' . $this->rest_base . '/settings', [
[
@@ -59,7 +63,7 @@ class StoreController extends WP_REST_Controller {
'permission_callback' => [$this, 'check_permission'],
],
]);
// GET /woonoow/v1/store/countries
register_rest_route($this->namespace, '/' . $this->rest_base . '/countries', [
[
@@ -68,7 +72,7 @@ class StoreController extends WP_REST_Controller {
'permission_callback' => [$this, 'check_permission'],
],
]);
// GET /woonoow/v1/store/timezones
register_rest_route($this->namespace, '/' . $this->rest_base . '/timezones', [
[
@@ -77,7 +81,7 @@ class StoreController extends WP_REST_Controller {
'permission_callback' => [$this, 'check_permission'],
],
]);
// GET /woonoow/v1/store/currencies
register_rest_route($this->namespace, '/' . $this->rest_base . '/currencies', [
[
@@ -86,7 +90,7 @@ class StoreController extends WP_REST_Controller {
'permission_callback' => [$this, 'check_permission'],
],
]);
// GET /woonoow/v1/store/customer-settings
register_rest_route($this->namespace, '/' . $this->rest_base . '/customer-settings', [
[
@@ -95,7 +99,7 @@ class StoreController extends WP_REST_Controller {
'permission_callback' => [$this, 'check_permission'],
],
]);
// POST /woonoow/v1/store/customer-settings
register_rest_route($this->namespace, '/' . $this->rest_base . '/customer-settings', [
[
@@ -104,15 +108,34 @@ class StoreController extends WP_REST_Controller {
'permission_callback' => [$this, 'check_permission'],
],
]);
// GET /woonoow/v1/store/security-settings
register_rest_route($this->namespace, '/' . $this->rest_base . '/security-settings', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'get_security_settings'],
'permission_callback' => [$this, 'check_permission'],
],
]);
// POST /woonoow/v1/store/security-settings
register_rest_route($this->namespace, '/' . $this->rest_base . '/security-settings', [
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [$this, 'save_security_settings'],
'permission_callback' => [$this, 'check_permission'],
],
]);
}
/**
* Get store branding (PUBLIC - for login page)
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response Response object
*/
public function get_branding(WP_REST_Request $request) {
public function get_branding(WP_REST_Request $request)
{
$branding = [
'store_name' => get_option('woonoow_store_name', '') ?: get_option('blogname', 'WooNooW'),
'store_logo' => get_option('woonoow_store_logo', ''),
@@ -120,26 +143,27 @@ class StoreController extends WP_REST_Controller {
'store_icon' => get_option('woonoow_store_icon', ''),
'store_tagline' => get_option('woonoow_store_tagline', ''),
];
$response = rest_ensure_response($branding);
$response->header('Cache-Control', 'max-age=300'); // Cache for 5 minutes
return $response;
}
/**
* Get store settings
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_settings(WP_REST_Request $request) {
public function get_settings(WP_REST_Request $request)
{
try {
$settings = StoreSettingsProvider::get_settings();
$response = rest_ensure_response($settings);
$response->header('Cache-Control', 'max-age=60');
return $response;
} catch (\Exception $e) {
return new WP_Error(
@@ -149,16 +173,17 @@ class StoreController extends WP_REST_Controller {
);
}
}
/**
* Save store settings
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function save_settings(WP_REST_Request $request) {
public function save_settings(WP_REST_Request $request)
{
$settings = $request->get_json_params();
if (empty($settings)) {
return new WP_Error(
'missing_settings',
@@ -166,10 +191,10 @@ class StoreController extends WP_REST_Controller {
['status' => 400]
);
}
try {
$result = StoreSettingsProvider::save_settings($settings);
if (!$result) {
return new WP_Error(
'save_failed',
@@ -177,7 +202,7 @@ class StoreController extends WP_REST_Controller {
['status' => 500]
);
}
return rest_ensure_response([
'success' => true,
'message' => 'Settings saved successfully',
@@ -191,20 +216,21 @@ class StoreController extends WP_REST_Controller {
);
}
}
/**
* Get countries
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_countries(WP_REST_Request $request) {
public function get_countries(WP_REST_Request $request)
{
try {
$countries = StoreSettingsProvider::get_countries();
$response = rest_ensure_response($countries);
$response->header('Cache-Control', 'max-age=3600'); // Cache for 1 hour
return $response;
} catch (\Exception $e) {
return new WP_Error(
@@ -214,20 +240,21 @@ class StoreController extends WP_REST_Controller {
);
}
}
/**
* Get timezones
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_timezones(WP_REST_Request $request) {
public function get_timezones(WP_REST_Request $request)
{
try {
$timezones = StoreSettingsProvider::get_timezones();
$response = rest_ensure_response($timezones);
$response->header('Cache-Control', 'max-age=3600'); // Cache for 1 hour
return $response;
} catch (\Exception $e) {
return new WP_Error(
@@ -237,20 +264,21 @@ class StoreController extends WP_REST_Controller {
);
}
}
/**
* Get currencies
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_currencies(WP_REST_Request $request) {
public function get_currencies(WP_REST_Request $request)
{
try {
$currencies = StoreSettingsProvider::get_currencies();
$response = rest_ensure_response($currencies);
$response->header('Cache-Control', 'max-age=3600'); // Cache for 1 hour
return $response;
} catch (\Exception $e) {
return new WP_Error(
@@ -260,20 +288,21 @@ class StoreController extends WP_REST_Controller {
);
}
}
/**
* Get customer settings
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_customer_settings(WP_REST_Request $request) {
public function get_customer_settings(WP_REST_Request $request)
{
try {
$settings = CustomerSettingsProvider::get_settings();
$response = rest_ensure_response($settings);
$response->header('Cache-Control', 'max-age=60');
return $response;
} catch (\Exception $e) {
return new WP_Error(
@@ -283,17 +312,18 @@ class StoreController extends WP_REST_Controller {
);
}
}
/**
* Save customer settings
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function save_customer_settings(WP_REST_Request $request) {
public function save_customer_settings(WP_REST_Request $request)
{
try {
$settings = $request->get_json_params();
if (empty($settings)) {
return new WP_Error(
'invalid_settings',
@@ -301,9 +331,9 @@ class StoreController extends WP_REST_Controller {
['status' => 400]
);
}
$updated = CustomerSettingsProvider::update_settings($settings);
if (!$updated) {
return new WP_Error(
'update_failed',
@@ -311,16 +341,15 @@ class StoreController extends WP_REST_Controller {
['status' => 500]
);
}
// Return updated settings
$new_settings = CustomerSettingsProvider::get_settings();
return new WP_REST_Response([
'success' => true,
'message' => __('Customer settings updated successfully', 'woonoow'),
'settings' => $new_settings,
], 200);
} catch (\Exception $e) {
return new WP_Error(
'save_customer_settings_failed',
@@ -329,13 +358,84 @@ class StoreController extends WP_REST_Controller {
);
}
}
/**
* Get security settings
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_security_settings(WP_REST_Request $request)
{
try {
$settings = SecuritySettingsProvider::get_settings();
$response = rest_ensure_response($settings);
$response->header('Cache-Control', 'max-age=60');
return $response;
} catch (\Exception $e) {
return new WP_Error(
'get_security_settings_failed',
$e->getMessage(),
['status' => 500]
);
}
}
/**
* Save security settings
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function save_security_settings(WP_REST_Request $request)
{
try {
$settings = $request->get_json_params();
if (empty($settings)) {
return new WP_Error(
'invalid_settings',
__('Invalid settings data', 'woonoow'),
['status' => 400]
);
}
$updated = SecuritySettingsProvider::update_settings($settings);
if (!$updated) {
return new WP_Error(
'update_failed',
__('Failed to update security settings', 'woonoow'),
['status' => 500]
);
}
// Return updated settings
$new_settings = SecuritySettingsProvider::get_settings();
return new WP_REST_Response([
'success' => true,
'message' => __('Security settings updated successfully', 'woonoow'),
'settings' => $new_settings,
], 200);
} catch (\Exception $e) {
return new WP_Error(
'save_security_settings_failed',
$e->getMessage(),
['status' => 500]
);
}
}
/**
* Check if user has permission
*
* @return bool True if user has permission
*/
public function check_permission() {
public function check_permission()
{
// Check WooCommerce capability first, fallback to manage_options
return current_user_can('manage_woocommerce') || current_user_can('manage_options');
}

View File

@@ -471,6 +471,31 @@ class SubscriptionsController
}
$enriched['billing_schedule'] = sprintf(__('Every %s%s', 'woonoow'), $interval, $period);
// Add payment method title
$payment_title = $subscription->payment_method; // Default to ID
// 1. Try from payment_meta (stored snapshot)
if (!empty($subscription->payment_meta)) {
$meta = json_decode($subscription->payment_meta, true);
if (isset($meta['method_title']) && !empty($meta['method_title'])) {
$payment_title = $meta['method_title'];
}
}
// 2. If it looks like an ID (no spaces, lowercase), try to get fresh title from gateway
if ($payment_title === $subscription->payment_method && function_exists('WC')) {
$gateways_handler = WC()->payment_gateways();
if ($gateways_handler) {
$gateways = $gateways_handler->payment_gateways();
if (isset($gateways[$subscription->payment_method])) {
$gw = $gateways[$subscription->payment_method];
$payment_title = $gw->get_title() ?: $gw->method_title;
}
}
}
$enriched['payment_method_title'] = $payment_title;
return $enriched;
}
}

View File

@@ -1,7 +1,8 @@
<?php
namespace WooNooW\Compat;
if ( ! defined('ABSPATH') ) exit;
if (! defined('ABSPATH')) exit;
/**
* Navigation Registry
@@ -11,36 +12,39 @@ if ( ! defined('ABSPATH') ) exit;
*
* @since 1.0.0
*/
class NavigationRegistry {
class NavigationRegistry
{
const NAV_OPTION = 'wnw_nav_tree';
const NAV_VERSION = '1.3.0'; // Added Subscriptions section
/**
* Initialize hooks
*/
public static function init() {
public static function init()
{
// Use 'init' hook instead of 'plugins_loaded' to avoid translation loading warnings (WP 6.7+)
add_action('init', [__CLASS__, 'build_nav_tree'], 10);
add_action('activated_plugin', [__CLASS__, 'flush']);
add_action('deactivated_plugin', [__CLASS__, 'flush']);
}
/**
* Build the complete navigation tree
*/
public static function build_nav_tree() {
public static function build_nav_tree()
{
// Check if we need to rebuild (version mismatch)
$cached = get_option(self::NAV_OPTION, []);
$cached_version = $cached['version'] ?? '';
if ($cached_version === self::NAV_VERSION && !empty($cached['tree'])) {
// Cache is valid, no need to rebuild
return;
}
// Base navigation tree (core WooNooW sections)
$tree = self::get_base_tree();
/**
* Filter: woonoow/nav_tree
*
@@ -64,7 +68,7 @@ class NavigationRegistry {
* });
*/
$tree = apply_filters('woonoow/nav_tree', $tree);
// Allow per-section modification
foreach ($tree as &$section) {
$key = $section['key'] ?? '';
@@ -90,7 +94,7 @@ class NavigationRegistry {
);
}
}
// Store in option
update_option(self::NAV_OPTION, [
'version' => self::NAV_VERSION,
@@ -98,13 +102,14 @@ class NavigationRegistry {
'updated' => time(),
], false);
}
/**
* Get base navigation tree (core sections)
*
* @return array Base navigation tree
*/
private static function get_base_tree(): array {
private static function get_base_tree(): array
{
$tree = [
[
'key' => 'dashboard',
@@ -198,37 +203,39 @@ class NavigationRegistry {
'children' => [], // Empty array = no submenu bar
],
];
return $tree;
}
/**
* Get marketing submenu children
*
* @return array Marketing submenu items
*/
private static function get_marketing_children(): array {
private static function get_marketing_children(): array
{
$children = [];
// Newsletter - only if module enabled
if (\WooNooW\Core\ModuleRegistry::is_enabled('newsletter')) {
$children[] = ['label' => __('Newsletter', 'woonoow'), 'mode' => 'spa', 'path' => '/marketing/newsletter'];
}
// Coupons - always available
$children[] = ['label' => __('Coupons', 'woonoow'), 'mode' => 'spa', 'path' => '/coupons'];
return $children;
}
/**
* Get settings submenu children
*
* @return array Settings submenu items
*/
private static function get_settings_children(): array {
private static function get_settings_children(): array
{
$admin = admin_url('admin.php');
$children = [
// Core Settings (Shopify-inspired)
['label' => __('Store Details', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/store'],
@@ -236,25 +243,27 @@ class NavigationRegistry {
['label' => __('Shipping & Delivery', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/shipping'],
['label' => __('Tax', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/tax'],
['label' => __('Customers', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/customers'],
['label' => __('Security', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/security'],
['label' => __('Notifications', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/notifications'],
['label' => __('Modules', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/modules'],
['label' => __('Developer', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/developer'],
];
return $children;
}
/**
* Get subscriptions navigation section
* Returns empty array if module is not enabled
*
* @return array Subscriptions section or empty array
*/
private static function get_subscriptions_section(): array {
private static function get_subscriptions_section(): array
{
if (!\WooNooW\Core\ModuleRegistry::is_enabled('subscription')) {
return [];
}
return [
[
'key' => 'subscriptions',
@@ -267,24 +276,26 @@ class NavigationRegistry {
],
];
}
/**
* Get the complete navigation tree
*
* @return array Navigation tree
*/
public static function get_nav_tree(): array {
public static function get_nav_tree(): array
{
$data = get_option(self::NAV_OPTION, []);
return $data['tree'] ?? self::get_base_tree();
}
/**
* Get a specific section by key
*
* @param string $key Section key
* @return array|null Section data or null if not found
*/
public static function get_section(string $key): ?array {
public static function get_section(string $key): ?array
{
$tree = self::get_nav_tree();
foreach ($tree as $section) {
if (($section['key'] ?? '') === $key) {
@@ -293,22 +304,24 @@ class NavigationRegistry {
}
return null;
}
/**
* Flush navigation cache
*/
public static function flush() {
public static function flush()
{
delete_option(self::NAV_OPTION);
// Rebuild immediately after flush
self::build_nav_tree();
}
/**
* Get navigation tree for frontend
*
* @return array Array suitable for JSON encoding
*/
public static function get_frontend_nav_tree(): array {
public static function get_frontend_nav_tree(): array
{
return self::get_nav_tree();
}
}

View File

@@ -0,0 +1,301 @@
<?php
/**
* Security Settings Provider
*
* Provides security-related settings including rate limiting and CAPTCHA.
*
* @package WooNooW
*/
namespace WooNooW\Compat;
class SecuritySettingsProvider
{
/**
* Get security settings
*
* @return array
*/
public static function get_settings()
{
return [
// Rate Limiting
'enable_checkout_rate_limit' => get_option('woonoow_enable_checkout_rate_limit', 'yes') === 'yes',
'rate_limit_orders' => intval(get_option('woonoow_rate_limit_orders', 5)),
'rate_limit_minutes' => intval(get_option('woonoow_rate_limit_minutes', 10)),
// CAPTCHA
'captcha_provider' => get_option('woonoow_captcha_provider', 'none'), // none, recaptcha, turnstile
'recaptcha_site_key' => get_option('woonoow_recaptcha_site_key', ''),
'recaptcha_secret_key' => get_option('woonoow_recaptcha_secret_key', ''),
'turnstile_site_key' => get_option('woonoow_turnstile_site_key', ''),
'turnstile_secret_key' => get_option('woonoow_turnstile_secret_key', ''),
];
}
/**
* Get public settings (safe to expose to frontend)
*
* @return array
*/
public static function get_public_settings()
{
$settings = self::get_settings();
return [
'captcha_provider' => $settings['captcha_provider'],
'recaptcha_site_key' => $settings['recaptcha_site_key'],
'turnstile_site_key' => $settings['turnstile_site_key'],
];
}
/**
* Update security settings
*
* @param array $settings
* @return bool
*/
public static function update_settings($settings)
{
// Rate Limiting
if (array_key_exists('enable_checkout_rate_limit', $settings)) {
$value = !empty($settings['enable_checkout_rate_limit']) ? 'yes' : 'no';
update_option('woonoow_enable_checkout_rate_limit', $value);
}
if (isset($settings['rate_limit_orders'])) {
$value = max(1, intval($settings['rate_limit_orders']));
update_option('woonoow_rate_limit_orders', $value);
}
if (isset($settings['rate_limit_minutes'])) {
$value = max(1, intval($settings['rate_limit_minutes']));
update_option('woonoow_rate_limit_minutes', $value);
}
// CAPTCHA Provider
if (isset($settings['captcha_provider'])) {
$valid_providers = ['none', 'recaptcha', 'turnstile'];
$value = in_array($settings['captcha_provider'], $valid_providers)
? $settings['captcha_provider']
: 'none';
update_option('woonoow_captcha_provider', $value);
}
// reCAPTCHA Keys
if (isset($settings['recaptcha_site_key'])) {
update_option('woonoow_recaptcha_site_key', sanitize_text_field($settings['recaptcha_site_key']));
}
if (isset($settings['recaptcha_secret_key'])) {
update_option('woonoow_recaptcha_secret_key', sanitize_text_field($settings['recaptcha_secret_key']));
}
// Turnstile Keys
if (isset($settings['turnstile_site_key'])) {
update_option('woonoow_turnstile_site_key', sanitize_text_field($settings['turnstile_site_key']));
}
if (isset($settings['turnstile_secret_key'])) {
update_option('woonoow_turnstile_secret_key', sanitize_text_field($settings['turnstile_secret_key']));
}
return true;
}
/**
* Check if rate limit is exceeded for an IP
*
* @param string|null $ip IP address (null = auto-detect)
* @return bool True if rate limit exceeded
*/
public static function is_rate_limited($ip = null)
{
$settings = self::get_settings();
if (!$settings['enable_checkout_rate_limit']) {
return false;
}
if ($ip === null) {
$ip = self::get_client_ip();
}
$transient_key = 'woonoow_rate_' . md5($ip);
$attempts = get_transient($transient_key);
if ($attempts === false) {
return false;
}
return intval($attempts) >= $settings['rate_limit_orders'];
}
/**
* Record an order attempt for rate limiting
*
* @param string|null $ip IP address (null = auto-detect)
* @return void
*/
public static function record_order_attempt($ip = null)
{
$settings = self::get_settings();
if (!$settings['enable_checkout_rate_limit']) {
return;
}
if ($ip === null) {
$ip = self::get_client_ip();
}
$transient_key = 'woonoow_rate_' . md5($ip);
$attempts = get_transient($transient_key);
if ($attempts === false) {
// First attempt, set with expiration
set_transient($transient_key, 1, $settings['rate_limit_minutes'] * MINUTE_IN_SECONDS);
} else {
// Increment attempts (keep same expiration by getting remaining time)
$attempts = intval($attempts) + 1;
set_transient($transient_key, $attempts, $settings['rate_limit_minutes'] * MINUTE_IN_SECONDS);
}
}
/**
* Validate CAPTCHA token
*
* @param string $token CAPTCHA token from frontend
* @return bool|WP_Error True if valid, WP_Error if invalid
*/
public static function validate_captcha($token)
{
$settings = self::get_settings();
if ($settings['captcha_provider'] === 'none') {
return true; // No CAPTCHA enabled
}
if (empty($token)) {
return new \WP_Error('captcha_missing', __('CAPTCHA verification required', 'woonoow'));
}
if ($settings['captcha_provider'] === 'recaptcha') {
return self::validate_recaptcha($token, $settings['recaptcha_secret_key']);
}
if ($settings['captcha_provider'] === 'turnstile') {
return self::validate_turnstile($token, $settings['turnstile_secret_key']);
}
return true;
}
/**
* Validate Google reCAPTCHA v3 token
*
* @param string $token Token from frontend
* @param string $secret_key Secret key
* @return bool|WP_Error
*/
private static function validate_recaptcha($token, $secret_key)
{
if (empty($secret_key)) {
return new \WP_Error('captcha_config', __('reCAPTCHA not configured', 'woonoow'));
}
$response = wp_remote_post('https://www.google.com/recaptcha/api/siteverify', [
'body' => [
'secret' => $secret_key,
'response' => $token,
'remoteip' => self::get_client_ip(),
],
'timeout' => 10,
]);
if (is_wp_error($response)) {
return new \WP_Error('captcha_error', __('CAPTCHA verification failed', 'woonoow'));
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (!isset($body['success']) || !$body['success']) {
return new \WP_Error('captcha_invalid', __('CAPTCHA verification failed', 'woonoow'));
}
// reCAPTCHA v3 returns a score (0.0 - 1.0), we accept 0.5 and above
$score = $body['score'] ?? 0;
if ($score < 0.5) {
return new \WP_Error('captcha_score', __('CAPTCHA score too low', 'woonoow'));
}
return true;
}
/**
* Validate Cloudflare Turnstile token
*
* @param string $token Token from frontend
* @param string $secret_key Secret key
* @return bool|WP_Error
*/
private static function validate_turnstile($token, $secret_key)
{
if (empty($secret_key)) {
return new \WP_Error('captcha_config', __('Turnstile not configured', 'woonoow'));
}
$response = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'body' => [
'secret' => $secret_key,
'response' => $token,
'remoteip' => self::get_client_ip(),
],
'timeout' => 10,
]);
if (is_wp_error($response)) {
return new \WP_Error('captcha_error', __('CAPTCHA verification failed', 'woonoow'));
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (!isset($body['success']) || !$body['success']) {
return new \WP_Error('captcha_invalid', __('CAPTCHA verification failed', 'woonoow'));
}
return true;
}
/**
* Get client IP address
*
* @return string
*/
private static function get_client_ip()
{
$headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'REMOTE_ADDR',
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
$ip = $_SERVER[$header];
// Handle comma-separated list (X-Forwarded-For)
if (strpos($ip, ',') !== false) {
$ip = trim(explode(',', $ip)[0]);
}
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
return '127.0.0.1';
}
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Campaign Manager
*
@@ -9,37 +10,43 @@
namespace WooNooW\Core\Campaigns;
use WooNooW\Database\SubscriberTable;
if (!defined('ABSPATH')) exit;
class CampaignManager {
class CampaignManager
{
const POST_TYPE = 'wnw_campaign';
const CRON_HOOK = 'woonoow_process_scheduled_campaigns';
private static $instance = null;
/**
* Get instance
*/
public static function instance() {
public static function instance()
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Initialize
*/
public static function init() {
public static function init()
{
add_action('init', [__CLASS__, 'register_post_type']);
add_action(self::CRON_HOOK, [__CLASS__, 'process_scheduled_campaigns']);
}
/**
* Register campaign post type
*/
public static function register_post_type() {
public static function register_post_type()
{
register_post_type(self::POST_TYPE, [
'labels' => [
'name' => __('Campaigns', 'woonoow'),
@@ -53,32 +60,33 @@ class CampaignManager {
'map_meta_cap' => true,
]);
}
/**
* Create a new campaign
*
* @param array $data Campaign data
* @return int|WP_Error Campaign ID or error
*/
public static function create($data) {
public static function create($data)
{
$post_data = [
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'post_title' => sanitize_text_field($data['title'] ?? 'Untitled Campaign'),
];
$campaign_id = wp_insert_post($post_data, true);
if (is_wp_error($campaign_id)) {
return $campaign_id;
}
// Save meta fields
self::update_meta($campaign_id, $data);
return $campaign_id;
}
/**
* Update campaign
*
@@ -86,13 +94,14 @@ class CampaignManager {
* @param array $data Campaign data
* @return bool|WP_Error
*/
public static function update($campaign_id, $data) {
public static function update($campaign_id, $data)
{
$post = get_post($campaign_id);
if (!$post || $post->post_type !== self::POST_TYPE) {
return new \WP_Error('invalid_campaign', __('Campaign not found', 'woonoow'));
}
// Update title if provided
if (isset($data['title'])) {
wp_update_post([
@@ -100,31 +109,32 @@ class CampaignManager {
'post_title' => sanitize_text_field($data['title']),
]);
}
// Update meta fields
self::update_meta($campaign_id, $data);
return true;
}
/**
* Update campaign meta
*
* @param int $campaign_id
* @param array $data
*/
private static function update_meta($campaign_id, $data) {
private static function update_meta($campaign_id, $data)
{
$meta_fields = [
'subject' => '_wnw_subject',
'content' => '_wnw_content',
'status' => '_wnw_status',
'scheduled_at' => '_wnw_scheduled_at',
];
foreach ($meta_fields as $key => $meta_key) {
if (isset($data[$key])) {
$value = $data[$key];
// Sanitize based on field type
if ($key === 'content') {
$value = wp_kses_post($value);
@@ -136,40 +146,42 @@ class CampaignManager {
} else {
$value = sanitize_text_field($value);
}
update_post_meta($campaign_id, $meta_key, $value);
}
}
// Set default status if not provided
if (!get_post_meta($campaign_id, '_wnw_status', true)) {
update_post_meta($campaign_id, '_wnw_status', 'draft');
}
}
/**
* Get campaign by ID
*
* @param int $campaign_id
* @return array|null
*/
public static function get($campaign_id) {
public static function get($campaign_id)
{
$post = get_post($campaign_id);
if (!$post || $post->post_type !== self::POST_TYPE) {
return null;
}
return self::format_campaign($post);
}
/**
* Get all campaigns
*
* @param array $args Query args
* @return array
*/
public static function get_all($args = []) {
public static function get_all($args = [])
{
$defaults = [
'post_type' => self::POST_TYPE,
'post_status' => 'any',
@@ -177,22 +189,23 @@ class CampaignManager {
'orderby' => 'date',
'order' => 'DESC',
];
$query_args = wp_parse_args($args, $defaults);
$query_args['post_type'] = self::POST_TYPE; // Force post type
$posts = get_posts($query_args);
return array_map([__CLASS__, 'format_campaign'], $posts);
}
/**
* Format campaign post to array
*
* @param WP_Post $post
* @return array
*/
private static function format_campaign($post) {
private static function format_campaign($post)
{
return [
'id' => $post->ID,
'title' => $post->post_title,
@@ -208,69 +221,71 @@ class CampaignManager {
'updated_at' => $post->post_modified,
];
}
/**
* Delete campaign
*
* @param int $campaign_id
* @return bool
*/
public static function delete($campaign_id) {
public static function delete($campaign_id)
{
$post = get_post($campaign_id);
if (!$post || $post->post_type !== self::POST_TYPE) {
return false;
}
return wp_delete_post($campaign_id, true) !== false;
}
/**
* Send campaign
*
* @param int $campaign_id
* @return array Result with sent/failed counts
*/
public static function send($campaign_id) {
public static function send($campaign_id)
{
$campaign = self::get($campaign_id);
if (!$campaign) {
return ['success' => false, 'error' => __('Campaign not found', 'woonoow')];
}
if ($campaign['status'] === 'sent') {
return ['success' => false, 'error' => __('Campaign already sent', 'woonoow')];
}
// Get subscribers
$subscribers = self::get_subscribers();
if (empty($subscribers)) {
return ['success' => false, 'error' => __('No subscribers to send to', 'woonoow')];
}
// Update status to sending
update_post_meta($campaign_id, '_wnw_status', 'sending');
update_post_meta($campaign_id, '_wnw_recipient_count', count($subscribers));
$sent = 0;
$failed = 0;
// Get email template
$template = self::render_campaign_email($campaign);
// Send in batches
$batch_size = 50;
$batches = array_chunk($subscribers, $batch_size);
foreach ($batches as $batch) {
foreach ($batch as $subscriber) {
$email = $subscriber['email'];
// Replace subscriber-specific variables
$body = str_replace('{subscriber_email}', $email, $template['body']);
$body = str_replace('{unsubscribe_url}', self::get_unsubscribe_url($email), $body);
// Send email
$result = wp_mail(
$email,
@@ -278,26 +293,26 @@ class CampaignManager {
$body,
['Content-Type: text/html; charset=UTF-8']
);
if ($result) {
$sent++;
} else {
$failed++;
}
}
// Small delay between batches
if (count($batches) > 1) {
sleep(2);
}
}
// Update campaign stats
update_post_meta($campaign_id, '_wnw_sent_count', $sent);
update_post_meta($campaign_id, '_wnw_failed_count', $failed);
update_post_meta($campaign_id, '_wnw_sent_at', current_time('mysql'));
update_post_meta($campaign_id, '_wnw_status', $failed > 0 && $sent === 0 ? 'failed' : 'sent');
return [
'success' => true,
'sent' => $sent,
@@ -305,7 +320,7 @@ class CampaignManager {
'total' => count($subscribers),
];
}
/**
* Send test email
*
@@ -313,19 +328,20 @@ class CampaignManager {
* @param string $email Test email address
* @return bool
*/
public static function send_test($campaign_id, $email) {
public static function send_test($campaign_id, $email)
{
$campaign = self::get($campaign_id);
if (!$campaign) {
return false;
}
$template = self::render_campaign_email($campaign);
// Replace subscriber-specific variables
$body = str_replace('{subscriber_email}', $email, $template['body']);
$body = str_replace('{unsubscribe_url}', '#', $body);
return wp_mail(
$email,
'[TEST] ' . $template['subject'],
@@ -333,43 +349,49 @@ class CampaignManager {
['Content-Type: text/html; charset=UTF-8']
);
}
/**
* Render campaign email using EmailRenderer
*
* @param array $campaign
* @return array ['subject' => string, 'body' => string]
*/
private static function render_campaign_email($campaign) {
private static function render_campaign_email($campaign)
{
$renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
// Get the campaign email template
$template = $renderer->get_template_settings('newsletter_campaign', 'customer');
// Fallback if no template configured
if (!$template) {
$subject = $campaign['subject'] ?: $campaign['title'];
$body = $campaign['content'];
} else {
$subject = $template['subject'] ?: $campaign['subject'];
// Replace {content} with campaign content
$body = str_replace('{content}', $campaign['content'], $template['body']);
// Replace {campaign_title}
$body = str_replace('{campaign_title}', $campaign['title'], $body);
}
// Replace common variables
$site_name = get_bloginfo('name');
$site_url = home_url();
// Replace campaign-specific variables in subject
$subject = str_replace('{campaign_title}', $campaign['title'], $subject);
$subject = str_replace(['{site_name}', '{store_name}'], $site_name, $subject);
$body = str_replace(['{site_name}', '{store_name}'], $site_name, $body);
$body = str_replace('{site_url}', $site_url, $body);
$body = str_replace('{current_date}', date_i18n(get_option('date_format')), $body);
$body = str_replace('{current_year}', date('Y'), $body);
// Parse card shortcodes before rendering
$body = $renderer->parse_cards($body);
// Render through email design template
$design_path = $renderer->get_design_template();
if (file_exists($design_path)) {
@@ -378,69 +400,66 @@ class CampaignManager {
'site_url' => $site_url,
]);
}
return [
'subject' => $subject,
'body' => $body,
];
}
/**
* Get subscribers
*
* @param array $filters Optional audience filters
* @return array
*/
private static function get_subscribers() {
// Check if using custom table
$use_table = !get_option('woonoow_newsletter_limit_enabled', true);
if ($use_table && self::has_subscribers_table()) {
global $wpdb;
$table = $wpdb->prefix . 'woonoow_subscribers';
return $wpdb->get_results(
"SELECT email, user_id FROM {$table} WHERE status = 'active'",
ARRAY_A
);
private static function get_subscribers($filters = [])
{
// Use SubscriberTable if available
if (SubscriberTable::table_exists()) {
return SubscriberTable::get_active($filters);
}
// Use wp_options storage
// Legacy: use wp_options storage
$subscribers = get_option('woonoow_newsletter_subscribers', []);
return array_filter($subscribers, function($sub) {
return array_filter($subscribers, function ($sub) {
return ($sub['status'] ?? 'active') === 'active';
});
}
/**
* Check if subscribers table exists
* Check if subscribers table exists (deprecated - use SubscriberTable::table_exists())
*
* @deprecated Use SubscriberTable::table_exists() instead
* @return bool
*/
private static function has_subscribers_table() {
global $wpdb;
$table = $wpdb->prefix . 'woonoow_subscribers';
return $wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table;
private static function has_subscribers_table()
{
return SubscriberTable::table_exists();
}
/**
* Get unsubscribe URL
*
* @param string $email
* @return string
*/
private static function get_unsubscribe_url($email) {
private static function get_unsubscribe_url($email)
{
// Use NewsletterController's secure token-based URL
return \WooNooW\API\NewsletterController::generate_unsubscribe_url($email);
}
/**
* Process scheduled campaigns (WP-Cron)
*/
public static function process_scheduled_campaigns() {
public static function process_scheduled_campaigns()
{
// Only if scheduling is enabled
if (!get_option('woonoow_campaign_scheduling_enabled', false)) {
return;
}
$campaigns = self::get_all([
'meta_query' => [
[
@@ -455,25 +474,27 @@ class CampaignManager {
],
],
]);
foreach ($campaigns as $campaign) {
self::send($campaign['id']);
}
}
/**
* Enable scheduling (registers cron)
*/
public static function enable_scheduling() {
public static function enable_scheduling()
{
if (!wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time(), 'hourly', self::CRON_HOOK);
}
}
/**
* Disable scheduling (clears cron)
*/
public static function disable_scheduling() {
public static function disable_scheduling()
{
wp_clear_scheduled_hook(self::CRON_HOOK);
}
}

View File

@@ -0,0 +1,107 @@
<?php
/**
* Channel Registry
*
* Manages registration and retrieval of notification channels
*
* @package WooNooW\Core\Notifications
*/
namespace WooNooW\Core\Notifications;
use WooNooW\Core\Notifications\Channels\ChannelInterface;
class ChannelRegistry
{
/**
* Registered channels
*
* @var array<string, ChannelInterface>
*/
private static $channels = [];
/**
* Register a notification channel
*
* @param ChannelInterface $channel Channel instance
* @return bool Success status
*/
public static function register(ChannelInterface $channel)
{
$id = $channel->get_id();
if (empty($id)) {
return false;
}
self::$channels[$id] = $channel;
return true;
}
/**
* Get a registered channel by ID
*
* @param string $channel_id Channel identifier
* @return ChannelInterface|null Channel instance or null if not found
*/
public static function get($channel_id)
{
return self::$channels[$channel_id] ?? null;
}
/**
* Get all registered channels
*
* @return array<string, ChannelInterface> Associative array of channel_id => channel_instance
*/
public static function get_all()
{
return self::$channels;
}
/**
* Check if a channel is registered
*
* @param string $channel_id Channel identifier
* @return bool True if channel exists
*/
public static function has($channel_id)
{
return isset(self::$channels[$channel_id]);
}
/**
* Unregister a channel
*
* @param string $channel_id Channel identifier
* @return bool Success status
*/
public static function unregister($channel_id)
{
if (isset(self::$channels[$channel_id])) {
unset(self::$channels[$channel_id]);
return true;
}
return false;
}
/**
* Get list of configured channel IDs
*
* Only returns channels that are properly configured (is_configured() returns true)
*
* @return array List of configured channel IDs
*/
public static function get_configured_channels()
{
$configured = [];
foreach (self::$channels as $id => $channel) {
if ($channel->is_configured()) {
$configured[] = $id;
}
}
return $configured;
}
}

View File

@@ -0,0 +1,57 @@
<?php
/**
* Channel Interface
*
* Contract for implementing custom notification channels (WhatsApp, SMS, Telegram, etc.)
*
* @package WooNooW\Core\Notifications\Channels
*/
namespace WooNooW\Core\Notifications\Channels;
interface ChannelInterface
{
/**
* Get channel unique identifier
*
* @return string Channel ID (e.g., 'whatsapp', 'sms', 'telegram')
*/
public function get_id();
/**
* Get channel display label
*
* @return string Channel label for UI (e.g., 'WhatsApp', 'SMS', 'Telegram')
*/
public function get_label();
/**
* Check if channel is properly configured
*
* Example: API keys are set, credentials are valid, etc.
*
* @return bool True if channel is ready to send notifications
*/
public function is_configured();
/**
* Send notification through this channel
*
* @param string $event_id Event identifier (e.g., 'order_completed', 'newsletter_confirm')
* @param string $recipient Recipient type ('customer', 'staff')
* @param array $data Notification context data (order, user, custom vars, etc.)
* @return bool|array Success status, or array with 'success' and 'message' keys
*/
public function send($event_id, $recipient, $data);
/**
* Get channel configuration fields for admin settings
*
* Optional. Returns array of field definitions for settings UI.
*
* @return array Field definitions (e.g., API key, sender number, etc.)
*/
public function get_config_fields();
}

View File

@@ -0,0 +1,252 @@
<?php
/**
* WhatsApp Channel - Example Implementation
*
* This is a reference implementation showing how to create a custom notification channel.
* Developers can use this as a template for implementing WhatsApp, SMS, Telegram, etc.
*
* @package WooNooW\Core\Notifications\Channels
*/
namespace WooNooW\Core\Notifications\Channels;
/**
* Example WhatsApp Channel Implementation
*
* This channel sends notifications via WhatsApp Business API.
* Replace API calls with your actual WhatsApp service provider (Twilio, MessageBird, etc.)
*/
class WhatsAppChannel implements ChannelInterface
{
/**
* Get channel ID
*/
public function get_id()
{
return 'whatsapp';
}
/**
* Get channel label
*/
public function get_label()
{
return __('WhatsApp', 'woonoow');
}
/**
* Check if channel is configured
*/
public function is_configured()
{
$api_key = get_option('woonoow_whatsapp_api_key', '');
$phone_number = get_option('woonoow_whatsapp_phone_number', '');
return !empty($api_key) && !empty($phone_number);
}
/**
* Send WhatsApp notification
*
* @param string $event_id Event identifier
* @param string $recipient Recipient type ('customer' or 'staff')
* @param array $data Context data (order, user, etc.)
* @return bool|array Success status
*/
public function send($event_id, $recipient, $data)
{
// Get recipient phone number
$phone = $this->get_recipient_phone($recipient, $data);
if (empty($phone)) {
return [
'success' => false,
'message' => 'No phone number available for recipient',
];
}
// Build message content based on event
$message = $this->build_message($event_id, $data);
if (empty($message)) {
return [
'success' => false,
'message' => 'Could not build message for event: ' . $event_id,
];
}
// Send via WhatsApp API
$result = $this->send_whatsapp_message($phone, $message);
// Log the send attempt
do_action('woonoow_whatsapp_sent', $event_id, $recipient, $phone, $result);
return $result;
}
/**
* Get configuration fields for admin settings
*/
public function get_config_fields()
{
return [
[
'id' => 'woonoow_whatsapp_api_key',
'label' => __('WhatsApp API Key', 'woonoow'),
'type' => 'text',
'description' => __('Your WhatsApp Business API key', 'woonoow'),
],
[
'id' => 'woonoow_whatsapp_phone_number',
'label' => __('WhatsApp Business Number', 'woonoow'),
'type' => 'text',
'description' => __('Your WhatsApp Business phone number (with country code)', 'woonoow'),
'placeholder' => '+1234567890',
],
[
'id' => 'woonoow_whatsapp_provider',
'label' => __('Service Provider', 'woonoow'),
'type' => 'select',
'options' => [
'twilio' => 'Twilio',
'messagebird' => 'MessageBird',
'custom' => 'Custom',
],
'default' => 'twilio',
],
];
}
/**
* Get recipient phone number
*
* @param string $recipient Recipient type
* @param array $data Context data
* @return string Phone number or empty string
*/
private function get_recipient_phone($recipient, $data)
{
if ($recipient === 'customer') {
// Get customer phone from order or user data
if (isset($data['order'])) {
return $data['order']->get_billing_phone();
}
if (isset($data['user_id'])) {
return get_user_meta($data['user_id'], 'billing_phone', true);
}
if (isset($data['email'])) {
$user = get_user_by('email', $data['email']);
if ($user) {
return get_user_meta($user->ID, 'billing_phone', true);
}
}
} elseif ($recipient === 'staff') {
// Get admin phone from settings
return get_option('woonoow_whatsapp_admin_phone', '');
}
return '';
}
/**
* Build message content based on event
*
* @param string $event_id Event identifier
* @param array $data Context data
* @return string Message text
*/
private function build_message($event_id, $data)
{
// Allow filtering message content
$message = apply_filters("woonoow_whatsapp_message_{$event_id}", '', $data);
if (!empty($message)) {
return $message;
}
// Default messages for common events
$site_name = get_bloginfo('name');
switch ($event_id) {
case 'order_completed':
if (isset($data['order'])) {
$order = $data['order'];
return sprintf(
"🎉 Your order #%s has been completed! Thank you for shopping with %s.",
$order->get_order_number(),
$site_name
);
}
break;
case 'newsletter_confirm':
if (isset($data['confirmation_url'])) {
return sprintf(
"Please confirm your newsletter subscription by clicking: %s",
$data['confirmation_url']
);
}
break;
// Add more event templates as needed
}
return '';
}
/**
* Send WhatsApp message via API
*
* Replace this with actual API integration for your provider
*
* @param string $phone Recipient phone number
* @param string $message Message text
* @return array Result with 'success' and 'message' keys
*/
private function send_whatsapp_message($phone, $message)
{
$api_key = get_option('woonoow_whatsapp_api_key', '');
$from_number = get_option('woonoow_whatsapp_phone_number', '');
$provider = get_option('woonoow_whatsapp_provider', 'twilio');
// Example: Twilio API (replace with your actual implementation)
if ($provider === 'twilio') {
$endpoint = 'https://api.twilio.com/2010-04-01/Accounts/YOUR_ACCOUNT_SID/Messages.json';
$response = wp_remote_post($endpoint, [
'headers' => [
'Authorization' => 'Basic ' . base64_encode($api_key),
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => [
'From' => 'whatsapp:' . $from_number,
'To' => 'whatsapp:' . $phone,
'Body' => $message,
],
]);
if (is_wp_error($response)) {
return [
'success' => false,
'message' => $response->get_error_message(),
];
}
$status_code = wp_remote_retrieve_response_code($response);
return [
'success' => $status_code >= 200 && $status_code < 300,
'message' => $status_code >= 200 && $status_code < 300
? 'WhatsApp message sent successfully'
: 'Failed to send WhatsApp message',
];
}
// For custom providers, implement your own logic here
return [
'success' => false,
'message' => 'Provider not configured',
];
}
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Default Email Templates (DEPRECATED)
*
@@ -17,8 +18,9 @@ namespace WooNooW\Core\Notifications;
use WooNooW\Email\DefaultTemplates as NewDefaultTemplates;
class DefaultEmailTemplates {
class DefaultEmailTemplates
{
/**
* Get default template for an event and recipient type
*
@@ -26,28 +28,30 @@ class DefaultEmailTemplates {
* @param string $recipient_type 'staff' or 'customer'
* @return array ['subject' => string, 'body' => string]
*/
public static function get_template($event_id, $recipient_type) {
public static function get_template($event_id, $recipient_type)
{
// Get templates directly from this class
$allTemplates = self::get_all_templates();
// Check if event exists for this recipient type
if (isset($allTemplates[$event_id][$recipient_type])) {
return $allTemplates[$event_id][$recipient_type];
}
// Fallback
return [
'subject' => __('Notification from {store_name}', 'woonoow'),
'body' => '[card]' . __('You have a new notification.', 'woonoow') . '[/card]',
];
}
/**
* Get all default templates (legacy method - kept for backwards compatibility)
*
* @return array
*/
private static function get_all_templates() {
private static function get_all_templates()
{
// This method is now deprecated but kept for backwards compatibility
// Use WooNooW\Email\DefaultTemplates instead
return [
@@ -83,7 +87,7 @@ class DefaultEmailTemplates {
[button url="{order_url}" style="solid"]' . __('View Order Details', 'woonoow') . '[/button]',
],
],
'order_processing' => [
'customer' => [
'subject' => __('Your Order #{order_number} is Being Processed', 'woonoow'),
@@ -112,7 +116,7 @@ class DefaultEmailTemplates {
[button url="{order_url}" style="solid"]' . __('Track Your Order', 'woonoow') . '[/button]',
],
],
'order_completed' => [
'customer' => [
'subject' => __('Your Order #{order_number} is Complete', 'woonoow'),
@@ -135,10 +139,10 @@ class DefaultEmailTemplates {
[/card]
[button url="{order_url}" style="solid"]' . __('View Order', 'woonoow') . '[/button]
[button url="{store_url}" style="outline"]' . __('Continue Shopping', 'woonoow') . '[/button]',
[button url="{shop_url}" style="outline"]' . __('Continue Shopping', 'woonoow') . '[/button]',
],
],
'order_cancelled' => [
'staff' => [
'subject' => __('Order #{order_number} Cancelled', 'woonoow'),
@@ -158,7 +162,7 @@ class DefaultEmailTemplates {
[button url="{order_url}" style="solid"]' . __('View Order Details', 'woonoow') . '[/button]',
],
],
'order_refunded' => [
'customer' => [
'subject' => __('Your Order #{order_number} Has Been Refunded', 'woonoow'),
@@ -183,7 +187,7 @@ class DefaultEmailTemplates {
[button url="{order_url}" style="solid"]' . __('View Order', 'woonoow') . '[/button]',
],
],
// PRODUCT EVENTS
'low_stock' => [
'staff' => [
@@ -209,7 +213,7 @@ class DefaultEmailTemplates {
[button url="{product_url}" style="solid"]' . __('View Product', 'woonoow') . '[/button]',
],
],
'out_of_stock' => [
'staff' => [
'subject' => __('Out of Stock Alert: {product_name}', 'woonoow'),
@@ -233,7 +237,7 @@ class DefaultEmailTemplates {
[button url="{product_url}" style="solid"]' . __('Manage Product', 'woonoow') . '[/button]',
],
],
// CUSTOMER EVENTS
'new_customer' => [
'customer' => [
@@ -261,10 +265,10 @@ class DefaultEmailTemplates {
[/card]
[button url="{account_url}" style="solid"]' . __('Go to My Account', 'woonoow') . '[/button]
[button url="{store_url}" style="outline"]' . __('Start Shopping', 'woonoow') . '[/button]',
[button url="{shop_url}" style="outline"]' . __('Start Shopping', 'woonoow') . '[/button]',
],
],
'customer_note' => [
'customer' => [
'subject' => __('Note Added to Your Order #{order_number}', 'woonoow'),
@@ -289,16 +293,17 @@ class DefaultEmailTemplates {
],
];
}
/**
* Get all new templates (direct access to new class)
*
* @return array
*/
public static function get_new_templates() {
public static function get_new_templates()
{
return NewDefaultTemplates::get_all_templates();
}
/**
* Get default subject from new templates
*
@@ -306,7 +311,8 @@ class DefaultEmailTemplates {
* @param string $event_id Event ID
* @return string
*/
public static function get_default_subject($recipient_type, $event_id) {
public static function get_default_subject($recipient_type, $event_id)
{
return NewDefaultTemplates::get_default_subject($recipient_type, $event_id);
}
}

View File

@@ -89,7 +89,7 @@ class EmailRenderer
* @param string $recipient_type
* @return array|null
*/
private function get_template_settings($event_id, $recipient_type)
public function get_template_settings($event_id, $recipient_type)
{
// Get saved template (with recipient_type for proper default template lookup)
$template = TemplateProvider::get_template($event_id, 'email', $recipient_type);
@@ -187,7 +187,7 @@ class EmailRenderer
'site_name' => get_bloginfo('name'),
'site_title' => get_bloginfo('name'),
'store_name' => get_bloginfo('name'),
'store_url' => home_url(),
'site_url' => home_url(),
'shop_url' => get_permalink(wc_get_page_id('shop')),
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
'support_email' => get_option('admin_email'),
@@ -381,7 +381,7 @@ class EmailRenderer
* @param string $content
* @return string
*/
private function parse_cards($content)
public function parse_cards($content)
{
// Use a single unified regex to match BOTH syntaxes in document order
// This ensures cards are rendered in the order they appear
@@ -473,8 +473,31 @@ class EmailRenderer
$hero_text_color = '#ffffff'; // Always white on gradient
// Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility
// Helper function to escape URL while preserving variable placeholders like {unsubscribe_url}
$escape_url_preserving_variables = function ($url) {
// If URL contains variable placeholder, don't escape (will be replaced later)
if (preg_match('/\{[a-z_]+\}/', $url)) {
// Just return the URL as-is - it will be replaced with a real URL later
return $url;
}
return esc_url($url);
};
// Helper function to generate button HTML
$generateButtonHtml = function ($url, $style, $text) use ($primary_color, $secondary_color, $button_text_color) {
$generateButtonHtml = function ($url, $style, $text) use ($primary_color, $secondary_color, $button_text_color, $escape_url_preserving_variables) {
$escaped_url = $escape_url_preserving_variables($url);
if ($style === 'link') {
// Plain link - just a simple <a> tag styled like regular text link (inline, no wrapper)
return sprintf(
'<a href="%s" style="color: %s; text-decoration: underline; font-family: \'Inter\', Arial, sans-serif;">%s</a>',
$escaped_url,
esc_attr($primary_color),
esc_html($text)
);
}
// Styled buttons (solid/outline) get table wrapper for email client compatibility
if ($style === 'outline') {
// Outline button - transparent background with border
$button_style = sprintf(
@@ -494,7 +517,7 @@ class EmailRenderer
// Use table-based button for better email client compatibility
return sprintf(
'<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="margin: 16px auto;"><tr><td align="center"><a href="%s" style="%s">%s</a></td></tr></table>',
esc_url($url),
$escaped_url,
$button_style,
esc_html($text)
);
@@ -542,9 +565,25 @@ class EmailRenderer
$content_style .= sprintf(' color: %s;', esc_attr($hero_text_color));
// Add inline color to all headings and paragraphs for email client compatibility
$content = preg_replace(
'/<(h[1-6]|p)([^>]*)>/',
'<$1$2 style="color: ' . esc_attr($hero_text_color) . ';">',
// Preserve existing style attributes (like text-align) by appending to them
$content = preg_replace_callback(
'/<(h[1-6]|p)([^>]*?)(\s+style=["\']([^"\']*)["\'])?([^>]*)>/',
function ($matches) use ($hero_text_color) {
$tag = $matches[1];
$before_style = $matches[2];
$existing_style = isset($matches[4]) ? $matches[4] : '';
$after_style = $matches[5];
$color_style = 'color: ' . esc_attr($hero_text_color) . ';';
if ($existing_style) {
// Append to existing style
$new_style = rtrim($existing_style, ';') . '; ' . $color_style;
return '<' . $tag . $before_style . ' style="' . $new_style . '"' . $after_style . '>';
} else {
// Add new style attribute
return '<' . $tag . $before_style . ' style="' . $color_style . '"' . $after_style . '>';
}
},
$content
);
}
@@ -560,6 +599,11 @@ class EmailRenderer
elseif ($type === 'warning') {
$style .= ' background-color: #fff8e1;';
}
// Basic card - plain text, no card styling (for footers/muted content)
elseif ($type === 'basic') {
$style = 'width: 100%; background-color: transparent;'; // No background
$content_style = 'padding: 0;'; // No padding
}
}
// Add background image
@@ -616,7 +660,7 @@ class EmailRenderer
*
* @return string
*/
private function get_design_template()
public function get_design_template()
{
// Use single base template (theme-agnostic)
$template_path = WOONOOW_PATH . 'templates/emails/base.html';
@@ -641,7 +685,7 @@ class EmailRenderer
* @param array $variables All variables
* @return string
*/
private function render_html($template_path, $content, $subject, $variables)
public function render_html($template_path, $content, $subject, $variables)
{
if (!file_exists($template_path)) {
// Fallback to plain HTML
@@ -654,6 +698,10 @@ class EmailRenderer
// Get email customization settings
$email_settings = get_option('woonoow_email_settings', []);
// Ensure required variables have defaults
$variables['site_url'] = $variables['site_url'] ?? home_url();
$variables['store_name'] = $variables['store_name'] ?? get_bloginfo('name');
// Email body background
$body_bg = '#f8f8f8';
@@ -668,7 +716,7 @@ class EmailRenderer
if (!empty($logo_url)) {
$header = sprintf(
'<a href="%s"><img src="%s" alt="%s" style="max-width: 200px; max-height: 60px;"></a>',
esc_url($variables['store_url']),
esc_url($variables['site_url']),
esc_url($logo_url),
esc_attr($variables['store_name'])
);
@@ -677,7 +725,7 @@ class EmailRenderer
$header_text = !empty($email_settings['header_text']) ? $email_settings['header_text'] : $variables['store_name'];
$header = sprintf(
'<a href="%s" style="font-size: 24px; font-weight: 700; color: #333; text-decoration: none;">%s</a>',
esc_url($variables['store_url']),
esc_url($variables['site_url']),
esc_html($header_text)
);
}
@@ -724,7 +772,7 @@ class EmailRenderer
$html = str_replace('{{email_content}}', $content, $html);
$html = str_replace('{{email_footer}}', $footer, $html);
$html = str_replace('{{store_name}}', esc_html($variables['store_name']), $html);
$html = str_replace('{{store_url}}', esc_url($variables['store_url']), $html);
$html = str_replace('{{site_url}}', esc_url($variables['site_url']), $html);
$html = str_replace('{{current_year}}', date('Y'), $html);
// Replace all other variables

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
<?php
/**
* Markdown to Email HTML Parser
*
@@ -17,21 +18,23 @@
namespace WooNooW\Core\Notifications;
class MarkdownParser {
class MarkdownParser
{
/**
* Parse markdown to email HTML
*
* @param string $markdown
* @return string
*/
public static function parse($markdown) {
public static function parse($markdown)
{
$html = $markdown;
// Parse card blocks first (:::card or :::card[type])
$html = preg_replace_callback(
'/:::card(?:\[(\w+)\])?\n([\s\S]*?):::/s',
function($matches) {
function ($matches) {
$type = $matches[1] ?? '';
$content = trim($matches[2]);
$parsed_content = self::parse_basics($content);
@@ -39,12 +42,12 @@ class MarkdownParser {
},
$html
);
// Parse button blocks [button url="..."]Text[/button] - already in correct format
// Also support legacy [button](url){text} syntax
$html = preg_replace_callback(
'/\[button(?:\s+style="(solid|outline)")?\]\((.*?)\)\s*\{([^}]+)\}/',
function($matches) {
function ($matches) {
$style = $matches[1] ?? '';
$url = $matches[2];
$text = $matches[3];
@@ -52,71 +55,88 @@ class MarkdownParser {
},
$html
);
// Horizontal rules
$html = preg_replace('/^---$/m', '<hr>', $html);
// Parse remaining markdown (outside cards)
$html = self::parse_basics($html);
return $html;
}
/**
* Parse basic markdown syntax
*
* @param string $text
* @return string
*/
private static function parse_basics($text) {
private static function parse_basics($text)
{
$html = $text;
// Protect variables from markdown parsing by temporarily replacing them
$variables = [];
$var_index = 0;
$html = preg_replace_callback('/\{([^}]+)\}/', function($matches) use (&$variables, &$var_index) {
$html = preg_replace_callback('/\{([^}]+)\}/', function ($matches) use (&$variables, &$var_index) {
$placeholder = '<!--VAR' . $var_index . '-->';
$variables[$placeholder] = $matches[0];
$var_index++;
return $placeholder;
}, $html);
// Protect existing HTML tags (h1-h6, p) with style attributes from being overwritten
$html_tags = [];
$tag_index = 0;
$html = preg_replace_callback('/<(h[1-6]|p)([^>]*style=[^>]*)>/', function ($matches) use (&$html_tags, &$tag_index) {
$placeholder = '<!--HTMLTAG' . $tag_index . '-->';
$html_tags[$placeholder] = $matches[0];
$tag_index++;
return $placeholder;
}, $html);
// Headings (must be done in order from h4 to h1 to avoid conflicts)
// Only match markdown syntax (lines starting with #), not existing HTML
$html = preg_replace('/^#### (.*)$/m', '<h4>$1</h4>', $html);
$html = preg_replace('/^### (.*)$/m', '<h3>$1</h3>', $html);
$html = preg_replace('/^## (.*)$/m', '<h2>$1</h2>', $html);
$html = preg_replace('/^# (.*)$/m', '<h1>$1</h1>', $html);
// Restore protected HTML tags
foreach ($html_tags as $placeholder => $original) {
$html = str_replace($placeholder, $original, $html);
}
// Bold (don't match across newlines)
$html = preg_replace('/\*\*([^\n*]+?)\*\*/', '<strong>$1</strong>', $html);
$html = preg_replace('/__([^\n_]+?)__/', '<strong>$1</strong>', $html);
// Italic (don't match across newlines)
$html = preg_replace('/\*([^\n*]+?)\*/', '<em>$1</em>', $html);
$html = preg_replace('/_([^\n_]+?)_/', '<em>$1</em>', $html);
// Horizontal rules
$html = preg_replace('/^---$/m', '<hr>', $html);
// Links (but not button syntax)
$html = preg_replace('/\[(?!button)([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $html);
// Process lines for paragraphs and lists
$lines = explode("\n", $html);
$in_list = false;
$paragraph_content = '';
$processed_lines = [];
$close_paragraph = function() use (&$paragraph_content, &$processed_lines) {
$close_paragraph = function () use (&$paragraph_content, &$processed_lines) {
if ($paragraph_content) {
$processed_lines[] = '<p>' . $paragraph_content . '</p>';
$paragraph_content = '';
}
};
foreach ($lines as $line) {
$trimmed = trim($line);
// Empty line - close paragraph or list
if (empty($trimmed)) {
if ($in_list) {
@@ -127,7 +147,7 @@ class MarkdownParser {
$processed_lines[] = '';
continue;
}
// Check if line is a list item
if (preg_match('/^[\*\-•✓✔]\s/', $trimmed)) {
$close_paragraph();
@@ -139,20 +159,20 @@ class MarkdownParser {
$processed_lines[] = '<li>' . $content . '</li>';
continue;
}
// Close list if we're in one
if ($in_list) {
$processed_lines[] = '</ul>';
$in_list = false;
}
// Block-level HTML tags - don't wrap in paragraph
if (preg_match('/^<(div|h1|h2|h3|h4|h5|h6|p|ul|ol|li|hr|table|blockquote)/i', $trimmed)) {
$close_paragraph();
$processed_lines[] = $line;
continue;
}
// Regular text line - accumulate in paragraph
if ($paragraph_content) {
// Add line break before continuation (THIS IS THE KEY FIX!)
@@ -162,30 +182,31 @@ class MarkdownParser {
$paragraph_content = $trimmed;
}
}
// Close any open tags
if ($in_list) {
$processed_lines[] = '</ul>';
}
$close_paragraph();
$html = implode("\n", $processed_lines);
// Restore variables
foreach ($variables as $placeholder => $original) {
$html = str_replace($placeholder, $original, $html);
}
return $html;
}
/**
* Convert newlines to <br> tags for email rendering
*
* @param string $html
* @return string
*/
public static function nl2br_email($html) {
public static function nl2br_email($html)
{
// Don't convert newlines inside HTML tags
$html = preg_replace('/(?<!>)\n(?!<)/', '<br>', $html);
return $html;

View File

@@ -1,4 +1,5 @@
<?php
/**
* Notification Manager
*
@@ -9,32 +10,43 @@
namespace WooNooW\Core\Notifications;
class NotificationManager {
use WooNooW\Core\Notifications\ChannelRegistry;
class NotificationManager
{
/**
* Check if a channel is enabled globally
*
* @param string $channel_id Channel ID (email, push, etc.)
* @return bool
*/
public static function is_channel_enabled($channel_id) {
public static function is_channel_enabled($channel_id)
{
// Check built-in channels
if ($channel_id === 'email') {
return (bool) get_option('woonoow_email_notifications_enabled', true);
} elseif ($channel_id === 'push') {
return (bool) get_option('woonoow_push_notifications_enabled', true);
}
// For addon channels, check if they're registered and enabled
// Check if channel is registered in ChannelRegistry
if (ChannelRegistry::has($channel_id)) {
$channel = ChannelRegistry::get($channel_id);
return $channel->is_configured();
}
// Legacy: check via filter (backward compatibility)
$channels = apply_filters('woonoow_notification_channels', []);
foreach ($channels as $channel) {
if ($channel['id'] === $channel_id) {
return isset($channel['enabled']) ? (bool) $channel['enabled'] : true;
}
}
return false;
}
/**
* Check if a channel is enabled for a specific event
*
@@ -42,24 +54,25 @@ class NotificationManager {
* @param string $channel_id Channel ID
* @return bool
*/
public static function is_event_channel_enabled($event_id, $channel_id) {
public static function is_event_channel_enabled($event_id, $channel_id)
{
$settings = get_option('woonoow_notification_settings', []);
if (!isset($settings[$event_id])) {
return false;
}
$event = $settings[$event_id];
if (!isset($event['channels'][$channel_id])) {
return false;
}
return isset($event['channels'][$channel_id]['enabled'])
? (bool) $event['channels'][$channel_id]['enabled']
return isset($event['channels'][$channel_id]['enabled'])
? (bool) $event['channels'][$channel_id]['enabled']
: false;
}
/**
* Check if notification should be sent
*
@@ -69,26 +82,27 @@ class NotificationManager {
* @param string $channel_id Channel ID
* @return bool
*/
public static function should_send_notification($event_id, $channel_id) {
public static function should_send_notification($event_id, $channel_id)
{
// Check if WooNooW notification system is enabled
$system_mode = get_option('woonoow_notification_system_mode', 'woonoow');
if ($system_mode !== 'woonoow') {
return false; // Use WooCommerce default emails instead
}
// Check if channel is globally enabled
if (!self::is_channel_enabled($channel_id)) {
return false;
}
// Check if channel is enabled for this specific event
if (!self::is_event_channel_enabled($event_id, $channel_id)) {
return false;
}
return true;
}
/**
* Get recipient for event channel
*
@@ -96,16 +110,17 @@ class NotificationManager {
* @param string $channel_id Channel ID
* @return string Recipient type (admin, customer, both)
*/
public static function get_recipient($event_id, $channel_id) {
public static function get_recipient($event_id, $channel_id)
{
$settings = get_option('woonoow_notification_settings', []);
if (!isset($settings[$event_id]['channels'][$channel_id]['recipient'])) {
return 'admin';
}
return $settings[$event_id]['channels'][$channel_id]['recipient'];
}
/**
* Send notification through specified channel
*
@@ -114,16 +129,25 @@ class NotificationManager {
* @param array $data Notification data
* @return bool Success status
*/
public static function send($event_id, $channel_id, $data = []) {
public static function send($event_id, $channel_id, $data = [])
{
// Validate if notification should be sent
if (!self::should_send_notification($event_id, $channel_id)) {
return false;
}
// Get recipient
$recipient = self::get_recipient($event_id, $channel_id);
// Allow addons to handle their own channels
// Try to use registered channel from ChannelRegistry
if (ChannelRegistry::has($channel_id)) {
$channel = ChannelRegistry::get($channel_id);
if ($channel->is_configured()) {
return $channel->send($event_id, $recipient, $data);
}
}
// Legacy: Allow addons to handle their own channels via filter
$sent = apply_filters(
'woonoow_send_notification',
false,
@@ -132,22 +156,22 @@ class NotificationManager {
$recipient,
$data
);
// If addon handled it, return
if ($sent !== false) {
return $sent;
}
// Handle built-in channels
// Handle built-in channels (email, push)
if ($channel_id === 'email') {
return self::send_email($event_id, $recipient, $data);
} elseif ($channel_id === 'push') {
return self::send_push($event_id, $recipient, $data);
}
return false;
}
/**
* Send email notification
*
@@ -156,25 +180,26 @@ class NotificationManager {
* @param array $data Notification data
* @return bool
*/
private static function send_email($event_id, $recipient, $data) {
private static function send_email($event_id, $recipient, $data)
{
// Use EmailRenderer to render the email
$renderer = EmailRenderer::instance();
$email_data = $renderer->render($event_id, $recipient, $data['order'] ?? $data['product'] ?? $data['customer'] ?? null, $data);
if (!$email_data) {
return false;
}
// Send email using wp_mail
$headers = ['Content-Type: text/html; charset=UTF-8'];
$sent = wp_mail($email_data['to'], $email_data['subject'], $email_data['body'], $headers);
// Trigger action for logging/tracking
do_action('woonoow_email_sent', $event_id, $recipient, $email_data, $sent);
return $sent;
}
/**
* Send push notification
*
@@ -183,7 +208,8 @@ class NotificationManager {
* @param array $data Notification data
* @return bool
*/
private static function send_push($event_id, $recipient, $data) {
private static function send_push($event_id, $recipient, $data)
{
// Push notification sending will be implemented later
// This is a placeholder for future implementation
do_action('woonoow_send_push_notification', $event_id, $recipient, $data);

View File

@@ -1,4 +1,5 @@
<?php
/**
* Notification Template Provider
*
@@ -11,27 +12,29 @@ namespace WooNooW\Core\Notifications;
use WooNooW\Email\DefaultTemplates as EmailDefaultTemplates;
class TemplateProvider {
class TemplateProvider
{
/**
* Option key for storing templates
*/
const OPTION_KEY = 'woonoow_notification_templates';
/**
* Get all templates
*
* @return array
*/
public static function get_templates() {
public static function get_templates()
{
$templates = get_option(self::OPTION_KEY, []);
// Merge with defaults
$defaults = self::get_default_templates();
return array_merge($defaults, $templates);
}
/**
* Get template for specific event and channel
*
@@ -40,25 +43,26 @@ class TemplateProvider {
* @param string $recipient_type Recipient type ('customer' or 'staff')
* @return array|null
*/
public static function get_template($event_id, $channel_id, $recipient_type = 'customer') {
public static function get_template($event_id, $channel_id, $recipient_type = 'customer')
{
$templates = self::get_templates();
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
if (isset($templates[$key])) {
return $templates[$key];
}
// Return default if exists
$defaults = self::get_default_templates();
if (isset($defaults[$key])) {
return $defaults[$key];
}
return null;
}
/**
* Save template
*
@@ -68,11 +72,12 @@ class TemplateProvider {
* @param string $recipient_type Recipient type ('customer' or 'staff')
* @return bool
*/
public static function save_template($event_id, $channel_id, $template, $recipient_type = 'customer') {
public static function save_template($event_id, $channel_id, $template, $recipient_type = 'customer')
{
$templates = get_option(self::OPTION_KEY, []);
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
$templates[$key] = [
'event_id' => $event_id,
'channel_id' => $channel_id,
@@ -82,10 +87,10 @@ class TemplateProvider {
'variables' => $template['variables'] ?? [],
'updated_at' => current_time('mysql'),
];
return update_option(self::OPTION_KEY, $templates);
}
/**
* Delete template (revert to default)
*
@@ -94,46 +99,48 @@ class TemplateProvider {
* @param string $recipient_type Recipient type ('customer' or 'staff')
* @return bool
*/
public static function delete_template($event_id, $channel_id, $recipient_type = 'customer') {
public static function delete_template($event_id, $channel_id, $recipient_type = 'customer')
{
$templates = get_option(self::OPTION_KEY, []);
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
if (isset($templates[$key])) {
unset($templates[$key]);
return update_option(self::OPTION_KEY, $templates);
}
return false;
}
/**
* Get default templates
*
* @return array
*/
public static function get_default_templates() {
public static function get_default_templates()
{
$templates = [];
// Get all events from EventRegistry (single source of truth)
$all_events = EventRegistry::get_all_events();
// Get email templates from DefaultTemplates
$allEmailTemplates = EmailDefaultTemplates::get_all_templates();
foreach ($all_events as $event) {
$event_id = $event['id'];
$recipient_type = $event['recipient_type'];
// Get template body from the new clean markdown source
$body = $allEmailTemplates[$recipient_type][$event_id] ?? '';
$subject = EmailDefaultTemplates::get_default_subject($recipient_type, $event_id);
// If template doesn't exist, create a simple fallback
if (empty($body)) {
$body = "[card]\n\n## Notification\n\nYou have a new notification about {$event_id}.\n\n[/card]";
$subject = __('Notification from {store_name}', 'woonoow');
}
$templates["{$recipient_type}_{$event_id}_email"] = [
'event_id' => $event_id,
'channel_id' => 'email',
@@ -143,7 +150,7 @@ class TemplateProvider {
'variables' => self::get_variables_for_event($event_id),
];
}
// Add push notification templates
$templates['staff_order_placed_push'] = [
'event_id' => 'order_placed',
@@ -217,42 +224,44 @@ class TemplateProvider {
'body' => __('A note has been added to order #{order_number}', 'woonoow'),
'variables' => self::get_order_variables(),
];
return $templates;
}
/**
* Get variables for a specific event
*
* @param string $event_id Event ID
* @return array
*/
private static function get_variables_for_event($event_id) {
private static function get_variables_for_event($event_id)
{
// Product events
if (in_array($event_id, ['low_stock', 'out_of_stock'])) {
return self::get_product_variables();
}
// Customer events (but not order-related)
if ($event_id === 'new_customer') {
return self::get_customer_variables();
}
// Subscription events
if (strpos($event_id, 'subscription_') === 0) {
return self::get_subscription_variables();
}
// All other events are order-related
return self::get_order_variables();
}
/**
* Get available order variables
*
* @return array
*/
public static function get_order_variables() {
public static function get_order_variables()
{
return [
'order_number' => __('Order Number', 'woonoow'),
'order_total' => __('Order Total', 'woonoow'),
@@ -272,49 +281,52 @@ class TemplateProvider {
'billing_address' => __('Billing Address', 'woonoow'),
'shipping_address' => __('Shipping Address', 'woonoow'),
'store_name' => __('Store Name', 'woonoow'),
'store_url' => __('Store URL', 'woonoow'),
'site_url' => __('Site URL', 'woonoow'),
'store_email' => __('Store Email', 'woonoow'),
];
}
/**
* Get available product variables
*
* @return array
*/
public static function get_product_variables() {
public static function get_product_variables()
{
return [
'product_name' => __('Product Name', 'woonoow'),
'product_sku' => __('Product SKU', 'woonoow'),
'product_url' => __('Product URL', 'woonoow'),
'stock_quantity' => __('Stock Quantity', 'woonoow'),
'store_name' => __('Store Name', 'woonoow'),
'store_url' => __('Store URL', 'woonoow'),
'site_url' => __('Site URL', 'woonoow'),
];
}
/**
* Get available customer variables
*
* @return array
*/
public static function get_customer_variables() {
public static function get_customer_variables()
{
return [
'customer_name' => __('Customer Name', 'woonoow'),
'customer_email' => __('Customer Email', 'woonoow'),
'customer_phone' => __('Customer Phone', 'woonoow'),
'store_name' => __('Store Name', 'woonoow'),
'store_url' => __('Store URL', 'woonoow'),
'site_url' => __('Site URL', 'woonoow'),
'store_email' => __('Store Email', 'woonoow'),
];
}
/**
* Get available subscription variables
*
* @return array
*/
public static function get_subscription_variables() {
public static function get_subscription_variables()
{
return [
'subscription_id' => __('Subscription ID', 'woonoow'),
'subscription_status' => __('Subscription Status', 'woonoow'),
@@ -327,11 +339,11 @@ class TemplateProvider {
'customer_name' => __('Customer Name', 'woonoow'),
'customer_email' => __('Customer Email', 'woonoow'),
'store_name' => __('Store Name', 'woonoow'),
'store_url' => __('Store URL', 'woonoow'),
'site_url' => __('Site URL', 'woonoow'),
'my_account_url' => __('My Account URL', 'woonoow'),
];
}
/**
* Replace variables in template
*
@@ -339,11 +351,12 @@ class TemplateProvider {
* @param array $data Data to replace variables
* @return string
*/
public static function replace_variables($content, $data) {
public static function replace_variables($content, $data)
{
foreach ($data as $key => $value) {
$content = str_replace('{' . $key . '}', $value, $content);
}
return $content;
}
}

View File

@@ -0,0 +1,366 @@
<?php
/**
* Subscriber Table - Custom database table for newsletter subscribers
*
* Provides scalable storage for subscribers instead of wp_options
*
* @package WooNooW\Database
*/
namespace WooNooW\Database;
class SubscriberTable
{
const TABLE_NAME = 'woonoow_subscribers';
const DB_VERSION = '1.0';
const DB_VERSION_OPTION = 'woonoow_subscribers_db_version';
/**
* Get full table name with prefix
*/
public static function get_table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE_NAME;
}
/**
* Create or update the subscribers table
*/
public static function create_table()
{
global $wpdb;
$table_name = self::get_table_name();
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table_name (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
user_id BIGINT(20) UNSIGNED DEFAULT NULL,
status ENUM('pending','active','unsubscribed') DEFAULT 'pending',
consent TINYINT(1) DEFAULT 0,
consent_text TEXT,
source VARCHAR(50) DEFAULT 'form',
subscribed_at DATETIME DEFAULT NULL,
confirmed_at DATETIME DEFAULT NULL,
unsubscribed_at DATETIME DEFAULT NULL,
ip_address VARCHAR(45) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY idx_email (email),
KEY idx_status (status),
KEY idx_user_id (user_id)
) $charset_collate;";
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
dbDelta($sql);
update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
}
/**
* Check if table exists
*/
public static function table_exists()
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->get_var("SHOW TABLES LIKE '$table_name'") === $table_name;
}
/**
* Migrate existing subscribers from wp_options to custom table
*
* @return array Migration result with counts
*/
public static function migrate_from_options()
{
global $wpdb;
// Ensure table exists
if (!self::table_exists()) {
self::create_table();
}
$table_name = self::get_table_name();
$subscribers = get_option('woonoow_newsletter_subscribers', []);
if (empty($subscribers)) {
return ['migrated' => 0, 'skipped' => 0, 'message' => 'No subscribers to migrate'];
}
$migrated = 0;
$skipped = 0;
foreach ($subscribers as $sub) {
if (empty($sub['email'])) {
$skipped++;
continue;
}
// Check if already exists in table
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM $table_name WHERE email = %s",
$sub['email']
));
if ($exists) {
$skipped++;
continue;
}
// Insert into new table
$result = $wpdb->insert($table_name, [
'email' => $sub['email'],
'user_id' => $sub['user_id'] ?? null,
'status' => $sub['status'] ?? 'active',
'consent' => !empty($sub['consent']) ? 1 : 0,
'subscribed_at' => $sub['subscribed_at'] ?? current_time('mysql'),
'confirmed_at' => $sub['confirmed_at'] ?? null,
'ip_address' => $sub['ip_address'] ?? null,
]);
if ($result) {
$migrated++;
} else {
$skipped++;
}
}
// If all migrated successfully, remove the option
if ($migrated > 0 && $skipped === 0) {
delete_option('woonoow_newsletter_subscribers');
}
return [
'migrated' => $migrated,
'skipped' => $skipped,
'message' => "Migrated $migrated subscribers, skipped $skipped",
];
}
// =========================================================================
// CRUD OPERATIONS
// =========================================================================
/**
* Add a new subscriber
*/
public static function add($data)
{
global $wpdb;
$table_name = self::get_table_name();
$result = $wpdb->insert($table_name, [
'email' => $data['email'],
'user_id' => $data['user_id'] ?? null,
'status' => $data['status'] ?? 'pending',
'consent' => !empty($data['consent']) ? 1 : 0,
'consent_text' => $data['consent_text'] ?? null,
'source' => $data['source'] ?? 'form',
'subscribed_at' => $data['subscribed_at'] ?? current_time('mysql'),
'ip_address' => $data['ip_address'] ?? null,
]);
if ($result) {
return $wpdb->insert_id;
}
return false;
}
/**
* Get a subscriber by email
*/
public static function get_by_email($email)
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table_name WHERE email = %s",
$email
), ARRAY_A);
}
/**
* Get a subscriber by ID
*/
public static function get($id)
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table_name WHERE id = %d",
$id
), ARRAY_A);
}
/**
* Update a subscriber
*/
public static function update($id, $data)
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->update($table_name, $data, ['id' => $id]);
}
/**
* Update subscriber by email
*/
public static function update_by_email($email, $data)
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->update($table_name, $data, ['email' => $email]);
}
/**
* Delete a subscriber
*/
public static function delete($id)
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->delete($table_name, ['id' => $id]);
}
/**
* Delete subscriber by email
*/
public static function delete_by_email($email)
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->delete($table_name, ['email' => $email]);
}
/**
* Get all active subscribers
*
* @param array $filters Optional filters
* @return array List of subscribers
*/
public static function get_active($filters = [])
{
global $wpdb;
$table_name = self::get_table_name();
$where = ["status = 'active'"];
$values = [];
// Filter: subscribed after date
if (!empty($filters['subscribed_after'])) {
$where[] = "subscribed_at >= %s";
$values[] = $filters['subscribed_after'];
}
// Filter: subscribed before date
if (!empty($filters['subscribed_before'])) {
$where[] = "subscribed_at <= %s";
$values[] = $filters['subscribed_before'];
}
// Filter: registered users only
if (!empty($filters['registered_only'])) {
$where[] = "user_id IS NOT NULL";
}
// Filter: guests only
if (!empty($filters['guests_only'])) {
$where[] = "user_id IS NULL";
}
$where_sql = implode(' AND ', $where);
$sql = "SELECT * FROM $table_name WHERE $where_sql ORDER BY subscribed_at DESC";
if (!empty($values)) {
$sql = $wpdb->prepare($sql, ...$values);
}
return $wpdb->get_results($sql, ARRAY_A);
}
/**
* Get all subscribers with pagination
*/
public static function get_all($args = [])
{
global $wpdb;
$table_name = self::get_table_name();
$defaults = [
'per_page' => 20,
'page' => 1,
'status' => '',
'search' => '',
'orderby' => 'subscribed_at',
'order' => 'DESC',
];
$args = wp_parse_args($args, $defaults);
$where = [];
$values = [];
if (!empty($args['status'])) {
$where[] = "status = %s";
$values[] = $args['status'];
}
if (!empty($args['search'])) {
$where[] = "email LIKE %s";
$values[] = '%' . $wpdb->esc_like($args['search']) . '%';
}
$where_sql = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
// Get total count
$count_sql = "SELECT COUNT(*) FROM $table_name $where_sql";
if (!empty($values)) {
$count_sql = $wpdb->prepare($count_sql, ...$values);
}
$total = (int) $wpdb->get_var($count_sql);
// Get paginated results
$offset = ($args['page'] - 1) * $args['per_page'];
$orderby = sanitize_sql_orderby($args['orderby'] . ' ' . $args['order']) ?: 'subscribed_at DESC';
$sql = "SELECT * FROM $table_name $where_sql ORDER BY $orderby LIMIT %d OFFSET %d";
$values[] = $args['per_page'];
$values[] = $offset;
$items = $wpdb->get_results($wpdb->prepare($sql, ...$values), ARRAY_A);
return [
'items' => $items,
'total' => $total,
'pages' => ceil($total / $args['per_page']),
];
}
/**
* Count subscribers by status
*/
public static function count_by_status($status = null)
{
global $wpdb;
$table_name = self::get_table_name();
if ($status) {
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $table_name WHERE status = %s",
$status
));
}
return (int) $wpdb->get_var("SELECT COUNT(*) FROM $table_name");
}
}

View File

@@ -1,50 +1,55 @@
<?php
namespace WooNooW\Frontend;
/**
* Frontend Assets Manager
* Handles loading of customer-spa assets
*/
class Assets {
class Assets
{
/**
* Initialize
*/
public static function init() {
public static function init()
{
add_action('wp_enqueue_scripts', [self::class, 'enqueue_assets'], 20);
add_action('wp_head', [self::class, 'add_inline_config'], 5);
add_action('wp_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100);
add_filter('script_loader_tag', [self::class, 'add_module_type'], 10, 3);
add_action('woocommerce_before_main_content', [self::class, 'inject_spa_mount_point'], 5);
}
/**
* Add type="module" to customer-spa scripts
*/
public static function add_module_type($tag, $handle, $src) {
public static function add_module_type($tag, $handle, $src)
{
// Add type="module" to our Vite scripts
if (strpos($handle, 'woonoow-customer') !== false) {
$tag = str_replace('<script ', '<script type="module" ', $tag);
}
return $tag;
}
/**
* Enqueue customer-spa assets
*/
public static function enqueue_assets() {
public static function enqueue_assets()
{
// Only load on pages with WooNooW shortcodes or in full SPA mode
if (!self::should_load_assets()) {
return;
}
// Check if dev mode is enabled
$is_dev = defined('WOONOOW_CUSTOMER_DEV') && WOONOOW_CUSTOMER_DEV;
if ($is_dev) {
// Dev mode: Load from Vite dev server
$dev_server = 'https://woonoow.local:5174';
// Vite client for HMR
wp_enqueue_script(
'woonoow-customer-vite',
@@ -53,7 +58,7 @@ class Assets {
null,
false // Load in header
);
// Main entry point
wp_enqueue_script(
'woonoow-customer-spa',
@@ -66,16 +71,16 @@ class Assets {
// Production mode: Load from build
$plugin_url = plugin_dir_url(dirname(dirname(__FILE__)));
$dist_path = plugin_dir_path(dirname(dirname(__FILE__))) . 'customer-spa/dist/';
// Check if build exists
if (!file_exists($dist_path)) {
return;
}
// Production build - load app.js and app.css directly
$js_url = $plugin_url . 'customer-spa/dist/app.js';
$css_url = $plugin_url . 'customer-spa/dist/app.css';
wp_enqueue_script(
'woonoow-customer-spa',
$js_url,
@@ -83,15 +88,15 @@ class Assets {
null,
true
);
// Add type="module" for Vite build
add_filter('script_loader_tag', function($tag, $handle, $src) {
add_filter('script_loader_tag', function ($tag, $handle, $src) {
if ($handle === 'woonoow-customer-spa') {
$tag = str_replace('<script ', '<script type="module" ', $tag);
}
return $tag;
}, 10, 3);
wp_enqueue_style(
'woonoow-customer-spa',
$css_url,
@@ -100,33 +105,35 @@ class Assets {
);
}
}
/**
* Inject SPA mounting point for full mode
*/
public static function inject_spa_mount_point() {
public static function inject_spa_mount_point()
{
if (!self::should_load_assets()) {
return;
}
// Check if we're in full mode and not on a page with shortcode
$spa_settings = get_option('woonoow_customer_spa_settings', []);
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
if ($mode === 'full') {
// Only inject if the mount point doesn't already exist (from shortcode)
echo '<div id="woonoow-customer-app" data-page="shop"><div class="woonoow-loading"><p>Loading...</p></div></div>';
}
}
/**
* Add inline config and scripts to page head
*/
public static function add_inline_config() {
public static function add_inline_config()
{
if (!self::should_load_assets()) {
return;
}
// Get Customer SPA settings
$spa_settings = get_option('woonoow_customer_spa_settings', []);
$default_settings = [
@@ -142,14 +149,14 @@ class Assets {
],
];
$theme_settings = array_replace_recursive($default_settings, $spa_settings);
// Get appearance settings and preload them
$stored_settings = get_option('woonoow_appearance_settings', []);
$default_appearance = \WooNooW\Admin\AppearanceController::get_default_settings();
// Merge stored settings with defaults to ensure new fields (like gradient colors) exist
$appearance_settings = array_replace_recursive($default_appearance, $stored_settings);
// Get WooCommerce currency settings
$currency_settings = [
'code' => get_woocommerce_currency(),
@@ -159,16 +166,16 @@ class Assets {
'decimalSeparator' => wc_get_price_decimal_separator(),
'decimals' => wc_get_price_decimals(),
];
// Get store logo from WooNooW Store Details (Settings > Store Details)
$logo_url = get_option('woonoow_store_logo', '');
// Get user billing/shipping data if logged in
$user_data = [
'isLoggedIn' => is_user_logged_in(),
'id' => get_current_user_id(),
];
if (is_user_logged_in()) {
$customer = new \WC_Customer(get_current_user_id());
$user_data['email'] = $customer->get_email();
@@ -193,15 +200,15 @@ class Assets {
'country' => $customer->get_shipping_country(),
];
}
// Determine SPA base path for BrowserRouter
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_page = $spa_page_id ? get_post($spa_page_id) : null;
// Check if SPA Entry Page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
$is_spa_wp_frontpage = $frontpage_id && $spa_page_id && $frontpage_id === (int) $spa_page_id;
// Get SPA Landing page (explicit setting, separate from Entry Page)
// This determines what content to show at the SPA root route "/"
$spa_frontpage_id = $appearance_settings['general']['spa_frontpage'] ?? 0;
@@ -211,14 +218,18 @@ class Assets {
if ($spa_frontpage) {
$front_page_slug = $spa_frontpage->post_name;
}
} elseif ($is_spa_wp_frontpage && $spa_page) {
// If the SPA Entry Page itself is set as the WP Frontpage,
// use its content as the SPA Frontpage content.
$front_page_slug = $spa_page->post_name;
}
// If SPA Entry Page is WP frontpage, base path is /, otherwise use Entry Page slug
$base_path = $is_spa_wp_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
// Check if BrowserRouter is enabled (default: true for SEO)
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
$config = [
'apiUrl' => rest_url('woonoow/v1'),
'apiRoot' => rest_url('woonoow/v1'),
@@ -236,19 +247,20 @@ class Assets {
'useBrowserRouter' => $use_browser_router,
'frontPageSlug' => $front_page_slug,
'spaMode' => $appearance_settings['general']['spa_mode'] ?? 'full',
'security' => \WooNooW\Compat\SecuritySettingsProvider::get_public_settings(),
];
?>
?>
<script type="text/javascript">
window.woonoowCustomer = <?php echo wp_json_encode($config); ?>;
</script>
<?php
// If dev mode, output scripts directly
$is_dev = defined('WOONOOW_CUSTOMER_DEV') && WOONOOW_CUSTOMER_DEV;
if ($is_dev) {
$dev_server = 'https://woonoow.local:5174';
?>
?>
<script type="module">
import RefreshRuntime from '<?php echo $dev_server; ?>/@react-refresh'
RefreshRuntime.injectIntoGlobalHook(window)
@@ -258,35 +270,36 @@ class Assets {
</script>
<script type="module" crossorigin src="<?php echo $dev_server; ?>/@vite/client"></script>
<script type="module" crossorigin src="<?php echo $dev_server; ?>/src/main.tsx"></script>
<?php
<?php
}
}
/**
* Check if we should load customer-spa assets
*/
private static function should_load_assets() {
private static function should_load_assets()
{
global $post;
// Check if we're serving SPA directly (set by serve_spa_for_frontpage_routes)
if (defined('WOONOOW_SERVE_SPA') && WOONOOW_SERVE_SPA) {
return true;
}
// Check if we're on a frontpage SPA route (by URL detection)
if (self::is_frontpage_spa_route()) {
return true;
}
// First check: Is this a designated SPA page?
if (self::is_spa_page()) {
return true;
}
// Get SPA mode from appearance settings (the correct source)
$appearance_settings = get_option('woonoow_appearance_settings', []);
$mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// If disabled, only load for pages with shortcodes
if ($mode === 'disabled') {
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
@@ -299,7 +312,7 @@ class Assets {
}
}
}
// Check for shortcodes on regular pages
if ($post) {
if (has_shortcode($post->post_content, 'woonoow_shop')) {
@@ -317,7 +330,7 @@ class Assets {
}
return false;
}
// Full SPA mode - load on all WooCommerce pages
if ($mode === 'full') {
if (function_exists('is_shop') && is_shop()) {
@@ -337,11 +350,11 @@ class Assets {
}
return false;
}
// Checkout-Only mode - load only on specific pages
if ($mode === 'checkout_only') {
$checkout_pages = isset($spa_settings['checkoutPages']) ? $spa_settings['checkoutPages'] : [];
if (!empty($checkout_pages['checkout']) && function_exists('is_checkout') && is_checkout() && !is_order_received_page()) {
return true;
}
@@ -356,7 +369,7 @@ class Assets {
}
return false;
}
// Check if current page has WooNooW shortcodes
if ($post && has_shortcode($post->post_content, 'woonoow_shop')) {
return true;
@@ -370,65 +383,67 @@ class Assets {
if ($post && has_shortcode($post->post_content, 'woonoow_account')) {
return true;
}
return false;
}
/**
* Check if current page is the designated SPA page
*/
private static function is_spa_page() {
private static function is_spa_page()
{
global $post;
if (!$post) {
return false;
}
// Get SPA page ID from appearance settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = isset($appearance_settings['general']['spa_page']) ? $appearance_settings['general']['spa_page'] : 0;
// Check if current page matches the SPA page
if ($spa_page_id && $post->ID == $spa_page_id) {
return true;
}
return false;
}
/**
* Check if current request is a frontpage SPA route
* Used to detect SPA routes by URL when SPA page is set as frontpage
*/
private static function is_frontpage_spa_route() {
private static function is_frontpage_spa_route()
{
// Get SPA settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only run in full SPA mode
if ($spa_mode !== 'full' || !$spa_page_id) {
return false;
}
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
return false;
}
// Get the current request path
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($request_uri, PHP_URL_PATH);
$path = '/' . trim($path, '/');
// Define SPA routes
$spa_routes = ['/', '/shop', '/cart', '/checkout', '/my-account', '/login', '/register', '/reset-password'];
// Check exact matches
if (in_array($path, $spa_routes)) {
return true;
}
// Check path prefixes
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
foreach ($prefix_routes as $prefix) {
@@ -436,30 +451,31 @@ class Assets {
return true;
}
}
return false;
}
/**
* Dequeue conflicting scripts when SPA is active
*/
public static function dequeue_conflicting_scripts() {
public static function dequeue_conflicting_scripts()
{
if (!self::should_load_assets()) {
return;
}
// Dequeue WooCommerce scripts that conflict with SPA
wp_dequeue_script('wc-cart-fragments');
wp_dequeue_script('woocommerce');
wp_dequeue_script('wc-add-to-cart');
wp_dequeue_script('wc-add-to-cart-variation');
// Dequeue WordPress block scripts that cause errors in SPA
wp_dequeue_script('wp-block-library');
wp_dequeue_script('wp-block-navigation');
wp_dequeue_script('wp-interactivity');
wp_dequeue_script('wp-interactivity-router');
// Keep only essential WooCommerce styles, dequeue others if needed
// wp_dequeue_style('woocommerce-general');
// wp_dequeue_style('woocommerce-layout');

View File

@@ -781,4 +781,21 @@ class LicenseManager
$license_id
), ARRAY_A);
}
/**
* Get licenses by order ID
*
* @param int $order_id
* @return array
*/
public static function get_licenses_by_order($order_id)
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
return $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table WHERE order_id = %d ORDER BY created_at ASC",
$order_id
), ARRAY_A);
}
}

View File

@@ -11,7 +11,7 @@ class TemplateRegistry
*/
public static function get_templates()
{
return [
return apply_filters('woonoow_page_templates', [
[
'id' => 'blank',
'label' => 'Blank Page',
@@ -40,7 +40,7 @@ class TemplateRegistry
'icon' => 'mail',
'sections' => self::get_contact_structure()
]
];
]);
}
/**