diff --git a/NEWSLETTER_CAMPAIGN_PLAN.md b/NEWSLETTER_CAMPAIGN_PLAN.md
new file mode 100644
index 0000000..b3550bd
--- /dev/null
+++ b/NEWSLETTER_CAMPAIGN_PLAN.md
@@ -0,0 +1,470 @@
+# Newsletter Campaign System - Architecture Plan
+
+## Overview
+
+A comprehensive newsletter system that separates **design templates** from **campaign content**, allowing efficient email broadcasting to subscribers without rebuilding existing infrastructure.
+
+---
+
+## System Architecture
+
+### 1. **Subscriber Management** ✅ (Already Built)
+- **Location**: `Marketing > Newsletter > Subscribers List`
+- **Features**:
+ - Email collection with validation (format + optional external API)
+ - Subscriber metadata (email, user_id, status, subscribed_at, ip_address)
+ - Search/filter subscribers
+ - Export to CSV
+ - Delete subscribers
+- **Storage**: WordPress options table (`woonoow_newsletter_subscribers`)
+
+### 2. **Email Design Templates** ✅ (Already Built - Reuse Notification System)
+- **Location**: Settings > Notifications > Email Builder
+- **Purpose**: Create the **visual design/layout** for newsletters
+- **Features**:
+ - Visual block editor (drag-and-drop cards, buttons, text)
+ - Markdown editor (mobile-friendly)
+ - Live preview with branding (logo, colors, social links)
+ - Shortcode support: `{campaign_title}`, `{campaign_content}`, `{unsubscribe_url}`, `{subscriber_email}`, `{site_name}`, etc.
+- **Storage**: Same as notification templates (`wp_options` or custom table)
+- **Events to Create**:
+ - `newsletter_campaign` (customer, marketing category) - For broadcast emails
+
+**Template Structure Example**:
+```markdown
+[card:hero]
+# {campaign_title}
+[/card]
+
+[card]
+{campaign_content}
+[/card]
+
+[card:basic]
+---
+You're receiving this because you subscribed to our newsletter.
+[Unsubscribe]({unsubscribe_url})
+[/card]
+```
+
+### 3. **Campaign Management** 🆕 (New Module)
+- **Location**: `Marketing > Newsletter > Campaigns` (new tab)
+- **Purpose**: Create campaign **content/message** that uses design templates
+- **Features**:
+ - Campaign list (draft, scheduled, sent, failed)
+ - Create/edit campaign
+ - Select design template
+ - Write campaign content (rich text editor - text only, no design)
+ - Preview (merge template + content)
+ - Schedule or send immediately
+ - Target audience (all subscribers, filtered by date, user_id, etc.)
+ - Track status (pending, sending, sent, failed)
+
+---
+
+## Database Schema
+
+### Table: `wp_woonoow_campaigns`
+
+```sql
+CREATE TABLE wp_woonoow_campaigns (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ title VARCHAR(255) NOT NULL,
+ subject VARCHAR(255) NOT NULL,
+ content LONGTEXT NOT NULL,
+ template_id VARCHAR(100) DEFAULT 'newsletter_campaign',
+ status ENUM('draft', 'scheduled', 'sending', 'sent', 'failed') DEFAULT 'draft',
+ scheduled_at DATETIME NULL,
+ sent_at DATETIME NULL,
+ total_recipients INT DEFAULT 0,
+ sent_count INT DEFAULT 0,
+ failed_count INT DEFAULT 0,
+ created_by BIGINT UNSIGNED,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_status (status),
+ INDEX idx_scheduled (scheduled_at),
+ INDEX idx_created_by (created_by)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+```
+
+### Table: `wp_woonoow_campaign_logs`
+
+```sql
+CREATE TABLE wp_woonoow_campaign_logs (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ campaign_id BIGINT UNSIGNED NOT NULL,
+ subscriber_email VARCHAR(255) NOT NULL,
+ status ENUM('pending', 'sent', 'failed') DEFAULT 'pending',
+ error_message TEXT NULL,
+ sent_at DATETIME NULL,
+ INDEX idx_campaign (campaign_id),
+ INDEX idx_status (status),
+ FOREIGN KEY (campaign_id) REFERENCES wp_woonoow_campaigns(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+```
+
+---
+
+## API Endpoints
+
+### Campaign CRUD
+
+```php
+// GET /woonoow/v1/newsletter/campaigns
+// List all campaigns with pagination
+CampaignsController::list_campaigns()
+
+// GET /woonoow/v1/newsletter/campaigns/{id}
+// Get single campaign
+CampaignsController::get_campaign($id)
+
+// POST /woonoow/v1/newsletter/campaigns
+// Create new campaign
+CampaignsController::create_campaign($data)
+
+// PUT /woonoow/v1/newsletter/campaigns/{id}
+// Update campaign
+CampaignsController::update_campaign($id, $data)
+
+// DELETE /woonoow/v1/newsletter/campaigns/{id}
+// Delete campaign
+CampaignsController::delete_campaign($id)
+
+// POST /woonoow/v1/newsletter/campaigns/{id}/preview
+// Preview campaign (merge template + content)
+CampaignsController::preview_campaign($id)
+
+// POST /woonoow/v1/newsletter/campaigns/{id}/send
+// Send campaign immediately or schedule
+CampaignsController::send_campaign($id, $schedule_time)
+
+// GET /woonoow/v1/newsletter/campaigns/{id}/stats
+// Get campaign statistics
+CampaignsController::get_campaign_stats($id)
+
+// GET /woonoow/v1/newsletter/templates
+// List available design templates
+CampaignsController::list_templates()
+```
+
+---
+
+## UI Components
+
+### 1. Campaign List Page
+**Route**: `/marketing/newsletter?tab=campaigns`
+
+**Features**:
+- Table with columns: Title, Subject, Status, Recipients, Sent Date, Actions
+- Filter by status (draft, scheduled, sent, failed)
+- Search by title/subject
+- Actions: Edit, Preview, Duplicate, Delete, Send Now
+- "Create Campaign" button
+
+### 2. Campaign Editor
+**Route**: `/marketing/newsletter/campaigns/new` or `/marketing/newsletter/campaigns/{id}/edit`
+
+**Form Fields**:
+```tsx
+- Campaign Title (internal name)
+- Email Subject (what subscribers see)
+- Design Template (dropdown: select from available templates)
+- Campaign Content (rich text editor - TipTap or similar)
+ - Bold, italic, links, headings, lists
+ - NO design elements (cards, buttons) - those are in template
+- Preview Button (opens modal with merged template + content)
+- Target Audience (future: filters, for now: all subscribers)
+- Schedule Options:
+ - Send Now
+ - Schedule for Later (date/time picker)
+- Save as Draft
+```
+
+### 3. Preview Modal
+**Component**: `CampaignPreview.tsx`
+
+**Features**:
+- Fetch design template
+- Replace `{campaign_title}` with campaign title
+- Replace `{campaign_content}` with campaign content
+- Replace `{unsubscribe_url}` with sample URL
+- Show full email preview with branding
+- "Send Test Email" button (send to admin email)
+
+### 4. Campaign Stats Page
+**Route**: `/marketing/newsletter/campaigns/{id}/stats`
+
+**Metrics**:
+- Total recipients
+- Sent count
+- Failed count
+- Sent date/time
+- Error log (for failed emails)
+
+---
+
+## Sending System
+
+### WP-Cron Job
+```php
+// Schedule hourly check for pending campaigns
+add_action('woonoow_send_scheduled_campaigns', 'WooNooW\Core\CampaignSender::process_scheduled');
+
+// Register cron schedule
+if (!wp_next_scheduled('woonoow_send_scheduled_campaigns')) {
+ wp_schedule_event(time(), 'hourly', 'woonoow_send_scheduled_campaigns');
+}
+```
+
+### Batch Processing
+```php
+class CampaignSender {
+ const BATCH_SIZE = 50; // Send 50 emails per batch
+ const BATCH_DELAY = 5; // 5 seconds between batches
+
+ public static function process_scheduled() {
+ // Find campaigns where status='scheduled' and scheduled_at <= now
+ $campaigns = self::get_pending_campaigns();
+
+ foreach ($campaigns as $campaign) {
+ self::send_campaign($campaign->id);
+ }
+ }
+
+ public static function send_campaign($campaign_id) {
+ $campaign = self::get_campaign($campaign_id);
+ $subscribers = self::get_subscribers();
+
+ // Update status to 'sending'
+ self::update_campaign_status($campaign_id, 'sending');
+
+ // Get design template
+ $template = self::get_template($campaign->template_id);
+
+ // Process in batches
+ $batches = array_chunk($subscribers, self::BATCH_SIZE);
+
+ foreach ($batches as $batch) {
+ foreach ($batch as $subscriber) {
+ self::send_to_subscriber($campaign, $template, $subscriber);
+ }
+
+ // Delay between batches to avoid rate limits
+ sleep(self::BATCH_DELAY);
+ }
+
+ // Update status to 'sent'
+ self::update_campaign_status($campaign_id, 'sent', [
+ 'sent_at' => current_time('mysql'),
+ 'sent_count' => count($subscribers),
+ ]);
+ }
+
+ private static function send_to_subscriber($campaign, $template, $subscriber) {
+ // Merge template with campaign content
+ $email_body = self::merge_template($template, $campaign, $subscriber);
+
+ // Send via notification system
+ do_action('woonoow/notification/send', [
+ 'event' => 'newsletter_campaign',
+ 'channel' => 'email',
+ 'recipient' => $subscriber['email'],
+ 'subject' => $campaign->subject,
+ 'body' => $email_body,
+ 'data' => [
+ 'campaign_id' => $campaign->id,
+ 'subscriber_email' => $subscriber['email'],
+ ],
+ ]);
+
+ // Log send attempt
+ self::log_send($campaign->id, $subscriber['email'], 'sent');
+ }
+
+ private static function merge_template($template, $campaign, $subscriber) {
+ $body = $template->body;
+
+ // Replace campaign variables
+ $body = str_replace('{campaign_title}', $campaign->title, $body);
+ $body = str_replace('{campaign_content}', $campaign->content, $body);
+
+ // Replace subscriber variables
+ $body = str_replace('{subscriber_email}', $subscriber['email'], $body);
+ $unsubscribe_url = add_query_arg([
+ 'action' => 'woonoow_unsubscribe',
+ 'email' => base64_encode($subscriber['email']),
+ 'token' => wp_create_nonce('unsubscribe_' . $subscriber['email']),
+ ], home_url());
+ $body = str_replace('{unsubscribe_url}', $unsubscribe_url, $body);
+
+ // Replace site variables
+ $body = str_replace('{site_name}', get_bloginfo('name'), $body);
+
+ return $body;
+ }
+}
+```
+
+---
+
+## Workflow
+
+### Creating a Campaign
+
+1. **Admin goes to**: Marketing > Newsletter > Campaigns
+2. **Clicks**: "Create Campaign"
+3. **Fills form**:
+ - Title: "Summer Sale 2025"
+ - Subject: "🌞 50% Off Summer Collection!"
+ - Template: Select "Newsletter Campaign" (design template)
+ - Content: Write message in rich text editor
+ ```
+ Hi there!
+
+ We're excited to announce our biggest summer sale yet!
+
+ Get 50% off all summer items this week only.
+
+ Shop now and save big!
+ ```
+4. **Clicks**: "Preview" → See full email with design + content merged
+5. **Clicks**: "Send Test Email" → Receive test at admin email
+6. **Chooses**: "Schedule for Later" → Select date/time
+7. **Clicks**: "Save & Schedule"
+
+### Sending Process
+
+1. **WP-Cron runs** every hour
+2. **Finds** campaigns where `status='scheduled'` and `scheduled_at <= now`
+3. **Processes** each campaign:
+ - Updates status to `sending`
+ - Gets all subscribers
+ - Sends in batches of 50
+ - Logs each send attempt
+ - Updates status to `sent` when complete
+4. **Admin can view** stats: total sent, failed, errors
+
+---
+
+## Minimal Feature Set (MVP)
+
+### Phase 1: Core Campaign System
+- ✅ Database tables (campaigns, campaign_logs)
+- ✅ API endpoints (CRUD, preview, send)
+- ✅ Campaign list UI
+- ✅ Campaign editor UI
+- ✅ Preview modal
+- ✅ Send immediately functionality
+- ✅ Basic stats page
+
+### Phase 2: Scheduling & Automation
+- ✅ Schedule for later
+- ✅ WP-Cron integration
+- ✅ Batch processing
+- ✅ Error handling & logging
+
+### Phase 3: Enhancements (Future)
+- 📧 Open tracking (pixel)
+- 🔗 Click tracking (link wrapping)
+- 🎯 Audience segmentation (filter by date, user role, etc.)
+- 📊 Analytics dashboard
+- 📋 Campaign templates library
+- 🔄 A/B testing
+- 🤖 Automation workflows
+
+---
+
+## Design Template Variables
+
+Templates can use these variables (replaced during send):
+
+### Campaign Variables
+- `{campaign_title}` - Campaign title
+- `{campaign_content}` - Campaign content (rich text)
+
+### Subscriber Variables
+- `{subscriber_email}` - Subscriber's email
+- `{unsubscribe_url}` - Unsubscribe link
+
+### Site Variables
+- `{site_name}` - Site name
+- `{site_url}` - Site URL
+- `{current_year}` - Current year
+
+---
+
+## File Structure
+
+```
+includes/
+├── Api/
+│ ├── NewsletterController.php (existing - subscribers)
+│ └── CampaignsController.php (new - campaigns CRUD)
+├── Core/
+│ ├── Validation.php (existing - email/phone validation)
+│ ├── CampaignSender.php (new - sending logic)
+│ └── Notifications/
+│ └── EventRegistry.php (add newsletter_campaign event)
+
+admin-spa/src/routes/Marketing/
+├── Newsletter.tsx (existing - subscribers list)
+├── Newsletter/
+│ ├── Campaigns.tsx (new - campaign list)
+│ ├── CampaignEditor.tsx (new - create/edit)
+│ ├── CampaignPreview.tsx (new - preview modal)
+│ └── CampaignStats.tsx (new - stats page)
+```
+
+---
+
+## Key Principles
+
+1. **Separation of Concerns**:
+ - Design templates = Visual layout (cards, buttons, colors)
+ - Campaign content = Message text (what to say)
+
+2. **Reuse Existing Infrastructure**:
+ - Email builder (notification system)
+ - Email sending (notification system)
+ - Branding settings (email customization)
+ - Subscriber management (already built)
+
+3. **Minimal Duplication**:
+ - Don't rebuild email builder
+ - Don't rebuild email sending
+ - Don't rebuild subscriber management
+
+4. **Efficient Workflow**:
+ - Create design template once
+ - Reuse for multiple campaigns
+ - Only write campaign content each time
+
+5. **Scalability**:
+ - Batch processing for large lists
+ - Queue system for reliability
+ - Error logging for debugging
+
+---
+
+## Success Metrics
+
+- ✅ Admin can create campaign in < 2 minutes
+- ✅ Preview shows accurate email with branding
+- ✅ Emails sent without rate limit issues
+- ✅ Failed sends are logged and visible
+- ✅ No duplicate code or functionality
+- ✅ System handles 10,000+ subscribers efficiently
+
+---
+
+## Next Steps
+
+1. Create database migration for campaign tables
+2. Build `CampaignsController.php` with all API endpoints
+3. Create `CampaignSender.php` with batch processing logic
+4. Add `newsletter_campaign` event to EventRegistry
+5. Build Campaign UI components (list, editor, preview, stats)
+6. Test with small subscriber list
+7. Optimize batch size and delays
+8. Document for users
diff --git a/VALIDATION_HOOKS.md b/VALIDATION_HOOKS.md
new file mode 100644
index 0000000..5b9dade
--- /dev/null
+++ b/VALIDATION_HOOKS.md
@@ -0,0 +1,293 @@
+# Validation Filter Hooks
+
+WooNooW provides extensible validation filter hooks that allow addons to integrate external validation services for emails and phone numbers.
+
+## Email Validation
+
+### Filter: `woonoow/validate_email`
+
+Validates email addresses with support for external API integration.
+
+**Parameters:**
+- `$is_valid` (bool|WP_Error): Initial validation state (default: true)
+- `$email` (string): The email address to validate
+- `$context` (string): Context of validation (e.g., 'newsletter_subscribe', 'checkout', 'registration')
+
+**Returns:** `true` if valid, `WP_Error` if invalid
+
+**Built-in Validation:**
+1. WordPress `is_email()` check
+2. Regex pattern validation: `xxxx@xxxx.xx` format
+3. Extensible via filter hook
+
+### Example: QuickEmailVerification.com Integration
+
+```php
+add_filter('woonoow/validate_email', function($is_valid, $email, $context) {
+ // Only validate for newsletter subscriptions
+ if ($context !== 'newsletter_subscribe') {
+ return $is_valid;
+ }
+
+ $api_key = get_option('my_addon_quickemail_api_key');
+ if (!$api_key) {
+ return $is_valid; // Skip if no API key configured
+ }
+
+ // Call QuickEmailVerification API
+ $response = wp_remote_get(
+ "https://api.quickemailverification.com/v1/verify?email={$email}&apikey={$api_key}",
+ ['timeout' => 5]
+ );
+
+ if (is_wp_error($response)) {
+ // Fallback to basic validation on API error
+ return $is_valid;
+ }
+
+ $data = json_decode(wp_remote_retrieve_body($response), true);
+
+ // Check validation result
+ if (isset($data['result']) && $data['result'] !== 'valid') {
+ return new WP_Error(
+ 'email_verification_failed',
+ sprintf('Email verification failed: %s', $data['reason'] ?? 'Unknown'),
+ ['status' => 400]
+ );
+ }
+
+ return true;
+}, 10, 3);
+```
+
+### Example: Hunter.io Email Verification
+
+```php
+add_filter('woonoow/validate_email', function($is_valid, $email, $context) {
+ $api_key = get_option('my_addon_hunter_api_key');
+ if (!$api_key) return $is_valid;
+
+ $response = wp_remote_get(
+ "https://api.hunter.io/v2/email-verifier?email={$email}&api_key={$api_key}"
+ );
+
+ if (is_wp_error($response)) return $is_valid;
+
+ $data = json_decode(wp_remote_retrieve_body($response), true);
+
+ if ($data['data']['status'] !== 'valid') {
+ return new WP_Error('email_invalid', 'Email address is not deliverable');
+ }
+
+ return true;
+}, 10, 3);
+```
+
+---
+
+## Phone Validation
+
+### Filter: `woonoow/validate_phone`
+
+Validates phone numbers with support for external API integration and WhatsApp verification.
+
+**Parameters:**
+- `$is_valid` (bool|WP_Error): Initial validation state (default: true)
+- `$phone` (string): The phone number to validate (cleaned, no formatting)
+- `$context` (string): Context of validation (e.g., 'checkout', 'registration', 'shipping')
+- `$country_code` (string): Country code if available (e.g., 'ID', 'US')
+
+**Returns:** `true` if valid, `WP_Error` if invalid
+
+**Built-in Validation:**
+1. Format check: 8-15 digits, optional `+` prefix
+2. Removes common formatting characters
+3. Extensible via filter hook
+
+### Example: WhatsApp Number Verification
+
+```php
+add_filter('woonoow/validate_phone', function($is_valid, $phone, $context, $country_code) {
+ // Only validate for checkout
+ if ($context !== 'checkout') {
+ return $is_valid;
+ }
+
+ $api_token = get_option('my_addon_whatsapp_api_token');
+ if (!$api_token) return $is_valid;
+
+ // Check if number is registered on WhatsApp
+ $response = wp_remote_post('https://api.whatsapp.com/v1/contacts', [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $api_token,
+ 'Content-Type' => 'application/json',
+ ],
+ 'body' => json_encode([
+ 'blocking' => 'wait',
+ 'contacts' => [$phone],
+ ]),
+ 'timeout' => 10,
+ ]);
+
+ if (is_wp_error($response)) {
+ return $is_valid; // Fallback on API error
+ }
+
+ $data = json_decode(wp_remote_retrieve_body($response), true);
+
+ // Check if WhatsApp ID exists
+ if (!isset($data['contacts'][0]['wa_id'])) {
+ return new WP_Error(
+ 'phone_not_whatsapp',
+ 'Phone number must be registered on WhatsApp for order notifications',
+ ['status' => 400]
+ );
+ }
+
+ return true;
+}, 10, 4);
+```
+
+### Example: Numverify Phone Validation
+
+```php
+add_filter('woonoow/validate_phone', function($is_valid, $phone, $context, $country_code) {
+ $api_key = get_option('my_addon_numverify_api_key');
+ if (!$api_key) return $is_valid;
+
+ $url = sprintf(
+ 'http://apilayer.net/api/validate?access_key=%s&number=%s&country_code=%s',
+ $api_key,
+ urlencode($phone),
+ urlencode($country_code)
+ );
+
+ $response = wp_remote_get($url, ['timeout' => 5]);
+
+ if (is_wp_error($response)) return $is_valid;
+
+ $data = json_decode(wp_remote_retrieve_body($response), true);
+
+ if (!$data['valid']) {
+ return new WP_Error(
+ 'phone_invalid',
+ sprintf('Invalid phone number: %s', $data['error'] ?? 'Unknown error')
+ );
+ }
+
+ // Store carrier info for later use
+ update_post_meta(get_current_user_id(), '_phone_carrier', $data['carrier'] ?? '');
+
+ return true;
+}, 10, 4);
+```
+
+### Filter: `woonoow/validate_phone_whatsapp`
+
+Convenience filter specifically for WhatsApp registration checks.
+
+**Parameters:**
+- `$is_registered` (bool|WP_Error): Initial state (default: true)
+- `$phone` (string): The phone number (cleaned)
+- `$context` (string): Context of validation
+- `$country_code` (string): Country code if available
+
+**Returns:** `true` if registered on WhatsApp, `WP_Error` if not
+
+---
+
+## Usage in Code
+
+### Email Validation
+
+```php
+use WooNooW\Core\Validation;
+
+// Validate email for newsletter
+$result = Validation::validate_email('user@example.com', 'newsletter_subscribe');
+
+if (is_wp_error($result)) {
+ // Handle error
+ echo $result->get_error_message();
+} else {
+ // Email is valid
+ // Proceed with subscription
+}
+```
+
+### Phone Validation
+
+```php
+use WooNooW\Core\Validation;
+
+// Validate phone for checkout
+$result = Validation::validate_phone('+628123456789', 'checkout', 'ID');
+
+if (is_wp_error($result)) {
+ // Handle error
+ echo $result->get_error_message();
+} else {
+ // Phone is valid
+ // Proceed with order
+}
+```
+
+### Phone + WhatsApp Validation
+
+```php
+use WooNooW\Core\Validation;
+
+// Validate phone and check WhatsApp registration
+$result = Validation::validate_phone_whatsapp('+628123456789', 'checkout', 'ID');
+
+if (is_wp_error($result)) {
+ // Phone invalid or not registered on WhatsApp
+ echo $result->get_error_message();
+} else {
+ // Phone is valid and registered on WhatsApp
+ // Proceed with order
+}
+```
+
+---
+
+## Validation Contexts
+
+Common contexts used throughout WooNooW:
+
+- `newsletter_subscribe` - Newsletter subscription form
+- `checkout` - Checkout process
+- `registration` - User registration
+- `shipping` - Shipping address validation
+- `billing` - Billing address validation
+- `general` - General validation (default)
+
+Addons can filter based on context to apply different validation rules for different scenarios.
+
+---
+
+## Best Practices
+
+1. **Always fallback gracefully** - If external API fails, return `$is_valid` to use basic validation
+2. **Use timeouts** - Set reasonable timeouts (5-10 seconds) for API calls
+3. **Cache results** - Cache validation results to avoid repeated API calls
+4. **Provide clear error messages** - Return descriptive WP_Error messages
+5. **Check context** - Only apply validation where needed to avoid unnecessary API calls
+6. **Handle API keys securely** - Store API keys in options, never hardcode
+7. **Log errors** - Log API errors for debugging without blocking users
+
+---
+
+## Error Codes
+
+### Email Validation Errors
+- `invalid_email` - Basic format validation failed
+- `invalid_email_format` - Regex pattern validation failed
+- `email_verification_failed` - External API verification failed
+- `email_validation_failed` - Generic validation failure
+
+### Phone Validation Errors
+- `invalid_phone` - Basic format validation failed
+- `phone_not_whatsapp` - Phone not registered on WhatsApp
+- `phone_invalid` - External API validation failed
+- `phone_validation_failed` - Generic validation failure
diff --git a/admin-spa/src/App.tsx b/admin-spa/src/App.tsx
index b3302b8..5d420a6 100644
--- a/admin-spa/src/App.tsx
+++ b/admin-spa/src/App.tsx
@@ -18,9 +18,9 @@ import ProductEdit from '@/routes/Products/Edit';
import ProductCategories from '@/routes/Products/Categories';
import ProductTags from '@/routes/Products/Tags';
import ProductAttributes from '@/routes/Products/Attributes';
-import CouponsIndex from '@/routes/Coupons';
-import CouponNew from '@/routes/Coupons/New';
-import CouponEdit from '@/routes/Coupons/Edit';
+import CouponsIndex from '@/routes/Marketing/Coupons';
+import CouponNew from '@/routes/Marketing/Coupons/New';
+import CouponEdit from '@/routes/Marketing/Coupons/Edit';
import CustomersIndex from '@/routes/Customers';
import CustomerNew from '@/routes/Customers/New';
import CustomerEdit from '@/routes/Customers/Edit';
@@ -250,7 +250,6 @@ import AppearanceThankYou from '@/routes/Appearance/ThankYou';
import AppearanceAccount from '@/routes/Appearance/Account';
import MarketingIndex from '@/routes/Marketing';
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
-import EmailTemplates from '@/routes/Marketing/EmailTemplates';
import MorePage from '@/routes/More';
// Addon Route Component - Dynamically loads addon components
@@ -515,10 +514,13 @@ function AppRoutes() {
} />
} />
- {/* Coupons */}
+ {/* Coupons (under Marketing) */}
} />
} />
} />
+ } />
+ } />
+ } />
{/* Customers */}
} />
@@ -565,7 +567,6 @@ function AppRoutes() {
{/* Marketing */}
} />
} />
- } />
{/* Dynamic Addon Routes */}
{addonRoutes.map((route: any) => (
diff --git a/admin-spa/src/routes/Coupons/CouponForm.tsx b/admin-spa/src/routes/Marketing/Coupons/CouponForm.tsx
similarity index 100%
rename from admin-spa/src/routes/Coupons/CouponForm.tsx
rename to admin-spa/src/routes/Marketing/Coupons/CouponForm.tsx
diff --git a/admin-spa/src/routes/Coupons/Edit.tsx b/admin-spa/src/routes/Marketing/Coupons/Edit.tsx
similarity index 100%
rename from admin-spa/src/routes/Coupons/Edit.tsx
rename to admin-spa/src/routes/Marketing/Coupons/Edit.tsx
diff --git a/admin-spa/src/routes/Coupons/New.tsx b/admin-spa/src/routes/Marketing/Coupons/New.tsx
similarity index 100%
rename from admin-spa/src/routes/Coupons/New.tsx
rename to admin-spa/src/routes/Marketing/Coupons/New.tsx
diff --git a/admin-spa/src/routes/Coupons/components/CouponCard.tsx b/admin-spa/src/routes/Marketing/Coupons/components/CouponCard.tsx
similarity index 100%
rename from admin-spa/src/routes/Coupons/components/CouponCard.tsx
rename to admin-spa/src/routes/Marketing/Coupons/components/CouponCard.tsx
diff --git a/admin-spa/src/routes/Coupons/components/CouponFilterSheet.tsx b/admin-spa/src/routes/Marketing/Coupons/components/CouponFilterSheet.tsx
similarity index 100%
rename from admin-spa/src/routes/Coupons/components/CouponFilterSheet.tsx
rename to admin-spa/src/routes/Marketing/Coupons/components/CouponFilterSheet.tsx
diff --git a/admin-spa/src/routes/Coupons/index.tsx b/admin-spa/src/routes/Marketing/Coupons/index.tsx
similarity index 100%
rename from admin-spa/src/routes/Coupons/index.tsx
rename to admin-spa/src/routes/Marketing/Coupons/index.tsx
diff --git a/admin-spa/src/routes/Marketing/EmailTemplates.tsx b/admin-spa/src/routes/Marketing/EmailTemplates.tsx
deleted file mode 100644
index add8ceb..0000000
--- a/admin-spa/src/routes/Marketing/EmailTemplates.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import React, { useState } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
-import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Textarea } from '@/components/ui/textarea';
-import { toast } from 'sonner';
-import { api } from '@/lib/api';
-import { ArrowLeft, Save } from 'lucide-react';
-import { useNavigate, useParams } from 'react-router-dom';
-
-export default function EmailTemplates() {
- const navigate = useNavigate();
- const { template } = useParams();
- const queryClient = useQueryClient();
-
- const [subject, setSubject] = useState('');
- const [content, setContent] = useState('');
-
- const { data: templateData, isLoading } = useQuery({
- queryKey: ['newsletter-template', template],
- queryFn: async () => {
- const response = await api.get(`/newsletter/template/${template}`);
- return response.data;
- },
- enabled: !!template,
- });
-
- React.useEffect(() => {
- if (templateData) {
- setSubject(templateData.subject || '');
- setContent(templateData.content || '');
- }
- }, [templateData]);
-
- const saveTemplate = useMutation({
- mutationFn: async () => {
- await api.post(`/newsletter/template/${template}`, {
- subject,
- content,
- });
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['newsletter-template'] });
- toast.success('Template saved successfully');
- },
- onError: () => {
- toast.error('Failed to save template');
- },
- });
-
- const handleSave = () => {
- saveTemplate.mutate();
- };
-
- return (
-
-
-
-
-
-
-
-
-
- setSubject(e.target.value)}
- placeholder="Welcome to {site_name} Newsletter!"
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Subject: {subject.replace('{site_name}', 'Your Store')}
-
-
- {content.replace('{site_name}', 'Your Store').replace('{email}', 'customer@example.com')}
-
-
-
-
- );
-}
diff --git a/admin-spa/src/routes/Marketing/Newsletter.tsx b/admin-spa/src/routes/Marketing/Newsletter.tsx
index 8406360..bf89fb4 100644
--- a/admin-spa/src/routes/Marketing/Newsletter.tsx
+++ b/admin-spa/src/routes/Marketing/Newsletter.tsx
@@ -32,7 +32,7 @@ export default function NewsletterSubscribers() {
const deleteSubscriber = useMutation({
mutationFn: async (email: string) => {
- await api.delete(`/newsletter/subscribers/${encodeURIComponent(email)}`);
+ await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
@@ -77,14 +77,14 @@ export default function NewsletterSubscribers() {
>
{/* Actions Bar */}
-
+
setSearchQuery(e.target.value)}
- className="pl-9"
+ className="!pl-9"
/>
@@ -175,7 +175,7 @@ export default function NewsletterSubscribers() {
@@ -189,7 +189,7 @@ export default function NewsletterSubscribers() {
diff --git a/admin-spa/src/routes/Settings/Notifications/Staff/Events.tsx b/admin-spa/src/routes/Settings/Notifications/Staff/Events.tsx
index 55c3bc8..719181b 100644
--- a/admin-spa/src/routes/Settings/Notifications/Staff/Events.tsx
+++ b/admin-spa/src/routes/Settings/Notifications/Staff/Events.tsx
@@ -105,6 +105,7 @@ export default function NotificationEvents() {
const orderEvents = eventsData?.orders || [];
const productEvents = eventsData?.products || [];
const customerEvents = eventsData?.customers || [];
+ const marketingEvents = eventsData?.marketing || [];
return (
@@ -340,6 +341,77 @@ export default function NotificationEvents() {
)}
+
+ {/* Marketing Events */}
+ {marketingEvents.length > 0 && (
+
+
+ {marketingEvents.map((event: NotificationEvent) => (
+
+
+
{event.label}
+
{event.description}
+
+
+
+ {channels?.map((channel: NotificationChannel) => {
+ const channelEnabled = event.channels?.[channel.id]?.enabled || false;
+ const recipient = event.channels?.[channel.id]?.recipient || 'admin';
+
+ return (
+
+
+
+ {getChannelIcon(channel.id)}
+
+
+
+ {channel.label}
+ {channel.builtin && (
+
+ {__('Built-in')}
+
+ )}
+
+ {channelEnabled && (
+
+ {__('Send to')}: {recipient === 'admin' ? __('Admin') : recipient === 'customer' ? __('Customer') : __('Both')}
+
+ )}
+
+
+
+ {channelEnabled && channel.enabled && (
+
+ )}
+ toggleChannel(event.id, channel.id, channelEnabled)}
+ disabled={!channel.enabled || updateMutation.isPending}
+ />
+
+
+ );
+ })}
+
+
+ ))}
+
+
+ )}
);
}
diff --git a/customer-spa/src/components/Layout/PageLayout.tsx b/customer-spa/src/components/Layout/PageLayout.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/customer-spa/src/hooks/useWishlist.ts b/customer-spa/src/hooks/useWishlist.ts
index c146b8f..f8e4553 100644
--- a/customer-spa/src/hooks/useWishlist.ts
+++ b/customer-spa/src/hooks/useWishlist.ts
@@ -21,8 +21,9 @@ export function useWishlist() {
const [isLoading, setIsLoading] = useState(false);
const [productIds, setProductIds] = useState
>(new Set());
- // Check if wishlist is enabled
- const isEnabled = (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false;
+ // Check if wishlist is enabled (default true if not explicitly set to false)
+ const settings = (window as any).woonoowCustomer?.settings;
+ const isEnabled = settings?.wishlist_enabled !== false;
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
// Load wishlist on mount
diff --git a/customer-spa/src/layouts/BaseLayout.tsx b/customer-spa/src/layouts/BaseLayout.tsx
index f3d8d91..5a3ec5d 100644
--- a/customer-spa/src/layouts/BaseLayout.tsx
+++ b/customer-spa/src/layouts/BaseLayout.tsx
@@ -1,6 +1,6 @@
import React, { ReactNode, useState } from 'react';
import { Link } from 'react-router-dom';
-import { ShoppingCart, User, Search, Menu, X } from 'lucide-react';
+import { Search, ShoppingCart, User, Menu, X, Heart } from 'lucide-react';
import { useLayout } from '../contexts/ThemeContext';
import { useCartStore } from '../lib/cart/store';
import { useHeaderSettings, useFooterSettings } from '../hooks/useAppearanceSettings';
@@ -130,6 +130,14 @@ function ClassicLayout({ children }: BaseLayoutProps) {
))}
+ {/* Wishlist */}
+ {headerSettings.elements.wishlist && (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false && user?.isLoggedIn && (
+
+
+ Wishlist
+
+ )}
+
{/* Cart */}
{headerSettings.elements.cart && (
diff --git a/customer-spa/src/pages/Account/Addresses.tsx b/customer-spa/src/pages/Account/Addresses.tsx
index 8e37e94..298b5a8 100644
--- a/customer-spa/src/pages/Account/Addresses.tsx
+++ b/customer-spa/src/pages/Account/Addresses.tsx
@@ -50,41 +50,21 @@ export default function Addresses() {
const loadAddresses = async () => {
try {
const response: any = await api.get('/account/addresses');
- console.log('API response:', response);
- console.log('Type of response:', typeof response);
- console.log('Is array:', Array.isArray(response));
- console.log('Response keys:', response ? Object.keys(response) : 'null');
- console.log('Response values:', response ? Object.values(response) : 'null');
// Handle different response structures
let data: Address[] = [];
if (Array.isArray(response)) {
- // Direct array response
data = response;
- console.log('Using direct array');
} else if (response && typeof response === 'object') {
- // Log all properties to debug
- console.log('Checking object properties...');
-
- // Check common wrapper properties
if (Array.isArray(response.data)) {
data = response.data;
- console.log('Using response.data');
} else if (Array.isArray(response.addresses)) {
data = response.addresses;
- console.log('Using response.addresses');
} else if (response.length !== undefined && typeof response === 'object') {
- // Might be array-like object, convert to array
data = Object.values(response).filter((item: any) => item && typeof item === 'object' && item.id) as Address[];
- console.log('Converted object to array:', data);
- } else {
- console.error('API returned unexpected structure:', response);
- console.error('Available keys:', Object.keys(response));
}
}
-
- console.log('Final addresses array:', data);
setAddresses(data);
} catch (error) {
console.error('Load addresses error:', error);
@@ -124,16 +104,11 @@ export default function Addresses() {
const handleSave = async () => {
try {
- console.log('Saving address:', formData);
if (editingAddress) {
- console.log('Updating address ID:', editingAddress.id);
- const response = await api.put(`/account/addresses/${editingAddress.id}`, formData);
- console.log('Update response:', response);
+ await api.put(`/account/addresses/${editingAddress.id}`, formData);
toast.success('Address updated successfully');
} else {
- console.log('Creating new address');
- const response = await api.post('/account/addresses', formData);
- console.log('Create response:', response);
+ await api.post('/account/addresses', formData);
toast.success('Address added successfully');
}
setShowModal(false);
diff --git a/customer-spa/src/pages/Product/index.tsx b/customer-spa/src/pages/Product/index.tsx
index ca95501..91348ea 100644
--- a/customer-spa/src/pages/Product/index.tsx
+++ b/customer-spa/src/pages/Product/index.tsx
@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import { useCartStore } from '@/lib/cart/store';
import { useProductSettings } from '@/hooks/useAppearanceSettings';
+import { useWishlist } from '@/hooks/useWishlist';
import { Button } from '@/components/ui/button';
import Container from '@/components/Layout/Container';
import { ProductCard } from '@/components/ProductCard';
@@ -23,6 +24,7 @@ export default function Product() {
const [selectedAttributes, setSelectedAttributes] = useState>({});
const thumbnailsRef = useRef(null);
const { addItem } = useCartStore();
+ const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist, isLoggedIn } = useWishlist();
// Fetch product details by slug
const { data: product, isLoading, error } = useQuery({
@@ -40,27 +42,19 @@ export default function Product() {
queryFn: async () => {
if (!product) return [];
- console.log('[Related Products] Fetching for product:', product.id);
- console.log('[Related Products] Categories:', product.categories);
-
try {
if (product.related_ids && product.related_ids.length > 0) {
const ids = product.related_ids.slice(0, 4).join(',');
- console.log('[Related Products] Using related_ids:', ids);
const response = await apiClient.get(`/shop/products?include=${ids}`);
- console.log('[Related Products] Response:', response);
return response.products || [];
}
const categoryId = product.categories?.[0]?.term_id || product.categories?.[0]?.id;
if (categoryId) {
- console.log('[Related Products] Using category:', categoryId);
const response = await apiClient.get(`/shop/products?category=${categoryId}&per_page=4&exclude=${product.id}`);
- console.log('[Related Products] Response:', response.products?.length, 'products');
return response.products || [];
}
- console.log('[Related Products] No category found');
return [];
} catch (error) {
console.error('Failed to fetch related products:', error);
@@ -70,15 +64,6 @@ export default function Product() {
enabled: !!product?.id && elements.related_products,
});
- // Debug logging
- console.log('[Related Products] Settings:', {
- enabled: elements.related_products,
- hasProduct: !!product?.id,
- queryEnabled: !!product?.id && elements.related_products,
- relatedProductsData: relatedProducts,
- relatedProductsLength: relatedProducts?.length
- });
-
// Set initial image when product loads
useEffect(() => {
if (product && !selectedImage) {
@@ -502,10 +487,21 @@ export default function Product() {
Add to Cart
-
+ {wishlistEnabled && (
+
+ )}
)}
@@ -694,7 +690,7 @@ export default function Product() {
{[1, 2, 3, 4, 5].map((star) => (
-