diff --git a/.agent/reports/email-notification-audit-2026-01-29.md b/.agent/reports/email-notification-audit-2026-01-29.md
new file mode 100644
index 0000000..f86fefb
--- /dev/null
+++ b/.agent/reports/email-notification-audit-2026-01-29.md
@@ -0,0 +1,228 @@
+# Email Notification System Audit
+
+**Date:** January 29, 2026
+**Status:** ✅ System Architecture Sound, Minor Issues Identified
+
+---
+
+## Executive Summary
+
+The WooNooW email notification system is **well-architected** with proper async handling, template rendering, and event management. The main components work together correctly. However, some potential gaps and improvements were identified.
+
+---
+
+## System Architecture
+
+```mermaid
+flowchart TD
+ A[WooCommerce Hooks] --> B[EmailManager]
+ B --> C{Is WooNooW Mode?}
+ C -->|Yes| D[EmailRenderer]
+ C -->|No| E[WC Default Emails]
+ D --> F[TemplateProvider]
+ F --> G[Get Template]
+ G --> H[Replace Variables]
+ H --> I[Parse Markdown/Cards]
+ I --> J[wp_mail]
+ J --> K[WooEmailOverride Intercepts]
+ K --> L[MailQueue::enqueue]
+ L --> M[Action Scheduler]
+ M --> N[MailQueue::sendNow]
+ N --> O[Actual wp_mail]
+```
+
+---
+
+## Core Components
+
+| File | Purpose |
+|------|---------|
+| [EmailManager.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailManager.php) | Hooks WC order events, disables WC emails, routes to renderer |
+| [EmailRenderer.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php) | Renders templates, replaces variables, parses markdown |
+| [TemplateProvider.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/TemplateProvider.php) | Manages templates, defaults, variable definitions |
+| [EventRegistry.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EventRegistry.php) | Central registry of all notification events |
+| [NotificationManager.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/NotificationManager.php) | Validates settings, dispatches to channels |
+| [WooEmailOverride.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Mail/WooEmailOverride.php) | Intercepts wp_mail via `pre_wp_mail` filter |
+| [MailQueue.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Mail/MailQueue.php) | Async queue via Action Scheduler |
+
+---
+
+## Email Flow Trace
+
+### 1. Event Trigger
+- WooCommerce fires hooks like `woocommerce_order_status_pending_to_processing`
+- `EmailManager::init_hooks()` registers callbacks for these hooks
+
+### 2. EmailManager Processing
+```php
+// In EmailManager.php
+add_action('woocommerce_order_status_pending_to_processing', [$this, 'send_order_processing_email']);
+```
+- Checks if WooNooW mode enabled: `is_enabled()`
+- Checks if event enabled: `is_event_enabled()`
+- Calls `send_email($event_id, $recipient_type, $order)`
+
+### 3. Email Rendering
+- `EmailRenderer::render()` called
+- Gets template from `TemplateProvider::get_template()`
+- Gets variables from `get_variables()` (order, customer, product data)
+- Replaces `{variable}` placeholders
+- Parses `[card]` markdown syntax
+- Wraps in HTML template from `templates/emails/base.html`
+
+### 4. wp_mail Interception
+- `wp_mail()` is called with rendered HTML
+- `WooEmailOverride::interceptMail()` catches via `pre_wp_mail` filter
+- Returns `true` to short-circuit synchronous send
+
+### 5. Queue & Async Send
+- `MailQueue::enqueue()` stores payload in `wp_options` (temp)
+- Schedules `woonoow/mail/send` action via Action Scheduler
+- `MailQueue::sendNow()` runs asynchronously:
+ - Retrieves payload from options
+ - Disables `WooEmailOverride` to prevent loop
+ - Calls actual `wp_mail()`
+ - Deletes temp option
+
+---
+
+## Findings
+
+### ✅ Working Correctly
+
+1. **Async Email Queue**: Properly prevents timeout issues
+2. **Template System**: Variables replaced correctly
+3. **Event Registry**: Single source of truth
+4. **Subscription Events**: Registered via `woonoow_notification_events_registry` filter
+5. **Global Toggle**: WooNooW vs WooCommerce mode works
+6. **WC Email Disable**: Default emails properly disabled when WooNooW active
+
+### ⚠️ Potential Issues
+
+#### 1. Missing Subscription Variable Population in EmailRenderer
+**Location:** [EmailRenderer.php:147-299](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php#L147-L299)
+
+**Issue:** `get_variables()` handles `WC_Order`, `WC_Product`, `WC_Customer` but NOT subscription objects. Subscription notifications pass data like:
+```php
+$data = [
+ 'subscription' => $subscription, // Custom subscription object
+ 'customer' => $user,
+ 'product' => $product,
+ ...
+]
+```
+
+**Impact:** Subscription email variables like `{subscription_id}`, `{billing_period}`, `{next_payment_date}` may not be replaced.
+
+**Recommendation:** Add subscription variable population in `EmailRenderer::get_variables()`.
+
+---
+
+#### 2. EmailRenderer Type Check for Subscription
+**Location:** [EmailRenderer.php:121-137](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php#L121-L137)
+
+**Issue:** `get_recipient_email()` only checks for `WC_Order` and `WC_Customer`. For subscriptions, `$data` is an array, so recipient email extraction fails.
+
+**Impact:** Subscription emails may not find recipient email.
+
+**Recommendation:** Handle array data or subscription object in `get_recipient_email()`.
+
+---
+
+#### 3. SubscriptionModule Sends to NotificationManager, Not EmailManager
+**Location:** [SubscriptionModule.php:529-531](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionModule.php#L529-L531)
+
+**Code:**
+```php
+\WooNooW\Core\Notifications\NotificationManager::send($event_id, 'email', $data);
+```
+
+**Issue:** This goes through `NotificationManager`, which calls its own `send_email()` that uses `EmailRenderer::render()`. The `EmailRenderer::render()` method receives `$data['subscription']` but doesn't know how to handle it.
+
+**Impact:** Subscription email rendering may fail silently.
+
+---
+
+#### 4. No Error Logging in Email Rendering Failures
+**Location:** [EmailRenderer.php:48-57](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php#L48-L57)
+
+**Issue:** When `get_template_settings()` returns null or `get_recipient_email()` returns null, the function returns null silently with only an empty debug log statement.
+
+**Recommendation:** Add proper `error_log()` calls for debugging.
+
+---
+
+#### 5. Duplicate wp_mail Calls
+**Location:** Multiple places call `wp_mail()` directly:
+- `EmailManager::send_email()` (line 521)
+- `EmailManager::send_password_reset_email()` (line 406)
+- `NotificationManager::send_email()` (line 170)
+- `NotificationsController` test endpoint (line 1013)
+- `CampaignManager` (lines 275, 329)
+- `NewsletterController` (line 203)
+
+**Issue:** All these are intercepted by `WooEmailOverride`, which is correct. However, if `WooEmailOverride` is disabled (testing mode), all send synchronously.
+
+**Status:** Working as designed.
+
+---
+
+## Subscription Email Gap Analysis
+
+The subscription module has these events defined but needs variable population:
+
+| Event | Variables Needed |
+|-------|-----------------|
+| `subscription_pending_cancellation` | subscription_id, product_name, end_date |
+| `subscription_cancelled` | subscription_id, cancel_reason |
+| `subscription_expired` | subscription_id, product_name |
+| `subscription_paused` | subscription_id, product_name |
+| `subscription_resumed` | subscription_id, product_name |
+| `subscription_renewal_failed` | subscription_id, failed_count, payment_link |
+| `subscription_renewal_payment_due` | subscription_id, payment_link |
+| `subscription_renewal_reminder` | subscription_id, next_payment_date |
+
+**Required Fix:** Add subscription data handling to `EmailRenderer::get_variables()`.
+
+---
+
+## Recommendations
+
+### High Priority
+
+1. **Fix `EmailRenderer::get_variables()`** - Add handling for subscription data arrays
+2. **Fix `EmailRenderer::get_recipient_email()`** - Handle array data with customer key
+
+### Medium Priority
+
+3. **Add error logging** - Replace empty debug conditions with actual logging
+4. **Clean up debug conditions** - Many `if (defined('WP_DEBUG') && WP_DEBUG) {}` are empty
+
+### Low Priority
+
+5. **Consolidate email sending paths** - Consider routing all through one method
+6. **Add email send failure tracking** - Log failed sends for troubleshooting
+
+---
+
+## Test Scripts Available
+
+| Script | Purpose |
+|--------|---------|
+| `check-settings.php` | Diagnose notification settings |
+| `test-email-flow.php` | Interactive email testing dashboard |
+| `test-email-direct.php` | Direct wp_mail testing |
+
+---
+
+## Documentation
+
+Comprehensive docs exist:
+- [NOTIFICATION_SYSTEM.md](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/NOTIFICATION_SYSTEM.md)
+- [EMAIL_DEBUGGING_GUIDE.md](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/EMAIL_DEBUGGING_GUIDE.md)
+
+---
+
+## Conclusion
+
+The email notification system is **production-ready** for order-related notifications. The main gap is **subscription email variable population**, which requires updates to `EmailRenderer.php` to properly handle subscription data and extract variables.
diff --git a/.agent/reports/license-activation-research-2026-01-31.md b/.agent/reports/license-activation-research-2026-01-31.md
new file mode 100644
index 0000000..275232d
--- /dev/null
+++ b/.agent/reports/license-activation-research-2026-01-31.md
@@ -0,0 +1,391 @@
+# OAuth-Style License Activation Research Report
+
+**Date:** January 31, 2026
+**Objective:** Design a strict license activation system requiring vendor site authentication
+
+---
+
+## Executive Summary
+
+After researching Elementor Pro, Tutor LMS, EDD Software Licensing, and industry standards, the **redirect-based OAuth-like activation flow** is the most secure and user-friendly approach. This pattern:
+- Prevents license key sharing by tying activation to user accounts
+- Provides better UX than manual key entry
+- Enables flexible license management
+- Creates an anti-piracy layer beyond just key validation
+
+---
+
+## Industry Analysis
+
+### 1. Elementor Pro
+
+| Aspect | Implementation |
+|--------|----------------|
+| **Flow** | "Connect & Activate" button → redirect to Elementor.com → login required → authorize connection → return to WP Admin |
+| **Why Listed** | Market leader with 5M+ users; sets the standard for premium plugin activation |
+| **Anti-Piracy** | Account-tied activation; no ability to share just a license key |
+| **Fallback** | Manual key entry via hidden URL parameter `?mode=manually` |
+
+**Key Pattern:** Elementor never shows the license key in the normal flow—users authenticate with their account, not a key.
+
+---
+
+### 2. Tutor LMS (Themeum)
+
+| Aspect | Implementation |
+|--------|----------------|
+| **Flow** | License settings → Enter key → "Connect" button → redirect to Themeum → login → confirm connection |
+| **Why Listed** | Popular LMS plugin; hybrid approach (key + account verification) |
+| **Anti-Piracy** | License keys tied to specific domains registered in user account |
+| **License Display** | Keys visible in account dashboard for copy-paste |
+
+**Key Pattern:** Requires domain registration in vendor account before activation works.
+
+---
+
+### 3. Easy Digital Downloads (EDD) Software Licensing
+
+| Aspect | Implementation |
+|--------|----------------|
+| **Flow** | API-based: plugin sends key + site URL to vendor → server validates → returns activation status |
+| **Why Listed** | Powers many WordPress plugin vendors (WPForms, MonsterInsights, etc.) |
+| **Anti-Piracy** | Activation limits (e.g., 1 site, 5 sites, unlimited); site URL tracking |
+| **Management** | Customer can manage activations in their EDD account |
+
+**Key Pattern:** Traditional key-based but with strict activation limits and site tracking.
+
+---
+
+### 4. WooCommerce Software License Manager
+
+| Aspect | Implementation |
+|--------|----------------|
+| **Flow** | REST API with key + secret authentication |
+| **Why Listed** | Common for WooCommerce-based vendors |
+| **Anti-Piracy** | API-key authentication; activation records |
+
+**Key Pattern:** Programmatic API access, less user-facing UX focus.
+
+---
+
+## Best Practices Identified
+
+### Anti-Piracy Measures
+
+| Measure | Effectiveness | UX Impact |
+|---------|---------------|-----------|
+| **Account authentication required** | ★★★★★ | Minor inconvenience |
+| **Activation limits per license** | ★★★★☆ | None |
+| **Domain/URL binding** | ★★★★☆ | None |
+| **Tying updates/support to valid license** | ★★★★★ | Incentivizes purchase |
+| **Periodic license re-validation** | ★★★☆☆ | Can cause issues |
+| **Encrypted API communication (HTTPS)** | ★★★★★ | None |
+
+### UX Considerations
+
+| Consideration | Priority |
+|---------------|----------|
+| One-click activation (minimal friction) | High |
+| Clear error messages | High |
+| License status visibility in WP Admin | Medium |
+| Easy deactivation for site migrations | High |
+| Fallback manual activation | Medium |
+
+---
+
+## Security Comparison
+
+| Method | Piracy Resistance | Implementation Complexity |
+|--------|-------------------|---------------------------|
+| **Simple key validation** | Low | Simple |
+| **Key + site URL binding** | Medium | Medium |
+| **Key + activation limits** | Medium-High | Medium |
+| **OAuth redirect + account tie** | High | Complex |
+| **OAuth + key + activation limits** | Very High | Complex |
+
+---
+
+## Your Proposed Flow Analysis
+
+### Original Flow Points
+
+1. User navigates to license page → clicks [ACTIVATE]
+2. Redirect to vendor site (licensing.woonoow.com or similar)
+3. Vendor site: login required
+4. Vendor shows licenses for user's account, filtered by product
+5. User selects license to connect
+6. Click "Connect This Site"
+7. Return to `return_url` after short delay
+
+### Identified Gaps
+
+| Gap | Risk | Solution |
+|-----|------|----------|
+| No state parameter | CSRF attack possible | Add signed `state` token |
+| No nonce verification | Replay attacks | Include one-time nonce |
+| Return URL manipulation | Redirect hijacking | Validate return URL on server |
+| No deactivation flow | User can't migrate | Add disconnect button |
+
+---
+
+## Perfected Implementation Plan
+
+### Architecture
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ WP ADMIN (Client Site) │
+├─────────────────────────────────────────────────────────────────┤
+│ Settings → License │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ Status: Not Connected │ │
+│ │ [🔗 Connect & Activate] │ │
+│ └──────────────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────────────────┘
+ │
+ ▼ Redirect with signed params
+┌──────────────────────────────────────────────────────────────────┐
+│ VENDOR SITE (License Server) │
+├──────────────────────────────────────────────────────────────────┤
+│ /license/connect? │
+│ product_id=woonoow-pro& │
+│ site_url=https://customer-site.com& │
+│ return_url=https://customer-site.com/wp-admin/...& │
+│ state=& │
+│ nonce= │
+├──────────────────────────────────────────────────────────────────┤
+│ 1. Force login if not authenticated │
+│ 2. Show licenses owned by user for this product │
+│ 3. User selects: "Pro License (3/5 sites used)" │
+│ 4. Click [Connect This Site] │
+│ 5. Server records activation │
+│ 6. Redirect back with activation token │
+└──────────────────────────────────────────────────────────────────┘
+ │
+ ▼ Callback with activation token
+┌──────────────────────────────────────────────────────────────────┐
+│ WP ADMIN (Client Site) │
+├──────────────────────────────────────────────────────────────────┤
+│ Callback handler: │
+│ 1. Verify state matches stored value │
+│ 2. Exchange activation_token for license_key via API │
+│ 3. Store license_key securely │
+│ 4. Show success: "License activated successfully!" │
+│ │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ Status: ✅ Active │ │
+│ │ License: Pro (expires Dec 31, 2026) │ │
+│ │ Sites: 4/5 activated │ │
+│ │ [Disconnect] │ │
+│ └──────────────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+### Detailed Flow
+
+#### Phase 1: Initiation (Client Plugin)
+
+```php
+// User clicks "Connect & Activate"
+$params = [
+ 'product_id' => 'woonoow-pro',
+ 'site_url' => home_url(),
+ 'return_url' => admin_url('admin.php?page=woonoow-license&action=callback'),
+ 'nonce' => wp_create_nonce('woonoow_license_connect'),
+ 'state' => $this->generate_state_token(), // Signed, stored in transient
+ 'timestamp' => time(),
+];
+
+$redirect_url = 'https://licensing.woonoow.com/connect?' . http_build_query($params);
+wp_redirect($redirect_url);
+```
+
+#### Phase 2: Authentication (Vendor Server)
+
+1. **Login Gate**: If user not logged in → redirect to login with `?redirect=/connect?...`
+2. **Validate Request**: Check `state`, `nonce`, `timestamp` (reject if >10 min old)
+3. **Fetch User Licenses**: Query licenses owned by authenticated user for `product_id`
+4. **Display License Selector**:
+ ```
+ ┌─────────────────────────────────────────────────┐
+ │ Connect site-name.com to your license │
+ ├─────────────────────────────────────────────────┤
+ │ ○ WooNooW Pro - Agency (Unlimited sites) │
+ │ ● WooNooW Pro - Business (3/5 sites) ←selected │
+ │ ○ WooNooW Pro - Personal (1/1 sites) [FULL] │
+ ├─────────────────────────────────────────────────┤
+ │ [Cancel] [Connect This Site] │
+ └─────────────────────────────────────────────────┘
+ ```
+5. **Record Activation**: Insert into `license_activations` table
+6. **Generate Callback**: Redirect to `return_url` with:
+ - `activation_token`: Short-lived token (5 min expiry)
+ - `state`: Original state for verification
+
+#### Phase 3: Callback (Client Plugin)
+
+```php
+// Handle callback
+$activation_token = sanitize_text_field($_GET['activation_token']);
+$state = sanitize_text_field($_GET['state']);
+
+// 1. Verify state matches stored transient
+if (!$this->verify_state_token($state)) {
+ wp_die('Invalid state. Possible CSRF attack.');
+}
+
+// 2. Exchange token for license details via secure API
+$response = wp_remote_post('https://licensing.woonoow.com/api/v1/token/exchange', [
+ 'body' => [
+ 'activation_token' => $activation_token,
+ 'site_url' => home_url(),
+ ],
+]);
+
+// 3. Store license data
+$license_data = json_decode(wp_remote_retrieve_body($response), true);
+update_option('woonoow_license', [
+ 'key' => $license_data['license_key'],
+ 'status' => 'active',
+ 'expires' => $license_data['expires_at'],
+ 'tier' => $license_data['tier'],
+ 'sites_used' => $license_data['sites_used'],
+ 'sites_max' => $license_data['sites_max'],
+]);
+
+// 4. Redirect with success
+wp_redirect(admin_url('admin.php?page=woonoow-license&activated=1'));
+```
+
+---
+
+### Security Parameters
+
+| Parameter | Purpose | Implementation |
+|-----------|---------|----------------|
+| `state` | CSRF protection | HMAC-signed, stored in transient, expires 10 min |
+| `nonce` | Replay prevention | One-time use, verified on server |
+| `timestamp` | Request freshness | Reject requests >10 min old |
+| `activation_token` | Secure exchange | Short-lived (5 min), single-use |
+| `site_url` | Domain binding | Stored with activation record |
+
+---
+
+### Database Schema (Vendor Server)
+
+```sql
+CREATE TABLE license_activations (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ license_id BIGINT NOT NULL,
+ site_url VARCHAR(255) NOT NULL,
+ activation_token VARCHAR(64),
+ token_expires_at DATETIME,
+ activated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_check DATETIME,
+ status ENUM('active', 'deactivated') DEFAULT 'active',
+ metadata JSON,
+ UNIQUE KEY unique_license_site (license_id, site_url),
+ FOREIGN KEY (license_id) REFERENCES licenses(id)
+);
+```
+
+---
+
+### Deactivation Flow
+
+```
+Client: [Disconnect] button clicked
+ → POST /api/v1/license/deactivate
+ → Body: { license_key, site_url }
+ → Server removes activation record
+ → Client clears stored license
+ → Show "Disconnected" status
+```
+
+---
+
+### Periodic Validation
+
+```php
+// Cron check every 24 hours
+add_action('woonoow_daily_license_check', function() {
+ $license = get_option('woonoow_license');
+ if (!$license) return;
+
+ $response = wp_remote_post('https://licensing.woonoow.com/api/v1/license/validate', [
+ 'body' => [
+ 'license_key' => $license['key'],
+ 'site_url' => home_url(),
+ ],
+ ]);
+
+ $data = json_decode(wp_remote_retrieve_body($response), true);
+
+ if ($data['status'] !== 'active') {
+ update_option('woonoow_license', ['status' => 'invalid']);
+ // Optionally disable premium features
+ }
+});
+```
+
+---
+
+## API Endpoints (Vendor Server)
+
+| Endpoint | Method | Purpose |
+|----------|--------|---------|
+| `/connect` | GET | OAuth-like authorization page |
+| `/api/v1/token/exchange` | POST | Exchange activation token for license |
+| `/api/v1/license/validate` | POST | Validate license status |
+| `/api/v1/license/deactivate` | POST | Remove site activation |
+| `/api/v1/license/info` | GET | Get license details |
+
+---
+
+## Comparison: Your Flow vs. Perfected
+
+| Aspect | Your Original | Perfected |
+|--------|---------------|-----------|
+| CSRF Protection | ❌ None | ✅ State token |
+| Replay Prevention | ❌ None | ✅ Nonce + timestamp |
+| Token Exchange | ❌ Direct return | ✅ Secure exchange |
+| Return URL Security | ❌ Unvalidated | ✅ Server whitelist |
+| Deactivation | ❌ Not mentioned | ✅ Full flow |
+| Periodic Validation | ❌ Not mentioned | ✅ Daily cron |
+| Fallback | ❌ None | ✅ Manual key entry |
+
+---
+
+## Implementation Phases
+
+### Phase 1: Server-Side (Licensing Portal)
+1. Create `/connect` authorization page
+2. Build license selection UI
+3. Implement activation recording
+4. Create token exchange API
+
+### Phase 2: Client-Side (WooNooW Plugin)
+1. Create Settings → License admin page
+2. Implement connect redirect
+3. Handle callback and token exchange
+4. Store license securely
+5. Add disconnect functionality
+
+### Phase 3: Validation & Updates
+1. Implement periodic license checks
+2. Gate premium features behind valid license
+3. Integrate with plugin update checker
+
+---
+
+## References
+
+| Source | Relevance |
+|--------|-----------|
+| Elementor Pro Activation | Primary reference for UX flow |
+| Tutor LMS / Themeum | Hybrid key+account approach |
+| OAuth 2.0 Authorization Code Flow | Security pattern basis |
+| EDD Software Licensing | Activation limits pattern |
+| OWASP API Security | State/nonce implementation |
diff --git a/.agent/reports/product-flow-audit-2026-01-29.md b/.agent/reports/product-flow-audit-2026-01-29.md
new file mode 100644
index 0000000..85bdb0d
--- /dev/null
+++ b/.agent/reports/product-flow-audit-2026-01-29.md
@@ -0,0 +1,212 @@
+# Product Create/Update Flow Audit Report
+
+**Date:** 2026-01-29
+**Scope:** Full trace of product creation, update, SKU validation, variation handling, virtual product setting, and customer-facing add-to-cart
+
+---
+
+## Executive Summary
+
+**Total Issues Found: 4**
+- **CRITICAL:** 2
+- **WARNING:** 1
+- **INFO:** 1
+
+---
+
+## Critical Issues
+
+### 🔴 Issue #1: SKU Validation Blocks Variation Updates
+
+**Severity:** CRITICAL
+**Location:** [ProductsController.php#L1009](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/ProductsController.php#L1009)
+
+**Problem:**
+When updating a variable product, the `save_product_variations` method sets SKU unconditionally:
+```php
+if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']);
+```
+
+WooCommerce validates that SKU must be unique across all products. When updating a variation that already has that SKU, WooCommerce throws an exception because it sees the SKU as a duplicate.
+
+**Root Cause:**
+WooCommerce's `set_sku()` method checks for uniqueness but doesn't know the variation already owns that SKU during the update.
+
+**Fix Required:**
+Before setting SKU, check if the new SKU is the same as the current SKU:
+```php
+if (isset($var_data['sku'])) {
+ $current_sku = $variation->get_sku();
+ $new_sku = $var_data['sku'];
+ // Only set if different (to avoid WC duplicate check issue)
+ if ($current_sku !== $new_sku) {
+ $variation->set_sku($new_sku);
+ }
+}
+```
+
+---
+
+### 🔴 Issue #2: Variation Selection Fails (Attribute Format Mismatch)
+
+**Severity:** CRITICAL
+**Location:**
+- Backend: [ShopController.php#L363-365](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Frontend/ShopController.php#L363)
+- Frontend: [Product/index.tsx#L97-127](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/Product/index.tsx#L97)
+
+**Problem:**
+"Please select all product options" error appears even when a variation is selected.
+
+**Root Cause:**
+Format mismatch between backend API and frontend matching logic:
+
+| Source | Format | Example |
+|--------|--------|---------|
+| API `variations.attributes` | `attribute_pa_color: "red"` | Lowercase, prefixed |
+| API `attributes` | `name: "Color"` | Human-readable |
+| Frontend `selectedAttributes` | `Color: "Red"` | Human-readable, case preserved |
+
+The matching logic at lines 100-120 has complex normalization but may fail at edge cases:
+- Taxonomy attributes use `pa_` prefix (e.g., `attribute_pa_color`)
+- Custom attributes use direct prefix (e.g., `attribute_size`)
+- The comparison normalizes both sides but attribute names in `selectedAttributes` are human-readable labels
+
+**Fix Required:**
+Improve variation matching by normalizing attribute names consistently:
+
+```typescript
+// In find matching variation logic:
+const variation = (product.variations as any[]).find(v => {
+ return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
+ const normalizedAttrName = attrName.toLowerCase();
+ const normalizedValue = attrValue.toLowerCase();
+
+ // Try all possible attribute key formats
+ const possibleKeys = [
+ `attribute_${normalizedAttrName}`,
+ `attribute_pa_${normalizedAttrName}`,
+ normalizedAttrName
+ ];
+
+ for (const key of possibleKeys) {
+ if (key in v.attributes) {
+ return v.attributes[key].toLowerCase() === normalizedValue;
+ }
+ }
+ return false;
+ });
+});
+```
+
+---
+
+## Warning Issues
+
+### 🟡 Issue #3: Virtual Product Setting May Not Persist for Variable Products
+
+**Severity:** WARNING
+**Location:** [ProductsController.php#L496-498](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/ProductsController.php#L496)
+
+**Problem:**
+User reports cannot change product to virtual. Investigation shows:
+- Admin-SPA correctly sends `virtual: true` in payload
+- Backend `update_product` correctly calls `$product->set_virtual()`
+- However, for variable products, virtual status may need to be set on each variation
+
+**Observation:**
+The backend code at lines 496-498 handles virtual correctly:
+```php
+if (isset($data['virtual'])) {
+ $product->set_virtual((bool) $data['virtual']);
+}
+```
+
+**Potential Issue:**
+WooCommerce may ignore parent product's virtual flag for variable products. Each variation may need to be set as virtual individually.
+
+**Fix Required:**
+When saving variations, also propagate virtual flag:
+```php
+// In save_product_variations, after setting other fields:
+if ($product->is_virtual()) {
+ $variation->set_virtual(true);
+}
+```
+
+---
+
+## Info Issues
+
+### ℹ️ Issue #4: Missing Error Handling in Add-to-Cart Backend
+
+**Severity:** INFO
+**Location:** [CartController.php#L202-203](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/Controllers/CartController.php#L202)
+
+**Observation:**
+When `add_to_cart()` returns false, the error message is generic:
+```php
+if (!$cart_item_key) {
+ return new WP_Error('add_to_cart_failed', 'Failed to add product to cart', ['status' => 400]);
+}
+```
+
+WooCommerce may have more specific notices in `wc_notice` stack that could provide better error messages.
+
+**Enhancement:**
+```php
+if (!$cart_item_key) {
+ $notices = wc_get_notices('error');
+ $message = !empty($notices) ? $notices[0]['notice'] : 'Failed to add product to cart';
+ wc_clear_notices();
+ return new WP_Error('add_to_cart_failed', $message, ['status' => 400]);
+}
+```
+
+---
+
+## Code Flow Summary
+
+### Product Update Flow
+```mermaid
+sequenceDiagram
+ Admin SPA->>ProductsController: PUT /products/{id}
+ ProductsController->>WC_Product: set_name, set_sku, etc.
+ ProductsController->>WC_Product: set_virtual, set_downloadable
+ ProductsController->>ProductsController: save_product_variations()
+ ProductsController->>WC_Product_Variation: set_sku (BUG: no duplicate check)
+ WC_Product_Variation-->>WooCommerce: validate_sku()
+ WooCommerce-->>ProductsController: Exception (duplicate SKU)
+```
+
+### Add-to-Cart Flow
+```mermaid
+sequenceDiagram
+ Customer SPA->>Product Page: Select variation
+ Product Page->>useState: selectedAttributes = {Color: "Red"}
+ Product Page->>useEffect: Find matching variation
+ Note right of Product Page: Mismatch: API has attribute_pa_color
+ Product Page-->>useState: selectedVariation = null
+ Customer->>Product Page: Click Add to Cart
+ Product Page->>Customer: "Please select all product options"
+```
+
+---
+
+## Files to Modify
+
+| File | Change |
+|------|--------|
+| `ProductsController.php` | Fix SKU check in `save_product_variations` |
+| `Product/index.tsx` | Fix variation matching logic |
+| `ProductsController.php` | Propagate virtual to variations |
+| `CartController.php` | (Optional) Improve error messages |
+
+---
+
+## Verification Plan
+
+After fixes:
+1. Create a variable product with SKU on variations
+2. Edit the product without changing SKU → should save successfully
+3. Add products to cart → verify variation selection works
+4. Test virtual product setting on simple and variable products
diff --git a/API_ROUTES.md b/API_ROUTES.md
index 1c7edb6..f6683c7 100644
--- a/API_ROUTES.md
+++ b/API_ROUTES.md
@@ -109,6 +109,31 @@ GET /analytics/orders # Order analytics
GET /analytics/customers # Customer analytics
```
+### Licensing Module (`LicensesController.php`)
+```
+# Admin Endpoints (admin auth required)
+GET /licenses # List licenses (with pagination, search)
+GET /licenses/{id} # Get single license
+POST /licenses # Create license
+PUT /licenses/{id} # Update license
+DELETE /licenses/{id} # Delete license
+
+# Public Endpoints (for client software validation)
+POST /licenses/validate # Validate license key
+POST /licenses/activate # Activate license on domain
+POST /licenses/deactivate # Deactivate license from domain
+
+# OAuth Endpoints (user auth required)
+GET /licenses/oauth/validate # Validate OAuth state and license ownership
+POST /licenses/oauth/confirm # Confirm activation and generate token
+```
+
+**Implementation Details:**
+- **List:** Supports pagination (`page`, `per_page`), search by key/email
+- **activate:** Supports Simple API and OAuth modes
+- **OAuth flow:** `oauth/validate` + `oauth/confirm` for secure user verification
+- See `LICENSING_MODULE.md` for full OAuth flow documentation
+
---
## Conflict Prevention Rules
diff --git a/LICENSING_MODULE.md b/LICENSING_MODULE.md
new file mode 100644
index 0000000..acab4b9
--- /dev/null
+++ b/LICENSING_MODULE.md
@@ -0,0 +1,284 @@
+# Licensing Module Documentation
+
+## Overview
+
+WooNooW's Licensing Module provides software license management for digital products. It supports two activation methods:
+
+1. **Simple API** - Direct license key validation via API
+2. **Secure OAuth** - User verification via vendor portal before activation
+
+---
+
+## API Endpoints
+
+### Admin Endpoints (Authenticated Admin)
+
+```
+GET /licenses # List all licenses (with pagination, search)
+GET /licenses/{id} # Get single license
+POST /licenses # Create license
+PUT /licenses/{id} # Update license
+DELETE /licenses/{id} # Delete license
+```
+
+### Public Endpoints (For Client Software)
+
+```
+POST /licenses/validate # Validate license key
+POST /licenses/activate # Activate license on domain
+POST /licenses/deactivate # Deactivate license from domain
+```
+
+### OAuth Endpoints (Authenticated User)
+
+```
+GET /licenses/oauth/validate # Validate OAuth state and license ownership
+POST /licenses/oauth/confirm # Confirm activation and get token
+```
+
+---
+
+## Activation Flows
+
+### 1. Simple API Flow
+
+Direct license activation without user verification. Suitable for trusted environments.
+
+```
+Client Vendor API
+ | |
+ |-- POST /licenses/activate -|
+ | {license_key, domain} |
+ | |
+ |<-- {success, activation_id}|
+```
+
+**Example Request:**
+```bash
+curl -X POST "https://vendor.com/wp-json/woonoow/v1/licenses/activate" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "license_key": "XXXX-YYYY-ZZZZ-WWWW",
+ "domain": "https://customer-site.com",
+ "machine_id": "optional-unique-id"
+ }'
+```
+
+**Example Response:**
+```json
+{
+ "success": true,
+ "activation_id": 123,
+ "license_key": "XXXX-YYYY-ZZZZ-WWWW",
+ "status": "active",
+ "expires_at": "2025-01-31T00:00:00Z",
+ "activation_limit": 3,
+ "activation_count": 1
+}
+```
+
+---
+
+### 2. Secure OAuth Flow (Recommended)
+
+User must verify ownership on vendor portal before activation. More secure.
+
+```
+Client Vendor Portal Vendor API
+ | | |
+ |-- POST /licenses/activate -| |
+ | {license_key, domain} | |
+ | | |
+ |<-- {oauth_redirect, state}-| |
+ | | |
+ |== User redirects browser ==| |
+ | | |
+ |-------- BROWSER ---------->| |
+ | /my-account/license-connect?license_key=...&state=...|
+ | | |
+ | [User logs in if needed] |
+ | [User sees confirmation page] |
+ | [User clicks "Authorize"] |
+ | | |
+ | |-- POST /oauth/confirm -->|
+ | |<-- {token} --------------|
+ | | |
+ |<------- REDIRECT ----------| |
+ | {return_url}?activation_token=xxx |
+ | | |
+ |-- POST /licenses/activate -----------------------> |
+ | {license_key, activation_token} |
+ | | |
+ |<-- {success, activation_id} --------------------------|
+```
+
+---
+
+## OAuth Flow Step by Step
+
+### Step 1: Client Requests Activation (OAuth Mode)
+
+```bash
+curl -X POST "https://vendor.com/wp-json/woonoow/v1/licenses/activate" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "license_key": "XXXX-YYYY-ZZZZ-WWWW",
+ "domain": "https://customer-site.com",
+ "return_url": "https://customer-site.com/activation-callback",
+ "activation_mode": "oauth"
+ }'
+```
+
+**Response (OAuth Required):**
+```json
+{
+ "success": false,
+ "oauth_required": true,
+ "oauth_redirect": "https://vendor.com/my-account/license-connect/?license_key=XXXX-YYYY-ZZZZ-WWWW&site_url=https://customer-site.com&return_url=https://customer-site.com/activation-callback&state=abc123&nonce=xyz789",
+ "state": "abc123"
+}
+```
+
+### Step 2: User Opens Browser to OAuth URL
+
+Client opens the `oauth_redirect` URL in user's browser. The user:
+1. Logs into vendor portal (if not already)
+2. Sees license activation confirmation page
+3. Reviews license key and requesting site
+4. Clicks "Authorize" to confirm
+
+### Step 3: User Gets Redirected Back
+
+After authorization, user is redirected to `return_url` with token:
+
+```
+https://customer-site.com/activation-callback?activation_token=xyz123&license_key=XXXX-YYYY-ZZZZ-WWWW&nonce=xyz789
+```
+
+### Step 4: Client Exchanges Token for Activation
+
+```bash
+curl -X POST "https://vendor.com/wp-json/woonoow/v1/licenses/activate" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "license_key": "XXXX-YYYY-ZZZZ-WWWW",
+ "domain": "https://customer-site.com",
+ "activation_token": "xyz123"
+ }'
+```
+
+**Response (Success):**
+```json
+{
+ "success": true,
+ "activation_id": 456,
+ "license_key": "XXXX-YYYY-ZZZZ-WWWW",
+ "status": "active"
+}
+```
+
+---
+
+## Configuration
+
+### Site-Level Settings
+
+In Admin SPA: **Settings > Licensing**
+
+| Setting | Description |
+|---------|-------------|
+| Default Activation Method | `api` or `oauth` - Default for all products |
+| License Key Format | Format pattern for generated keys |
+| Default Validity Period | Days until license expires |
+| Default Activation Limit | Max activations per license |
+
+### Per-Product Settings
+
+In Admin SPA: **Products > Edit Product > General Tab**
+
+| Setting | Description |
+|---------|-------------|
+| Enable Licensing | Toggle to enable license generation |
+| Activation Method | `Use Site Default`, `Simple API`, or `Secure OAuth` |
+
+---
+
+## Database Schema
+
+### Licenses Table (`wp_woonoow_licenses`)
+
+| Column | Type | Description |
+|--------|------|-------------|
+| id | BIGINT | Primary key |
+| license_key | VARCHAR(255) | Unique license key |
+| product_id | BIGINT | WooCommerce product ID |
+| order_id | BIGINT | WooCommerce order ID |
+| user_id | BIGINT | Customer user ID |
+| status | VARCHAR(50) | active, inactive, expired, revoked |
+| activation_limit | INT | Max allowed activations |
+| activation_count | INT | Current activation count |
+| expires_at | DATETIME | Expiration date |
+| created_at | DATETIME | Created timestamp |
+| updated_at | DATETIME | Updated timestamp |
+
+### Activations Table (`wp_woonoow_license_activations`)
+
+| Column | Type | Description |
+|--------|------|-------------|
+| id | BIGINT | Primary key |
+| license_id | BIGINT | Foreign key to licenses |
+| domain | VARCHAR(255) | Activated domain |
+| machine_id | VARCHAR(255) | Optional machine identifier |
+| status | VARCHAR(50) | active, deactivated, pending |
+| user_agent | TEXT | Client user agent |
+| activated_at | DATETIME | Activation timestamp |
+
+---
+
+## Customer SPA: License Connect Page
+
+The OAuth confirmation page is available at:
+```
+/my-account/license-connect/
+```
+
+### Query Parameters
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| license_key | Yes | License key to activate |
+| site_url | Yes | Requesting site URL |
+| return_url | Yes | Callback URL after authorization |
+| state | Yes | CSRF protection token |
+| nonce | No | Additional security nonce |
+
+### UI Features
+
+- **Focused Layout** - No header/sidebar/footer, just the authorization card
+- **Brand Display** - Shows vendor site name
+- **License Details** - Displays license key, site URL, product name
+- **Security Warning** - Warns user to only authorize trusted sites
+- **Authorize/Deny Buttons** - Clear actions for user
+
+---
+
+## Security Considerations
+
+1. **State Token** - Prevents CSRF attacks, expires after 5 minutes
+2. **Activation Token** - Single-use, expires after 5 minutes
+3. **User Verification** - OAuth ensures license owner authorizes activation
+4. **Domain Validation** - Tracks activated domains for audit
+5. **Rate Limiting** - Consider implementing on activation endpoints
+
+---
+
+## Files Reference
+
+| File | Purpose |
+|------|---------|
+| `includes/Modules/Licensing/LicensingModule.php` | Module registration, endpoint handlers |
+| `includes/Modules/Licensing/LicenseManager.php` | Core license operations |
+| `includes/Api/LicensesController.php` | REST API endpoints |
+| `customer-spa/src/pages/Account/LicenseConnect.tsx` | OAuth confirmation UI |
+| `customer-spa/src/pages/Account/index.tsx` | Routing for license pages |
+| `customer-spa/src/App.tsx` | Top-level routing (license-connect outside BaseLayout) |
diff --git a/admin-spa/src/routes/Appearance/Pages/store/usePageEditorStore.ts b/admin-spa/src/routes/Appearance/Pages/store/usePageEditorStore.ts
index 302b3b3..3f5f638 100644
--- a/admin-spa/src/routes/Appearance/Pages/store/usePageEditorStore.ts
+++ b/admin-spa/src/routes/Appearance/Pages/store/usePageEditorStore.ts
@@ -60,6 +60,7 @@ export interface PageItem {
slug?: string;
title: string;
url?: string;
+ isFrontPage?: boolean;
isSpaLanding?: boolean;
}
diff --git a/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx b/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx
index 5bb90df..267adf2 100644
--- a/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx
+++ b/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx
@@ -40,6 +40,7 @@ export type ProductFormData = {
licensing_enabled?: boolean;
license_activation_limit?: string;
license_duration_days?: string;
+ license_activation_method?: '' | 'api' | 'oauth';
// Subscription
subscription_enabled?: boolean;
subscription_period?: 'day' | 'week' | 'month' | 'year';
@@ -95,6 +96,7 @@ export function ProductFormTabbed({
const [licensingEnabled, setLicensingEnabled] = useState(initial?.licensing_enabled || false);
const [licenseActivationLimit, setLicenseActivationLimit] = useState(initial?.license_activation_limit || '');
const [licenseDurationDays, setLicenseDurationDays] = useState(initial?.license_duration_days || '');
+ const [licenseActivationMethod, setLicenseActivationMethod] = useState<'' | 'api' | 'oauth'>(initial?.license_activation_method || '');
// Subscription state
const [subscriptionEnabled, setSubscriptionEnabled] = useState(initial?.subscription_enabled || false);
const [subscriptionPeriod, setSubscriptionPeriod] = useState<'day' | 'week' | 'month' | 'year'>(initial?.subscription_period || 'month');
@@ -131,6 +133,7 @@ export function ProductFormTabbed({
setLicensingEnabled(initial.licensing_enabled || false);
setLicenseActivationLimit(initial.license_activation_limit || '');
setLicenseDurationDays(initial.license_duration_days || '');
+ setLicenseActivationMethod(initial.license_activation_method || '');
// Subscription
setSubscriptionEnabled(initial.subscription_enabled || false);
setSubscriptionPeriod(initial.subscription_period || 'month');
@@ -199,6 +202,7 @@ export function ProductFormTabbed({
licensing_enabled: licensingEnabled,
license_activation_limit: licensingEnabled ? licenseActivationLimit : undefined,
license_duration_days: licensingEnabled ? licenseDurationDays : undefined,
+ license_activation_method: licensingEnabled ? licenseActivationMethod : undefined,
// Subscription
subscription_enabled: subscriptionEnabled,
subscription_period: subscriptionEnabled ? subscriptionPeriod : undefined,
@@ -261,6 +265,8 @@ export function ProductFormTabbed({
setLicenseActivationLimit={setLicenseActivationLimit}
licenseDurationDays={licenseDurationDays}
setLicenseDurationDays={setLicenseDurationDays}
+ licenseActivationMethod={licenseActivationMethod}
+ setLicenseActivationMethod={setLicenseActivationMethod}
subscriptionEnabled={subscriptionEnabled}
setSubscriptionEnabled={setSubscriptionEnabled}
subscriptionPeriod={subscriptionPeriod}
diff --git a/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx b/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx
index c5183a0..284ecb6 100644
--- a/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx
+++ b/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx
@@ -50,6 +50,8 @@ type GeneralTabProps = {
setLicenseActivationLimit?: (value: string) => void;
licenseDurationDays?: string;
setLicenseDurationDays?: (value: string) => void;
+ licenseActivationMethod?: '' | 'api' | 'oauth';
+ setLicenseActivationMethod?: (value: '' | 'api' | 'oauth') => void;
// Subscription
subscriptionEnabled?: boolean;
setSubscriptionEnabled?: (value: boolean) => void;
@@ -95,6 +97,8 @@ export function GeneralTab({
setLicenseActivationLimit,
licenseDurationDays,
setLicenseDurationDays,
+ licenseActivationMethod,
+ setLicenseActivationMethod,
subscriptionEnabled,
setSubscriptionEnabled,
subscriptionPeriod,
@@ -498,6 +502,27 @@ export function GeneralTab({
+ {setLicenseActivationMethod && (
+
+
+
+
+ {__('Override site-level activation method for this product')}
+
+
+ )}
)}
>
diff --git a/customer-spa/src/App.tsx b/customer-spa/src/App.tsx
index d347893..fe7e7bb 100644
--- a/customer-spa/src/App.tsx
+++ b/customer-spa/src/App.tsx
@@ -80,49 +80,60 @@ function AppRoutes() {
const frontPageSlug = getFrontPageSlug();
return (
-
-
- {/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */}
-
- ) : (
-
- )
- }
- />
+
+ {/* License Connect - Standalone focused page without layout */}
+ } />
- {/* Shop Routes */}
- } />
- } />
+ {/* All other routes wrapped in BaseLayout */}
+
+
+ {/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */}
+
+ ) : (
+
+ )
+ }
+ />
- {/* Cart & Checkout */}
- } />
- } />
- } />
- } />
- } />
+ {/* Shop Routes */}
+ } />
+ } />
- {/* Wishlist - Public route accessible to guests */}
- } />
+ {/* Cart & Checkout */}
+ } />
+ } />
+ } />
+ } />
+ } />
- {/* Login & Auth */}
- } />
- } />
- } />
+ {/* Wishlist - Public route accessible to guests */}
+ } />
- {/* My Account */}
- } />
+ {/* Login & Auth */}
+ } />
+ } />
+ } />
- {/* Dynamic Pages - CPT content with path base (e.g., /blog/:slug) */}
- } />
+ {/* My Account */}
+ } />
- {/* Dynamic Pages - Structural pages (e.g., /about, /contact) */}
- } />
-
-
+ {/* Dynamic Pages - CPT content with path base (e.g., /blog/:slug) */}
+ } />
+
+ {/* Dynamic Pages - Structural pages (e.g., /about, /contact) */}
+ } />
+
+
+ }
+ />
+
);
}
diff --git a/customer-spa/src/hooks/useWishlist.ts b/customer-spa/src/hooks/useWishlist.ts
index c5db5bc..4570163 100644
--- a/customer-spa/src/hooks/useWishlist.ts
+++ b/customer-spa/src/hooks/useWishlist.ts
@@ -1,4 +1,5 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useMemo } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { toast } from 'sonner';
@@ -19,114 +20,115 @@ interface WishlistItem {
const GUEST_WISHLIST_KEY = 'woonoow_guest_wishlist';
export function useWishlist() {
- const [items, setItems] = useState([]);
- const [isLoading, setIsLoading] = useState(false);
- const [productIds, setProductIds] = useState>(new Set());
-
+ const queryClient = useQueryClient();
+ const [guestIds, setGuestIds] = useState>(new Set());
+
// 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 guest wishlist from localStorage
- const loadGuestWishlist = useCallback(() => {
- try {
- const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
- if (stored) {
- const guestIds = JSON.parse(stored) as number[];
- setProductIds(new Set(guestIds));
+ // Load guest wishlist on mount
+ useEffect(() => {
+ if (!isLoggedIn) {
+ try {
+ const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
+ if (stored) {
+ const ids = JSON.parse(stored) as number[];
+ setGuestIds(new Set(ids));
+ }
+ } catch (error) {
+ console.error('Failed to load guest wishlist:', error);
}
- } catch (error) {
- console.error('Failed to load guest wishlist:', error);
}
- }, []);
+ }, [isLoggedIn]);
- // Save guest wishlist to localStorage
+ // Save guest wishlist helper
const saveGuestWishlist = useCallback((ids: Set) => {
try {
localStorage.setItem(GUEST_WISHLIST_KEY, JSON.stringify(Array.from(ids)));
+ setGuestIds(ids);
} catch (error) {
console.error('Failed to save guest wishlist:', error);
}
}, []);
- // Load wishlist on mount
- useEffect(() => {
- if (isEnabled) {
- if (isLoggedIn) {
- loadWishlist();
- } else {
- loadGuestWishlist();
- }
- }
- }, [isEnabled, isLoggedIn]);
+ // Fetch wishlist items (Server)
+ const { data: serverItems = [], isLoading: isServerLoading } = useQuery({
+ queryKey: ['wishlist'],
+ queryFn: async () => {
+ return await api.get('/account/wishlist');
+ },
+ enabled: isEnabled && isLoggedIn,
+ staleTime: 60 * 1000, // 1 minute cache
+ retry: 1,
+ });
- const loadWishlist = useCallback(async () => {
- if (!isLoggedIn) return;
-
- try {
- setIsLoading(true);
- const data = await api.get('/account/wishlist');
- setItems(data);
- setProductIds(new Set(data.map(item => item.product_id)));
- } catch (error) {
- console.error('Failed to load wishlist:', error);
- } finally {
- setIsLoading(false);
- }
- }, [isLoggedIn]);
+ // Calculate merged state
+ const items = isLoggedIn ? serverItems : []; // Guest items not stored as full objects here, usually handled by fetching products by ID elsewhere
+ const isLoading = isLoggedIn ? isServerLoading : false;
- const addToWishlist = useCallback(async (productId: number) => {
- // Guest mode: store in localStorage only
- if (!isLoggedIn) {
- const newIds = new Set(productIds);
- newIds.add(productId);
- setProductIds(newIds);
- saveGuestWishlist(newIds);
+ // Compute set of IDs for O(1) lookup
+ const productIds = useMemo(() => {
+ if (isLoggedIn) {
+ return new Set(serverItems.map(item => item.product_id));
+ }
+ return guestIds;
+ }, [isLoggedIn, serverItems, guestIds]);
+
+ // Mutations
+ const addMutation = useMutation({
+ mutationFn: async (productId: number) => {
+ return await api.post('/account/wishlist', { product_id: productId });
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['wishlist'] });
toast.success('Added to wishlist');
- return true;
- }
-
- // Logged in: use API
- try {
- await api.post('/account/wishlist', { product_id: productId });
- await loadWishlist(); // Reload to get full product details
- toast.success('Added to wishlist');
- return true;
- } catch (error: any) {
+ },
+ onError: (error: any) => {
const message = error?.message || 'Failed to add to wishlist';
toast.error(message);
- return false;
}
- }, [isLoggedIn, productIds, loadWishlist, saveGuestWishlist]);
+ });
+
+ const removeMutation = useMutation({
+ mutationFn: async (productId: number) => {
+ return await api.delete(`/account/wishlist/${productId}`);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['wishlist'] });
+ toast.success('Removed from wishlist');
+ },
+ onError: () => {
+ toast.error('Failed to remove from wishlist');
+ }
+ });
+
+ const addToWishlist = useCallback(async (productId: number) => {
+ if (!isLoggedIn) {
+ const newIds = new Set(guestIds);
+ newIds.add(productId);
+ saveGuestWishlist(newIds);
+ toast.success('Added to wishlist');
+ return true;
+ }
+
+ await addMutation.mutateAsync(productId);
+ return true;
+ }, [isLoggedIn, guestIds, saveGuestWishlist, addMutation]);
const removeFromWishlist = useCallback(async (productId: number) => {
- // Guest mode: remove from localStorage only
if (!isLoggedIn) {
- const newIds = new Set(productIds);
+ const newIds = new Set(guestIds);
newIds.delete(productId);
- setProductIds(newIds);
saveGuestWishlist(newIds);
toast.success('Removed from wishlist');
return true;
}
- // Logged in: use API
- try {
- await api.delete(`/account/wishlist/${productId}`);
- setItems(items.filter(item => item.product_id !== productId));
- setProductIds(prev => {
- const newSet = new Set(prev);
- newSet.delete(productId);
- return newSet;
- });
- toast.success('Removed from wishlist');
- return true;
- } catch (error) {
- toast.error('Failed to remove from wishlist');
- return false;
- }
- }, [isLoggedIn, productIds, items, saveGuestWishlist]);
+ await removeMutation.mutateAsync(productId);
+ return true;
+ }, [isLoggedIn, guestIds, saveGuestWishlist, removeMutation]);
const toggleWishlist = useCallback(async (productId: number) => {
if (productIds.has(productId)) {
@@ -145,12 +147,12 @@ export function useWishlist() {
isLoading,
isEnabled,
isLoggedIn,
- count: items.length,
+ count: productIds.size,
productIds,
addToWishlist,
removeFromWishlist,
toggleWishlist,
isInWishlist,
- refresh: loadWishlist,
+ refresh: () => queryClient.invalidateQueries({ queryKey: ['wishlist'] }),
};
}
diff --git a/customer-spa/src/pages/Account/LicenseConnect.tsx b/customer-spa/src/pages/Account/LicenseConnect.tsx
new file mode 100644
index 0000000..937f310
--- /dev/null
+++ b/customer-spa/src/pages/Account/LicenseConnect.tsx
@@ -0,0 +1,289 @@
+import React, { useState, useEffect } from 'react';
+import { useSearchParams, useNavigate } from 'react-router-dom';
+import { api } from '@/lib/api/client';
+import { Button } from '@/components/ui/button';
+import { Shield, Globe, Key, Check, X, Loader2, AlertTriangle } from 'lucide-react';
+
+export default function LicenseConnect() {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+
+ const [loading, setLoading] = useState(false);
+ const [confirming, setConfirming] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(false);
+ const [licenseInfo, setLicenseInfo] = useState(null);
+
+ // Get params from URL
+ const licenseKey = searchParams.get('license_key') || '';
+ const siteUrl = searchParams.get('site_url') || '';
+ const returnUrl = searchParams.get('return_url') || '';
+ const state = searchParams.get('state') || '';
+ const nonce = searchParams.get('nonce') || '';
+
+ // Get site name from window
+ const siteName = (window as any).woonoowCustomer?.siteName || 'WooNooW';
+
+ // Validate and load license info
+ useEffect(() => {
+ if (!licenseKey || !siteUrl || !state) {
+ setError('Invalid license connection request. Missing required parameters.');
+ return;
+ }
+
+ const loadLicenseInfo = async () => {
+ setLoading(true);
+ try {
+ const response = await api.get(`/licenses/oauth/validate?license_key=${encodeURIComponent(licenseKey)}&state=${encodeURIComponent(state)}`);
+ setLicenseInfo(response);
+ } catch (err: any) {
+ setError(err.message || 'Failed to validate license connection request.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadLicenseInfo();
+ }, [licenseKey, siteUrl, state]);
+
+ // Handle confirmation
+ const handleConfirm = async () => {
+ setConfirming(true);
+ setError(null);
+
+ try {
+ const response = await api.post<{ success?: boolean; redirect_url?: string }>('/licenses/oauth/confirm', {
+ license_key: licenseKey,
+ site_url: siteUrl,
+ state: state,
+ nonce: nonce,
+ });
+
+ if (response.success && response.redirect_url) {
+ // Redirect to return URL with activation token
+ window.location.href = response.redirect_url;
+ } else {
+ setSuccess(true);
+ }
+ } catch (err: any) {
+ setError(err.message || 'Failed to confirm license activation.');
+ } finally {
+ setConfirming(false);
+ }
+ };
+
+ // Handle cancel
+ const handleCancel = () => {
+ if (returnUrl) {
+ window.location.href = `${returnUrl}?error=cancelled&message=User%20cancelled%20the%20license%20activation`;
+ } else {
+ navigate('/my-account/licenses');
+ }
+ };
+
+ // Full-page focused container
+ const PageWrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {/* Minimal header with brand */}
+
+
+ {/* Centered content */}
+
+ {children}
+
+
+ {/* Minimal footer */}
+
+
+ );
+
+ // Render error state (when no license info is loaded)
+ if (error && !licenseInfo) {
+ return (
+
+
+
+
+
+
+ Connection Error
+
+
+ {error}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // Render loading state
+ if (loading) {
+ return (
+
+
+
+
+
Validating license request...
+
Please wait
+
+
+
+ );
+ }
+
+ // Render success state
+ if (success) {
+ return (
+
+
+
+
+
+
+ License Activated!
+
+
+ Your license has been successfully activated for the specified site.
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // Render confirmation page
+ return (
+
+
+
+ {/* Header */}
+
+
+
Activate Your License
+
+ A site is requesting to activate your license
+
+
+
+ {/* Content */}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* License Info Cards */}
+
+
+
+
+
+
+
License Key
+
{licenseKey}
+
+
+
+
+
+
+
+
+
Requesting Site
+
{siteUrl}
+
+
+
+ {licenseInfo?.product_name && (
+
+
+
+
+
+
Product
+
{licenseInfo.product_name}
+
+
+ )}
+
+
+ {/* Warning */}
+
+
+
+ By confirming, you authorize this site to use your license.
+ Only confirm if you trust the requesting site.
+
+
+
+
+ {/* Footer Actions */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/customer-spa/src/pages/Account/index.tsx b/customer-spa/src/pages/Account/index.tsx
index 5073b83..fd1d2e3 100644
--- a/customer-spa/src/pages/Account/index.tsx
+++ b/customer-spa/src/pages/Account/index.tsx
@@ -10,6 +10,7 @@ import Addresses from './Addresses';
import Wishlist from './Wishlist';
import AccountDetails from './AccountDetails';
import Licenses from './Licenses';
+import LicenseConnect from './LicenseConnect';
import Subscriptions from './Subscriptions';
import SubscriptionDetail from './SubscriptionDetail';
@@ -19,10 +20,15 @@ export default function Account() {
// Redirect to login if not authenticated
if (!user?.isLoggedIn) {
- const currentPath = location.pathname;
+ const currentPath = location.pathname + location.search;
return ;
}
+ // Check if this is the license-connect route (render without AccountLayout)
+ if (location.pathname.includes('/license-connect')) {
+ return ;
+ }
+
return (
@@ -43,4 +49,3 @@ export default function Account() {
);
}
-
diff --git a/customer-spa/src/pages/Product/index.tsx b/customer-spa/src/pages/Product/index.tsx
index b4ac1ef..64badfd 100644
--- a/customer-spa/src/pages/Product/index.tsx
+++ b/customer-spa/src/pages/Product/index.tsx
@@ -98,25 +98,47 @@ export default function Product() {
if (!v.attributes) return false;
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
- const normalizedValue = attrValue.toLowerCase().trim();
+ const normalizedSelectedValue = attrValue.toLowerCase().trim();
+ const attrNameLower = attrName.toLowerCase();
- // Check all attribute keys in variation (case-insensitive)
- for (const [vKey, vValue] of Object.entries(v.attributes)) {
- const vKeyLower = vKey.toLowerCase();
- const attrNameLower = attrName.toLowerCase();
+ // Find the attribute definition to get the slug
+ const attrDef = product.attributes?.find((a: any) => a.name === attrName);
+ const attrSlug = attrDef?.slug || attrNameLower.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
- if (vKeyLower === `attribute_${attrNameLower}` ||
- vKeyLower === `attribute_pa_${attrNameLower}` ||
- vKeyLower === attrNameLower) {
+ // Try to find a matching key in the variation attributes
+ let variationValue: string | undefined = undefined;
- const varValueNormalized = String(vValue).toLowerCase().trim();
- if (varValueNormalized === normalizedValue) {
- return true;
- }
- }
+ // Check for common WooCommerce attribute key formats
+ // 1. Check strict slug format (attribute_7-days-...)
+ if (`attribute_${attrSlug}` in v.attributes) {
+ variationValue = v.attributes[`attribute_${attrSlug}`];
+ }
+ // 2. Check pa_ format (attribute_pa_color)
+ else if (`attribute_pa_${attrSlug}` in v.attributes) {
+ variationValue = v.attributes[`attribute_pa_${attrSlug}`];
+ }
+ // 3. Fallback to name-based checks (legacy)
+ else if (`attribute_${attrNameLower}` in v.attributes) {
+ variationValue = v.attributes[`attribute_${attrNameLower}`];
+ } else if (`attribute_pa_${attrNameLower}` in v.attributes) {
+ variationValue = v.attributes[`attribute_pa_${attrNameLower}`];
+ } else if (attrNameLower in v.attributes) {
+ variationValue = v.attributes[attrNameLower];
}
- return false;
+ // If key is undefined/missing in variation, it means "Any" -> Match
+ if (variationValue === undefined || variationValue === null) {
+ return true;
+ }
+
+ // If empty string, it also means "Any" -> Match
+ const normalizedVarValue = String(variationValue).toLowerCase().trim();
+ if (normalizedVarValue === '') {
+ return true;
+ }
+
+ // Otherwise, values must match
+ return normalizedVarValue === normalizedSelectedValue;
});
});
@@ -181,11 +203,36 @@ export default function Product() {
}
}
+ // Construct variation params using keys from the matched variation
+ // but filling in values from user selection (handles "Any" variations with empty values)
+ let variation_params: Record = {};
+ if (product.type === 'variable' && selectedVariation?.attributes) {
+ // Get keys from the variation's attributes (these are the correct WooCommerce keys)
+ Object.keys(selectedVariation.attributes).forEach(key => {
+ // Key format is like "attribute_7-days-auto-closing-variation-plan"
+ // Extract the slug part after "attribute_"
+ const slug = key.replace(/^attribute_/, '');
+
+ // Find the matching user-selected value by attribute name
+ const attrDef = product.attributes?.find((a: any) =>
+ a.slug === slug || a.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') === slug
+ );
+
+ if (attrDef && selectedAttributes[attrDef.name]) {
+ variation_params[key] = selectedAttributes[attrDef.name];
+ } else {
+ // Fallback to stored value if no user selection
+ variation_params[key] = selectedVariation.attributes[key];
+ }
+ });
+ }
+
try {
await apiClient.post(apiClient.endpoints.cart.add, {
product_id: product.id,
quantity,
variation_id: selectedVariation?.id || 0,
+ variation: variation_params,
});
addItem({
@@ -320,8 +367,8 @@ export default function Product() {
key={index}
onClick={() => setSelectedImage(img)}
className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
- ? 'bg-primary w-6'
- : 'bg-gray-300 hover:bg-gray-400'
+ ? 'bg-primary w-6'
+ : 'bg-gray-300 hover:bg-gray-400'
}`}
aria-label={`View image ${index + 1}`}
/>
@@ -354,8 +401,8 @@ export default function Product() {
key={index}
onClick={() => setSelectedImage(img)}
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img
- ? 'border-primary ring-4 ring-primary ring-offset-2'
- : 'border-gray-300 hover:border-gray-400'
+ ? 'border-primary ring-4 ring-primary ring-offset-2'
+ : 'border-gray-300 hover:border-gray-400'
}`}
>
handleAttributeChange(attr.name, option)}
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
- ? 'bg-gray-900 text-white border-gray-900 shadow-lg'
- : 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
+ ? 'bg-gray-900 text-white border-gray-900 shadow-lg'
+ : 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
}`}
>
{option}
@@ -503,8 +550,8 @@ export default function Product() {