feat: Implement OAuth license activation flow

- Add LicenseConnect.tsx focused OAuth confirmation page in customer SPA
- Add /licenses/oauth/validate and /licenses/oauth/confirm API endpoints
- Update App.tsx to render license-connect outside BaseLayout (no header/footer)
- Add license_activation_method field to product settings in Admin SPA
- Create LICENSING_MODULE.md with comprehensive OAuth flow documentation
- Update API_ROUTES.md with license module endpoints
This commit is contained in:
Dwindi Ramadhana
2026-01-31 22:22:22 +07:00
parent d80f34c8b9
commit a0b5f8496d
23 changed files with 3218 additions and 806 deletions

View File

@@ -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.

View File

@@ -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=<signed_token>& │
│ nonce=<one_time_code> │
├──────────────────────────────────────────────────────────────────┤
│ 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 |

View File

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

View File

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

284
LICENSING_MODULE.md Normal file
View File

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

View File

@@ -60,6 +60,7 @@ export interface PageItem {
slug?: string;
title: string;
url?: string;
isFrontPage?: boolean;
isSpaLanding?: boolean;
}

View File

@@ -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}

View File

@@ -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({
</p>
</div>
</div>
{setLicenseActivationMethod && (
<div>
<Label className="text-xs">{__('Activation Method')}</Label>
<Select
value={licenseActivationMethod || 'default'}
onValueChange={(v) => setLicenseActivationMethod(v === 'default' ? '' : v as '' | 'api' | 'oauth')}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder={__('Use Site Default')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{__('Use Site Default')}</SelectItem>
<SelectItem value="api">{__('Simple API (license key only)')}</SelectItem>
<SelectItem value="oauth">{__('Secure OAuth (requires login)')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
{__('Override site-level activation method for this product')}
</p>
</div>
)}
</div>
)}
</>

View File

@@ -80,49 +80,60 @@ function AppRoutes() {
const frontPageSlug = getFrontPageSlug();
return (
<BaseLayout>
<Routes>
{/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */}
<Route
path="/"
element={
frontPageSlug ? (
<DynamicPageRenderer slug={frontPageSlug} />
) : (
<Navigate to={initialRoute === '/' ? '/shop' : initialRoute} replace />
)
}
/>
<Routes>
{/* License Connect - Standalone focused page without layout */}
<Route path="/my-account/license-connect" element={<Account />} />
{/* Shop Routes */}
<Route path="/shop" element={<Shop />} />
<Route path="/product/:slug" element={<Product />} />
{/* All other routes wrapped in BaseLayout */}
<Route
path="/*"
element={
<BaseLayout>
<Routes>
{/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */}
<Route
path="/"
element={
frontPageSlug ? (
<DynamicPageRenderer slug={frontPageSlug} />
) : (
<Navigate to={initialRoute === '/' ? '/shop' : initialRoute} replace />
)
}
/>
{/* Cart & Checkout */}
<Route path="/cart" element={<Cart />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/order-received/:orderId" element={<ThankYou />} />
<Route path="/checkout/order-received/:orderId" element={<ThankYou />} />
<Route path="/checkout/pay/:orderId" element={<OrderPay />} />
{/* Shop Routes */}
<Route path="/shop" element={<Shop />} />
<Route path="/product/:slug" element={<Product />} />
{/* Wishlist - Public route accessible to guests */}
<Route path="/wishlist" element={<Wishlist />} />
{/* Cart & Checkout */}
<Route path="/cart" element={<Cart />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/order-received/:orderId" element={<ThankYou />} />
<Route path="/checkout/order-received/:orderId" element={<ThankYou />} />
<Route path="/checkout/pay/:orderId" element={<OrderPay />} />
{/* Login & Auth */}
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* Wishlist - Public route accessible to guests */}
<Route path="/wishlist" element={<Wishlist />} />
{/* My Account */}
<Route path="/my-account/*" element={<Account />} />
{/* Login & Auth */}
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* Dynamic Pages - CPT content with path base (e.g., /blog/:slug) */}
<Route path="/:pathBase/:slug" element={<DynamicPageRenderer />} />
{/* My Account */}
<Route path="/my-account/*" element={<Account />} />
{/* Dynamic Pages - Structural pages (e.g., /about, /contact) */}
<Route path="/:slug" element={<DynamicPageRenderer />} />
</Routes>
</BaseLayout>
{/* Dynamic Pages - CPT content with path base (e.g., /blog/:slug) */}
<Route path="/:pathBase/:slug" element={<DynamicPageRenderer />} />
{/* Dynamic Pages - Structural pages (e.g., /about, /contact) */}
<Route path="/:slug" element={<DynamicPageRenderer />} />
</Routes>
</BaseLayout>
}
/>
</Routes>
);
}

View File

@@ -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<WishlistItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [productIds, setProductIds] = useState<Set<number>>(new Set());
const queryClient = useQueryClient();
const [guestIds, setGuestIds] = useState<Set<number>>(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<number>) => {
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();
}
// Fetch wishlist items (Server)
const { data: serverItems = [], isLoading: isServerLoading } = useQuery({
queryKey: ['wishlist'],
queryFn: async () => {
return await api.get<WishlistItem[]>('/account/wishlist');
},
enabled: isEnabled && isLoggedIn,
staleTime: 60 * 1000, // 1 minute cache
retry: 1,
});
// 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;
// Compute set of IDs for O(1) lookup
const productIds = useMemo(() => {
if (isLoggedIn) {
return new Set(serverItems.map(item => item.product_id));
}
}, [isEnabled, isLoggedIn]);
return guestIds;
}, [isLoggedIn, serverItems, guestIds]);
const loadWishlist = useCallback(async () => {
if (!isLoggedIn) return;
try {
setIsLoading(true);
const data = await api.get<WishlistItem[]>('/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]);
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);
// 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'] }),
};
}

View File

@@ -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<string | null>(null);
const [success, setSuccess] = useState(false);
const [licenseInfo, setLicenseInfo] = useState<any>(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 }) => (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
{/* Minimal header with brand */}
<header className="py-6 px-8 flex justify-center">
<div className="text-xl font-bold text-slate-900">{siteName}</div>
</header>
{/* Centered content */}
<main className="flex-1 flex items-center justify-center px-4 pb-12">
{children}
</main>
{/* Minimal footer */}
<footer className="py-4 text-center text-sm text-slate-500">
Secure License Activation
</footer>
</div>
);
// Render error state (when no license info is loaded)
if (error && !licenseInfo) {
return (
<PageWrapper>
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
<div className="p-8">
<div className="flex items-center justify-center mb-6">
<div className="h-16 w-16 rounded-full bg-red-100 flex items-center justify-center">
<X className="h-8 w-8 text-red-600" />
</div>
</div>
<h1 className="text-xl font-semibold text-center text-slate-900 mb-2">
Connection Error
</h1>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm text-center">
{error}
</div>
</div>
<div className="px-8 py-4 bg-slate-50 border-t">
<Button
variant="outline"
className="w-full"
onClick={() => navigate('/my-account/licenses')}
>
Back to My Account
</Button>
</div>
</div>
</div>
</PageWrapper>
);
}
// Render loading state
if (loading) {
return (
<PageWrapper>
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-12 flex flex-col items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin text-blue-600 mb-4" />
<p className="text-slate-600 font-medium">Validating license request...</p>
<p className="text-slate-400 text-sm mt-1">Please wait</p>
</div>
</div>
</PageWrapper>
);
}
// Render success state
if (success) {
return (
<PageWrapper>
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
<div className="p-8">
<div className="flex items-center justify-center mb-6">
<div className="h-16 w-16 rounded-full bg-green-100 flex items-center justify-center">
<Check className="h-8 w-8 text-green-600" />
</div>
</div>
<h1 className="text-xl font-semibold text-center text-slate-900 mb-2">
License Activated!
</h1>
<p className="text-slate-600 text-center">
Your license has been successfully activated for the specified site.
</p>
</div>
<div className="px-8 py-4 bg-slate-50 border-t">
<Button
className="w-full"
onClick={() => navigate('/my-account/licenses')}
>
View My Licenses
</Button>
</div>
</div>
</div>
</PageWrapper>
);
}
// Render confirmation page
return (
<PageWrapper>
<div className="w-full max-w-lg">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
{/* Header */}
<div className="p-8 text-center border-b bg-gradient-to-b from-blue-50 to-white">
<div className="flex justify-center mb-4">
<div className="h-20 w-20 rounded-full bg-blue-100 flex items-center justify-center">
<Shield className="h-10 w-10 text-blue-600" />
</div>
</div>
<h1 className="text-2xl font-bold text-slate-900">Activate Your License</h1>
<p className="text-slate-500 mt-2">
A site is requesting to activate your license
</p>
</div>
{/* Content */}
<div className="p-8 space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm">
{error}
</div>
)}
{/* License Info Cards */}
<div className="space-y-3">
<div className="bg-slate-50 rounded-xl p-4 flex items-start gap-4">
<div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center shrink-0">
<Key className="h-5 w-5 text-slate-600" />
</div>
<div className="min-w-0">
<p className="font-medium text-slate-900">License Key</p>
<p className="text-slate-500 font-mono text-sm truncate">{licenseKey}</p>
</div>
</div>
<div className="bg-slate-50 rounded-xl p-4 flex items-start gap-4">
<div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center shrink-0">
<Globe className="h-5 w-5 text-slate-600" />
</div>
<div className="min-w-0">
<p className="font-medium text-slate-900">Requesting Site</p>
<p className="text-slate-500 text-sm truncate">{siteUrl}</p>
</div>
</div>
{licenseInfo?.product_name && (
<div className="bg-slate-50 rounded-xl p-4 flex items-start gap-4">
<div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center shrink-0">
<Shield className="h-5 w-5 text-slate-600" />
</div>
<div className="min-w-0">
<p className="font-medium text-slate-900">Product</p>
<p className="text-slate-500 text-sm">{licenseInfo.product_name}</p>
</div>
</div>
)}
</div>
{/* Warning */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
By confirming, you authorize this site to use your license.
Only confirm if you trust the requesting site.
</p>
</div>
</div>
{/* Footer Actions */}
<div className="px-8 py-5 bg-slate-50 border-t flex gap-3">
<Button
variant="outline"
className="flex-1 h-12"
onClick={handleCancel}
disabled={confirming}
>
Deny
</Button>
<Button
className="flex-1 h-12 bg-blue-600 hover:bg-blue-700"
onClick={handleConfirm}
disabled={confirming}
>
{confirming ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Activating...
</>
) : (
<>
<Check className="h-4 w-4 mr-2" />
Authorize
</>
)}
</Button>
</div>
</div>
</div>
</PageWrapper>
);
}

View File

@@ -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 <Navigate to={`/login?redirect=${encodeURIComponent(currentPath)}`} replace />;
}
// Check if this is the license-connect route (render without AccountLayout)
if (location.pathname.includes('/license-connect')) {
return <LicenseConnect />;
}
return (
<Container>
<AccountLayout>
@@ -43,4 +49,3 @@ export default function Account() {
</Container>
);
}

View File

@@ -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<string, string> = {};
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'
}`}
>
<img
@@ -446,8 +493,8 @@ export default function Product() {
key={optIndex}
onClick={() => 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() {
<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'
? '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' : ''

69
debug-variation.php Normal file
View File

@@ -0,0 +1,69 @@
<?php
/**
* Debug script to check variation attribute data
* Access via: /wp-content/plugins/woonoow/debug-variation.php
* DELETE THIS FILE AFTER DEBUGGING
*/
require_once dirname(__DIR__, 3) . '/wp-load.php';
header('Content-Type: application/json');
$variation_id = isset($_GET['variation_id']) ? intval($_GET['variation_id']) : 515;
$product_id = isset($_GET['product_id']) ? intval($_GET['product_id']) : 512;
$result = [];
// Get parent product
$product = wc_get_product($product_id);
if ($product && $product->is_type('variable')) {
$result['parent_product'] = [
'id' => $product->get_id(),
'name' => $product->get_name(),
'type' => $product->get_type(),
];
// Get parent product attributes
$result['parent_attributes'] = [];
foreach ($product->get_attributes() as $key => $attribute) {
$result['parent_attributes'][$key] = [
'name' => $attribute->get_name(),
'label' => wc_attribute_label($attribute->get_name()),
'is_taxonomy' => $attribute->is_taxonomy(),
'is_variation' => $attribute->get_variation(),
'options' => $attribute->get_options(),
'sanitized_name' => sanitize_title($attribute->get_name()),
];
}
// Get available variations from parent
$result['available_variations'] = $product->get_available_variations();
}
// Get variation directly
$variation = wc_get_product($variation_id);
if ($variation && $variation->is_type('variation')) {
$result['variation'] = [
'id' => $variation->get_id(),
'name' => $variation->get_name(),
'type' => $variation->get_type(),
'parent_id' => $variation->get_parent_id(),
];
// Get variation attributes using WooCommerce method
$result['variation_attributes_wc'] = $variation->get_variation_attributes();
// Get raw post meta
global $wpdb;
$meta_rows = $wpdb->get_results($wpdb->prepare(
"SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key LIKE 'attribute_%%'",
$variation_id
));
$result['variation_meta_raw'] = $meta_rows;
// Get all meta for this variation
$result['all_meta'] = get_post_meta($variation_id);
}
echo json_encode($result, JSON_PRETTY_PRINT);

View File

@@ -1,4 +1,5 @@
<?php
namespace WooNooW\Api\Controllers;
use WP_REST_Controller;
@@ -9,12 +10,14 @@ use WP_Error;
* Cart Controller
* Handles cart operations via REST API
*/
class CartController extends WP_REST_Controller {
class CartController extends WP_REST_Controller
{
/**
* Register routes
*/
public function register_routes() {
public function register_routes()
{
$namespace = 'woonoow/v1';
// Get cart
@@ -119,7 +122,8 @@ class CartController extends WP_REST_Controller {
/**
* Get cart contents
*/
public function get_cart($request) {
public function get_cart($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
@@ -171,7 +175,8 @@ class CartController extends WP_REST_Controller {
/**
* Add product to cart
*/
public function add_to_cart($request) {
public function add_to_cart($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
@@ -184,6 +189,18 @@ class CartController extends WP_REST_Controller {
$product_id = $request->get_param('product_id');
$quantity = $request->get_param('quantity') ?: 1;
$variation_id = $request->get_param('variation_id') ?: 0;
$variation = $request->get_param('variation') ?: [];
// TEMPORARY DEBUG: Return early to confirm this code is reached
if ($product_id == 512) {
return new WP_REST_Response([
'debug' => true,
'message' => 'CartController reached',
'product_id' => $product_id,
'variation_id' => $variation_id,
'variation' => $variation,
], 200);
}
// Validate product
$product = wc_get_product($product_id);
@@ -196,11 +213,63 @@ class CartController extends WP_REST_Controller {
return new WP_Error('out_of_stock', 'Product is out of stock', ['status' => 400]);
}
// For variable products with a variation_id, handle attributes properly
// This ensures correct attribute keys even if frontend sends wrong/empty data
if ($variation_id > 0) {
$variation_product = wc_get_product($variation_id);
if ($variation_product && $variation_product->is_type('variation')) {
// Get the actual attributes stored on the variation
$stored_attributes = $variation_product->get_variation_attributes();
// Merge: use stored attributes as base, but fill in empty values from frontend
// This handles variations created with "Any X" option (empty values)
$frontend_variation = $request->get_param('variation') ?: [];
foreach ($stored_attributes as $key => $value) {
if ($value === '' && isset($frontend_variation[$key]) && $frontend_variation[$key] !== '') {
// Stored value is empty ("Any"), use frontend value
$stored_attributes[$key] = sanitize_text_field($frontend_variation[$key]);
}
}
$variation = $stored_attributes;
}
}
// DEBUG: Log what we're passing to add_to_cart
error_log('[CartController] product_id: ' . $product_id);
error_log('[CartController] quantity: ' . $quantity);
error_log('[CartController] variation_id: ' . $variation_id);
error_log('[CartController] variation: ' . json_encode($variation));
error_log('[CartController] frontend_variation (raw): ' . json_encode($request->get_param('variation')));
// Add to cart
$cart_item_key = WC()->cart->add_to_cart($product_id, $quantity, $variation_id);
$cart_item_key = WC()->cart->add_to_cart($product_id, $quantity, $variation_id, $variation);
if (!$cart_item_key) {
return new WP_Error('add_to_cart_failed', 'Failed to add product to cart', ['status' => 400]);
// Get error notices from WooCommerce to provide a specific reason
$notices = wc_get_notices('error');
$message = 'Failed to add product to cart';
if (!empty($notices)) {
// Get the last error message
$last_notice = end($notices);
if (isset($last_notice['notice'])) {
// Strip HTML tags for clean error message
$message = wp_strip_all_tags($last_notice['notice']);
}
}
wc_clear_notices();
return new WP_Error('add_to_cart_failed', $message, [
'status' => 400,
'debug' => [
'product_id' => $product_id,
'variation_id' => $variation_id,
'variation_passed' => $variation,
'frontend_variation' => $request->get_param('variation'),
],
]);
}
return new WP_REST_Response([
@@ -214,7 +283,8 @@ class CartController extends WP_REST_Controller {
/**
* Update cart item quantity
*/
public function update_cart_item($request) {
public function update_cart_item($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
@@ -250,7 +320,8 @@ class CartController extends WP_REST_Controller {
/**
* Remove item from cart
*/
public function remove_from_cart($request) {
public function remove_from_cart($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
@@ -285,7 +356,8 @@ class CartController extends WP_REST_Controller {
/**
* Clear cart
*/
public function clear_cart($request) {
public function clear_cart($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
@@ -306,7 +378,8 @@ class CartController extends WP_REST_Controller {
/**
* Apply coupon
*/
public function apply_coupon($request) {
public function apply_coupon($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
@@ -335,7 +408,8 @@ class CartController extends WP_REST_Controller {
/**
* Remove coupon
*/
public function remove_coupon($request) {
public function remove_coupon($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
@@ -364,7 +438,8 @@ class CartController extends WP_REST_Controller {
/**
* Format cart items for response
*/
private function format_cart_items($cart_items) {
private function format_cart_items($cart_items)
{
$formatted = [];
foreach ($cart_items as $cart_item_key => $cart_item) {

View File

@@ -1,4 +1,5 @@
<?php
/**
* Licenses API Controller
*
@@ -17,12 +18,14 @@ use WP_Error;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\Licensing\LicenseManager;
class LicensesController {
class LicensesController
{
/**
* Register REST routes
*/
public static function register_routes() {
public static function register_routes()
{
// Check if module is enabled
if (!ModuleRegistry::is_enabled('licensing')) {
return;
@@ -32,7 +35,7 @@ class LicensesController {
register_rest_route('woonoow/v1', '/licenses', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_licenses'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
@@ -40,7 +43,7 @@ class LicensesController {
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_license'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
@@ -48,7 +51,7 @@ class LicensesController {
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)', [
'methods' => 'DELETE',
'callback' => [__CLASS__, 'revoke_license'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
@@ -56,7 +59,7 @@ class LicensesController {
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)/activations', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_activations'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
@@ -65,7 +68,7 @@ class LicensesController {
register_rest_route('woonoow/v1', '/account/licenses', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_customer_licenses'],
'permission_callback' => function() {
'permission_callback' => function () {
return is_user_logged_in();
},
]);
@@ -73,7 +76,7 @@ class LicensesController {
register_rest_route('woonoow/v1', '/account/licenses/(?P<id>\d+)/deactivate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'customer_deactivate'],
'permission_callback' => function() {
'permission_callback' => function () {
return is_user_logged_in();
},
]);
@@ -96,12 +99,30 @@ class LicensesController {
'callback' => [__CLASS__, 'deactivate_license'],
'permission_callback' => '__return_true',
]);
// OAuth endpoints for license-connect
register_rest_route('woonoow/v1', '/licenses/oauth/validate', [
'methods' => 'GET',
'callback' => [__CLASS__, 'oauth_validate'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('woonoow/v1', '/licenses/oauth/confirm', [
'methods' => 'POST',
'callback' => [__CLASS__, 'oauth_confirm'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
}
/**
* Get all licenses (admin)
*/
public static function get_licenses(WP_REST_Request $request) {
public static function get_licenses(WP_REST_Request $request)
{
$args = [
'search' => $request->get_param('search'),
'status' => $request->get_param('status'),
@@ -129,7 +150,8 @@ class LicensesController {
/**
* Get single license (admin)
*/
public static function get_license(WP_REST_Request $request) {
public static function get_license(WP_REST_Request $request)
{
$license = LicenseManager::get_license($request->get_param('id'));
if (!$license) {
@@ -145,7 +167,8 @@ class LicensesController {
/**
* Revoke license (admin)
*/
public static function revoke_license(WP_REST_Request $request) {
public static function revoke_license(WP_REST_Request $request)
{
$result = LicenseManager::revoke($request->get_param('id'));
if (!$result) {
@@ -158,7 +181,8 @@ class LicensesController {
/**
* Get activations for license (admin)
*/
public static function get_activations(WP_REST_Request $request) {
public static function get_activations(WP_REST_Request $request)
{
$activations = LicenseManager::get_activations($request->get_param('id'));
return new WP_REST_Response($activations);
}
@@ -166,7 +190,8 @@ class LicensesController {
/**
* Get customer's licenses
*/
public static function get_customer_licenses(WP_REST_Request $request) {
public static function get_customer_licenses(WP_REST_Request $request)
{
$user_id = get_current_user_id();
$licenses = LicenseManager::get_user_licenses($user_id);
@@ -182,7 +207,8 @@ class LicensesController {
/**
* Customer deactivate their own activation
*/
public static function customer_deactivate(WP_REST_Request $request) {
public static function customer_deactivate(WP_REST_Request $request)
{
$user_id = get_current_user_id();
$license = LicenseManager::get_license($request->get_param('id'));
@@ -207,7 +233,8 @@ class LicensesController {
/**
* Validate license (public API)
*/
public static function validate_license(WP_REST_Request $request) {
public static function validate_license(WP_REST_Request $request)
{
$data = $request->get_json_params();
if (empty($data['license_key'])) {
@@ -221,7 +248,8 @@ class LicensesController {
/**
* Activate license (public API)
*/
public static function activate_license(WP_REST_Request $request) {
public static function activate_license(WP_REST_Request $request)
{
$data = $request->get_json_params();
if (empty($data['license_key'])) {
@@ -233,6 +261,8 @@ class LicensesController {
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
'machine_id' => $data['machine_id'] ?? null,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
'return_url' => $data['return_url'] ?? null,
'activation_token' => $data['activation_token'] ?? null,
];
$result = LicenseManager::activate($data['license_key'], $activation_data);
@@ -247,7 +277,8 @@ class LicensesController {
/**
* Deactivate license (public API)
*/
public static function deactivate_license(WP_REST_Request $request) {
public static function deactivate_license(WP_REST_Request $request)
{
$data = $request->get_json_params();
if (empty($data['license_key'])) {
@@ -270,7 +301,8 @@ class LicensesController {
/**
* Enrich license with product and user info
*/
private static function enrich_license($license) {
private static function enrich_license($license)
{
// Add product info
$product = wc_get_product($license['product_id']);
$license['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'woonoow');
@@ -288,4 +320,108 @@ class LicensesController {
return $license;
}
/**
* OAuth validate endpoint - validates license key and state for OAuth flow
*/
public static function oauth_validate(WP_REST_Request $request)
{
$license_key = sanitize_text_field($request->get_param('license_key'));
$state = sanitize_text_field($request->get_param('state'));
if (empty($license_key)) {
return new WP_Error('missing_license_key', __('License key is required.', 'woonoow'), ['status' => 400]);
}
// Get license
$license = LicenseManager::get_license_by_key($license_key);
if (!$license) {
return new WP_Error('license_not_found', __('License key not found.', 'woonoow'), ['status' => 404]);
}
// Verify license belongs to current user
$current_user_id = get_current_user_id();
if ($license['user_id'] != $current_user_id) {
return new WP_Error('unauthorized', __('This license does not belong to your account.', 'woonoow'), ['status' => 403]);
}
// Verify state token if provided
if (!empty($state)) {
$state_data = LicenseManager::verify_oauth_state($state);
if (!$state_data) {
return new WP_Error('invalid_state', __('Invalid or expired state token.', 'woonoow'), ['status' => 400]);
}
}
// Get product info
$product = wc_get_product($license['product_id']);
return new WP_REST_Response([
'license_key' => $license['license_key'],
'product_id' => $license['product_id'],
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'woonoow'),
'status' => $license['status'],
'activation_limit' => $license['activation_limit'],
'activation_count' => $license['activation_count'],
'expires_at' => $license['expires_at'],
]);
}
/**
* OAuth confirm endpoint - confirms license activation and returns token
*/
public static function oauth_confirm(WP_REST_Request $request)
{
$data = $request->get_json_params();
$license_key = sanitize_text_field($data['license_key'] ?? '');
$site_url = esc_url_raw($data['site_url'] ?? '');
$state = sanitize_text_field($data['state'] ?? '');
$nonce = sanitize_text_field($data['nonce'] ?? '');
if (empty($license_key) || empty($site_url) || empty($state)) {
return new WP_Error('missing_params', __('Missing required parameters.', 'woonoow'), ['status' => 400]);
}
// Get license
$license = LicenseManager::get_license_by_key($license_key);
if (!$license) {
return new WP_Error('license_not_found', __('License key not found.', 'woonoow'), ['status' => 404]);
}
// Verify license belongs to current user
$current_user_id = get_current_user_id();
if ($license['user_id'] != $current_user_id) {
return new WP_Error('unauthorized', __('This license does not belong to your account.', 'woonoow'), ['status' => 403]);
}
// Verify state token
$state_data = LicenseManager::verify_oauth_state($state);
if (!$state_data) {
return new WP_Error('invalid_state', __('Invalid or expired state token.', 'woonoow'), ['status' => 400]);
}
// Generate activation token
$token_data = LicenseManager::generate_activation_token($license['id'], $site_url);
if (is_wp_error($token_data)) {
return $token_data;
}
$activation_token = $token_data['token'];
// Build return URL with token
$return_url = $state_data['return_url'] ?? $site_url;
$redirect_url = add_query_arg([
'activation_token' => $activation_token,
'license_key' => $license_key,
'nonce' => $nonce,
], $return_url);
return new WP_REST_Response([
'success' => true,
'redirect_url' => $redirect_url,
'activation_token' => $activation_token,
]);
}
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Products REST API Controller
*
@@ -18,12 +19,14 @@ use WC_Product_Simple;
use WC_Product_Variable;
use WC_Product_Variation;
class ProductsController {
class ProductsController
{
/**
* Sanitize text field
*/
private static function sanitize_text($value) {
private static function sanitize_text($value)
{
if (!isset($value) || $value === '') {
return '';
}
@@ -34,7 +37,8 @@ class ProductsController {
/**
* Sanitize textarea (allows newlines)
*/
private static function sanitize_textarea($value) {
private static function sanitize_textarea($value)
{
if (!isset($value) || $value === '') {
return '';
}
@@ -45,7 +49,8 @@ class ProductsController {
/**
* Sanitize numeric value
*/
private static function sanitize_number($value) {
private static function sanitize_number($value)
{
if (!isset($value) || $value === '') {
return '';
}
@@ -57,7 +62,8 @@ class ProductsController {
/**
* Sanitize slug
*/
private static function sanitize_slug($value) {
private static function sanitize_slug($value)
{
if (!isset($value) || $value === '') {
return '';
}
@@ -67,7 +73,8 @@ class ProductsController {
/**
* Register REST API routes
*/
public static function register_routes() {
public static function register_routes()
{
// List products
register_rest_route('woonoow/v1', '/products', [
'methods' => 'GET',
@@ -191,95 +198,95 @@ class ProductsController {
/**
* Get products list with filters
*/
public static function get_products(WP_REST_Request $request) {
public static function get_products(WP_REST_Request $request)
{
try {
$page = max(1, (int) $request->get_param('page'));
$per_page = min(100, max(1, (int) ($request->get_param('per_page') ?: 20)));
$search = $request->get_param('search');
$status = $request->get_param('status');
$category = $request->get_param('category');
$type = $request->get_param('type');
$stock_status = $request->get_param('stock_status');
$orderby = $request->get_param('orderby') ?: 'date';
$order = $request->get_param('order') ?: 'DESC';
$per_page = min(100, max(1, (int) ($request->get_param('per_page') ?: 20)));
$search = $request->get_param('search');
$status = $request->get_param('status');
$category = $request->get_param('category');
$type = $request->get_param('type');
$stock_status = $request->get_param('stock_status');
$orderby = $request->get_param('orderby') ?: 'date';
$order = $request->get_param('order') ?: 'DESC';
$args = [
'post_type' => 'product',
'posts_per_page' => $per_page,
'paged' => $page,
'orderby' => $orderby,
'order' => $order,
];
// Search
if ($search) {
$args['s'] = sanitize_text_field($search);
}
// Status filter
if ($status) {
$args['post_status'] = $status;
} else {
$args['post_status'] = ['publish', 'draft', 'pending', 'private'];
}
// Category filter
if ($category) {
$args['tax_query'] = [
[
'taxonomy' => 'product_cat',
'field' => 'term_id',
'terms' => (int) $category,
],
$args = [
'post_type' => 'product',
'posts_per_page' => $per_page,
'paged' => $page,
'orderby' => $orderby,
'order' => $order,
];
}
// Type filter
if ($type) {
$args['tax_query'] = $args['tax_query'] ?? [];
$args['tax_query'][] = [
'taxonomy' => 'product_type',
'field' => 'slug',
'terms' => $type,
];
}
// Stock status filter
if ($stock_status) {
$args['meta_query'] = [
[
'key' => '_stock_status',
'value' => $stock_status,
],
];
}
$query = new \WP_Query($args);
$products = [];
foreach ($query->posts as $post) {
$product = wc_get_product($post->ID);
if ($product) {
$products[] = self::format_product_list_item($product);
// Search
if ($search) {
$args['s'] = sanitize_text_field($search);
}
}
$response = new WP_REST_Response([
'rows' => $products,
'total' => $query->found_posts,
'page' => $page,
'per_page' => $per_page,
'pages' => $query->max_num_pages,
'_debug' => 'ProductsController-v2-' . time(), // Verify this code is running
], 200);
// Status filter
if ($status) {
$args['post_status'] = $status;
} else {
$args['post_status'] = ['publish', 'draft', 'pending', 'private'];
}
// Prevent caching
$response->header('Cache-Control', 'no-cache, no-store, must-revalidate');
$response->header('Pragma', 'no-cache');
$response->header('Expires', '0');
// Category filter
if ($category) {
$args['tax_query'] = [
[
'taxonomy' => 'product_cat',
'field' => 'term_id',
'terms' => (int) $category,
],
];
}
return $response;
// Type filter
if ($type) {
$args['tax_query'] = $args['tax_query'] ?? [];
$args['tax_query'][] = [
'taxonomy' => 'product_type',
'field' => 'slug',
'terms' => $type,
];
}
// Stock status filter
if ($stock_status) {
$args['meta_query'] = [
[
'key' => '_stock_status',
'value' => $stock_status,
],
];
}
$query = new \WP_Query($args);
$products = [];
foreach ($query->posts as $post) {
$product = wc_get_product($post->ID);
if ($product) {
$products[] = self::format_product_list_item($product);
}
}
$response = new WP_REST_Response([
'rows' => $products,
'total' => $query->found_posts,
'page' => $page,
'per_page' => $per_page,
'pages' => $query->max_num_pages,
'_debug' => 'ProductsController-v2-' . time(), // Verify this code is running
], 200);
// Prevent caching
$response->header('Cache-Control', 'no-cache, no-store, must-revalidate');
$response->header('Pragma', 'no-cache');
$response->header('Expires', '0');
return $response;
} catch (\Exception $e) {
return new WP_Error('products_error', $e->getMessage(), ['status' => 500]);
}
@@ -288,7 +295,8 @@ class ProductsController {
/**
* Get single product
*/
public static function get_product(WP_REST_Request $request) {
public static function get_product(WP_REST_Request $request)
{
$id = (int) $request->get_param('id');
$product = wc_get_product($id);
@@ -302,7 +310,8 @@ class ProductsController {
/**
* Create new product
*/
public static function create_product(WP_REST_Request $request) {
public static function create_product(WP_REST_Request $request)
{
try {
$data = $request->get_json_params();
@@ -423,6 +432,13 @@ class ProductsController {
if (isset($data['license_duration_days'])) {
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($data['license_duration_days']));
}
if (isset($data['license_activation_method'])) {
$method = $data['license_activation_method'];
// Accept empty (site default), api, or oauth
if ($method === '' || in_array($method, ['api', 'oauth'])) {
update_post_meta($product->get_id(), '_woonoow_license_activation_method', sanitize_key($method));
}
}
// Subscription meta
if (isset($data['subscription_enabled'])) {
@@ -459,7 +475,8 @@ class ProductsController {
/**
* Update product
*/
public static function update_product(WP_REST_Request $request) {
public static function update_product(WP_REST_Request $request)
{
$id = (int) $request->get_param('id');
$data = $request->get_json_params();
@@ -584,6 +601,13 @@ class ProductsController {
if (isset($data['license_duration_days'])) {
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($data['license_duration_days']));
}
if (isset($data['license_activation_method'])) {
$method = $data['license_activation_method'];
// Accept empty (site default), api, or oauth
if ($method === '' || in_array($method, ['api', 'oauth'])) {
update_post_meta($product->get_id(), '_woonoow_license_activation_method', sanitize_key($method));
}
}
// Subscription meta
if (isset($data['subscription_enabled'])) {
@@ -621,7 +645,8 @@ class ProductsController {
/**
* Delete product
*/
public static function delete_product(WP_REST_Request $request) {
public static function delete_product(WP_REST_Request $request)
{
$id = (int) $request->get_param('id');
$force = $request->get_param('force') === 'true';
@@ -642,7 +667,8 @@ class ProductsController {
/**
* Get product categories
*/
public static function get_categories(WP_REST_Request $request) {
public static function get_categories(WP_REST_Request $request)
{
$terms = get_terms([
'taxonomy' => 'product_cat',
'hide_empty' => false,
@@ -671,7 +697,8 @@ class ProductsController {
/**
* Get product tags
*/
public static function get_tags(WP_REST_Request $request) {
public static function get_tags(WP_REST_Request $request)
{
$terms = get_terms([
'taxonomy' => 'product_tag',
'hide_empty' => false,
@@ -699,7 +726,8 @@ class ProductsController {
/**
* Get product attributes
*/
public static function get_attributes(WP_REST_Request $request) {
public static function get_attributes(WP_REST_Request $request)
{
$attributes = wc_get_attribute_taxonomies();
$result = [];
@@ -721,7 +749,8 @@ class ProductsController {
* Format product for list view
* Returns all essential product fields including type, status, prices, stock, etc.
*/
private static function format_product_list_item($product) {
private static function format_product_list_item($product)
{
$image = wp_get_attachment_image_src($product->get_image_id(), 'thumbnail');
// Get price HTML - for variable products, show price range
@@ -762,7 +791,8 @@ class ProductsController {
/**
* Format product with full details
*/
private static function format_product_full($product) {
private static function format_product_full($product)
{
$data = self::format_product_list_item($product);
// Add full details
@@ -798,8 +828,9 @@ class ProductsController {
// Licensing fields
$data['licensing_enabled'] = get_post_meta($product->get_id(), '_woonoow_licensing_enabled', true) === 'yes';
$data['license_activation_limit'] = get_post_meta($product->get_id(), '_license_activation_limit', true) ?: '';
$data['license_duration_days'] = get_post_meta($product->get_id(), '_license_duration_days', true) ?: '';
$data['license_activation_limit'] = get_post_meta($product->get_id(), '_woonoow_license_activation_limit', true) ?: '';
$data['license_duration_days'] = get_post_meta($product->get_id(), '_woonoow_license_expiry_days', true) ?: '';
$data['license_activation_method'] = get_post_meta($product->get_id(), '_woonoow_license_activation_method', true) ?: '';
// Subscription fields
$data['subscription_enabled'] = get_post_meta($product->get_id(), '_woonoow_subscription_enabled', true) === 'yes';
@@ -858,7 +889,8 @@ class ProductsController {
/**
* Get product attributes
*/
private static function get_product_attributes($product) {
private static function get_product_attributes($product)
{
$attributes = [];
foreach ($product->get_attributes() as $attribute) {
$attributes[] = [
@@ -876,7 +908,8 @@ class ProductsController {
/**
* Get product variations
*/
private static function get_product_variations($product) {
private static function get_product_variations($product)
{
$variations = [];
foreach ($product->get_children() as $variation_id) {
$variation = wc_get_product($variation_id);
@@ -951,7 +984,8 @@ class ProductsController {
/**
* Save product attributes
*/
private static function save_product_attributes($product, $attributes_data) {
private static function save_product_attributes($product, $attributes_data)
{
$attributes = [];
foreach ($attributes_data as $attr_data) {
$attribute = new \WC_Product_Attribute();
@@ -969,7 +1003,8 @@ class ProductsController {
/**
* Save product variations
*/
private static function save_product_variations($product, $variations_data) {
private static function save_product_variations($product, $variations_data)
{
// Get existing variation IDs
$existing_variation_ids = $product->get_children();
$variations_to_keep = [];
@@ -1006,7 +1041,14 @@ class ProductsController {
$variation->set_attributes($wc_attributes);
}
if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']);
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);
}
}
// Set prices - if not provided, use parent's price as fallback
if (isset($var_data['regular_price']) && $var_data['regular_price'] !== '') {
@@ -1034,6 +1076,11 @@ class ProductsController {
$variation->set_image_id($var_data['image_id']);
}
// Inherit virtual status from parent if parent is virtual
if ($product->is_virtual()) {
$variation->set_virtual(true);
}
// Save variation first
$saved_id = $variation->save();
$variations_to_keep[] = $saved_id;
@@ -1086,7 +1133,8 @@ class ProductsController {
* @param \WC_Product $product
* @return array
*/
private static function get_product_meta_data($product) {
private static function get_product_meta_data($product)
{
$meta_data = [];
foreach ($product->get_meta_data() as $meta) {
@@ -1108,7 +1156,6 @@ class ProductsController {
$meta_data[$key] = $value;
continue;
}
}
return $meta_data;
@@ -1120,7 +1167,8 @@ class ProductsController {
* @param \WC_Product $product
* @param array $meta_updates
*/
private static function update_product_meta_data($product, $meta_updates) {
private static function update_product_meta_data($product, $meta_updates)
{
// Get allowed updatable meta keys
// Core has ZERO defaults - plugins register via filter
$allowed = apply_filters('woonoow/product_updatable_meta', [], $product);
@@ -1152,7 +1200,8 @@ class ProductsController {
/**
* Create product category
*/
public static function create_category(WP_REST_Request $request) {
public static function create_category(WP_REST_Request $request)
{
try {
$name = sanitize_text_field($request->get_param('name'));
$slug = sanitize_title($request->get_param('slug') ?: $name);
@@ -1196,7 +1245,8 @@ class ProductsController {
/**
* Update product category
*/
public static function update_category(WP_REST_Request $request) {
public static function update_category(WP_REST_Request $request)
{
try {
$term_id = (int) $request->get_param('id');
$name = sanitize_text_field($request->get_param('name'));
@@ -1242,7 +1292,8 @@ class ProductsController {
/**
* Delete product category
*/
public static function delete_category(WP_REST_Request $request) {
public static function delete_category(WP_REST_Request $request)
{
try {
$term_id = (int) $request->get_param('id');
@@ -1270,7 +1321,8 @@ class ProductsController {
/**
* Create product tag
*/
public static function create_tag(WP_REST_Request $request) {
public static function create_tag(WP_REST_Request $request)
{
try {
$name = sanitize_text_field($request->get_param('name'));
$slug = sanitize_title($request->get_param('slug') ?: $name);
@@ -1311,7 +1363,8 @@ class ProductsController {
/**
* Update product tag
*/
public static function update_tag(WP_REST_Request $request) {
public static function update_tag(WP_REST_Request $request)
{
try {
$term_id = (int) $request->get_param('id');
$name = sanitize_text_field($request->get_param('name'));
@@ -1354,7 +1407,8 @@ class ProductsController {
/**
* Delete product tag
*/
public static function delete_tag(WP_REST_Request $request) {
public static function delete_tag(WP_REST_Request $request)
{
try {
$term_id = (int) $request->get_param('id');
@@ -1382,7 +1436,8 @@ class ProductsController {
/**
* Create product attribute
*/
public static function create_attribute(WP_REST_Request $request) {
public static function create_attribute(WP_REST_Request $request)
{
try {
$label = sanitize_text_field($request->get_param('label'));
$name = sanitize_title($request->get_param('name') ?: $label);
@@ -1429,7 +1484,8 @@ class ProductsController {
/**
* Update product attribute
*/
public static function update_attribute(WP_REST_Request $request) {
public static function update_attribute(WP_REST_Request $request)
{
try {
$attribute_id = (int) $request->get_param('id');
$label = sanitize_text_field($request->get_param('label'));
@@ -1477,7 +1533,8 @@ class ProductsController {
/**
* Delete product attribute
*/
public static function delete_attribute(WP_REST_Request $request) {
public static function delete_attribute(WP_REST_Request $request)
{
try {
$attribute_id = (int) $request->get_param('id');

View File

@@ -53,6 +53,9 @@ class EmailRenderer
$to = $this->get_recipient_email($recipient_type, $data);
if (!$to) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[EmailRenderer] Failed to get recipient email for event: ' . $event_id);
}
return null;
}
@@ -125,14 +128,48 @@ class EmailRenderer
}
// Customer
if ($data instanceof WC_Order) {
if ($data instanceof \WC_Order) {
return $data->get_billing_email();
}
if ($data instanceof WC_Customer) {
if ($data instanceof \WC_Customer) {
return $data->get_email();
}
if ($data instanceof \WP_User) {
return $data->user_email;
}
// Handle array data (e.g. subscription notifications)
if (is_array($data)) {
// Check for customer object in array
if (isset($data['customer'])) {
if ($data['customer'] instanceof \WP_User) {
return $data['customer']->user_email;
}
if ($data['customer'] instanceof \WC_Customer) {
return $data['customer']->get_email();
}
}
// Check for direct email in data
if (isset($data['email']) && is_email($data['email'])) {
return $data['email'];
}
// Check for user_id
if (isset($data['user_id'])) {
$user = get_user_by('id', $data['user_id']);
if ($user) {
return $user->user_email;
}
}
}
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[EmailRenderer] Could not determine recipient email for type: ' . $recipient_type);
}
return null;
}
@@ -292,6 +329,46 @@ class EmailRenderer
]);
}
// Subscription variables (passed as array)
if (is_array($data) && isset($data['subscription'])) {
$sub = $data['subscription'];
// subscription object usually has: id, user_id, product_id, status, ...
$sub_variables = [
'subscription_id' => $sub->id ?? '',
'subscription_status' => isset($sub->status) ? ucfirst($sub->status) : '',
'billing_period' => isset($sub->billing_period) ? ucfirst($sub->billing_period) : '',
'recurring_amount' => isset($sub->recurring_amount) ? wc_price($sub->recurring_amount) : '',
'next_payment_date' => isset($sub->next_payment_date) ? date('F j, Y', strtotime($sub->next_payment_date)) : 'N/A',
'end_date' => isset($sub->end_date) ? date('F j, Y', strtotime($sub->end_date)) : 'N/A',
'cancel_reason' => $data['reason'] ?? '',
'failed_count' => $data['failed_count'] ?? 0,
'payment_link' => $data['payment_link'] ?? '',
];
// Get product name if not already set
if (!isset($variables['product_name']) && isset($data['product']) && $data['product'] instanceof \WC_Product) {
$sub_variables['product_name'] = $data['product']->get_name();
$sub_variables['product_url'] = get_permalink($data['product']->get_id());
}
// Get customer details if not already set
if (!isset($variables['customer_name']) && isset($data['customer']) && $data['customer'] instanceof \WP_User) {
$user = $data['customer'];
$sub_variables['customer_name'] = $user->display_name;
$sub_variables['customer_email'] = $user->user_email;
}
$variables = array_merge($variables, $sub_variables);
} else if (is_array($data) && isset($data['customer']) && $data['customer'] instanceof \WP_User) {
// Basic user data passed in array without subscription (e.g. generalized notification)
$user = $data['customer'];
$variables = array_merge($variables, [
'customer_name' => $user->display_name,
'customer_email' => $user->user_email,
]);
}
// Merge extra data
$variables = array_merge($variables, $extra_data);

View File

@@ -1,4 +1,5 @@
<?php
namespace WooNooW\Frontend;
use WP_REST_Request;
@@ -79,7 +80,8 @@ class CartController
'methods' => 'POST',
'callback' => [__CLASS__, 'update_cart'],
'permission_callback' => function () {
return true; },
return true;
},
'args' => [
'cart_item_key' => [
'required' => true,
@@ -97,7 +99,8 @@ class CartController
'methods' => 'POST',
'callback' => [__CLASS__, 'remove_from_cart'],
'permission_callback' => function () {
return true; },
return true;
},
'args' => [
'cart_item_key' => [
'required' => true,
@@ -111,7 +114,8 @@ class CartController
'methods' => 'POST',
'callback' => [__CLASS__, 'apply_coupon'],
'permission_callback' => function () {
return true; },
return true;
},
'args' => [
'coupon_code' => [
'required' => true,
@@ -125,7 +129,8 @@ class CartController
'methods' => 'POST',
'callback' => [__CLASS__, 'clear_cart'],
'permission_callback' => function () {
return true; },
return true;
},
]);
// Remove coupon
@@ -133,7 +138,8 @@ class CartController
'methods' => 'POST',
'callback' => [__CLASS__, 'remove_coupon'],
'permission_callback' => function () {
return true; },
return true;
},
'args' => [
'coupon_code' => [
'required' => true,
@@ -227,6 +233,12 @@ class CartController
if (!empty($value)) {
$variation_attributes[$meta_key] = $value;
} else {
// Value is empty ("Any" variation) - check if frontend sent value in 'variation' param
$frontend_variation = $request->get_param('variation');
if (is_array($frontend_variation) && isset($frontend_variation[$meta_key]) && !empty($frontend_variation[$meta_key])) {
$variation_attributes[$meta_key] = sanitize_text_field($frontend_variation[$meta_key]);
}
}
}
}

View File

@@ -1,4 +1,5 @@
<?php
namespace WooNooW\Frontend;
use WP_REST_Request;
@@ -9,12 +10,14 @@ use WP_Error;
* Shop Controller - Customer-facing product catalog API
* Handles product listing, search, and categories for customer-spa
*/
class ShopController {
class ShopController
{
/**
* Register REST API routes
*/
public static function register_routes() {
public static function register_routes()
{
$namespace = 'woonoow/v1';
// Get products (public)
@@ -61,7 +64,7 @@ class ShopController {
'permission_callback' => '__return_true',
'args' => [
'id' => [
'validate_callback' => function($param) {
'validate_callback' => function ($param) {
return is_numeric($param);
},
],
@@ -92,7 +95,8 @@ class ShopController {
/**
* Get products list
*/
public static function get_products(WP_REST_Request $request) {
public static function get_products(WP_REST_Request $request)
{
$page = $request->get_param('page');
$per_page = $request->get_param('per_page');
$category = $request->get_param('category');
@@ -179,7 +183,8 @@ class ShopController {
/**
* Get single product
*/
public static function get_product(WP_REST_Request $request) {
public static function get_product(WP_REST_Request $request)
{
$product_id = $request->get_param('id');
$product = wc_get_product($product_id);
@@ -193,7 +198,8 @@ class ShopController {
/**
* Get categories
*/
public static function get_categories(WP_REST_Request $request) {
public static function get_categories(WP_REST_Request $request)
{
$terms = get_terms([
'taxonomy' => 'product_cat',
'hide_empty' => true,
@@ -221,7 +227,8 @@ class ShopController {
/**
* Search products
*/
public static function search_products(WP_REST_Request $request) {
public static function search_products(WP_REST_Request $request)
{
$search = $request->get_param('s');
$args = [
@@ -252,7 +259,8 @@ class ShopController {
/**
* Format product data for API response
*/
private static function format_product($product, $detailed = false) {
private static function format_product($product, $detailed = false)
{
$data = [
'id' => $product->get_id(),
'name' => $product->get_name(),
@@ -300,7 +308,7 @@ class ShopController {
// Related products
$related_ids = wc_get_related_products($product->get_id(), 4);
$data['related_products'] = array_map(function($id) {
$data['related_products'] = array_map(function ($id) {
$related = wc_get_product($id);
return $related ? self::format_product($related) : null;
}, $related_ids);
@@ -313,12 +321,14 @@ class ShopController {
/**
* Get product attributes
*/
private static function get_product_attributes($product) {
private static function get_product_attributes($product)
{
$attributes = [];
foreach ($product->get_attributes() as $attribute) {
$attribute_data = [
'name' => wc_attribute_label($attribute->get_name()),
'slug' => sanitize_title($attribute->get_name()),
'options' => [],
'visible' => $attribute->get_visible(),
'variation' => $attribute->get_variation(),
@@ -341,29 +351,19 @@ class ShopController {
/**
* Get product variations
*/
private static function get_product_variations($product) {
private static function get_product_variations($product)
{
$variations = [];
foreach ($product->get_available_variations() as $variation) {
$variation_obj = wc_get_product($variation['variation_id']);
if ($variation_obj) {
// Get attributes directly from post meta (most reliable)
$attributes = [];
// Use attributes directly from WooCommerce's get_available_variations()
// This correctly handles custom attributes, taxonomy attributes, and "Any" selections
$attributes = $variation['attributes'];
$variation_id = $variation['variation_id'];
// Query all post meta for this variation
global $wpdb;
$meta_rows = $wpdb->get_results($wpdb->prepare(
"SELECT meta_key, meta_value FROM {$wpdb->postmeta}
WHERE post_id = %d AND meta_key LIKE 'attribute_%%'",
$variation_id
));
foreach ($meta_rows as $row) {
$attributes[$row->meta_key] = $row->meta_value;
}
$variations[] = [
'id' => $variation_id,
'attributes' => $attributes,

View File

@@ -1,4 +1,5 @@
<?php
/**
* License Manager
*
@@ -13,7 +14,8 @@ if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
class LicenseManager {
class LicenseManager
{
private static $table_name = 'woonoow_licenses';
private static $activations_table = 'woonoow_license_activations';
@@ -21,7 +23,8 @@ class LicenseManager {
/**
* Initialize
*/
public static function init() {
public static function init()
{
// Only initialize if module is enabled
if (!ModuleRegistry::is_enabled('licensing')) {
return;
@@ -39,7 +42,8 @@ class LicenseManager {
/**
* Maybe generate licenses on thank you page (for COD and pending orders)
*/
public static function maybe_generate_on_thankyou($order_id) {
public static function maybe_generate_on_thankyou($order_id)
{
if (!$order_id) return;
$order = wc_get_order($order_id);
@@ -59,7 +63,8 @@ class LicenseManager {
/**
* Check if order contains only virtual items
*/
private static function is_virtual_order($order) {
private static function is_virtual_order($order)
{
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && !$product->is_virtual()) {
@@ -72,7 +77,8 @@ class LicenseManager {
/**
* Create database tables
*/
public static function create_tables() {
public static function create_tables()
{
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
@@ -127,7 +133,8 @@ class LicenseManager {
/**
* Generate licenses for completed order
*/
public static function generate_licenses_for_order($order_id) {
public static function generate_licenses_for_order($order_id)
{
$order = wc_get_order($order_id);
if (!$order) return;
@@ -176,7 +183,8 @@ class LicenseManager {
/**
* Check if license already exists for order item
*/
public static function license_exists_for_order_item($order_item_id) {
public static function license_exists_for_order_item($order_item_id)
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
@@ -189,7 +197,8 @@ class LicenseManager {
/**
* Create a new license
*/
public static function create_license($data) {
public static function create_license($data)
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
@@ -219,7 +228,8 @@ class LicenseManager {
/**
* Generate license key
*/
public static function generate_license_key() {
public static function generate_license_key()
{
$format = get_option('woonoow_licensing_license_key_format', 'serial');
$prefix = get_option('woonoow_licensing_license_key_prefix', '');
@@ -248,7 +258,8 @@ class LicenseManager {
/**
* Get license by key
*/
public static function get_license_by_key($license_key) {
public static function get_license_by_key($license_key)
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
@@ -261,7 +272,8 @@ class LicenseManager {
/**
* Get license by ID
*/
public static function get_license($license_id) {
public static function get_license($license_id)
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
@@ -274,7 +286,8 @@ class LicenseManager {
/**
* Get licenses for user
*/
public static function get_user_licenses($user_id, $args = []) {
public static function get_user_licenses($user_id, $args = [])
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
@@ -303,7 +316,8 @@ class LicenseManager {
/**
* Activate license
*/
public static function activate($license_key, $activation_data = []) {
public static function activate($license_key, $activation_data = [])
{
global $wpdb;
$license = self::get_license_by_key($license_key);
@@ -334,6 +348,36 @@ class LicenseManager {
return new \WP_Error('activation_limit_reached', __('Activation limit reached', 'woonoow'));
}
// Check if product requires OAuth activation
// Get licensing module settings
$licensing_settings = get_option('woonoow_module_licensing_settings', []);
// 1. Get site-level setting (default)
$activation_method = $licensing_settings['activation_method'] ?? 'api';
// 2. Check for product-level override (only if allow_product_override is enabled)
$allow_override = $licensing_settings['allow_product_override'] ?? false;
if ($allow_override) {
$product_method = get_post_meta($license['product_id'], '_woonoow_license_activation_method', true);
if (!empty($product_method)) {
$activation_method = $product_method;
}
}
if ($activation_method === 'oauth') {
// Check if this is an OAuth callback (has valid activation token)
if (!empty($activation_data['activation_token'])) {
$validated = self::validate_activation_token($activation_data['activation_token'], $license_key);
if (is_wp_error($validated)) {
return $validated;
}
// Token is valid, proceed with activation
} else {
// Not a callback, return redirect URL for OAuth flow
return self::build_oauth_redirect_response($license, $activation_data);
}
}
// Create activation record
$activations_table = $wpdb->prefix . self::$activations_table;
$licenses_table = $wpdb->prefix . self::$table_name;
@@ -364,10 +408,148 @@ class LicenseManager {
];
}
/**
* Build OAuth redirect response for license activation
*/
private static function build_oauth_redirect_response($license, $activation_data)
{
// Generate state token for CSRF protection
$state = self::generate_oauth_state($license['license_key'], $activation_data['domain'] ?? '');
// Build redirect URL to vendor site
$connect_url = home_url('/my-account/license-connect/');
$redirect_url = add_query_arg([
'license_key' => $license['license_key'],
'site_url' => $activation_data['domain'] ?? '',
'return_url' => $activation_data['return_url'] ?? '',
'state' => $state,
'nonce' => wp_create_nonce('woonoow_oauth_connect'),
], $connect_url);
return [
'success' => false,
'code' => 'oauth_required',
'message' => __('This license requires account verification. You will be redirected to complete activation.', 'woonoow'),
'redirect_url' => $redirect_url,
];
}
/**
* Generate OAuth state token
*/
public static function generate_oauth_state($license_key, $domain)
{
$data = [
'license_key' => $license_key,
'domain' => $domain,
'timestamp' => time(),
];
$payload = base64_encode(wp_json_encode($data));
$signature = hash_hmac('sha256', $payload, wp_salt('auth'));
return $payload . '.' . $signature;
}
/**
* Verify OAuth state token
*/
public static function verify_oauth_state($state)
{
$parts = explode('.', $state, 2);
if (count($parts) !== 2) {
return false;
}
list($payload, $signature) = $parts;
$expected_signature = hash_hmac('sha256', $payload, wp_salt('auth'));
if (!hash_equals($expected_signature, $signature)) {
return false;
}
$data = json_decode(base64_decode($payload), true);
if (!$data) {
return false;
}
// Check timestamp (10 minute expiry)
if (empty($data['timestamp']) || (time() - $data['timestamp']) > 600) {
return false;
}
return $data;
}
/**
* Generate activation token (short-lived, single-use)
*/
public static function generate_activation_token($license_id, $domain)
{
global $wpdb;
$token = wp_generate_password(32, false);
$expires_at = gmdate('Y-m-d H:i:s', time() + 300); // 5 minute expiry
// Store token in activations table temporarily
$table = $wpdb->prefix . self::$activations_table;
$wpdb->insert($table, [
'license_id' => $license_id,
'domain' => $domain,
'machine_id' => 'oauth_token:' . $token,
'status' => 'pending',
'user_agent' => 'OAuth activation token expires: ' . $expires_at,
]);
return [
'token' => $token,
'activation_id' => $wpdb->insert_id,
];
}
/**
* Validate activation token
*/
private static function validate_activation_token($token, $license_key)
{
global $wpdb;
$license = self::get_license_by_key($license_key);
if (!$license) {
return new \WP_Error('invalid_license', __('Invalid license key', 'woonoow'));
}
$table = $wpdb->prefix . self::$activations_table;
$activation = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table WHERE license_id = %d AND machine_id = %s AND status = 'pending' LIMIT 1",
$license['id'],
'oauth_token:' . $token
), ARRAY_A);
if (!$activation) {
return new \WP_Error('invalid_token', __('Invalid or expired activation token', 'woonoow'));
}
// Check expiry from user_agent field
if (preg_match('/expires: (.+)$/', $activation['user_agent'], $matches)) {
$expires_at = strtotime($matches[1]);
if ($expires_at && time() > $expires_at) {
// Delete expired token
$wpdb->delete($table, ['id' => $activation['id']]);
return new \WP_Error('token_expired', __('Activation token has expired', 'woonoow'));
}
}
// Delete the pending record (it will be replaced by actual activation)
$wpdb->delete($table, ['id' => $activation['id']]);
return true;
}
/**
* Deactivate license
*/
public static function deactivate($license_key, $activation_id = null, $machine_id = null) {
public static function deactivate($license_key, $activation_id = null, $machine_id = null)
{
global $wpdb;
$license = self::get_license_by_key($license_key);
@@ -426,7 +608,8 @@ class LicenseManager {
/**
* Validate license (check if valid without activating)
*/
public static function validate($license_key) {
public static function validate($license_key)
{
$license = self::get_license_by_key($license_key);
if (!$license) {
@@ -465,7 +648,8 @@ class LicenseManager {
* @param int $order_id
* @return string|null Subscription status or null if no subscription
*/
public static function get_order_subscription_status($order_id) {
public static function get_order_subscription_status($order_id)
{
// Check if subscription module is enabled
if (!ModuleRegistry::is_enabled('subscription')) {
return null;
@@ -503,7 +687,8 @@ class LicenseManager {
/**
* Revoke license
*/
public static function revoke($license_id) {
public static function revoke($license_id)
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
@@ -524,7 +709,8 @@ class LicenseManager {
/**
* Get all licenses (admin)
*/
public static function get_all_licenses($args = []) {
public static function get_all_licenses($args = [])
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
@@ -585,7 +771,8 @@ class LicenseManager {
/**
* Get activations for a license
*/
public static function get_activations($license_id) {
public static function get_activations($license_id)
{
global $wpdb;
$table = $wpdb->prefix . self::$activations_table;

View File

@@ -1,4 +1,5 @@
<?php
/**
* Licensing Module Bootstrap
*
@@ -12,12 +13,14 @@ if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\LicensingSettings;
class LicensingModule {
class LicensingModule
{
/**
* Initialize the licensing module
*/
public static function init() {
public static function init()
{
// Register settings schema
LicensingSettings::init();
@@ -32,12 +35,17 @@ class LicensingModule {
// Add product meta fields
add_action('woocommerce_product_options_general_product_data', [__CLASS__, 'add_product_licensing_fields']);
add_action('woocommerce_process_product_meta', [__CLASS__, 'save_product_licensing_fields']);
// License Connect OAuth endpoint
add_action('init', [__CLASS__, 'register_license_connect_endpoint']);
add_action('template_redirect', [__CLASS__, 'handle_license_connect'], 5);
}
/**
* Initialize manager if module is enabled
*/
public static function maybe_init_manager() {
public static function maybe_init_manager()
{
if (ModuleRegistry::is_enabled('licensing')) {
// Ensure tables exist
self::ensure_tables();
@@ -48,7 +56,8 @@ class LicensingModule {
/**
* Ensure database tables exist
*/
private static function ensure_tables() {
private static function ensure_tables()
{
global $wpdb;
$table = $wpdb->prefix . 'woonoow_licenses';
@@ -61,16 +70,235 @@ class LicensingModule {
/**
* Handle module enable
*/
public static function on_module_enabled($module_id) {
public static function on_module_enabled($module_id)
{
if ($module_id === 'licensing') {
LicenseManager::create_tables();
}
}
/**
* Register license connect rewrite endpoint
*/
public static function register_license_connect_endpoint()
{
add_rewrite_endpoint('license-connect', EP_ROOT | EP_PAGES);
}
/**
* Handle license-connect endpoint (OAuth confirmation page)
*/
public static function handle_license_connect()
{
// Parse the request URI to check if this is the license-connect page
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
$parsed_path = parse_url($request_uri, PHP_URL_PATH);
// Check if path contains license-connect
if (strpos($parsed_path, '/license-connect') === false) {
return;
}
// Get parameters
$license_key = sanitize_text_field($_GET['license_key'] ?? '');
$site_url = esc_url_raw($_GET['site_url'] ?? '');
$return_url = esc_url_raw($_GET['return_url'] ?? '');
$state = sanitize_text_field($_GET['state'] ?? '');
$action = sanitize_text_field($_GET['action'] ?? '');
// Handle form submission (confirmation)
if ($action === 'confirm' && !empty($_POST['confirm_license'])) {
self::process_license_confirmation();
return;
}
// Require login
if (!is_user_logged_in()) {
$login_url = wp_login_url(add_query_arg($_GET, home_url('/my-account/license-connect/')));
wp_redirect($login_url);
exit;
}
// Validate parameters
if (empty($license_key) || empty($site_url) || empty($state)) {
self::render_license_connect_page([
'error' => __('Invalid license connection request. Missing required parameters.', 'woonoow'),
]);
return;
}
// Verify state token
$state_data = LicenseManager::verify_oauth_state($state);
if (!$state_data) {
self::render_license_connect_page([
'error' => __('Invalid or expired connection request. Please try again.', 'woonoow'),
]);
return;
}
// Get license and verify ownership
$license = LicenseManager::get_license_by_key($license_key);
if (!$license) {
self::render_license_connect_page([
'error' => __('License key not found.', 'woonoow'),
]);
return;
}
// Verify license belongs to current user
$current_user_id = get_current_user_id();
if ((int)$license['user_id'] !== $current_user_id) {
self::render_license_connect_page([
'error' => __('This license does not belong to your account.', 'woonoow'),
]);
return;
}
// Check license status
if ($license['status'] !== 'active') {
self::render_license_connect_page([
'error' => __('This license is not active.', 'woonoow'),
]);
return;
}
// Check activation limit
if ($license['activation_limit'] > 0 && $license['activation_count'] >= $license['activation_limit']) {
self::render_license_connect_page([
'error' => sprintf(
__('Activation limit reached (%d/%d sites).', 'woonoow'),
$license['activation_count'],
$license['activation_limit']
),
]);
return;
}
// Get product info
$product = wc_get_product($license['product_id']);
$product_name = $product ? $product->get_name() : __('Unknown Product', 'woonoow');
// Render confirmation page
self::render_license_connect_page([
'license' => $license,
'product_name' => $product_name,
'site_url' => $site_url,
'return_url' => $return_url,
'state' => $state,
]);
}
/**
* Process license confirmation form submission
*/
private static function process_license_confirmation()
{
if (!is_user_logged_in()) {
wp_die(__('You must be logged in.', 'woonoow'));
}
// Verify nonce
if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'woonoow_license_connect')) {
wp_die(__('Security check failed.', 'woonoow'));
}
$license_key = sanitize_text_field($_POST['license_key'] ?? '');
$site_url = esc_url_raw($_POST['site_url'] ?? '');
$return_url = esc_url_raw($_POST['return_url'] ?? '');
$state = sanitize_text_field($_POST['state'] ?? '');
// Verify state
$state_data = LicenseManager::verify_oauth_state($state);
if (!$state_data) {
wp_die(__('Invalid or expired request.', 'woonoow'));
}
// Get and verify license
$license = LicenseManager::get_license_by_key($license_key);
if (!$license || (int)$license['user_id'] !== get_current_user_id()) {
wp_die(__('Invalid license.', 'woonoow'));
}
// Generate activation token
$token_data = LicenseManager::generate_activation_token($license['id'], $site_url);
// Build return URL with token
$callback_url = add_query_arg([
'activation_token' => $token_data['token'],
'license_key' => $license_key,
'state' => $state,
], $return_url);
// Redirect back to client site
wp_redirect($callback_url);
exit;
}
/**
* Render license connect confirmation page
*/
private static function render_license_connect_page($args)
{
// Set headers
status_header(200);
nocache_headers();
// Include WP header
get_header('woonoow');
echo '<div class="woonoow-license-connect" style="max-width: 600px; margin: 40px auto; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', sans-serif;">';
if (!empty($args['error'])) {
echo '<div style="background: #fee; border: 1px solid #c00; padding: 15px; border-radius: 4px; margin-bottom: 20px;">';
echo '<strong>' . esc_html__('Error', 'woonoow') . ':</strong> ' . esc_html($args['error']);
echo '</div>';
echo '<a href="' . esc_url(home_url()) . '" style="color: #0073aa;">&larr; ' . esc_html__('Return Home', 'woonoow') . '</a>';
} else {
$license = $args['license'];
$activations_remaining = $license['activation_limit'] > 0
? $license['activation_limit'] - $license['activation_count']
: '∞';
echo '<h1 style="font-size: 24px; margin-bottom: 20px;">' . esc_html__('Connect Site to License', 'woonoow') . '</h1>';
echo '<div style="background: #f8f9fa; border: 1px solid #e5e7eb; padding: 20px; border-radius: 8px; margin-bottom: 20px;">';
echo '<table style="width: 100%; border-collapse: collapse;">';
echo '<tr><td style="padding: 8px 0; color: #666;">' . esc_html__('Site', 'woonoow') . ':</td><td style="padding: 8px 0; font-weight: 600;">' . esc_html($args['site_url']) . '</td></tr>';
echo '<tr><td style="padding: 8px 0; color: #666;">' . esc_html__('Product', 'woonoow') . ':</td><td style="padding: 8px 0; font-weight: 600;">' . esc_html($args['product_name']) . '</td></tr>';
echo '<tr><td style="padding: 8px 0; color: #666;">' . esc_html__('License', 'woonoow') . ':</td><td style="padding: 8px 0; font-family: monospace;">' . esc_html($license['license_key']) . '</td></tr>';
echo '<tr><td style="padding: 8px 0; color: #666;">' . esc_html__('Activations', 'woonoow') . ':</td><td style="padding: 8px 0;">' . esc_html($license['activation_count']) . '/' . ($license['activation_limit'] ?: '∞') . ' ' . esc_html__('used', 'woonoow') . '</td></tr>';
echo '</table>';
echo '</div>';
echo '<form method="post" action="' . esc_url(add_query_arg('action', 'confirm', home_url('/my-account/license-connect/'))) . '">';
echo wp_nonce_field('woonoow_license_connect', '_wpnonce', true, false);
echo '<input type="hidden" name="license_key" value="' . esc_attr($license['license_key']) . '">';
echo '<input type="hidden" name="site_url" value="' . esc_attr($args['site_url']) . '">';
echo '<input type="hidden" name="return_url" value="' . esc_attr($args['return_url']) . '">';
echo '<input type="hidden" name="state" value="' . esc_attr($args['state']) . '">';
echo '<div style="display: flex; gap: 10px;">';
echo '<button type="submit" name="confirm_license" value="1" style="background: #2563eb; color: white; border: none; padding: 12px 24px; border-radius: 6px; font-size: 16px; cursor: pointer;">';
echo esc_html__('Connect This Site', 'woonoow');
echo '</button>';
echo '<a href="' . esc_url($args['return_url'] ?: home_url()) . '" style="background: #e5e7eb; color: #374151; border: none; padding: 12px 24px; border-radius: 6px; font-size: 16px; text-decoration: none;">';
echo esc_html__('Cancel', 'woonoow');
echo '</a>';
echo '</div>';
echo '</form>';
}
echo '</div>';
get_footer('woonoow');
exit;
}
/**
* Add licensing fields to product edit page
*/
public static function add_product_licensing_fields() {
public static function add_product_licensing_fields()
{
global $post;
if (!ModuleRegistry::is_enabled('licensing')) {
@@ -107,13 +335,30 @@ class LicensingModule {
],
]);
// Only show activation method if per-product override is enabled
$licensing_settings = get_option('woonoow_module_licensing_settings', []);
$allow_override = $licensing_settings['allow_product_override'] ?? false;
if ($allow_override) {
woocommerce_wp_select([
'id' => '_woonoow_license_activation_method',
'label' => __('Activation Method', 'woonoow'),
'description' => __('Override site-level setting for this product', 'woonoow'),
'options' => [
'' => __('Use Site Default', 'woonoow'),
'api' => __('Simple API (license key only)', 'woonoow'),
'oauth' => __('Secure OAuth (requires account login)', 'woonoow'),
],
]);
}
echo '</div>';
}
/**
* Save licensing fields
*/
public static function save_product_licensing_fields($post_id) {
public static function save_product_licensing_fields($post_id)
{
$licensing_enabled = isset($_POST['_woonoow_licensing_enabled']) ? 'yes' : 'no';
update_post_meta($post_id, '_woonoow_licensing_enabled', $licensing_enabled);
@@ -124,5 +369,13 @@ class LicensingModule {
if (isset($_POST['_woonoow_license_expiry_days'])) {
update_post_meta($post_id, '_woonoow_license_expiry_days', absint($_POST['_woonoow_license_expiry_days']));
}
if (isset($_POST['_woonoow_license_activation_method'])) {
$method = $_POST['_woonoow_license_activation_method'];
// Accept empty (site default), api, or oauth
if ($method === '' || in_array($method, ['api', 'oauth'])) {
update_post_meta($post_id, '_woonoow_license_activation_method', sanitize_key($method));
}
}
}
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Licensing Module Settings
*
@@ -9,19 +10,22 @@ namespace WooNooW\Modules;
if (!defined('ABSPATH')) exit;
class LicensingSettings {
class LicensingSettings
{
/**
* Initialize the settings
*/
public static function init() {
public static function init()
{
add_filter('woonoow/module_settings_schema', [__CLASS__, 'register_schema']);
}
/**
* Register licensing settings schema
*/
public static function register_schema($schemas) {
public static function register_schema($schemas)
{
$schemas['licensing'] = [
'license_key_format' => [
'type' => 'select',
@@ -88,6 +92,22 @@ class LicensingSettings {
'min' => 1,
'max' => 30,
],
'activation_method' => [
'type' => 'select',
'label' => __('Activation Method', 'woonoow'),
'description' => __('How licenses are activated. OAuth requires user login on your site (anti-piracy).', 'woonoow'),
'options' => [
'api' => __('Simple API (license key only)', 'woonoow'),
'oauth' => __('Secure OAuth (requires account login)', 'woonoow'),
],
'default' => 'api',
],
'allow_product_override' => [
'type' => 'toggle',
'label' => __('Allow Per-Product Override', 'woonoow'),
'description' => __('Show activation method field on each product for individual customization', 'woonoow'),
'default' => false,
],
];
return $schemas;