feat: Newsletter system improvements and validation framework
- Fix: Marketing events now display in Staff notifications tab - Reorganize: Move Coupons to Marketing/Coupons for better organization - Add: Comprehensive email/phone validation with extensible filter hooks - Email validation with regex pattern (xxxx@xxxx.xx) - Phone validation with WhatsApp verification support - Filter hooks for external API integration (QuickEmailVerification, etc.) - Fix: Newsletter template routes now use centralized notification email builder - Add: Validation.php class for reusable validation logic - Add: VALIDATION_HOOKS.md documentation with integration examples - Add: NEWSLETTER_CAMPAIGN_PLAN.md architecture for future campaign system - Fix: API delete method call in Newsletter.tsx (delete -> del) - Remove: Duplicate EmailTemplates.tsx (using notification system instead) - Update: Newsletter controller to use centralized Validation class Breaking changes: - Coupons routes moved from /routes/Coupons to /routes/Marketing/Coupons - Legacy /coupons routes maintained for backward compatibility
This commit is contained in:
470
NEWSLETTER_CAMPAIGN_PLAN.md
Normal file
470
NEWSLETTER_CAMPAIGN_PLAN.md
Normal file
@@ -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
|
||||||
293
VALIDATION_HOOKS.md
Normal file
293
VALIDATION_HOOKS.md
Normal file
@@ -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
|
||||||
@@ -18,9 +18,9 @@ import ProductEdit from '@/routes/Products/Edit';
|
|||||||
import ProductCategories from '@/routes/Products/Categories';
|
import ProductCategories from '@/routes/Products/Categories';
|
||||||
import ProductTags from '@/routes/Products/Tags';
|
import ProductTags from '@/routes/Products/Tags';
|
||||||
import ProductAttributes from '@/routes/Products/Attributes';
|
import ProductAttributes from '@/routes/Products/Attributes';
|
||||||
import CouponsIndex from '@/routes/Coupons';
|
import CouponsIndex from '@/routes/Marketing/Coupons';
|
||||||
import CouponNew from '@/routes/Coupons/New';
|
import CouponNew from '@/routes/Marketing/Coupons/New';
|
||||||
import CouponEdit from '@/routes/Coupons/Edit';
|
import CouponEdit from '@/routes/Marketing/Coupons/Edit';
|
||||||
import CustomersIndex from '@/routes/Customers';
|
import CustomersIndex from '@/routes/Customers';
|
||||||
import CustomerNew from '@/routes/Customers/New';
|
import CustomerNew from '@/routes/Customers/New';
|
||||||
import CustomerEdit from '@/routes/Customers/Edit';
|
import CustomerEdit from '@/routes/Customers/Edit';
|
||||||
@@ -250,7 +250,6 @@ import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
|||||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||||
import MarketingIndex from '@/routes/Marketing';
|
import MarketingIndex from '@/routes/Marketing';
|
||||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
|
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
|
||||||
import EmailTemplates from '@/routes/Marketing/EmailTemplates';
|
|
||||||
import MorePage from '@/routes/More';
|
import MorePage from '@/routes/More';
|
||||||
|
|
||||||
// Addon Route Component - Dynamically loads addon components
|
// Addon Route Component - Dynamically loads addon components
|
||||||
@@ -515,10 +514,13 @@ function AppRoutes() {
|
|||||||
<Route path="/orders/:id" element={<OrderDetail />} />
|
<Route path="/orders/:id" element={<OrderDetail />} />
|
||||||
<Route path="/orders/:id/edit" element={<OrderEdit />} />
|
<Route path="/orders/:id/edit" element={<OrderEdit />} />
|
||||||
|
|
||||||
{/* Coupons */}
|
{/* Coupons (under Marketing) */}
|
||||||
<Route path="/coupons" element={<CouponsIndex />} />
|
<Route path="/coupons" element={<CouponsIndex />} />
|
||||||
<Route path="/coupons/new" element={<CouponNew />} />
|
<Route path="/coupons/new" element={<CouponNew />} />
|
||||||
<Route path="/coupons/:id/edit" element={<CouponEdit />} />
|
<Route path="/coupons/:id/edit" element={<CouponEdit />} />
|
||||||
|
<Route path="/marketing/coupons" element={<CouponsIndex />} />
|
||||||
|
<Route path="/marketing/coupons/new" element={<CouponNew />} />
|
||||||
|
<Route path="/marketing/coupons/:id/edit" element={<CouponEdit />} />
|
||||||
|
|
||||||
{/* Customers */}
|
{/* Customers */}
|
||||||
<Route path="/customers" element={<CustomersIndex />} />
|
<Route path="/customers" element={<CustomersIndex />} />
|
||||||
@@ -565,7 +567,6 @@ function AppRoutes() {
|
|||||||
{/* Marketing */}
|
{/* Marketing */}
|
||||||
<Route path="/marketing" element={<MarketingIndex />} />
|
<Route path="/marketing" element={<MarketingIndex />} />
|
||||||
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
|
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
|
||||||
<Route path="/marketing/newsletter/template/:template" element={<EmailTemplates />} />
|
|
||||||
|
|
||||||
{/* Dynamic Addon Routes */}
|
{/* Dynamic Addon Routes */}
|
||||||
{addonRoutes.map((route: any) => (
|
{addonRoutes.map((route: any) => (
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<SettingsLayout
|
|
||||||
title={`Edit ${template === 'welcome' ? 'Welcome' : 'Confirmation'} Email Template`}
|
|
||||||
description="Customize the email template sent to newsletter subscribers"
|
|
||||||
>
|
|
||||||
<div className="mb-4">
|
|
||||||
<Button variant="ghost" onClick={() => navigate('/marketing/newsletter')}>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Newsletter
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SettingsCard
|
|
||||||
title="Email Template"
|
|
||||||
description="Use variables like {site_name}, {email}, {unsubscribe_url}"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="subject">Email Subject</Label>
|
|
||||||
<Input
|
|
||||||
id="subject"
|
|
||||||
value={subject}
|
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
|
||||||
placeholder="Welcome to {site_name} Newsletter!"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="content">Email Content</Label>
|
|
||||||
<Textarea
|
|
||||||
id="content"
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
rows={15}
|
|
||||||
placeholder="Thank you for subscribing to our newsletter! You'll receive updates about our latest products and offers. Best regards, {site_name}"
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
|
||||||
Available variables: <code>{'{site_name}'}</code>, <code>{'{email}'}</code>, <code>{'{unsubscribe_url}'}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={() => navigate('/marketing/newsletter')}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={saveTemplate.isPending}>
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
{saveTemplate.isPending ? 'Saving...' : 'Save Template'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
<SettingsCard
|
|
||||||
title="Preview"
|
|
||||||
description="Preview how your email will look"
|
|
||||||
>
|
|
||||||
<div className="border rounded-lg p-6 bg-muted/50">
|
|
||||||
<div className="mb-4">
|
|
||||||
<strong>Subject:</strong> {subject.replace('{site_name}', 'Your Store')}
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-pre-wrap">
|
|
||||||
{content.replace('{site_name}', 'Your Store').replace('{email}', 'customer@example.com')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,7 @@ export default function NewsletterSubscribers() {
|
|||||||
|
|
||||||
const deleteSubscriber = useMutation({
|
const deleteSubscriber = useMutation({
|
||||||
mutationFn: async (email: string) => {
|
mutationFn: async (email: string) => {
|
||||||
await api.delete(`/newsletter/subscribers/${encodeURIComponent(email)}`);
|
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
|
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
|
||||||
@@ -77,14 +77,14 @@ export default function NewsletterSubscribers() {
|
|||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Actions Bar */}
|
{/* Actions Bar */}
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div className="relative flex-1 max-w-sm">
|
<div className="relative flex-1 max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by email..."
|
placeholder="Filter subscribers..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-9"
|
className="!pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -175,7 +175,7 @@ export default function NewsletterSubscribers() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate('/settings/notifications/customer/newsletter_welcome/edit')}
|
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_welcome&channel=email&recipient=customer')}
|
||||||
>
|
>
|
||||||
Edit Template
|
Edit Template
|
||||||
</Button>
|
</Button>
|
||||||
@@ -189,7 +189,7 @@ export default function NewsletterSubscribers() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate('/settings/notifications/staff/newsletter_subscribed_admin/edit')}
|
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_subscribed_admin&channel=email&recipient=staff')}
|
||||||
>
|
>
|
||||||
Edit Template
|
Edit Template
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export default function NotificationEvents() {
|
|||||||
const orderEvents = eventsData?.orders || [];
|
const orderEvents = eventsData?.orders || [];
|
||||||
const productEvents = eventsData?.products || [];
|
const productEvents = eventsData?.products || [];
|
||||||
const customerEvents = eventsData?.customers || [];
|
const customerEvents = eventsData?.customers || [];
|
||||||
|
const marketingEvents = eventsData?.marketing || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -340,6 +341,77 @@ export default function NotificationEvents() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Marketing Events */}
|
||||||
|
{marketingEvents.length > 0 && (
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Marketing Events')}
|
||||||
|
description={__('Notifications about newsletter and marketing activities')}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{marketingEvents.map((event: NotificationEvent) => (
|
||||||
|
<div key={event.id} className="pb-6 border-b last:border-0">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="font-medium text-sm">{event.label}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{event.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{channels?.map((channel: NotificationChannel) => {
|
||||||
|
const channelEnabled = event.channels?.[channel.id]?.enabled || false;
|
||||||
|
const recipient = event.channels?.[channel.id]?.recipient || 'admin';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={channel.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg border bg-card"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${channelEnabled ? 'bg-green-500/20 text-green-600' : 'bg-muted text-muted-foreground'}`}>
|
||||||
|
{getChannelIcon(channel.id)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{channel.label}</span>
|
||||||
|
{channel.builtin && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{__('Built-in')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{channelEnabled && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{__('Send to')}: {recipient === 'admin' ? __('Admin') : recipient === 'customer' ? __('Customer') : __('Both')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{channelEnabled && channel.enabled && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openTemplateEditor(event.id, channel.id)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
checked={channelEnabled}
|
||||||
|
onCheckedChange={() => toggleChannel(event.id, channel.id, channelEnabled)}
|
||||||
|
disabled={!channel.enabled || updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
0
customer-spa/src/components/Layout/PageLayout.tsx
Normal file
0
customer-spa/src/components/Layout/PageLayout.tsx
Normal file
@@ -21,8 +21,9 @@ export function useWishlist() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [productIds, setProductIds] = useState<Set<number>>(new Set());
|
const [productIds, setProductIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// Check if wishlist is enabled
|
// Check if wishlist is enabled (default true if not explicitly set to false)
|
||||||
const isEnabled = (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false;
|
const settings = (window as any).woonoowCustomer?.settings;
|
||||||
|
const isEnabled = settings?.wishlist_enabled !== false;
|
||||||
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
|
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
|
||||||
|
|
||||||
// Load wishlist on mount
|
// Load wishlist on mount
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { ReactNode, useState } from 'react';
|
import React, { ReactNode, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 { useLayout } from '../contexts/ThemeContext';
|
||||||
import { useCartStore } from '../lib/cart/store';
|
import { useCartStore } from '../lib/cart/store';
|
||||||
import { useHeaderSettings, useFooterSettings } from '../hooks/useAppearanceSettings';
|
import { useHeaderSettings, useFooterSettings } from '../hooks/useAppearanceSettings';
|
||||||
@@ -130,6 +130,14 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Wishlist */}
|
||||||
|
{headerSettings.elements.wishlist && (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false && user?.isLoggedIn && (
|
||||||
|
<Link to="/my-account/wishlist" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||||
|
<Heart className="h-5 w-5" />
|
||||||
|
<span className="hidden lg:block">Wishlist</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Cart */}
|
{/* Cart */}
|
||||||
{headerSettings.elements.cart && (
|
{headerSettings.elements.cart && (
|
||||||
<Link to="/cart" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
<Link to="/cart" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||||
|
|||||||
@@ -50,41 +50,21 @@ export default function Addresses() {
|
|||||||
const loadAddresses = async () => {
|
const loadAddresses = async () => {
|
||||||
try {
|
try {
|
||||||
const response: any = await api.get('/account/addresses');
|
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
|
// Handle different response structures
|
||||||
let data: Address[] = [];
|
let data: Address[] = [];
|
||||||
|
|
||||||
if (Array.isArray(response)) {
|
if (Array.isArray(response)) {
|
||||||
// Direct array response
|
|
||||||
data = response;
|
data = response;
|
||||||
console.log('Using direct array');
|
|
||||||
} else if (response && typeof response === 'object') {
|
} 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)) {
|
if (Array.isArray(response.data)) {
|
||||||
data = response.data;
|
data = response.data;
|
||||||
console.log('Using response.data');
|
|
||||||
} else if (Array.isArray(response.addresses)) {
|
} else if (Array.isArray(response.addresses)) {
|
||||||
data = response.addresses;
|
data = response.addresses;
|
||||||
console.log('Using response.addresses');
|
|
||||||
} else if (response.length !== undefined && typeof response === 'object') {
|
} 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[];
|
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);
|
setAddresses(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Load addresses error:', error);
|
console.error('Load addresses error:', error);
|
||||||
@@ -124,16 +104,11 @@ export default function Addresses() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('Saving address:', formData);
|
|
||||||
if (editingAddress) {
|
if (editingAddress) {
|
||||||
console.log('Updating address ID:', editingAddress.id);
|
await api.put(`/account/addresses/${editingAddress.id}`, formData);
|
||||||
const response = await api.put(`/account/addresses/${editingAddress.id}`, formData);
|
|
||||||
console.log('Update response:', response);
|
|
||||||
toast.success('Address updated successfully');
|
toast.success('Address updated successfully');
|
||||||
} else {
|
} else {
|
||||||
console.log('Creating new address');
|
await api.post('/account/addresses', formData);
|
||||||
const response = await api.post('/account/addresses', formData);
|
|
||||||
console.log('Create response:', response);
|
|
||||||
toast.success('Address added successfully');
|
toast.success('Address added successfully');
|
||||||
}
|
}
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useCartStore } from '@/lib/cart/store';
|
import { useCartStore } from '@/lib/cart/store';
|
||||||
import { useProductSettings } from '@/hooks/useAppearanceSettings';
|
import { useProductSettings } from '@/hooks/useAppearanceSettings';
|
||||||
|
import { useWishlist } from '@/hooks/useWishlist';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import Container from '@/components/Layout/Container';
|
import Container from '@/components/Layout/Container';
|
||||||
import { ProductCard } from '@/components/ProductCard';
|
import { ProductCard } from '@/components/ProductCard';
|
||||||
@@ -23,6 +24,7 @@ export default function Product() {
|
|||||||
const [selectedAttributes, setSelectedAttributes] = useState<Record<string, string>>({});
|
const [selectedAttributes, setSelectedAttributes] = useState<Record<string, string>>({});
|
||||||
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
||||||
const { addItem } = useCartStore();
|
const { addItem } = useCartStore();
|
||||||
|
const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist, isLoggedIn } = useWishlist();
|
||||||
|
|
||||||
// Fetch product details by slug
|
// Fetch product details by slug
|
||||||
const { data: product, isLoading, error } = useQuery<ProductType | null>({
|
const { data: product, isLoading, error } = useQuery<ProductType | null>({
|
||||||
@@ -40,27 +42,19 @@ export default function Product() {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!product) return [];
|
if (!product) return [];
|
||||||
|
|
||||||
console.log('[Related Products] Fetching for product:', product.id);
|
|
||||||
console.log('[Related Products] Categories:', product.categories);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (product.related_ids && product.related_ids.length > 0) {
|
if (product.related_ids && product.related_ids.length > 0) {
|
||||||
const ids = product.related_ids.slice(0, 4).join(',');
|
const ids = product.related_ids.slice(0, 4).join(',');
|
||||||
console.log('[Related Products] Using related_ids:', ids);
|
|
||||||
const response = await apiClient.get<ProductsResponse>(`/shop/products?include=${ids}`);
|
const response = await apiClient.get<ProductsResponse>(`/shop/products?include=${ids}`);
|
||||||
console.log('[Related Products] Response:', response);
|
|
||||||
return response.products || [];
|
return response.products || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryId = product.categories?.[0]?.term_id || product.categories?.[0]?.id;
|
const categoryId = product.categories?.[0]?.term_id || product.categories?.[0]?.id;
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
console.log('[Related Products] Using category:', categoryId);
|
|
||||||
const response = await apiClient.get<ProductsResponse>(`/shop/products?category=${categoryId}&per_page=4&exclude=${product.id}`);
|
const response = await apiClient.get<ProductsResponse>(`/shop/products?category=${categoryId}&per_page=4&exclude=${product.id}`);
|
||||||
console.log('[Related Products] Response:', response.products?.length, 'products');
|
|
||||||
return response.products || [];
|
return response.products || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Related Products] No category found');
|
|
||||||
return [];
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch related products:', error);
|
console.error('Failed to fetch related products:', error);
|
||||||
@@ -70,15 +64,6 @@ export default function Product() {
|
|||||||
enabled: !!product?.id && elements.related_products,
|
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
|
// Set initial image when product loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (product && !selectedImage) {
|
if (product && !selectedImage) {
|
||||||
@@ -502,10 +487,21 @@ export default function Product() {
|
|||||||
<ShoppingCart className="h-5 w-5" />
|
<ShoppingCart className="h-5 w-5" />
|
||||||
Add to Cart
|
Add to Cart
|
||||||
</button>
|
</button>
|
||||||
<button className="w-full h-14 flex items-center justify-center gap-2 bg-white text-gray-900 rounded-xl font-semibold text-base border-2 border-gray-200 hover:border-gray-400 transition-all">
|
{wishlistEnabled && (
|
||||||
<Heart className="h-5 w-5" />
|
<button
|
||||||
Add to Wishlist
|
onClick={() => product && toggleWishlist(product.id)}
|
||||||
</button>
|
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${
|
||||||
|
product && isInWishlist(product.id)
|
||||||
|
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
|
||||||
|
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Heart className={`h-5 w-5 ${
|
||||||
|
product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
||||||
|
}`} />
|
||||||
|
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -694,7 +690,7 @@ export default function Product() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
<svg key={star} className={`w-5 h-5 ${star <= (product.average_rating || 0) ? 'text-yellow-400' : 'text-gray-300'} fill-current`} viewBox="0 0 20 20">
|
<svg key={star} className={`w-5 h-5 ${star <= Number(product.average_rating || 0) ? 'text-yellow-400' : 'text-gray-300'} fill-current`} viewBox="0 0 20 20">
|
||||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||||
</svg>
|
</svg>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface ProductCategory {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
|
term_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
@@ -31,6 +32,11 @@ export interface Product {
|
|||||||
attributes?: any[];
|
attributes?: any[];
|
||||||
variations?: number[];
|
variations?: number[];
|
||||||
permalink?: string;
|
permalink?: string;
|
||||||
|
related_ids?: number[];
|
||||||
|
virtual?: boolean;
|
||||||
|
downloadable?: boolean;
|
||||||
|
review_count?: number;
|
||||||
|
average_rating?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductsResponse {
|
export interface ProductsResponse {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace WooNooW\API;
|
|||||||
use WP_REST_Request;
|
use WP_REST_Request;
|
||||||
use WP_REST_Response;
|
use WP_REST_Response;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
|
use WooNooW\Core\Validation;
|
||||||
|
|
||||||
class NewsletterController {
|
class NewsletterController {
|
||||||
const API_NAMESPACE = 'woonoow/v1';
|
const API_NAMESPACE = 'woonoow/v1';
|
||||||
@@ -112,8 +113,11 @@ class NewsletterController {
|
|||||||
public static function subscribe(WP_REST_Request $request) {
|
public static function subscribe(WP_REST_Request $request) {
|
||||||
$email = sanitize_email($request->get_param('email'));
|
$email = sanitize_email($request->get_param('email'));
|
||||||
|
|
||||||
if (!is_email($email)) {
|
// Use centralized validation with extensible filter hooks
|
||||||
return new WP_Error('invalid_email', 'Invalid email address', ['status' => 400]);
|
$validation = Validation::validate_email($email, 'newsletter_subscribe');
|
||||||
|
|
||||||
|
if (is_wp_error($validation)) {
|
||||||
|
return $validation;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get existing subscribers (now stored as objects with metadata)
|
// Get existing subscribers (now stored as objects with metadata)
|
||||||
|
|||||||
@@ -68,19 +68,12 @@ class ProductsController {
|
|||||||
* Register REST API routes
|
* Register REST API routes
|
||||||
*/
|
*/
|
||||||
public static function register_routes() {
|
public static function register_routes() {
|
||||||
error_log('WooNooW ProductsController::register_routes() START');
|
|
||||||
|
|
||||||
// List products
|
// List products
|
||||||
$callback = [__CLASS__, 'get_products'];
|
register_rest_route('woonoow/v1', '/products', [
|
||||||
$is_callable = is_callable($callback);
|
|
||||||
error_log('WooNooW ProductsController: Callback is_callable: ' . ($is_callable ? 'YES' : 'NO'));
|
|
||||||
|
|
||||||
$result = register_rest_route('woonoow/v1', '/products', [
|
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => $callback,
|
'callback' => [__CLASS__, 'get_products'],
|
||||||
'permission_callback' => [Permissions::class, 'check_admin_permission'],
|
'permission_callback' => [Permissions::class, 'check_admin_permission'],
|
||||||
]);
|
]);
|
||||||
error_log('WooNooW ProductsController: GET /products registered: ' . ($result ? 'SUCCESS' : 'FAILED'));
|
|
||||||
|
|
||||||
// Get single product
|
// Get single product
|
||||||
register_rest_route('woonoow/v1', '/products/(?P<id>\d+)', [
|
register_rest_route('woonoow/v1', '/products/(?P<id>\d+)', [
|
||||||
@@ -136,8 +129,6 @@ class ProductsController {
|
|||||||
* Get products list with filters
|
* Get products list with filters
|
||||||
*/
|
*/
|
||||||
public static function get_products(WP_REST_Request $request) {
|
public static function get_products(WP_REST_Request $request) {
|
||||||
error_log('WooNooW ProductsController::get_products() CALLED - START');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$page = max(1, (int) $request->get_param('page'));
|
$page = max(1, (int) $request->get_param('page'));
|
||||||
$per_page = min(100, max(1, (int) ($request->get_param('per_page') ?: 20)));
|
$per_page = min(100, max(1, (int) ($request->get_param('per_page') ?: 20)));
|
||||||
@@ -206,12 +197,7 @@ class ProductsController {
|
|||||||
foreach ($query->posts as $post) {
|
foreach ($query->posts as $post) {
|
||||||
$product = wc_get_product($post->ID);
|
$product = wc_get_product($post->ID);
|
||||||
if ($product) {
|
if ($product) {
|
||||||
$formatted = self::format_product_list_item($product);
|
$products[] = self::format_product_list_item($product);
|
||||||
// Debug: Log first product to verify structure
|
|
||||||
if (empty($products)) {
|
|
||||||
error_log('WooNooW Debug - First product data: ' . print_r($formatted, true));
|
|
||||||
}
|
|
||||||
$products[] = $formatted;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,14 +214,10 @@ class ProductsController {
|
|||||||
$response->header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
$response->header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
$response->header('Pragma', 'no-cache');
|
$response->header('Pragma', 'no-cache');
|
||||||
$response->header('Expires', '0');
|
$response->header('Expires', '0');
|
||||||
$response->header('X-WooNooW-Version', '2.0'); // Debug header
|
|
||||||
|
|
||||||
error_log('WooNooW ProductsController::get_products() CALLED - END SUCCESS');
|
|
||||||
return $response;
|
return $response;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log('WooNooW ProductsController::get_products() ERROR: ' . $e->getMessage());
|
|
||||||
error_log('WooNooW ProductsController::get_products() TRACE: ' . $e->getTraceAsString());
|
|
||||||
return new WP_Error('products_error', $e->getMessage(), ['status' => 500]);
|
return new WP_Error('products_error', $e->getMessage(), ['status' => 500]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,17 +268,14 @@ class StoreController extends WP_REST_Controller {
|
|||||||
* @return WP_REST_Response|WP_Error Response object or error
|
* @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) {
|
||||||
error_log('WooNooW: get_customer_settings called');
|
|
||||||
try {
|
try {
|
||||||
$settings = CustomerSettingsProvider::get_settings();
|
$settings = CustomerSettingsProvider::get_settings();
|
||||||
error_log('WooNooW: Customer settings retrieved: ' . print_r($settings, true));
|
|
||||||
|
|
||||||
$response = rest_ensure_response($settings);
|
$response = rest_ensure_response($settings);
|
||||||
$response->header('Cache-Control', 'max-age=60');
|
$response->header('Cache-Control', 'max-age=60');
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log('WooNooW: get_customer_settings exception: ' . $e->getMessage());
|
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'get_customer_settings_failed',
|
'get_customer_settings_failed',
|
||||||
$e->getMessage(),
|
$e->getMessage(),
|
||||||
|
|||||||
@@ -39,46 +39,49 @@ class CustomerSettingsProvider {
|
|||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function update_settings($settings) {
|
public static function update_settings($settings) {
|
||||||
$updated = true;
|
|
||||||
|
|
||||||
// General settings
|
// General settings
|
||||||
if (isset($settings['auto_register_members'])) {
|
if (array_key_exists('auto_register_members', $settings)) {
|
||||||
$updated = $updated && update_option('woonoow_auto_register_members', $settings['auto_register_members'] ? 'yes' : 'no');
|
$value = !empty($settings['auto_register_members']) ? 'yes' : 'no';
|
||||||
|
update_option('woonoow_auto_register_members', $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($settings['multiple_addresses_enabled'])) {
|
if (array_key_exists('multiple_addresses_enabled', $settings)) {
|
||||||
$updated = $updated && update_option('woonoow_multiple_addresses_enabled', $settings['multiple_addresses_enabled'] ? 'yes' : 'no');
|
$value = !empty($settings['multiple_addresses_enabled']) ? 'yes' : 'no';
|
||||||
|
update_option('woonoow_multiple_addresses_enabled', $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($settings['wishlist_enabled'])) {
|
if (array_key_exists('wishlist_enabled', $settings)) {
|
||||||
$updated = $updated && update_option('woonoow_wishlist_enabled', $settings['wishlist_enabled'] ? 'yes' : 'no');
|
$value = !empty($settings['wishlist_enabled']) ? 'yes' : 'no';
|
||||||
|
update_option('woonoow_wishlist_enabled', $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// VIP settings
|
// VIP settings
|
||||||
if (isset($settings['vip_min_spent'])) {
|
if (isset($settings['vip_min_spent'])) {
|
||||||
$updated = $updated && update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent']));
|
update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent']));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($settings['vip_min_orders'])) {
|
if (isset($settings['vip_min_orders'])) {
|
||||||
$updated = $updated && update_option('woonoow_vip_min_orders', intval($settings['vip_min_orders']));
|
update_option('woonoow_vip_min_orders', intval($settings['vip_min_orders']));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($settings['vip_timeframe'])) {
|
if (isset($settings['vip_timeframe'])) {
|
||||||
$timeframe = in_array($settings['vip_timeframe'], ['all', '30', '90', '365'])
|
$timeframe = in_array($settings['vip_timeframe'], ['all', '30', '90', '365'])
|
||||||
? $settings['vip_timeframe']
|
? $settings['vip_timeframe']
|
||||||
: 'all';
|
: 'all';
|
||||||
$updated = $updated && update_option('woonoow_vip_timeframe', $timeframe);
|
update_option('woonoow_vip_timeframe', $timeframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($settings['vip_require_both'])) {
|
if (array_key_exists('vip_require_both', $settings)) {
|
||||||
$updated = $updated && update_option('woonoow_vip_require_both', $settings['vip_require_both'] ? 'yes' : 'no');
|
$value = !empty($settings['vip_require_both']) ? 'yes' : 'no';
|
||||||
|
update_option('woonoow_vip_require_both', $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($settings['vip_exclude_refunded'])) {
|
if (array_key_exists('vip_exclude_refunded', $settings)) {
|
||||||
$updated = $updated && update_option('woonoow_vip_exclude_refunded', $settings['vip_exclude_refunded'] ? 'yes' : 'no');
|
$value = !empty($settings['vip_exclude_refunded']) ? 'yes' : 'no';
|
||||||
|
update_option('woonoow_vip_exclude_refunded', $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $updated;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
218
includes/Core/Validation.php
Normal file
218
includes/Core/Validation.php
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
namespace WooNooW\Core;
|
||||||
|
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation utilities for WooNooW
|
||||||
|
*
|
||||||
|
* Provides extensible validation for emails, phone numbers, and other data types
|
||||||
|
* with filter hooks for external API integration.
|
||||||
|
*
|
||||||
|
* @package WooNooW\Core
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class Validation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email address with extensible filter hooks
|
||||||
|
*
|
||||||
|
* @param string $email Email address to validate
|
||||||
|
* @param string $context Context of validation (e.g., 'newsletter_subscribe', 'checkout', 'registration')
|
||||||
|
* @return true|WP_Error True if valid, WP_Error if invalid
|
||||||
|
*/
|
||||||
|
public static function validate_email($email, $context = 'general') {
|
||||||
|
$email = sanitize_email($email);
|
||||||
|
|
||||||
|
// Basic format validation
|
||||||
|
if (!is_email($email)) {
|
||||||
|
return new WP_Error('invalid_email', __('Invalid email address', 'woonoow'), ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced email validation with regex pattern (xxxx@xxxx.xx)
|
||||||
|
if (!preg_match('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/', $email)) {
|
||||||
|
return new WP_Error('invalid_email_format', __('Email must be in format: xxxx@xxxx.xx', 'woonoow'), ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter to validate email address.
|
||||||
|
*
|
||||||
|
* Allows addons to extend validation using external APIs like quickemailverification.com
|
||||||
|
*
|
||||||
|
* @param bool|WP_Error $is_valid True if valid, WP_Error if invalid
|
||||||
|
* @param string $email The email address to validate
|
||||||
|
* @param string $context The context of validation
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*
|
||||||
|
* Example usage in addon:
|
||||||
|
* ```php
|
||||||
|
* add_filter('woonoow/validate_email', function($is_valid, $email, $context) {
|
||||||
|
* if ($context !== 'newsletter_subscribe') return $is_valid;
|
||||||
|
*
|
||||||
|
* // Call external API (QuickEmailVerification)
|
||||||
|
* $api_key = get_option('woonoow_quickemail_api_key');
|
||||||
|
* if (!$api_key) return $is_valid;
|
||||||
|
*
|
||||||
|
* $response = wp_remote_get("https://api.quickemailverification.com/v1/verify?email={$email}&apikey={$api_key}");
|
||||||
|
*
|
||||||
|
* if (is_wp_error($response)) {
|
||||||
|
* return $is_valid; // Fallback to basic validation on API error
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* $data = json_decode(wp_remote_retrieve_body($response), true);
|
||||||
|
*
|
||||||
|
* if (isset($data['result']) && $data['result'] !== 'valid') {
|
||||||
|
* return new WP_Error('email_verification_failed', 'Email address could not be verified: ' . ($data['reason'] ?? 'Unknown'));
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* return true;
|
||||||
|
* }, 10, 3);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
$email_validation = apply_filters('woonoow/validate_email', true, $email, $context);
|
||||||
|
|
||||||
|
if (is_wp_error($email_validation)) {
|
||||||
|
return $email_validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($email_validation !== true) {
|
||||||
|
return new WP_Error('email_validation_failed', __('Email validation failed', 'woonoow'), ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate phone number with extensible filter hooks
|
||||||
|
*
|
||||||
|
* @param string $phone Phone number to validate
|
||||||
|
* @param string $context Context of validation (e.g., 'checkout', 'registration', 'shipping')
|
||||||
|
* @param string $country_code Optional country code (e.g., 'ID', 'US')
|
||||||
|
* @return true|WP_Error True if valid, WP_Error if invalid
|
||||||
|
*/
|
||||||
|
public static function validate_phone($phone, $context = 'general', $country_code = '') {
|
||||||
|
$phone = sanitize_text_field($phone);
|
||||||
|
|
||||||
|
// Remove common formatting characters
|
||||||
|
$clean_phone = preg_replace('/[\s\-\(\)\.]+/', '', $phone);
|
||||||
|
|
||||||
|
// Basic validation: must contain only digits, +, and be at least 8 characters
|
||||||
|
if (!preg_match('/^\+?[0-9]{8,15}$/', $clean_phone)) {
|
||||||
|
return new WP_Error('invalid_phone', __('Phone number must be 8-15 digits and may start with +', 'woonoow'), ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter to validate phone number.
|
||||||
|
*
|
||||||
|
* Allows addons to extend validation using external APIs or WhatsApp verification
|
||||||
|
*
|
||||||
|
* @param bool|WP_Error $is_valid True if valid, WP_Error if invalid
|
||||||
|
* @param string $phone The phone number to validate (cleaned)
|
||||||
|
* @param string $context The context of validation
|
||||||
|
* @param string $country_code Country code if available
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*
|
||||||
|
* Example usage for WhatsApp verification:
|
||||||
|
* ```php
|
||||||
|
* add_filter('woonoow/validate_phone', function($is_valid, $phone, $context, $country_code) {
|
||||||
|
* if ($context !== 'checkout') return $is_valid;
|
||||||
|
*
|
||||||
|
* // Check if number is registered on WhatsApp
|
||||||
|
* $api_key = get_option('woonoow_whatsapp_verify_api_key');
|
||||||
|
* if (!$api_key) return $is_valid;
|
||||||
|
*
|
||||||
|
* $response = wp_remote_post('https://api.whatsapp.com/v1/contacts', [
|
||||||
|
* 'headers' => ['Authorization' => 'Bearer ' . $api_key],
|
||||||
|
* 'body' => json_encode(['blocking' => 'wait', 'contacts' => [$phone]]),
|
||||||
|
* ]);
|
||||||
|
*
|
||||||
|
* if (is_wp_error($response)) {
|
||||||
|
* return $is_valid; // Fallback on API error
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* $data = json_decode(wp_remote_retrieve_body($response), true);
|
||||||
|
*
|
||||||
|
* if (!isset($data['contacts'][0]['wa_id'])) {
|
||||||
|
* return new WP_Error('phone_not_whatsapp', 'Phone number is not registered on WhatsApp');
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* return true;
|
||||||
|
* }, 10, 4);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Example usage for general phone validation API:
|
||||||
|
* ```php
|
||||||
|
* add_filter('woonoow/validate_phone', function($is_valid, $phone, $context, $country_code) {
|
||||||
|
* // Use numverify.com or similar service
|
||||||
|
* $api_key = get_option('woonoow_numverify_api_key');
|
||||||
|
* if (!$api_key) return $is_valid;
|
||||||
|
*
|
||||||
|
* $response = wp_remote_get("http://apilayer.net/api/validate?access_key={$api_key}&number={$phone}&country_code={$country_code}");
|
||||||
|
*
|
||||||
|
* 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', 'Phone number validation failed: ' . ($data['error'] ?? 'Invalid number'));
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* return true;
|
||||||
|
* }, 10, 4);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
$phone_validation = apply_filters('woonoow/validate_phone', true, $clean_phone, $context, $country_code);
|
||||||
|
|
||||||
|
if (is_wp_error($phone_validation)) {
|
||||||
|
return $phone_validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($phone_validation !== true) {
|
||||||
|
return new WP_Error('phone_validation_failed', __('Phone number validation failed', 'woonoow'), ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate phone number and check WhatsApp registration
|
||||||
|
*
|
||||||
|
* Convenience method that validates phone and checks WhatsApp in one call
|
||||||
|
*
|
||||||
|
* @param string $phone Phone number to validate
|
||||||
|
* @param string $context Context of validation
|
||||||
|
* @param string $country_code Optional country code
|
||||||
|
* @return true|WP_Error True if valid and registered on WhatsApp, WP_Error otherwise
|
||||||
|
*/
|
||||||
|
public static function validate_phone_whatsapp($phone, $context = 'general', $country_code = '') {
|
||||||
|
// First validate the phone number format
|
||||||
|
$validation = self::validate_phone($phone, $context, $country_code);
|
||||||
|
|
||||||
|
if (is_wp_error($validation)) {
|
||||||
|
return $validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean phone for WhatsApp check
|
||||||
|
$clean_phone = preg_replace('/[\s\-\(\)\.]+/', '', $phone);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter to check if phone is registered on WhatsApp
|
||||||
|
*
|
||||||
|
* @param bool|WP_Error $is_registered True if registered, WP_Error if not or error
|
||||||
|
* @param string $phone The phone number (cleaned)
|
||||||
|
* @param string $context The context of validation
|
||||||
|
* @param string $country_code Country code if available
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
$whatsapp_check = apply_filters('woonoow/validate_phone_whatsapp', true, $clean_phone, $context, $country_code);
|
||||||
|
|
||||||
|
if (is_wp_error($whatsapp_check)) {
|
||||||
|
return $whatsapp_check;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user