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:
Dwindi Ramadhana
2025-12-26 10:59:48 +07:00
parent 0b08ddefa1
commit 0b2c8a56d6
23 changed files with 1132 additions and 232 deletions

470
NEWSLETTER_CAMPAIGN_PLAN.md Normal file
View 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
View 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

View File

@@ -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() {
<Route path="/orders/:id" element={<OrderDetail />} />
<Route path="/orders/:id/edit" element={<OrderEdit />} />
{/* Coupons */}
{/* Coupons (under Marketing) */}
<Route path="/coupons" element={<CouponsIndex />} />
<Route path="/coupons/new" element={<CouponNew />} />
<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 */}
<Route path="/customers" element={<CustomersIndex />} />
@@ -565,7 +567,6 @@ function AppRoutes() {
{/* Marketing */}
<Route path="/marketing" element={<MarketingIndex />} />
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
<Route path="/marketing/newsletter/template/:template" element={<EmailTemplates />} />
{/* Dynamic Addon Routes */}
{addonRoutes.map((route: any) => (

View File

@@ -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!&#10;&#10;You'll receive updates about our latest products and offers.&#10;&#10;Best regards,&#10;{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>
);
}

View File

@@ -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() {
>
<div className="space-y-4">
{/* 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">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder="Search by email..."
placeholder="Filter subscribers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
className="!pl-9"
/>
</div>
<div className="flex gap-2">
@@ -175,7 +175,7 @@ export default function NewsletterSubscribers() {
<Button
variant="outline"
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
</Button>
@@ -189,7 +189,7 @@ export default function NewsletterSubscribers() {
<Button
variant="outline"
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
</Button>

View File

@@ -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 (
<div className="space-y-6">
@@ -340,6 +341,77 @@ export default function NotificationEvents() {
</div>
</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>
);
}

View File

@@ -21,8 +21,9 @@ export function useWishlist() {
const [isLoading, setIsLoading] = useState(false);
const [productIds, setProductIds] = useState<Set<number>>(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

View File

@@ -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) {
</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 */}
{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">

View File

@@ -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);

View File

@@ -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<Record<string, string>>({});
const thumbnailsRef = useRef<HTMLDivElement>(null);
const { addItem } = useCartStore();
const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist, isLoggedIn } = useWishlist();
// Fetch product details by slug
const { data: product, isLoading, error } = useQuery<ProductType | null>({
@@ -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<ProductsResponse>(`/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<ProductsResponse>(`/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() {
<ShoppingCart className="h-5 w-5" />
Add to Cart
</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">
<Heart className="h-5 w-5" />
Add to Wishlist
</button>
{wishlistEnabled && (
<button
onClick={() => product && toggleWishlist(product.id)}
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>
)}
@@ -694,7 +690,7 @@ export default function Product() {
<div className="flex items-center gap-2">
<div className="flex">
{[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" />
</svg>
))}

View File

@@ -7,6 +7,7 @@ export interface ProductCategory {
name: string;
slug: string;
count?: number;
term_id?: number;
}
export interface Product {
@@ -31,6 +32,11 @@ export interface Product {
attributes?: any[];
variations?: number[];
permalink?: string;
related_ids?: number[];
virtual?: boolean;
downloadable?: boolean;
review_count?: number;
average_rating?: string;
}
export interface ProductsResponse {

View File

@@ -4,6 +4,7 @@ namespace WooNooW\API;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WooNooW\Core\Validation;
class NewsletterController {
const API_NAMESPACE = 'woonoow/v1';
@@ -112,8 +113,11 @@ class NewsletterController {
public static function subscribe(WP_REST_Request $request) {
$email = sanitize_email($request->get_param('email'));
if (!is_email($email)) {
return new WP_Error('invalid_email', 'Invalid email address', ['status' => 400]);
// Use centralized validation with extensible filter hooks
$validation = Validation::validate_email($email, 'newsletter_subscribe');
if (is_wp_error($validation)) {
return $validation;
}
// Get existing subscribers (now stored as objects with metadata)

View File

@@ -68,19 +68,12 @@ class ProductsController {
* Register REST API routes
*/
public static function register_routes() {
error_log('WooNooW ProductsController::register_routes() START');
// List products
$callback = [__CLASS__, 'get_products'];
$is_callable = is_callable($callback);
error_log('WooNooW ProductsController: Callback is_callable: ' . ($is_callable ? 'YES' : 'NO'));
$result = register_rest_route('woonoow/v1', '/products', [
register_rest_route('woonoow/v1', '/products', [
'methods' => 'GET',
'callback' => $callback,
'callback' => [__CLASS__, 'get_products'],
'permission_callback' => [Permissions::class, 'check_admin_permission'],
]);
error_log('WooNooW ProductsController: GET /products registered: ' . ($result ? 'SUCCESS' : 'FAILED'));
// Get single product
register_rest_route('woonoow/v1', '/products/(?P<id>\d+)', [
@@ -136,8 +129,6 @@ class ProductsController {
* Get products list with filters
*/
public static function get_products(WP_REST_Request $request) {
error_log('WooNooW ProductsController::get_products() CALLED - START');
try {
$page = max(1, (int) $request->get_param('page'));
$per_page = min(100, max(1, (int) ($request->get_param('per_page') ?: 20)));
@@ -206,12 +197,7 @@ class ProductsController {
foreach ($query->posts as $post) {
$product = wc_get_product($post->ID);
if ($product) {
$formatted = 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;
$products[] = self::format_product_list_item($product);
}
}
@@ -228,14 +214,10 @@ class ProductsController {
$response->header('Cache-Control', 'no-cache, no-store, must-revalidate');
$response->header('Pragma', 'no-cache');
$response->header('Expires', '0');
$response->header('X-WooNooW-Version', '2.0'); // Debug header
error_log('WooNooW ProductsController::get_products() CALLED - END SUCCESS');
return $response;
} 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]);
}
}

View File

@@ -268,17 +268,14 @@ class StoreController extends WP_REST_Controller {
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_customer_settings(WP_REST_Request $request) {
error_log('WooNooW: get_customer_settings called');
try {
$settings = CustomerSettingsProvider::get_settings();
error_log('WooNooW: Customer settings retrieved: ' . print_r($settings, true));
$response = rest_ensure_response($settings);
$response->header('Cache-Control', 'max-age=60');
return $response;
} catch (\Exception $e) {
error_log('WooNooW: get_customer_settings exception: ' . $e->getMessage());
return new WP_Error(
'get_customer_settings_failed',
$e->getMessage(),

View File

@@ -39,46 +39,49 @@ class CustomerSettingsProvider {
* @return bool
*/
public static function update_settings($settings) {
$updated = true;
// General settings
if (isset($settings['auto_register_members'])) {
$updated = $updated && update_option('woonoow_auto_register_members', $settings['auto_register_members'] ? 'yes' : 'no');
if (array_key_exists('auto_register_members', $settings)) {
$value = !empty($settings['auto_register_members']) ? 'yes' : 'no';
update_option('woonoow_auto_register_members', $value);
}
if (isset($settings['multiple_addresses_enabled'])) {
$updated = $updated && update_option('woonoow_multiple_addresses_enabled', $settings['multiple_addresses_enabled'] ? 'yes' : 'no');
if (array_key_exists('multiple_addresses_enabled', $settings)) {
$value = !empty($settings['multiple_addresses_enabled']) ? 'yes' : 'no';
update_option('woonoow_multiple_addresses_enabled', $value);
}
if (isset($settings['wishlist_enabled'])) {
$updated = $updated && update_option('woonoow_wishlist_enabled', $settings['wishlist_enabled'] ? 'yes' : 'no');
if (array_key_exists('wishlist_enabled', $settings)) {
$value = !empty($settings['wishlist_enabled']) ? 'yes' : 'no';
update_option('woonoow_wishlist_enabled', $value);
}
// VIP settings
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'])) {
$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'])) {
$timeframe = in_array($settings['vip_timeframe'], ['all', '30', '90', '365'])
? $settings['vip_timeframe']
: 'all';
$updated = $updated && update_option('woonoow_vip_timeframe', $timeframe);
update_option('woonoow_vip_timeframe', $timeframe);
}
if (isset($settings['vip_require_both'])) {
$updated = $updated && update_option('woonoow_vip_require_both', $settings['vip_require_both'] ? 'yes' : 'no');
if (array_key_exists('vip_require_both', $settings)) {
$value = !empty($settings['vip_require_both']) ? 'yes' : 'no';
update_option('woonoow_vip_require_both', $value);
}
if (isset($settings['vip_exclude_refunded'])) {
$updated = $updated && update_option('woonoow_vip_exclude_refunded', $settings['vip_exclude_refunded'] ? 'yes' : 'no');
if (array_key_exists('vip_exclude_refunded', $settings)) {
$value = !empty($settings['vip_exclude_refunded']) ? 'yes' : 'no';
update_option('woonoow_vip_exclude_refunded', $value);
}
return $updated;
return true;
}
/**

View 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;
}
}