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:
228
.agent/reports/email-notification-audit-2026-01-29.md
Normal file
228
.agent/reports/email-notification-audit-2026-01-29.md
Normal 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.
|
||||||
391
.agent/reports/license-activation-research-2026-01-31.md
Normal file
391
.agent/reports/license-activation-research-2026-01-31.md
Normal 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 |
|
||||||
212
.agent/reports/product-flow-audit-2026-01-29.md
Normal file
212
.agent/reports/product-flow-audit-2026-01-29.md
Normal 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
|
||||||
@@ -109,6 +109,31 @@ GET /analytics/orders # Order analytics
|
|||||||
GET /analytics/customers # Customer 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
|
## Conflict Prevention Rules
|
||||||
|
|||||||
284
LICENSING_MODULE.md
Normal file
284
LICENSING_MODULE.md
Normal 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) |
|
||||||
@@ -60,6 +60,7 @@ export interface PageItem {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
title: string;
|
title: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
isFrontPage?: boolean;
|
||||||
isSpaLanding?: boolean;
|
isSpaLanding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type ProductFormData = {
|
|||||||
licensing_enabled?: boolean;
|
licensing_enabled?: boolean;
|
||||||
license_activation_limit?: string;
|
license_activation_limit?: string;
|
||||||
license_duration_days?: string;
|
license_duration_days?: string;
|
||||||
|
license_activation_method?: '' | 'api' | 'oauth';
|
||||||
// Subscription
|
// Subscription
|
||||||
subscription_enabled?: boolean;
|
subscription_enabled?: boolean;
|
||||||
subscription_period?: 'day' | 'week' | 'month' | 'year';
|
subscription_period?: 'day' | 'week' | 'month' | 'year';
|
||||||
@@ -95,6 +96,7 @@ export function ProductFormTabbed({
|
|||||||
const [licensingEnabled, setLicensingEnabled] = useState(initial?.licensing_enabled || false);
|
const [licensingEnabled, setLicensingEnabled] = useState(initial?.licensing_enabled || false);
|
||||||
const [licenseActivationLimit, setLicenseActivationLimit] = useState(initial?.license_activation_limit || '');
|
const [licenseActivationLimit, setLicenseActivationLimit] = useState(initial?.license_activation_limit || '');
|
||||||
const [licenseDurationDays, setLicenseDurationDays] = useState(initial?.license_duration_days || '');
|
const [licenseDurationDays, setLicenseDurationDays] = useState(initial?.license_duration_days || '');
|
||||||
|
const [licenseActivationMethod, setLicenseActivationMethod] = useState<'' | 'api' | 'oauth'>(initial?.license_activation_method || '');
|
||||||
// Subscription state
|
// Subscription state
|
||||||
const [subscriptionEnabled, setSubscriptionEnabled] = useState(initial?.subscription_enabled || false);
|
const [subscriptionEnabled, setSubscriptionEnabled] = useState(initial?.subscription_enabled || false);
|
||||||
const [subscriptionPeriod, setSubscriptionPeriod] = useState<'day' | 'week' | 'month' | 'year'>(initial?.subscription_period || 'month');
|
const [subscriptionPeriod, setSubscriptionPeriod] = useState<'day' | 'week' | 'month' | 'year'>(initial?.subscription_period || 'month');
|
||||||
@@ -131,6 +133,7 @@ export function ProductFormTabbed({
|
|||||||
setLicensingEnabled(initial.licensing_enabled || false);
|
setLicensingEnabled(initial.licensing_enabled || false);
|
||||||
setLicenseActivationLimit(initial.license_activation_limit || '');
|
setLicenseActivationLimit(initial.license_activation_limit || '');
|
||||||
setLicenseDurationDays(initial.license_duration_days || '');
|
setLicenseDurationDays(initial.license_duration_days || '');
|
||||||
|
setLicenseActivationMethod(initial.license_activation_method || '');
|
||||||
// Subscription
|
// Subscription
|
||||||
setSubscriptionEnabled(initial.subscription_enabled || false);
|
setSubscriptionEnabled(initial.subscription_enabled || false);
|
||||||
setSubscriptionPeriod(initial.subscription_period || 'month');
|
setSubscriptionPeriod(initial.subscription_period || 'month');
|
||||||
@@ -199,6 +202,7 @@ export function ProductFormTabbed({
|
|||||||
licensing_enabled: licensingEnabled,
|
licensing_enabled: licensingEnabled,
|
||||||
license_activation_limit: licensingEnabled ? licenseActivationLimit : undefined,
|
license_activation_limit: licensingEnabled ? licenseActivationLimit : undefined,
|
||||||
license_duration_days: licensingEnabled ? licenseDurationDays : undefined,
|
license_duration_days: licensingEnabled ? licenseDurationDays : undefined,
|
||||||
|
license_activation_method: licensingEnabled ? licenseActivationMethod : undefined,
|
||||||
// Subscription
|
// Subscription
|
||||||
subscription_enabled: subscriptionEnabled,
|
subscription_enabled: subscriptionEnabled,
|
||||||
subscription_period: subscriptionEnabled ? subscriptionPeriod : undefined,
|
subscription_period: subscriptionEnabled ? subscriptionPeriod : undefined,
|
||||||
@@ -261,6 +265,8 @@ export function ProductFormTabbed({
|
|||||||
setLicenseActivationLimit={setLicenseActivationLimit}
|
setLicenseActivationLimit={setLicenseActivationLimit}
|
||||||
licenseDurationDays={licenseDurationDays}
|
licenseDurationDays={licenseDurationDays}
|
||||||
setLicenseDurationDays={setLicenseDurationDays}
|
setLicenseDurationDays={setLicenseDurationDays}
|
||||||
|
licenseActivationMethod={licenseActivationMethod}
|
||||||
|
setLicenseActivationMethod={setLicenseActivationMethod}
|
||||||
subscriptionEnabled={subscriptionEnabled}
|
subscriptionEnabled={subscriptionEnabled}
|
||||||
setSubscriptionEnabled={setSubscriptionEnabled}
|
setSubscriptionEnabled={setSubscriptionEnabled}
|
||||||
subscriptionPeriod={subscriptionPeriod}
|
subscriptionPeriod={subscriptionPeriod}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ type GeneralTabProps = {
|
|||||||
setLicenseActivationLimit?: (value: string) => void;
|
setLicenseActivationLimit?: (value: string) => void;
|
||||||
licenseDurationDays?: string;
|
licenseDurationDays?: string;
|
||||||
setLicenseDurationDays?: (value: string) => void;
|
setLicenseDurationDays?: (value: string) => void;
|
||||||
|
licenseActivationMethod?: '' | 'api' | 'oauth';
|
||||||
|
setLicenseActivationMethod?: (value: '' | 'api' | 'oauth') => void;
|
||||||
// Subscription
|
// Subscription
|
||||||
subscriptionEnabled?: boolean;
|
subscriptionEnabled?: boolean;
|
||||||
setSubscriptionEnabled?: (value: boolean) => void;
|
setSubscriptionEnabled?: (value: boolean) => void;
|
||||||
@@ -95,6 +97,8 @@ export function GeneralTab({
|
|||||||
setLicenseActivationLimit,
|
setLicenseActivationLimit,
|
||||||
licenseDurationDays,
|
licenseDurationDays,
|
||||||
setLicenseDurationDays,
|
setLicenseDurationDays,
|
||||||
|
licenseActivationMethod,
|
||||||
|
setLicenseActivationMethod,
|
||||||
subscriptionEnabled,
|
subscriptionEnabled,
|
||||||
setSubscriptionEnabled,
|
setSubscriptionEnabled,
|
||||||
subscriptionPeriod,
|
subscriptionPeriod,
|
||||||
@@ -498,6 +502,27 @@ export function GeneralTab({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ function AppRoutes() {
|
|||||||
const frontPageSlug = getFrontPageSlug();
|
const frontPageSlug = getFrontPageSlug();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* License Connect - Standalone focused page without layout */}
|
||||||
|
<Route path="/my-account/license-connect" element={<Account />} />
|
||||||
|
|
||||||
|
{/* All other routes wrapped in BaseLayout */}
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
<BaseLayout>
|
<BaseLayout>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */}
|
{/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */}
|
||||||
@@ -123,6 +131,9 @@ function AppRoutes() {
|
|||||||
<Route path="/:slug" element={<DynamicPageRenderer />} />
|
<Route path="/:slug" element={<DynamicPageRenderer />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { api } from '@/lib/api/client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -19,114 +20,115 @@ interface WishlistItem {
|
|||||||
const GUEST_WISHLIST_KEY = 'woonoow_guest_wishlist';
|
const GUEST_WISHLIST_KEY = 'woonoow_guest_wishlist';
|
||||||
|
|
||||||
export function useWishlist() {
|
export function useWishlist() {
|
||||||
const [items, setItems] = useState<WishlistItem[]>([]);
|
const queryClient = useQueryClient();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [guestIds, setGuestIds] = useState<Set<number>>(new Set());
|
||||||
const [productIds, setProductIds] = useState<Set<number>>(new Set());
|
|
||||||
|
|
||||||
// Check if wishlist is enabled (default true if not explicitly set to false)
|
// Check if wishlist is enabled (default true if not explicitly set to false)
|
||||||
const settings = (window as any).woonoowCustomer?.settings;
|
const settings = (window as any).woonoowCustomer?.settings;
|
||||||
const isEnabled = settings?.wishlist_enabled !== false;
|
const isEnabled = settings?.wishlist_enabled !== false;
|
||||||
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
|
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
|
||||||
|
|
||||||
// Load guest wishlist from localStorage
|
// Load guest wishlist on mount
|
||||||
const loadGuestWishlist = useCallback(() => {
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
|
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const guestIds = JSON.parse(stored) as number[];
|
const ids = JSON.parse(stored) as number[];
|
||||||
setProductIds(new Set(guestIds));
|
setGuestIds(new Set(ids));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load guest wishlist:', 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>) => {
|
const saveGuestWishlist = useCallback((ids: Set<number>) => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(GUEST_WISHLIST_KEY, JSON.stringify(Array.from(ids)));
|
localStorage.setItem(GUEST_WISHLIST_KEY, JSON.stringify(Array.from(ids)));
|
||||||
|
setGuestIds(ids);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save guest wishlist:', error);
|
console.error('Failed to save guest wishlist:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load wishlist on mount
|
// Fetch wishlist items (Server)
|
||||||
useEffect(() => {
|
const { data: serverItems = [], isLoading: isServerLoading } = useQuery({
|
||||||
if (isEnabled) {
|
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) {
|
if (isLoggedIn) {
|
||||||
loadWishlist();
|
return new Set(serverItems.map(item => item.product_id));
|
||||||
} else {
|
|
||||||
loadGuestWishlist();
|
|
||||||
}
|
}
|
||||||
}
|
return guestIds;
|
||||||
}, [isEnabled, isLoggedIn]);
|
}, [isLoggedIn, serverItems, guestIds]);
|
||||||
|
|
||||||
const loadWishlist = useCallback(async () => {
|
// Mutations
|
||||||
if (!isLoggedIn) return;
|
const addMutation = useMutation({
|
||||||
|
mutationFn: async (productId: number) => {
|
||||||
try {
|
return await api.post('/account/wishlist', { product_id: productId });
|
||||||
setIsLoading(true);
|
},
|
||||||
const data = await api.get<WishlistItem[]>('/account/wishlist');
|
onSuccess: () => {
|
||||||
setItems(data);
|
queryClient.invalidateQueries({ queryKey: ['wishlist'] });
|
||||||
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);
|
|
||||||
toast.success('Added to wishlist');
|
toast.success('Added to wishlist');
|
||||||
return true;
|
},
|
||||||
}
|
onError: (error: any) => {
|
||||||
|
|
||||||
// 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) {
|
|
||||||
const message = error?.message || 'Failed to add to wishlist';
|
const message = error?.message || 'Failed to add to wishlist';
|
||||||
toast.error(message);
|
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) => {
|
const removeFromWishlist = useCallback(async (productId: number) => {
|
||||||
// Guest mode: remove from localStorage only
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
const newIds = new Set(productIds);
|
const newIds = new Set(guestIds);
|
||||||
newIds.delete(productId);
|
newIds.delete(productId);
|
||||||
setProductIds(newIds);
|
|
||||||
saveGuestWishlist(newIds);
|
saveGuestWishlist(newIds);
|
||||||
toast.success('Removed from wishlist');
|
toast.success('Removed from wishlist');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logged in: use API
|
await removeMutation.mutateAsync(productId);
|
||||||
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;
|
return true;
|
||||||
} catch (error) {
|
}, [isLoggedIn, guestIds, saveGuestWishlist, removeMutation]);
|
||||||
toast.error('Failed to remove from wishlist');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [isLoggedIn, productIds, items, saveGuestWishlist]);
|
|
||||||
|
|
||||||
const toggleWishlist = useCallback(async (productId: number) => {
|
const toggleWishlist = useCallback(async (productId: number) => {
|
||||||
if (productIds.has(productId)) {
|
if (productIds.has(productId)) {
|
||||||
@@ -145,12 +147,12 @@ export function useWishlist() {
|
|||||||
isLoading,
|
isLoading,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
count: items.length,
|
count: productIds.size,
|
||||||
productIds,
|
productIds,
|
||||||
addToWishlist,
|
addToWishlist,
|
||||||
removeFromWishlist,
|
removeFromWishlist,
|
||||||
toggleWishlist,
|
toggleWishlist,
|
||||||
isInWishlist,
|
isInWishlist,
|
||||||
refresh: loadWishlist,
|
refresh: () => queryClient.invalidateQueries({ queryKey: ['wishlist'] }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
289
customer-spa/src/pages/Account/LicenseConnect.tsx
Normal file
289
customer-spa/src/pages/Account/LicenseConnect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import Addresses from './Addresses';
|
|||||||
import Wishlist from './Wishlist';
|
import Wishlist from './Wishlist';
|
||||||
import AccountDetails from './AccountDetails';
|
import AccountDetails from './AccountDetails';
|
||||||
import Licenses from './Licenses';
|
import Licenses from './Licenses';
|
||||||
|
import LicenseConnect from './LicenseConnect';
|
||||||
import Subscriptions from './Subscriptions';
|
import Subscriptions from './Subscriptions';
|
||||||
import SubscriptionDetail from './SubscriptionDetail';
|
import SubscriptionDetail from './SubscriptionDetail';
|
||||||
|
|
||||||
@@ -19,10 +20,15 @@ export default function Account() {
|
|||||||
|
|
||||||
// Redirect to login if not authenticated
|
// Redirect to login if not authenticated
|
||||||
if (!user?.isLoggedIn) {
|
if (!user?.isLoggedIn) {
|
||||||
const currentPath = location.pathname;
|
const currentPath = location.pathname + location.search;
|
||||||
return <Navigate to={`/login?redirect=${encodeURIComponent(currentPath)}`} replace />;
|
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 (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<AccountLayout>
|
<AccountLayout>
|
||||||
@@ -43,4 +49,3 @@ export default function Account() {
|
|||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,25 +98,47 @@ export default function Product() {
|
|||||||
if (!v.attributes) return false;
|
if (!v.attributes) return false;
|
||||||
|
|
||||||
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
|
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
|
||||||
const normalizedValue = attrValue.toLowerCase().trim();
|
const normalizedSelectedValue = attrValue.toLowerCase().trim();
|
||||||
|
|
||||||
// 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();
|
const attrNameLower = attrName.toLowerCase();
|
||||||
|
|
||||||
if (vKeyLower === `attribute_${attrNameLower}` ||
|
// Find the attribute definition to get the slug
|
||||||
vKeyLower === `attribute_pa_${attrNameLower}` ||
|
const attrDef = product.attributes?.find((a: any) => a.name === attrName);
|
||||||
vKeyLower === attrNameLower) {
|
const attrSlug = attrDef?.slug || attrNameLower.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
|
||||||
const varValueNormalized = String(vValue).toLowerCase().trim();
|
// Try to find a matching key in the variation attributes
|
||||||
if (varValueNormalized === normalizedValue) {
|
let variationValue: string | undefined = undefined;
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If key is undefined/missing in variation, it means "Any" -> Match
|
||||||
|
if (variationValue === undefined || variationValue === null) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// If empty string, it also means "Any" -> Match
|
||||||
|
const normalizedVarValue = String(variationValue).toLowerCase().trim();
|
||||||
|
if (normalizedVarValue === '') {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
// 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 {
|
try {
|
||||||
await apiClient.post(apiClient.endpoints.cart.add, {
|
await apiClient.post(apiClient.endpoints.cart.add, {
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
quantity,
|
quantity,
|
||||||
variation_id: selectedVariation?.id || 0,
|
variation_id: selectedVariation?.id || 0,
|
||||||
|
variation: variation_params,
|
||||||
});
|
});
|
||||||
|
|
||||||
addItem({
|
addItem({
|
||||||
|
|||||||
69
debug-variation.php
Normal file
69
debug-variation.php
Normal 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);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace WooNooW\Api\Controllers;
|
namespace WooNooW\Api\Controllers;
|
||||||
|
|
||||||
use WP_REST_Controller;
|
use WP_REST_Controller;
|
||||||
@@ -9,12 +10,14 @@ use WP_Error;
|
|||||||
* Cart Controller
|
* Cart Controller
|
||||||
* Handles cart operations via REST API
|
* Handles cart operations via REST API
|
||||||
*/
|
*/
|
||||||
class CartController extends WP_REST_Controller {
|
class CartController extends WP_REST_Controller
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register routes
|
* Register routes
|
||||||
*/
|
*/
|
||||||
public function register_routes() {
|
public function register_routes()
|
||||||
|
{
|
||||||
$namespace = 'woonoow/v1';
|
$namespace = 'woonoow/v1';
|
||||||
|
|
||||||
// Get cart
|
// Get cart
|
||||||
@@ -119,7 +122,8 @@ class CartController extends WP_REST_Controller {
|
|||||||
/**
|
/**
|
||||||
* Get cart contents
|
* Get cart contents
|
||||||
*/
|
*/
|
||||||
public function get_cart($request) {
|
public function get_cart($request)
|
||||||
|
{
|
||||||
if (!function_exists('WC')) {
|
if (!function_exists('WC')) {
|
||||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
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
|
* Add product to cart
|
||||||
*/
|
*/
|
||||||
public function add_to_cart($request) {
|
public function add_to_cart($request)
|
||||||
|
{
|
||||||
if (!function_exists('WC')) {
|
if (!function_exists('WC')) {
|
||||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
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');
|
$product_id = $request->get_param('product_id');
|
||||||
$quantity = $request->get_param('quantity') ?: 1;
|
$quantity = $request->get_param('quantity') ?: 1;
|
||||||
$variation_id = $request->get_param('variation_id') ?: 0;
|
$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
|
// Validate product
|
||||||
$product = wc_get_product($product_id);
|
$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]);
|
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
|
// 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) {
|
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([
|
return new WP_REST_Response([
|
||||||
@@ -214,7 +283,8 @@ class CartController extends WP_REST_Controller {
|
|||||||
/**
|
/**
|
||||||
* Update cart item quantity
|
* Update cart item quantity
|
||||||
*/
|
*/
|
||||||
public function update_cart_item($request) {
|
public function update_cart_item($request)
|
||||||
|
{
|
||||||
if (!function_exists('WC')) {
|
if (!function_exists('WC')) {
|
||||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
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
|
* Remove item from cart
|
||||||
*/
|
*/
|
||||||
public function remove_from_cart($request) {
|
public function remove_from_cart($request)
|
||||||
|
{
|
||||||
if (!function_exists('WC')) {
|
if (!function_exists('WC')) {
|
||||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
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
|
* Clear cart
|
||||||
*/
|
*/
|
||||||
public function clear_cart($request) {
|
public function clear_cart($request)
|
||||||
|
{
|
||||||
if (!function_exists('WC')) {
|
if (!function_exists('WC')) {
|
||||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
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
|
* Apply coupon
|
||||||
*/
|
*/
|
||||||
public function apply_coupon($request) {
|
public function apply_coupon($request)
|
||||||
|
{
|
||||||
if (!function_exists('WC')) {
|
if (!function_exists('WC')) {
|
||||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
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
|
* Remove coupon
|
||||||
*/
|
*/
|
||||||
public function remove_coupon($request) {
|
public function remove_coupon($request)
|
||||||
|
{
|
||||||
if (!function_exists('WC')) {
|
if (!function_exists('WC')) {
|
||||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
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
|
* Format cart items for response
|
||||||
*/
|
*/
|
||||||
private function format_cart_items($cart_items) {
|
private function format_cart_items($cart_items)
|
||||||
|
{
|
||||||
$formatted = [];
|
$formatted = [];
|
||||||
|
|
||||||
foreach ($cart_items as $cart_item_key => $cart_item) {
|
foreach ($cart_items as $cart_item_key => $cart_item) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Licenses API Controller
|
* Licenses API Controller
|
||||||
*
|
*
|
||||||
@@ -17,12 +18,14 @@ use WP_Error;
|
|||||||
use WooNooW\Core\ModuleRegistry;
|
use WooNooW\Core\ModuleRegistry;
|
||||||
use WooNooW\Modules\Licensing\LicenseManager;
|
use WooNooW\Modules\Licensing\LicenseManager;
|
||||||
|
|
||||||
class LicensesController {
|
class LicensesController
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register REST routes
|
* Register REST routes
|
||||||
*/
|
*/
|
||||||
public static function register_routes() {
|
public static function register_routes()
|
||||||
|
{
|
||||||
// Check if module is enabled
|
// Check if module is enabled
|
||||||
if (!ModuleRegistry::is_enabled('licensing')) {
|
if (!ModuleRegistry::is_enabled('licensing')) {
|
||||||
return;
|
return;
|
||||||
@@ -96,12 +99,30 @@ class LicensesController {
|
|||||||
'callback' => [__CLASS__, 'deactivate_license'],
|
'callback' => [__CLASS__, 'deactivate_license'],
|
||||||
'permission_callback' => '__return_true',
|
'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)
|
* Get all licenses (admin)
|
||||||
*/
|
*/
|
||||||
public static function get_licenses(WP_REST_Request $request) {
|
public static function get_licenses(WP_REST_Request $request)
|
||||||
|
{
|
||||||
$args = [
|
$args = [
|
||||||
'search' => $request->get_param('search'),
|
'search' => $request->get_param('search'),
|
||||||
'status' => $request->get_param('status'),
|
'status' => $request->get_param('status'),
|
||||||
@@ -129,7 +150,8 @@ class LicensesController {
|
|||||||
/**
|
/**
|
||||||
* Get single license (admin)
|
* 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'));
|
$license = LicenseManager::get_license($request->get_param('id'));
|
||||||
|
|
||||||
if (!$license) {
|
if (!$license) {
|
||||||
@@ -145,7 +167,8 @@ class LicensesController {
|
|||||||
/**
|
/**
|
||||||
* Revoke license (admin)
|
* 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'));
|
$result = LicenseManager::revoke($request->get_param('id'));
|
||||||
|
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
@@ -158,7 +181,8 @@ class LicensesController {
|
|||||||
/**
|
/**
|
||||||
* Get activations for license (admin)
|
* 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'));
|
$activations = LicenseManager::get_activations($request->get_param('id'));
|
||||||
return new WP_REST_Response($activations);
|
return new WP_REST_Response($activations);
|
||||||
}
|
}
|
||||||
@@ -166,7 +190,8 @@ class LicensesController {
|
|||||||
/**
|
/**
|
||||||
* Get customer's licenses
|
* 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();
|
$user_id = get_current_user_id();
|
||||||
$licenses = LicenseManager::get_user_licenses($user_id);
|
$licenses = LicenseManager::get_user_licenses($user_id);
|
||||||
|
|
||||||
@@ -182,7 +207,8 @@ class LicensesController {
|
|||||||
/**
|
/**
|
||||||
* Customer deactivate their own activation
|
* 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();
|
$user_id = get_current_user_id();
|
||||||
$license = LicenseManager::get_license($request->get_param('id'));
|
$license = LicenseManager::get_license($request->get_param('id'));
|
||||||
|
|
||||||
@@ -207,7 +233,8 @@ class LicensesController {
|
|||||||
/**
|
/**
|
||||||
* Validate license (public API)
|
* 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();
|
$data = $request->get_json_params();
|
||||||
|
|
||||||
if (empty($data['license_key'])) {
|
if (empty($data['license_key'])) {
|
||||||
@@ -221,7 +248,8 @@ class LicensesController {
|
|||||||
/**
|
/**
|
||||||
* Activate license (public API)
|
* 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();
|
$data = $request->get_json_params();
|
||||||
|
|
||||||
if (empty($data['license_key'])) {
|
if (empty($data['license_key'])) {
|
||||||
@@ -233,6 +261,8 @@ class LicensesController {
|
|||||||
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
|
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||||
'machine_id' => $data['machine_id'] ?? null,
|
'machine_id' => $data['machine_id'] ?? null,
|
||||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 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);
|
$result = LicenseManager::activate($data['license_key'], $activation_data);
|
||||||
@@ -247,7 +277,8 @@ class LicensesController {
|
|||||||
/**
|
/**
|
||||||
* Deactivate license (public API)
|
* 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();
|
$data = $request->get_json_params();
|
||||||
|
|
||||||
if (empty($data['license_key'])) {
|
if (empty($data['license_key'])) {
|
||||||
@@ -270,7 +301,8 @@ class LicensesController {
|
|||||||
/**
|
/**
|
||||||
* Enrich license with product and user info
|
* Enrich license with product and user info
|
||||||
*/
|
*/
|
||||||
private static function enrich_license($license) {
|
private static function enrich_license($license)
|
||||||
|
{
|
||||||
// Add product info
|
// Add product info
|
||||||
$product = wc_get_product($license['product_id']);
|
$product = wc_get_product($license['product_id']);
|
||||||
$license['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'woonoow');
|
$license['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'woonoow');
|
||||||
@@ -288,4 +320,108 @@ class LicensesController {
|
|||||||
|
|
||||||
return $license;
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Products REST API Controller
|
* Products REST API Controller
|
||||||
*
|
*
|
||||||
@@ -18,12 +19,14 @@ use WC_Product_Simple;
|
|||||||
use WC_Product_Variable;
|
use WC_Product_Variable;
|
||||||
use WC_Product_Variation;
|
use WC_Product_Variation;
|
||||||
|
|
||||||
class ProductsController {
|
class ProductsController
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize text field
|
* Sanitize text field
|
||||||
*/
|
*/
|
||||||
private static function sanitize_text($value) {
|
private static function sanitize_text($value)
|
||||||
|
{
|
||||||
if (!isset($value) || $value === '') {
|
if (!isset($value) || $value === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -34,7 +37,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Sanitize textarea (allows newlines)
|
* Sanitize textarea (allows newlines)
|
||||||
*/
|
*/
|
||||||
private static function sanitize_textarea($value) {
|
private static function sanitize_textarea($value)
|
||||||
|
{
|
||||||
if (!isset($value) || $value === '') {
|
if (!isset($value) || $value === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -45,7 +49,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Sanitize numeric value
|
* Sanitize numeric value
|
||||||
*/
|
*/
|
||||||
private static function sanitize_number($value) {
|
private static function sanitize_number($value)
|
||||||
|
{
|
||||||
if (!isset($value) || $value === '') {
|
if (!isset($value) || $value === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -57,7 +62,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Sanitize slug
|
* Sanitize slug
|
||||||
*/
|
*/
|
||||||
private static function sanitize_slug($value) {
|
private static function sanitize_slug($value)
|
||||||
|
{
|
||||||
if (!isset($value) || $value === '') {
|
if (!isset($value) || $value === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -67,7 +73,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Register REST API routes
|
* Register REST API routes
|
||||||
*/
|
*/
|
||||||
public static function register_routes() {
|
public static function register_routes()
|
||||||
|
{
|
||||||
// List products
|
// List products
|
||||||
register_rest_route('woonoow/v1', '/products', [
|
register_rest_route('woonoow/v1', '/products', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
@@ -191,7 +198,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Get products list with filters
|
* Get products list with filters
|
||||||
*/
|
*/
|
||||||
public static function get_products(WP_REST_Request $request) {
|
public static function get_products(WP_REST_Request $request)
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$page = max(1, (int) $request->get_param('page'));
|
$page = max(1, (int) $request->get_param('page'));
|
||||||
$per_page = min(100, max(1, (int) ($request->get_param('per_page') ?: 20)));
|
$per_page = min(100, max(1, (int) ($request->get_param('per_page') ?: 20)));
|
||||||
@@ -279,7 +287,6 @@ class ProductsController {
|
|||||||
$response->header('Expires', '0');
|
$response->header('Expires', '0');
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return new WP_Error('products_error', $e->getMessage(), ['status' => 500]);
|
return new WP_Error('products_error', $e->getMessage(), ['status' => 500]);
|
||||||
}
|
}
|
||||||
@@ -288,7 +295,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Get single product
|
* 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');
|
$id = (int) $request->get_param('id');
|
||||||
$product = wc_get_product($id);
|
$product = wc_get_product($id);
|
||||||
|
|
||||||
@@ -302,7 +310,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Create new product
|
* Create new product
|
||||||
*/
|
*/
|
||||||
public static function create_product(WP_REST_Request $request) {
|
public static function create_product(WP_REST_Request $request)
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$data = $request->get_json_params();
|
$data = $request->get_json_params();
|
||||||
|
|
||||||
@@ -423,6 +432,13 @@ class ProductsController {
|
|||||||
if (isset($data['license_duration_days'])) {
|
if (isset($data['license_duration_days'])) {
|
||||||
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($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
|
// Subscription meta
|
||||||
if (isset($data['subscription_enabled'])) {
|
if (isset($data['subscription_enabled'])) {
|
||||||
@@ -459,7 +475,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Update product
|
* 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');
|
$id = (int) $request->get_param('id');
|
||||||
$data = $request->get_json_params();
|
$data = $request->get_json_params();
|
||||||
|
|
||||||
@@ -584,6 +601,13 @@ class ProductsController {
|
|||||||
if (isset($data['license_duration_days'])) {
|
if (isset($data['license_duration_days'])) {
|
||||||
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($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
|
// Subscription meta
|
||||||
if (isset($data['subscription_enabled'])) {
|
if (isset($data['subscription_enabled'])) {
|
||||||
@@ -621,7 +645,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Delete product
|
* 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');
|
$id = (int) $request->get_param('id');
|
||||||
$force = $request->get_param('force') === 'true';
|
$force = $request->get_param('force') === 'true';
|
||||||
|
|
||||||
@@ -642,7 +667,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Get product categories
|
* Get product categories
|
||||||
*/
|
*/
|
||||||
public static function get_categories(WP_REST_Request $request) {
|
public static function get_categories(WP_REST_Request $request)
|
||||||
|
{
|
||||||
$terms = get_terms([
|
$terms = get_terms([
|
||||||
'taxonomy' => 'product_cat',
|
'taxonomy' => 'product_cat',
|
||||||
'hide_empty' => false,
|
'hide_empty' => false,
|
||||||
@@ -671,7 +697,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Get product tags
|
* Get product tags
|
||||||
*/
|
*/
|
||||||
public static function get_tags(WP_REST_Request $request) {
|
public static function get_tags(WP_REST_Request $request)
|
||||||
|
{
|
||||||
$terms = get_terms([
|
$terms = get_terms([
|
||||||
'taxonomy' => 'product_tag',
|
'taxonomy' => 'product_tag',
|
||||||
'hide_empty' => false,
|
'hide_empty' => false,
|
||||||
@@ -699,7 +726,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Get product attributes
|
* 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();
|
$attributes = wc_get_attribute_taxonomies();
|
||||||
$result = [];
|
$result = [];
|
||||||
|
|
||||||
@@ -721,7 +749,8 @@ class ProductsController {
|
|||||||
* Format product for list view
|
* Format product for list view
|
||||||
* Returns all essential product fields including type, status, prices, stock, etc.
|
* 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');
|
$image = wp_get_attachment_image_src($product->get_image_id(), 'thumbnail');
|
||||||
|
|
||||||
// Get price HTML - for variable products, show price range
|
// Get price HTML - for variable products, show price range
|
||||||
@@ -762,7 +791,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Format product with full details
|
* 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);
|
$data = self::format_product_list_item($product);
|
||||||
|
|
||||||
// Add full details
|
// Add full details
|
||||||
@@ -798,8 +828,9 @@ class ProductsController {
|
|||||||
|
|
||||||
// Licensing fields
|
// Licensing fields
|
||||||
$data['licensing_enabled'] = get_post_meta($product->get_id(), '_woonoow_licensing_enabled', true) === 'yes';
|
$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_activation_limit'] = get_post_meta($product->get_id(), '_woonoow_license_activation_limit', true) ?: '';
|
||||||
$data['license_duration_days'] = get_post_meta($product->get_id(), '_license_duration_days', 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
|
// Subscription fields
|
||||||
$data['subscription_enabled'] = get_post_meta($product->get_id(), '_woonoow_subscription_enabled', true) === 'yes';
|
$data['subscription_enabled'] = get_post_meta($product->get_id(), '_woonoow_subscription_enabled', true) === 'yes';
|
||||||
@@ -858,7 +889,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Get product attributes
|
* Get product attributes
|
||||||
*/
|
*/
|
||||||
private static function get_product_attributes($product) {
|
private static function get_product_attributes($product)
|
||||||
|
{
|
||||||
$attributes = [];
|
$attributes = [];
|
||||||
foreach ($product->get_attributes() as $attribute) {
|
foreach ($product->get_attributes() as $attribute) {
|
||||||
$attributes[] = [
|
$attributes[] = [
|
||||||
@@ -876,7 +908,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Get product variations
|
* Get product variations
|
||||||
*/
|
*/
|
||||||
private static function get_product_variations($product) {
|
private static function get_product_variations($product)
|
||||||
|
{
|
||||||
$variations = [];
|
$variations = [];
|
||||||
foreach ($product->get_children() as $variation_id) {
|
foreach ($product->get_children() as $variation_id) {
|
||||||
$variation = wc_get_product($variation_id);
|
$variation = wc_get_product($variation_id);
|
||||||
@@ -951,7 +984,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Save product attributes
|
* Save product attributes
|
||||||
*/
|
*/
|
||||||
private static function save_product_attributes($product, $attributes_data) {
|
private static function save_product_attributes($product, $attributes_data)
|
||||||
|
{
|
||||||
$attributes = [];
|
$attributes = [];
|
||||||
foreach ($attributes_data as $attr_data) {
|
foreach ($attributes_data as $attr_data) {
|
||||||
$attribute = new \WC_Product_Attribute();
|
$attribute = new \WC_Product_Attribute();
|
||||||
@@ -969,7 +1003,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Save product variations
|
* 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
|
// Get existing variation IDs
|
||||||
$existing_variation_ids = $product->get_children();
|
$existing_variation_ids = $product->get_children();
|
||||||
$variations_to_keep = [];
|
$variations_to_keep = [];
|
||||||
@@ -1006,7 +1041,14 @@ class ProductsController {
|
|||||||
$variation->set_attributes($wc_attributes);
|
$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
|
// Set prices - if not provided, use parent's price as fallback
|
||||||
if (isset($var_data['regular_price']) && $var_data['regular_price'] !== '') {
|
if (isset($var_data['regular_price']) && $var_data['regular_price'] !== '') {
|
||||||
@@ -1034,6 +1076,11 @@ class ProductsController {
|
|||||||
$variation->set_image_id($var_data['image_id']);
|
$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
|
// Save variation first
|
||||||
$saved_id = $variation->save();
|
$saved_id = $variation->save();
|
||||||
$variations_to_keep[] = $saved_id;
|
$variations_to_keep[] = $saved_id;
|
||||||
@@ -1086,7 +1133,8 @@ class ProductsController {
|
|||||||
* @param \WC_Product $product
|
* @param \WC_Product $product
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
private static function get_product_meta_data($product) {
|
private static function get_product_meta_data($product)
|
||||||
|
{
|
||||||
$meta_data = [];
|
$meta_data = [];
|
||||||
|
|
||||||
foreach ($product->get_meta_data() as $meta) {
|
foreach ($product->get_meta_data() as $meta) {
|
||||||
@@ -1108,7 +1156,6 @@ class ProductsController {
|
|||||||
$meta_data[$key] = $value;
|
$meta_data[$key] = $value;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $meta_data;
|
return $meta_data;
|
||||||
@@ -1120,7 +1167,8 @@ class ProductsController {
|
|||||||
* @param \WC_Product $product
|
* @param \WC_Product $product
|
||||||
* @param array $meta_updates
|
* @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
|
// Get allowed updatable meta keys
|
||||||
// Core has ZERO defaults - plugins register via filter
|
// Core has ZERO defaults - plugins register via filter
|
||||||
$allowed = apply_filters('woonoow/product_updatable_meta', [], $product);
|
$allowed = apply_filters('woonoow/product_updatable_meta', [], $product);
|
||||||
@@ -1152,7 +1200,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Create product category
|
* Create product category
|
||||||
*/
|
*/
|
||||||
public static function create_category(WP_REST_Request $request) {
|
public static function create_category(WP_REST_Request $request)
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$name = sanitize_text_field($request->get_param('name'));
|
$name = sanitize_text_field($request->get_param('name'));
|
||||||
$slug = sanitize_title($request->get_param('slug') ?: $name);
|
$slug = sanitize_title($request->get_param('slug') ?: $name);
|
||||||
@@ -1196,7 +1245,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Update product category
|
* Update product category
|
||||||
*/
|
*/
|
||||||
public static function update_category(WP_REST_Request $request) {
|
public static function update_category(WP_REST_Request $request)
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$term_id = (int) $request->get_param('id');
|
$term_id = (int) $request->get_param('id');
|
||||||
$name = sanitize_text_field($request->get_param('name'));
|
$name = sanitize_text_field($request->get_param('name'));
|
||||||
@@ -1242,7 +1292,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Delete product category
|
* Delete product category
|
||||||
*/
|
*/
|
||||||
public static function delete_category(WP_REST_Request $request) {
|
public static function delete_category(WP_REST_Request $request)
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$term_id = (int) $request->get_param('id');
|
$term_id = (int) $request->get_param('id');
|
||||||
|
|
||||||
@@ -1270,7 +1321,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Create product tag
|
* Create product tag
|
||||||
*/
|
*/
|
||||||
public static function create_tag(WP_REST_Request $request) {
|
public static function create_tag(WP_REST_Request $request)
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$name = sanitize_text_field($request->get_param('name'));
|
$name = sanitize_text_field($request->get_param('name'));
|
||||||
$slug = sanitize_title($request->get_param('slug') ?: $name);
|
$slug = sanitize_title($request->get_param('slug') ?: $name);
|
||||||
@@ -1311,7 +1363,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Update product tag
|
* Update product tag
|
||||||
*/
|
*/
|
||||||
public static function update_tag(WP_REST_Request $request) {
|
public static function update_tag(WP_REST_Request $request)
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$term_id = (int) $request->get_param('id');
|
$term_id = (int) $request->get_param('id');
|
||||||
$name = sanitize_text_field($request->get_param('name'));
|
$name = sanitize_text_field($request->get_param('name'));
|
||||||
@@ -1354,7 +1407,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Delete product tag
|
* Delete product tag
|
||||||
*/
|
*/
|
||||||
public static function delete_tag(WP_REST_Request $request) {
|
public static function delete_tag(WP_REST_Request $request)
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$term_id = (int) $request->get_param('id');
|
$term_id = (int) $request->get_param('id');
|
||||||
|
|
||||||
@@ -1382,7 +1436,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Create product attribute
|
* Create product attribute
|
||||||
*/
|
*/
|
||||||
public static function create_attribute(WP_REST_Request $request) {
|
public static function create_attribute(WP_REST_Request $request)
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$label = sanitize_text_field($request->get_param('label'));
|
$label = sanitize_text_field($request->get_param('label'));
|
||||||
$name = sanitize_title($request->get_param('name') ?: $label);
|
$name = sanitize_title($request->get_param('name') ?: $label);
|
||||||
@@ -1429,7 +1484,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Update product attribute
|
* Update product attribute
|
||||||
*/
|
*/
|
||||||
public static function update_attribute(WP_REST_Request $request) {
|
public static function update_attribute(WP_REST_Request $request)
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$attribute_id = (int) $request->get_param('id');
|
$attribute_id = (int) $request->get_param('id');
|
||||||
$label = sanitize_text_field($request->get_param('label'));
|
$label = sanitize_text_field($request->get_param('label'));
|
||||||
@@ -1477,7 +1533,8 @@ class ProductsController {
|
|||||||
/**
|
/**
|
||||||
* Delete product attribute
|
* Delete product attribute
|
||||||
*/
|
*/
|
||||||
public static function delete_attribute(WP_REST_Request $request) {
|
public static function delete_attribute(WP_REST_Request $request)
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$attribute_id = (int) $request->get_param('id');
|
$attribute_id = (int) $request->get_param('id');
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ class EmailRenderer
|
|||||||
$to = $this->get_recipient_email($recipient_type, $data);
|
$to = $this->get_recipient_email($recipient_type, $data);
|
||||||
|
|
||||||
if (!$to) {
|
if (!$to) {
|
||||||
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
|
error_log('[EmailRenderer] Failed to get recipient email for event: ' . $event_id);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,14 +128,48 @@ class EmailRenderer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Customer
|
// Customer
|
||||||
if ($data instanceof WC_Order) {
|
if ($data instanceof \WC_Order) {
|
||||||
return $data->get_billing_email();
|
return $data->get_billing_email();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($data instanceof WC_Customer) {
|
if ($data instanceof \WC_Customer) {
|
||||||
return $data->get_email();
|
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;
|
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
|
// Merge extra data
|
||||||
$variables = array_merge($variables, $extra_data);
|
$variables = array_merge($variables, $extra_data);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace WooNooW\Frontend;
|
namespace WooNooW\Frontend;
|
||||||
|
|
||||||
use WP_REST_Request;
|
use WP_REST_Request;
|
||||||
@@ -79,7 +80,8 @@ class CartController
|
|||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'update_cart'],
|
'callback' => [__CLASS__, 'update_cart'],
|
||||||
'permission_callback' => function () {
|
'permission_callback' => function () {
|
||||||
return true; },
|
return true;
|
||||||
|
},
|
||||||
'args' => [
|
'args' => [
|
||||||
'cart_item_key' => [
|
'cart_item_key' => [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
@@ -97,7 +99,8 @@ class CartController
|
|||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'remove_from_cart'],
|
'callback' => [__CLASS__, 'remove_from_cart'],
|
||||||
'permission_callback' => function () {
|
'permission_callback' => function () {
|
||||||
return true; },
|
return true;
|
||||||
|
},
|
||||||
'args' => [
|
'args' => [
|
||||||
'cart_item_key' => [
|
'cart_item_key' => [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
@@ -111,7 +114,8 @@ class CartController
|
|||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'apply_coupon'],
|
'callback' => [__CLASS__, 'apply_coupon'],
|
||||||
'permission_callback' => function () {
|
'permission_callback' => function () {
|
||||||
return true; },
|
return true;
|
||||||
|
},
|
||||||
'args' => [
|
'args' => [
|
||||||
'coupon_code' => [
|
'coupon_code' => [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
@@ -125,7 +129,8 @@ class CartController
|
|||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'clear_cart'],
|
'callback' => [__CLASS__, 'clear_cart'],
|
||||||
'permission_callback' => function () {
|
'permission_callback' => function () {
|
||||||
return true; },
|
return true;
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Remove coupon
|
// Remove coupon
|
||||||
@@ -133,7 +138,8 @@ class CartController
|
|||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'remove_coupon'],
|
'callback' => [__CLASS__, 'remove_coupon'],
|
||||||
'permission_callback' => function () {
|
'permission_callback' => function () {
|
||||||
return true; },
|
return true;
|
||||||
|
},
|
||||||
'args' => [
|
'args' => [
|
||||||
'coupon_code' => [
|
'coupon_code' => [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
@@ -227,6 +233,12 @@ class CartController
|
|||||||
|
|
||||||
if (!empty($value)) {
|
if (!empty($value)) {
|
||||||
$variation_attributes[$meta_key] = $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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace WooNooW\Frontend;
|
namespace WooNooW\Frontend;
|
||||||
|
|
||||||
use WP_REST_Request;
|
use WP_REST_Request;
|
||||||
@@ -9,12 +10,14 @@ use WP_Error;
|
|||||||
* Shop Controller - Customer-facing product catalog API
|
* Shop Controller - Customer-facing product catalog API
|
||||||
* Handles product listing, search, and categories for customer-spa
|
* Handles product listing, search, and categories for customer-spa
|
||||||
*/
|
*/
|
||||||
class ShopController {
|
class ShopController
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register REST API routes
|
* Register REST API routes
|
||||||
*/
|
*/
|
||||||
public static function register_routes() {
|
public static function register_routes()
|
||||||
|
{
|
||||||
$namespace = 'woonoow/v1';
|
$namespace = 'woonoow/v1';
|
||||||
|
|
||||||
// Get products (public)
|
// Get products (public)
|
||||||
@@ -92,7 +95,8 @@ class ShopController {
|
|||||||
/**
|
/**
|
||||||
* Get products list
|
* 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');
|
$page = $request->get_param('page');
|
||||||
$per_page = $request->get_param('per_page');
|
$per_page = $request->get_param('per_page');
|
||||||
$category = $request->get_param('category');
|
$category = $request->get_param('category');
|
||||||
@@ -179,7 +183,8 @@ class ShopController {
|
|||||||
/**
|
/**
|
||||||
* Get single product
|
* 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_id = $request->get_param('id');
|
||||||
$product = wc_get_product($product_id);
|
$product = wc_get_product($product_id);
|
||||||
|
|
||||||
@@ -193,7 +198,8 @@ class ShopController {
|
|||||||
/**
|
/**
|
||||||
* Get categories
|
* Get categories
|
||||||
*/
|
*/
|
||||||
public static function get_categories(WP_REST_Request $request) {
|
public static function get_categories(WP_REST_Request $request)
|
||||||
|
{
|
||||||
$terms = get_terms([
|
$terms = get_terms([
|
||||||
'taxonomy' => 'product_cat',
|
'taxonomy' => 'product_cat',
|
||||||
'hide_empty' => true,
|
'hide_empty' => true,
|
||||||
@@ -221,7 +227,8 @@ class ShopController {
|
|||||||
/**
|
/**
|
||||||
* Search products
|
* 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');
|
$search = $request->get_param('s');
|
||||||
|
|
||||||
$args = [
|
$args = [
|
||||||
@@ -252,7 +259,8 @@ class ShopController {
|
|||||||
/**
|
/**
|
||||||
* Format product data for API response
|
* Format product data for API response
|
||||||
*/
|
*/
|
||||||
private static function format_product($product, $detailed = false) {
|
private static function format_product($product, $detailed = false)
|
||||||
|
{
|
||||||
$data = [
|
$data = [
|
||||||
'id' => $product->get_id(),
|
'id' => $product->get_id(),
|
||||||
'name' => $product->get_name(),
|
'name' => $product->get_name(),
|
||||||
@@ -313,12 +321,14 @@ class ShopController {
|
|||||||
/**
|
/**
|
||||||
* Get product attributes
|
* Get product attributes
|
||||||
*/
|
*/
|
||||||
private static function get_product_attributes($product) {
|
private static function get_product_attributes($product)
|
||||||
|
{
|
||||||
$attributes = [];
|
$attributes = [];
|
||||||
|
|
||||||
foreach ($product->get_attributes() as $attribute) {
|
foreach ($product->get_attributes() as $attribute) {
|
||||||
$attribute_data = [
|
$attribute_data = [
|
||||||
'name' => wc_attribute_label($attribute->get_name()),
|
'name' => wc_attribute_label($attribute->get_name()),
|
||||||
|
'slug' => sanitize_title($attribute->get_name()),
|
||||||
'options' => [],
|
'options' => [],
|
||||||
'visible' => $attribute->get_visible(),
|
'visible' => $attribute->get_visible(),
|
||||||
'variation' => $attribute->get_variation(),
|
'variation' => $attribute->get_variation(),
|
||||||
@@ -341,29 +351,19 @@ class ShopController {
|
|||||||
/**
|
/**
|
||||||
* Get product variations
|
* Get product variations
|
||||||
*/
|
*/
|
||||||
private static function get_product_variations($product) {
|
private static function get_product_variations($product)
|
||||||
|
{
|
||||||
$variations = [];
|
$variations = [];
|
||||||
|
|
||||||
foreach ($product->get_available_variations() as $variation) {
|
foreach ($product->get_available_variations() as $variation) {
|
||||||
$variation_obj = wc_get_product($variation['variation_id']);
|
$variation_obj = wc_get_product($variation['variation_id']);
|
||||||
|
|
||||||
if ($variation_obj) {
|
if ($variation_obj) {
|
||||||
// Get attributes directly from post meta (most reliable)
|
// Use attributes directly from WooCommerce's get_available_variations()
|
||||||
$attributes = [];
|
// This correctly handles custom attributes, taxonomy attributes, and "Any" selections
|
||||||
|
$attributes = $variation['attributes'];
|
||||||
$variation_id = $variation['variation_id'];
|
$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[] = [
|
$variations[] = [
|
||||||
'id' => $variation_id,
|
'id' => $variation_id,
|
||||||
'attributes' => $attributes,
|
'attributes' => $attributes,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* License Manager
|
* License Manager
|
||||||
*
|
*
|
||||||
@@ -13,7 +14,8 @@ if (!defined('ABSPATH')) exit;
|
|||||||
|
|
||||||
use WooNooW\Core\ModuleRegistry;
|
use WooNooW\Core\ModuleRegistry;
|
||||||
|
|
||||||
class LicenseManager {
|
class LicenseManager
|
||||||
|
{
|
||||||
|
|
||||||
private static $table_name = 'woonoow_licenses';
|
private static $table_name = 'woonoow_licenses';
|
||||||
private static $activations_table = 'woonoow_license_activations';
|
private static $activations_table = 'woonoow_license_activations';
|
||||||
@@ -21,7 +23,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Initialize
|
* Initialize
|
||||||
*/
|
*/
|
||||||
public static function init() {
|
public static function init()
|
||||||
|
{
|
||||||
// Only initialize if module is enabled
|
// Only initialize if module is enabled
|
||||||
if (!ModuleRegistry::is_enabled('licensing')) {
|
if (!ModuleRegistry::is_enabled('licensing')) {
|
||||||
return;
|
return;
|
||||||
@@ -39,7 +42,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Maybe generate licenses on thank you page (for COD and pending orders)
|
* 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;
|
if (!$order_id) return;
|
||||||
|
|
||||||
$order = wc_get_order($order_id);
|
$order = wc_get_order($order_id);
|
||||||
@@ -59,7 +63,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Check if order contains only virtual items
|
* 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) {
|
foreach ($order->get_items() as $item) {
|
||||||
$product = $item->get_product();
|
$product = $item->get_product();
|
||||||
if ($product && !$product->is_virtual()) {
|
if ($product && !$product->is_virtual()) {
|
||||||
@@ -72,7 +77,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Create database tables
|
* Create database tables
|
||||||
*/
|
*/
|
||||||
public static function create_tables() {
|
public static function create_tables()
|
||||||
|
{
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$charset_collate = $wpdb->get_charset_collate();
|
$charset_collate = $wpdb->get_charset_collate();
|
||||||
|
|
||||||
@@ -127,7 +133,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Generate licenses for completed order
|
* 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);
|
$order = wc_get_order($order_id);
|
||||||
if (!$order) return;
|
if (!$order) return;
|
||||||
|
|
||||||
@@ -176,7 +183,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Check if license already exists for order item
|
* 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;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . self::$table_name;
|
$table = $wpdb->prefix . self::$table_name;
|
||||||
|
|
||||||
@@ -189,7 +197,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Create a new license
|
* Create a new license
|
||||||
*/
|
*/
|
||||||
public static function create_license($data) {
|
public static function create_license($data)
|
||||||
|
{
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . self::$table_name;
|
$table = $wpdb->prefix . self::$table_name;
|
||||||
|
|
||||||
@@ -219,7 +228,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Generate license key
|
* Generate license key
|
||||||
*/
|
*/
|
||||||
public static function generate_license_key() {
|
public static function generate_license_key()
|
||||||
|
{
|
||||||
$format = get_option('woonoow_licensing_license_key_format', 'serial');
|
$format = get_option('woonoow_licensing_license_key_format', 'serial');
|
||||||
$prefix = get_option('woonoow_licensing_license_key_prefix', '');
|
$prefix = get_option('woonoow_licensing_license_key_prefix', '');
|
||||||
|
|
||||||
@@ -248,7 +258,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Get license by key
|
* Get license by key
|
||||||
*/
|
*/
|
||||||
public static function get_license_by_key($license_key) {
|
public static function get_license_by_key($license_key)
|
||||||
|
{
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . self::$table_name;
|
$table = $wpdb->prefix . self::$table_name;
|
||||||
|
|
||||||
@@ -261,7 +272,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Get license by ID
|
* Get license by ID
|
||||||
*/
|
*/
|
||||||
public static function get_license($license_id) {
|
public static function get_license($license_id)
|
||||||
|
{
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . self::$table_name;
|
$table = $wpdb->prefix . self::$table_name;
|
||||||
|
|
||||||
@@ -274,7 +286,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Get licenses for user
|
* Get licenses for user
|
||||||
*/
|
*/
|
||||||
public static function get_user_licenses($user_id, $args = []) {
|
public static function get_user_licenses($user_id, $args = [])
|
||||||
|
{
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . self::$table_name;
|
$table = $wpdb->prefix . self::$table_name;
|
||||||
|
|
||||||
@@ -303,7 +316,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Activate license
|
* Activate license
|
||||||
*/
|
*/
|
||||||
public static function activate($license_key, $activation_data = []) {
|
public static function activate($license_key, $activation_data = [])
|
||||||
|
{
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$license = self::get_license_by_key($license_key);
|
$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'));
|
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
|
// Create activation record
|
||||||
$activations_table = $wpdb->prefix . self::$activations_table;
|
$activations_table = $wpdb->prefix . self::$activations_table;
|
||||||
$licenses_table = $wpdb->prefix . self::$table_name;
|
$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
|
* 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;
|
global $wpdb;
|
||||||
$license = self::get_license_by_key($license_key);
|
$license = self::get_license_by_key($license_key);
|
||||||
|
|
||||||
@@ -426,7 +608,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Validate license (check if valid without activating)
|
* 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);
|
$license = self::get_license_by_key($license_key);
|
||||||
|
|
||||||
if (!$license) {
|
if (!$license) {
|
||||||
@@ -465,7 +648,8 @@ class LicenseManager {
|
|||||||
* @param int $order_id
|
* @param int $order_id
|
||||||
* @return string|null Subscription status or null if no subscription
|
* @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
|
// Check if subscription module is enabled
|
||||||
if (!ModuleRegistry::is_enabled('subscription')) {
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||||
return null;
|
return null;
|
||||||
@@ -503,7 +687,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Revoke license
|
* Revoke license
|
||||||
*/
|
*/
|
||||||
public static function revoke($license_id) {
|
public static function revoke($license_id)
|
||||||
|
{
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . self::$table_name;
|
$table = $wpdb->prefix . self::$table_name;
|
||||||
|
|
||||||
@@ -524,7 +709,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Get all licenses (admin)
|
* Get all licenses (admin)
|
||||||
*/
|
*/
|
||||||
public static function get_all_licenses($args = []) {
|
public static function get_all_licenses($args = [])
|
||||||
|
{
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . self::$table_name;
|
$table = $wpdb->prefix . self::$table_name;
|
||||||
|
|
||||||
@@ -585,7 +771,8 @@ class LicenseManager {
|
|||||||
/**
|
/**
|
||||||
* Get activations for a license
|
* Get activations for a license
|
||||||
*/
|
*/
|
||||||
public static function get_activations($license_id) {
|
public static function get_activations($license_id)
|
||||||
|
{
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . self::$activations_table;
|
$table = $wpdb->prefix . self::$activations_table;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Licensing Module Bootstrap
|
* Licensing Module Bootstrap
|
||||||
*
|
*
|
||||||
@@ -12,12 +13,14 @@ if (!defined('ABSPATH')) exit;
|
|||||||
use WooNooW\Core\ModuleRegistry;
|
use WooNooW\Core\ModuleRegistry;
|
||||||
use WooNooW\Modules\LicensingSettings;
|
use WooNooW\Modules\LicensingSettings;
|
||||||
|
|
||||||
class LicensingModule {
|
class LicensingModule
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the licensing module
|
* Initialize the licensing module
|
||||||
*/
|
*/
|
||||||
public static function init() {
|
public static function init()
|
||||||
|
{
|
||||||
// Register settings schema
|
// Register settings schema
|
||||||
LicensingSettings::init();
|
LicensingSettings::init();
|
||||||
|
|
||||||
@@ -32,12 +35,17 @@ class LicensingModule {
|
|||||||
// Add product meta fields
|
// Add product meta fields
|
||||||
add_action('woocommerce_product_options_general_product_data', [__CLASS__, 'add_product_licensing_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']);
|
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
|
* Initialize manager if module is enabled
|
||||||
*/
|
*/
|
||||||
public static function maybe_init_manager() {
|
public static function maybe_init_manager()
|
||||||
|
{
|
||||||
if (ModuleRegistry::is_enabled('licensing')) {
|
if (ModuleRegistry::is_enabled('licensing')) {
|
||||||
// Ensure tables exist
|
// Ensure tables exist
|
||||||
self::ensure_tables();
|
self::ensure_tables();
|
||||||
@@ -48,7 +56,8 @@ class LicensingModule {
|
|||||||
/**
|
/**
|
||||||
* Ensure database tables exist
|
* Ensure database tables exist
|
||||||
*/
|
*/
|
||||||
private static function ensure_tables() {
|
private static function ensure_tables()
|
||||||
|
{
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . 'woonoow_licenses';
|
$table = $wpdb->prefix . 'woonoow_licenses';
|
||||||
|
|
||||||
@@ -61,16 +70,235 @@ class LicensingModule {
|
|||||||
/**
|
/**
|
||||||
* Handle module enable
|
* Handle module enable
|
||||||
*/
|
*/
|
||||||
public static function on_module_enabled($module_id) {
|
public static function on_module_enabled($module_id)
|
||||||
|
{
|
||||||
if ($module_id === 'licensing') {
|
if ($module_id === 'licensing') {
|
||||||
LicenseManager::create_tables();
|
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;">← ' . 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
|
* Add licensing fields to product edit page
|
||||||
*/
|
*/
|
||||||
public static function add_product_licensing_fields() {
|
public static function add_product_licensing_fields()
|
||||||
|
{
|
||||||
global $post;
|
global $post;
|
||||||
|
|
||||||
if (!ModuleRegistry::is_enabled('licensing')) {
|
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>';
|
echo '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save licensing fields
|
* 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';
|
$licensing_enabled = isset($_POST['_woonoow_licensing_enabled']) ? 'yes' : 'no';
|
||||||
update_post_meta($post_id, '_woonoow_licensing_enabled', $licensing_enabled);
|
update_post_meta($post_id, '_woonoow_licensing_enabled', $licensing_enabled);
|
||||||
|
|
||||||
@@ -124,5 +369,13 @@ class LicensingModule {
|
|||||||
if (isset($_POST['_woonoow_license_expiry_days'])) {
|
if (isset($_POST['_woonoow_license_expiry_days'])) {
|
||||||
update_post_meta($post_id, '_woonoow_license_expiry_days', absint($_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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Licensing Module Settings
|
* Licensing Module Settings
|
||||||
*
|
*
|
||||||
@@ -9,19 +10,22 @@ namespace WooNooW\Modules;
|
|||||||
|
|
||||||
if (!defined('ABSPATH')) exit;
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
class LicensingSettings {
|
class LicensingSettings
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the settings
|
* Initialize the settings
|
||||||
*/
|
*/
|
||||||
public static function init() {
|
public static function init()
|
||||||
|
{
|
||||||
add_filter('woonoow/module_settings_schema', [__CLASS__, 'register_schema']);
|
add_filter('woonoow/module_settings_schema', [__CLASS__, 'register_schema']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register licensing settings schema
|
* Register licensing settings schema
|
||||||
*/
|
*/
|
||||||
public static function register_schema($schemas) {
|
public static function register_schema($schemas)
|
||||||
|
{
|
||||||
$schemas['licensing'] = [
|
$schemas['licensing'] = [
|
||||||
'license_key_format' => [
|
'license_key_format' => [
|
||||||
'type' => 'select',
|
'type' => 'select',
|
||||||
@@ -88,6 +92,22 @@ class LicensingSettings {
|
|||||||
'min' => 1,
|
'min' => 1,
|
||||||
'max' => 30,
|
'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;
|
return $schemas;
|
||||||
|
|||||||
Reference in New Issue
Block a user