Compare commits
16 Commits
3357fbfcf1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0b5f8496d | ||
|
|
d80f34c8b9 | ||
|
|
6d2136d3b5 | ||
|
|
0e9ace902d | ||
|
|
f4f7ff10f0 | ||
|
|
8e53a9d65b | ||
|
|
c5b572b2c2 | ||
|
|
75cd338c60 | ||
|
|
e66f5e54a1 | ||
|
|
fe243a42cb | ||
|
|
6c79e7cbac | ||
|
|
f3540a8448 | ||
|
|
bdded61221 | ||
|
|
749cfb3f92 | ||
|
|
9331989102 | ||
|
|
1ff9a36af3 |
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
|
||||
173
.agent/reports/subscription-flow-audit-2026-01-29.md
Normal file
173
.agent/reports/subscription-flow-audit-2026-01-29.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Subscription Module Comprehensive Audit Report
|
||||
|
||||
**Date:** 2026-01-29
|
||||
**Scope:** Full module trace including orders, notifications, permissions, payment gateway integration, auto/manual renewal, early renewal
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
I performed a comprehensive audit of the subscription module and implemented fixes for all Critical and Warning issues.
|
||||
|
||||
**Total Issues Found: 11**
|
||||
- **CRITICAL:** 2 ✅ FIXED
|
||||
- **WARNING:** 5 ✅ FIXED
|
||||
- **INFO:** 4 (No action required)
|
||||
|
||||
---
|
||||
|
||||
## Fixes Implemented
|
||||
|
||||
### ✅ Critical Issue #1: `handle_renewal_success` Now Sets Status to Active
|
||||
|
||||
**File:** [SubscriptionManager.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L708-L719)
|
||||
|
||||
**Change:**
|
||||
```diff
|
||||
$wpdb->update(
|
||||
self::$table_subscriptions,
|
||||
[
|
||||
+ 'status' => 'active',
|
||||
'next_payment_date' => $next_payment,
|
||||
'last_payment_date' => current_time('mysql'),
|
||||
'failed_payment_count' => 0,
|
||||
],
|
||||
['id' => $subscription_id],
|
||||
- ['%s', '%s', '%d'],
|
||||
+ ['%s', '%s', '%s', '%d'],
|
||||
['%d']
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ✅ Critical Issue #2: Added Renewal Reminder Handler
|
||||
|
||||
**File:** [SubscriptionModule.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionModule.php)
|
||||
|
||||
**Changes:**
|
||||
1. Added action hook registration:
|
||||
```php
|
||||
add_action('woonoow/subscription/renewal_reminder', [__CLASS__, 'on_renewal_reminder'], 10, 1);
|
||||
```
|
||||
|
||||
2. Added event registration:
|
||||
```php
|
||||
$events['subscription_renewal_reminder'] = [
|
||||
'id' => 'subscription_renewal_reminder',
|
||||
'label' => __('Subscription Renewal Reminder', 'woonoow'),
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
3. Added handler method:
|
||||
```php
|
||||
public static function on_renewal_reminder($subscription)
|
||||
{
|
||||
if (!$subscription || !isset($subscription->id)) {
|
||||
return;
|
||||
}
|
||||
self::send_subscription_notification('subscription_renewal_reminder', $subscription->id);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ✅ Warning Issue #3: Added Duplicate Renewal Order Prevention
|
||||
|
||||
**File:** [SubscriptionManager.php::renew](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L511-L535)
|
||||
|
||||
**Change:** Before creating a new renewal order, the system now checks for existing pending orders:
|
||||
```php
|
||||
$existing_pending = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT so.order_id FROM ... WHERE ... AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold')",
|
||||
$subscription_id
|
||||
));
|
||||
|
||||
if ($existing_pending) {
|
||||
return ['success' => true, 'order_id' => (int) $existing_pending->order_id, 'status' => 'existing'];
|
||||
}
|
||||
```
|
||||
|
||||
Also allowed `on-hold` subscriptions to renew (in addition to `active`).
|
||||
|
||||
---
|
||||
|
||||
### ✅ Warning Issue #4: Removed Duplicate Route Registration
|
||||
|
||||
**File:** [CheckoutController.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/CheckoutController.php)
|
||||
|
||||
**Change:** Removed duplicate `/checkout/pay-order/{id}` route registration (was registered twice).
|
||||
|
||||
---
|
||||
|
||||
### ✅ Warning Issue #5: Added `has_settings` to Subscription Module
|
||||
|
||||
**File:** [ModuleRegistry.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/ModuleRegistry.php#L64-L78)
|
||||
|
||||
**Change:**
|
||||
```diff
|
||||
'subscription' => [
|
||||
// ...
|
||||
'default_enabled' => false,
|
||||
+ 'has_settings' => true,
|
||||
'features' => [...],
|
||||
],
|
||||
```
|
||||
|
||||
Now subscription settings will appear in Admin SPA > Settings > Modules > Subscription.
|
||||
|
||||
---
|
||||
|
||||
### ✅ Issue #10: Replaced Transient Tracking with Database Column
|
||||
|
||||
**Files:**
|
||||
- [SubscriptionManager.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php) - Added `reminder_sent_at` column
|
||||
- [SubscriptionScheduler.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionScheduler.php) - Updated to use database column
|
||||
|
||||
**Changes:**
|
||||
1. Added column to table schema:
|
||||
```sql
|
||||
reminder_sent_at DATETIME DEFAULT NULL,
|
||||
```
|
||||
|
||||
2. Updated scheduler logic:
|
||||
```php
|
||||
// Query now includes:
|
||||
AND (reminder_sent_at IS NULL OR reminder_sent_at < last_payment_date OR ...)
|
||||
|
||||
// After sending:
|
||||
$wpdb->update($table, ['reminder_sent_at' => current_time('mysql')], ...);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Remaining INFO Issues (No Action Required)
|
||||
|
||||
| # | Issue | Status |
|
||||
|---|-------|--------|
|
||||
| 6 | Payment gateway integration is placeholder only | Phase 2 - needs separate adapter classes |
|
||||
| 7 | ThankYou page doesn't display subscription info | Enhancement for future |
|
||||
| 9 | "Renew Early" only for active subscriptions | Confirmed as acceptable UX |
|
||||
| 11 | API permissions correctly configured | Verified ✓ |
|
||||
|
||||
---
|
||||
|
||||
## Summary of Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `SubscriptionManager.php` | • Fixed `handle_renewal_success` to set status<br>• Added duplicate order prevention<br>• Added `reminder_sent_at` column |
|
||||
| `SubscriptionModule.php` | • Added renewal reminder hook<br>• Added event registration<br>• Added handler method |
|
||||
| `SubscriptionScheduler.php` | • Replaced transient tracking with database column |
|
||||
| `CheckoutController.php` | • Removed duplicate route registration |
|
||||
| `ModuleRegistry.php` | • Added `has_settings => true` for subscription |
|
||||
|
||||
---
|
||||
|
||||
## Database Migration Note
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `reminder_sent_at` column has been added to the subscriptions table schema. Since `dbDelta()` is used, it should be added automatically on next module re-enable or table check. However, for existing installations, you may need to:
|
||||
> 1. Disable and re-enable the Subscription module in Admin SPA, OR
|
||||
> 2. Run: `ALTER TABLE wp_woonoow_subscriptions ADD COLUMN reminder_sent_at DATETIME DEFAULT NULL;`
|
||||
@@ -109,6 +109,31 @@ GET /analytics/orders # Order analytics
|
||||
GET /analytics/customers # Customer analytics
|
||||
```
|
||||
|
||||
### Licensing Module (`LicensesController.php`)
|
||||
```
|
||||
# Admin Endpoints (admin auth required)
|
||||
GET /licenses # List licenses (with pagination, search)
|
||||
GET /licenses/{id} # Get single license
|
||||
POST /licenses # Create license
|
||||
PUT /licenses/{id} # Update license
|
||||
DELETE /licenses/{id} # Delete license
|
||||
|
||||
# Public Endpoints (for client software validation)
|
||||
POST /licenses/validate # Validate license key
|
||||
POST /licenses/activate # Activate license on domain
|
||||
POST /licenses/deactivate # Deactivate license from domain
|
||||
|
||||
# OAuth Endpoints (user auth required)
|
||||
GET /licenses/oauth/validate # Validate OAuth state and license ownership
|
||||
POST /licenses/oauth/confirm # Confirm activation and generate token
|
||||
```
|
||||
|
||||
**Implementation Details:**
|
||||
- **List:** Supports pagination (`page`, `per_page`), search by key/email
|
||||
- **activate:** Supports Simple API and OAuth modes
|
||||
- **OAuth flow:** `oauth/validate` + `oauth/confirm` for secure user verification
|
||||
- See `LICENSING_MODULE.md` for full OAuth flow documentation
|
||||
|
||||
---
|
||||
|
||||
## Conflict Prevention Rules
|
||||
|
||||
284
LICENSING_MODULE.md
Normal file
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) |
|
||||
28
admin-spa/package-lock.json
generated
28
admin-spa/package-lock.json
generated
@@ -57,6 +57,7 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||
@@ -2898,6 +2899,33 @@
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz",
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||
|
||||
@@ -23,6 +23,8 @@ import ProductTags from '@/routes/Products/Tags';
|
||||
import ProductAttributes from '@/routes/Products/Attributes';
|
||||
import Licenses from '@/routes/Products/Licenses';
|
||||
import LicenseDetail from '@/routes/Products/Licenses/Detail';
|
||||
import SubscriptionsIndex from '@/routes/Subscriptions';
|
||||
import SubscriptionDetail from '@/routes/Subscriptions/Detail';
|
||||
import CouponsIndex from '@/routes/Marketing/Coupons';
|
||||
import CouponNew from '@/routes/Marketing/Coupons/New';
|
||||
import CouponEdit from '@/routes/Marketing/Coupons/Edit';
|
||||
@@ -31,7 +33,7 @@ import CustomerNew from '@/routes/Customers/New';
|
||||
import CustomerEdit from '@/routes/Customers/Edit';
|
||||
import CustomerDetail from '@/routes/Customers/Detail';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Palette, Mail, Maximize2, Minimize2, Loader2 } from 'lucide-react';
|
||||
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Palette, Mail, Maximize2, Minimize2, Loader2, PanelLeftClose, PanelLeft, HelpCircle, ExternalLink, Repeat } from 'lucide-react';
|
||||
import { Toaster } from 'sonner';
|
||||
import { useShortcuts } from "@/hooks/useShortcuts";
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
@@ -134,8 +136,14 @@ function ActiveNavLink({ to, startsWith, end, className, children, childPaths }:
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const link = "flex items-center gap-2 rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0";
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
const link = "flex items-center gap-2 rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0 transition-all";
|
||||
const linkCollapsed = "flex items-center justify-center rounded-md p-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0 transition-all";
|
||||
const active = "bg-secondary";
|
||||
const { main } = useActiveSection();
|
||||
|
||||
@@ -149,14 +157,27 @@ function Sidebar() {
|
||||
'mail': Mail,
|
||||
'palette': Palette,
|
||||
'settings': SettingsIcon,
|
||||
'help-circle': HelpCircle,
|
||||
'repeat': Repeat,
|
||||
};
|
||||
|
||||
// Get navigation tree from backend
|
||||
const navTree = (window as any).WNW_NAV_TREE || [];
|
||||
|
||||
return (
|
||||
<aside className="w-56 flex-shrink-0 p-3 border-r border-border sticky top-16 h-[calc(100vh-64px)] overflow-y-auto bg-background">
|
||||
<nav className="flex flex-col gap-1">
|
||||
<aside className={`flex-shrink-0 border-r border-border sticky top-16 h-[calc(100vh-64px)] overflow-y-auto bg-background flex flex-col transition-all duration-200 ${collapsed ? 'w-14' : 'w-56'}`}>
|
||||
{/* Toggle button */}
|
||||
<div className={`p-2 border-b border-border ${collapsed ? 'flex justify-center' : 'flex justify-end'}`}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
title={collapsed ? __('Expand sidebar') : __('Collapse sidebar')}
|
||||
>
|
||||
{collapsed ? <PanelLeft className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className={`flex flex-col gap-1 flex-1 ${collapsed ? 'p-1' : 'p-3'}`}>
|
||||
{navTree.map((item: any) => {
|
||||
const IconComponent = iconMap[item.icon] || Package;
|
||||
const isActive = main.key === item.key;
|
||||
@@ -164,10 +185,11 @@ function Sidebar() {
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.path}
|
||||
className={`${link} ${isActive ? active : ''}`}
|
||||
className={`${collapsed ? linkCollapsed : link} ${isActive ? active : ''}`}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<IconComponent className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
<IconComponent className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -192,6 +214,7 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
|
||||
'mail': Mail,
|
||||
'palette': Palette,
|
||||
'settings': SettingsIcon,
|
||||
'repeat': Repeat,
|
||||
};
|
||||
|
||||
// Get navigation tree from backend
|
||||
@@ -261,6 +284,8 @@ import AppearanceCart from '@/routes/Appearance/Cart';
|
||||
import AppearanceCheckout from '@/routes/Appearance/Checkout';
|
||||
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||
import AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor';
|
||||
import AppearancePages from '@/routes/Appearance/Pages';
|
||||
import MarketingIndex from '@/routes/Marketing';
|
||||
import Newsletter from '@/routes/Marketing/Newsletter';
|
||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||
@@ -455,6 +480,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
||||
) : (
|
||||
<div className="font-semibold">{siteTitle}</div>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={window.WNW_CONFIG?.storeUrl || '/store/'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={__('Visit Store')}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{__('Store')}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm opacity-70 hidden sm:block">{window.WNW_API?.isDev ? 'Dev Server' : 'Production'}</div>
|
||||
@@ -556,6 +592,10 @@ function AppRoutes() {
|
||||
<Route path="/orders/:id/invoice" element={<OrderInvoice />} />
|
||||
<Route path="/orders/:id/label" element={<OrderLabel />} />
|
||||
|
||||
{/* Subscriptions */}
|
||||
<Route path="/subscriptions" element={<SubscriptionsIndex />} />
|
||||
<Route path="/subscriptions/:id" element={<SubscriptionDetail />} />
|
||||
|
||||
{/* Coupons (under Marketing) */}
|
||||
<Route path="/coupons" element={<CouponsIndex />} />
|
||||
<Route path="/coupons/new" element={<CouponNew />} />
|
||||
@@ -608,6 +648,8 @@ function AppRoutes() {
|
||||
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
|
||||
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
|
||||
<Route path="/appearance/account" element={<AppearanceAccount />} />
|
||||
<Route path="/appearance/menus" element={<AppearanceMenus />} />
|
||||
<Route path="/appearance/pages" element={<AppearancePages />} />
|
||||
|
||||
{/* Marketing */}
|
||||
<Route path="/marketing" element={<MarketingIndex />} />
|
||||
@@ -638,6 +680,42 @@ function Shell() {
|
||||
const location = useLocation();
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sidebar collapsed state with localStorage persistence
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
|
||||
try { return localStorage.getItem('wnwSidebarCollapsed') === '1'; } catch { return false; }
|
||||
});
|
||||
const [wasAutoCollapsed, setWasAutoCollapsed] = useState(false);
|
||||
|
||||
// Save sidebar state to localStorage
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('wnwSidebarCollapsed', sidebarCollapsed ? '1' : '0'); } catch { /* ignore */ }
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
// Check if current route is Page Editor (auto-collapse route)
|
||||
const isPageEditorRoute = location.pathname === '/appearance/pages';
|
||||
|
||||
// Auto-collapse/expand sidebar based on route
|
||||
useEffect(() => {
|
||||
if (isPageEditorRoute) {
|
||||
// Auto-collapse when entering Page Editor (if not already collapsed)
|
||||
if (!sidebarCollapsed) {
|
||||
setSidebarCollapsed(true);
|
||||
setWasAutoCollapsed(true);
|
||||
}
|
||||
} else {
|
||||
// Auto-expand when leaving Page Editor (only if we auto-collapsed it)
|
||||
if (wasAutoCollapsed && sidebarCollapsed) {
|
||||
setSidebarCollapsed(false);
|
||||
setWasAutoCollapsed(false);
|
||||
}
|
||||
}
|
||||
}, [isPageEditorRoute]);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setSidebarCollapsed(v => !v);
|
||||
setWasAutoCollapsed(false); // Manual toggle clears auto state
|
||||
};
|
||||
|
||||
// Check if standalone mode - force fullscreen and hide toggle
|
||||
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
|
||||
const fullscreen = isStandalone ? true : on;
|
||||
@@ -660,7 +738,7 @@ function Shell() {
|
||||
{fullscreen ? (
|
||||
isDesktop ? (
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<Sidebar />
|
||||
<Sidebar collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
|
||||
<main className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
{/* Flex wrapper: desktop = col-reverse (SubmenuBar first, PageHeader second) */}
|
||||
<div className="flex flex-col-reverse">
|
||||
|
||||
@@ -14,24 +14,24 @@ interface BlockRendererProps {
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
export function BlockRenderer({
|
||||
block,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
export function BlockRenderer({
|
||||
block,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
isFirst,
|
||||
isLast
|
||||
isLast
|
||||
}: BlockRendererProps) {
|
||||
|
||||
|
||||
// Prevent navigation in builder
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'A' ||
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.closest('a') ||
|
||||
target.tagName === 'A' ||
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.closest('a') ||
|
||||
target.closest('button') ||
|
||||
target.classList.contains('button') ||
|
||||
target.classList.contains('button-outline') ||
|
||||
@@ -42,7 +42,7 @@ export function BlockRenderer({
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const renderBlockContent = () => {
|
||||
switch (block.type) {
|
||||
case 'card':
|
||||
@@ -75,48 +75,48 @@ export function BlockRenderer({
|
||||
marginBottom: '24px'
|
||||
},
|
||||
hero: {
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
background: 'linear-gradient(135deg, var(--wn-gradient-start, #667eea) 0%, var(--wn-gradient-end, #764ba2) 100%)',
|
||||
color: '#fff',
|
||||
borderRadius: '8px',
|
||||
padding: '32px 40px',
|
||||
marginBottom: '24px'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Convert markdown to HTML for visual rendering
|
||||
const htmlContent = parseMarkdownBasics(block.content);
|
||||
|
||||
|
||||
return (
|
||||
<div style={cardStyles[block.cardType]}>
|
||||
<div
|
||||
<div
|
||||
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2 [&_.button]:inline-block [&_.button]:bg-purple-600 [&_.button]:text-white [&_.button]:px-7 [&_.button]:py-3.5 [&_.button]:rounded-md [&_.button]:no-underline [&_.button]:font-semibold [&_.button-outline]:inline-block [&_.button-outline]:bg-transparent [&_.button-outline]:text-purple-600 [&_.button-outline]:px-6 [&_.button-outline]:py-3 [&_.button-outline]:rounded-md [&_.button-outline]:no-underline [&_.button-outline]:font-semibold [&_.button-outline]:border-2 [&_.button-outline]:border-purple-600"
|
||||
style={block.cardType === 'hero' ? { color: '#fff' } : {}}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
case 'button': {
|
||||
const buttonStyle: React.CSSProperties = block.style === 'solid'
|
||||
? {
|
||||
display: 'inline-block',
|
||||
background: '#7f54b3',
|
||||
color: '#fff',
|
||||
padding: '14px 28px',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
}
|
||||
display: 'inline-block',
|
||||
background: 'var(--wn-primary, #7f54b3)',
|
||||
color: '#fff',
|
||||
padding: '14px 28px',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
}
|
||||
: {
|
||||
display: 'inline-block',
|
||||
background: 'transparent',
|
||||
color: '#7f54b3',
|
||||
padding: '12px 26px',
|
||||
border: '2px solid #7f54b3',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
};
|
||||
display: 'inline-block',
|
||||
background: 'transparent',
|
||||
color: 'var(--wn-secondary, #7f54b3)',
|
||||
padding: '12px 26px',
|
||||
border: '2px solid var(--wn-secondary, #7f54b3)',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
textAlign: block.align || 'center',
|
||||
@@ -130,7 +130,7 @@ export function BlockRenderer({
|
||||
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
|
||||
buttonStyle.width = '100%';
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<a href={block.link} style={buttonStyle}>
|
||||
@@ -166,13 +166,13 @@ export function BlockRenderer({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
case 'divider':
|
||||
return <hr className="border-t border-gray-300 my-4" />;
|
||||
|
||||
|
||||
case 'spacer':
|
||||
return <div style={{ height: `${block.height}px` }} />;
|
||||
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export function BlockRenderer({
|
||||
<div className={`transition-all ${isEditing ? 'ring-2 ring-purple-500 ring-offset-2' : ''}`}>
|
||||
{renderBlockContent()}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Hover Controls */}
|
||||
<div className="absolute -right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col gap-1 bg-white rounded-md shadow-lg border p-1">
|
||||
{!isFirst && (
|
||||
|
||||
@@ -107,7 +107,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
if (block.type === 'card') {
|
||||
// Convert markdown to HTML for rich text editor
|
||||
const htmlContent = parseMarkdownBasics(block.content);
|
||||
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
|
||||
setEditingContent(htmlContent);
|
||||
setEditingCardType(block.cardType);
|
||||
} else if (block.type === 'button') {
|
||||
|
||||
77
admin-spa/src/components/MediaUploader.tsx
Normal file
77
admin-spa/src/components/MediaUploader.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Image, Upload } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface MediaUploaderProps {
|
||||
onSelect: (url: string, id?: number) => void;
|
||||
type?: 'image' | 'video' | 'audio' | 'file';
|
||||
title?: string;
|
||||
buttonText?: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MediaUploader({
|
||||
onSelect,
|
||||
type = 'image',
|
||||
title = __('Select Image'),
|
||||
buttonText = __('Use Image'),
|
||||
className,
|
||||
children
|
||||
}: MediaUploaderProps) {
|
||||
const frameRef = useRef<any>(null);
|
||||
|
||||
const openMediaModal = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check if wp.media is available
|
||||
const wp = (window as any).wp;
|
||||
if (!wp || !wp.media) {
|
||||
console.warn('WordPress media library not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reuse existing frame
|
||||
if (frameRef.current) {
|
||||
frameRef.current.open();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new frame
|
||||
frameRef.current = wp.media({
|
||||
title,
|
||||
button: {
|
||||
text: buttonText,
|
||||
},
|
||||
library: {
|
||||
type,
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Handle selection
|
||||
frameRef.current.on('select', () => {
|
||||
const state = frameRef.current.state();
|
||||
const selection = state.get('selection');
|
||||
|
||||
if (selection.length > 0) {
|
||||
const attachment = selection.first().toJSON();
|
||||
onSelect(attachment.url, attachment.id);
|
||||
}
|
||||
});
|
||||
|
||||
frameRef.current.open();
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={openMediaModal} className={className}>
|
||||
{children || (
|
||||
<Button variant="outline" size="sm" type="button">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{__('Select Image')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-[999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-[99999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -28,19 +28,45 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
>(({ className, ...props }, ref) => {
|
||||
// Get or create portal container inside the app for proper CSS scoping
|
||||
const getPortalContainer = () => {
|
||||
const appContainer = document.getElementById('woonoow-admin-app');
|
||||
if (!appContainer) return document.body;
|
||||
|
||||
let portalRoot = document.getElementById('woonoow-dialog-portal');
|
||||
if (!portalRoot) {
|
||||
portalRoot = document.createElement('div');
|
||||
portalRoot.id = 'woonoow-dialog-portal';
|
||||
// Copy theme class from documentElement for proper CSS variable inheritance
|
||||
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
portalRoot.className = themeClass;
|
||||
appContainer.appendChild(portalRoot);
|
||||
} else {
|
||||
// Update theme class in case it changed
|
||||
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
if (!portalRoot.classList.contains(themeClass)) {
|
||||
portalRoot.classList.remove('light', 'dark');
|
||||
portalRoot.classList.add(themeClass);
|
||||
}
|
||||
}
|
||||
return portalRoot;
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialogPortal container={getPortalContainer()}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[99999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
})
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
|
||||
@@ -40,7 +40,17 @@ const DialogContent = React.forwardRef<
|
||||
if (!portalRoot) {
|
||||
portalRoot = document.createElement('div');
|
||||
portalRoot.id = 'woonoow-dialog-portal';
|
||||
// Copy theme class from documentElement for proper CSS variable inheritance
|
||||
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
portalRoot.className = themeClass;
|
||||
appContainer.appendChild(portalRoot);
|
||||
} else {
|
||||
// Update theme class in case it changed
|
||||
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
if (!portalRoot.classList.contains(themeClass)) {
|
||||
portalRoot.classList.remove('light', 'dark');
|
||||
portalRoot.classList.add(themeClass);
|
||||
}
|
||||
}
|
||||
return portalRoot;
|
||||
};
|
||||
|
||||
@@ -57,20 +57,46 @@ DropdownMenuSubContent.displayName =
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => {
|
||||
// Get or create portal container inside the app for proper CSS scoping
|
||||
const getPortalContainer = () => {
|
||||
const appContainer = document.getElementById('woonoow-admin-app');
|
||||
if (!appContainer) return document.body;
|
||||
|
||||
let portalRoot = document.getElementById('woonoow-dropdown-portal');
|
||||
if (!portalRoot) {
|
||||
portalRoot = document.createElement('div');
|
||||
portalRoot.id = 'woonoow-dropdown-portal';
|
||||
// Copy theme class from documentElement for proper CSS variable inheritance
|
||||
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
portalRoot.className = themeClass;
|
||||
appContainer.appendChild(portalRoot);
|
||||
} else {
|
||||
// Update theme class in case it changed
|
||||
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
if (!portalRoot.classList.contains(themeClass)) {
|
||||
portalRoot.classList.remove('light', 'dark');
|
||||
portalRoot.classList.add(themeClass);
|
||||
}
|
||||
}
|
||||
return portalRoot;
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal container={getPortalContainer()}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
})
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
|
||||
@@ -50,6 +50,8 @@ export function RichTextEditor({
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
// ButtonExtension MUST come before Link to ensure buttons are parsed first
|
||||
ButtonExtension,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
@@ -65,7 +67,6 @@ export function RichTextEditor({
|
||||
class: 'max-w-full h-auto rounded',
|
||||
},
|
||||
}),
|
||||
ButtonExtension,
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface Option {
|
||||
/** What to render in the button/list. Can be a string or React node. */
|
||||
label: React.ReactNode;
|
||||
/** Optional text used for filtering. Falls back to string label or value. */
|
||||
searchText?: string;
|
||||
triggerLabel?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -55,7 +55,7 @@ export function SearchableSelect({
|
||||
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
|
||||
|
||||
return (
|
||||
<Popover open={disabled ? false : open} onOpenChange={(o)=> !disabled && setOpen(o)}>
|
||||
<Popover open={disabled ? false : open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -65,7 +65,7 @@ export function SearchableSelect({
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
{selected ? selected.label : placeholder}
|
||||
{selected ? (selected.triggerLabel ?? selected.label) : placeholder}
|
||||
<ChevronsUpDown className="opacity-50 h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -89,7 +89,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
"relative z-[9999] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
|
||||
@@ -39,6 +39,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
return [
|
||||
{
|
||||
tag: 'a[data-button]',
|
||||
priority: 100, // Higher priority than Link extension (default 50)
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.getAttribute('data-text') || node.textContent || 'Click Here',
|
||||
href: node.getAttribute('data-href') || node.getAttribute('href') || '#',
|
||||
@@ -47,6 +48,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
},
|
||||
{
|
||||
tag: 'a.button',
|
||||
priority: 100,
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.textContent || 'Click Here',
|
||||
href: node.getAttribute('href') || '#',
|
||||
@@ -55,6 +57,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
},
|
||||
{
|
||||
tag: 'a.button-outline',
|
||||
priority: 100,
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.textContent || 'Click Here',
|
||||
href: node.getAttribute('href') || '#',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import React, { createContext, useContext, ReactNode, useEffect } from 'react';
|
||||
|
||||
interface AppContextType {
|
||||
isStandalone: boolean;
|
||||
@@ -7,15 +7,44 @@ interface AppContextType {
|
||||
|
||||
const AppContext = createContext<AppContextType | undefined>(undefined);
|
||||
|
||||
export function AppProvider({
|
||||
children,
|
||||
isStandalone,
|
||||
exitFullscreen
|
||||
}: {
|
||||
children: ReactNode;
|
||||
isStandalone: boolean;
|
||||
export function AppProvider({
|
||||
children,
|
||||
isStandalone,
|
||||
exitFullscreen
|
||||
}: {
|
||||
children: ReactNode;
|
||||
isStandalone: boolean;
|
||||
exitFullscreen?: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Fetch and apply appearance settings (colors)
|
||||
const loadAppearance = async () => {
|
||||
try {
|
||||
const restUrl = (window as any).WNW_CONFIG?.restUrl || '';
|
||||
const response = await fetch(`${restUrl}/appearance/settings`);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
// API returns { success: true, data: { general: { colors: {...} } } }
|
||||
const colors = result.data?.general?.colors;
|
||||
if (colors) {
|
||||
const root = document.documentElement;
|
||||
// Inject all color settings as CSS variables
|
||||
if (colors.primary) root.style.setProperty('--wn-primary', colors.primary);
|
||||
if (colors.secondary) root.style.setProperty('--wn-secondary', colors.secondary);
|
||||
if (colors.accent) root.style.setProperty('--wn-accent', colors.accent);
|
||||
if (colors.text) root.style.setProperty('--wn-text', colors.text);
|
||||
if (colors.background) root.style.setProperty('--wn-background', colors.background);
|
||||
if (colors.gradientStart) root.style.setProperty('--wn-gradient-start', colors.gradientStart);
|
||||
if (colors.gradientEnd) root.style.setProperty('--wn-gradient-end', colors.gradientEnd);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load appearance settings', e);
|
||||
}
|
||||
};
|
||||
loadAppearance();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{ isStandalone, exitFullscreen }}>
|
||||
{children}
|
||||
|
||||
@@ -68,8 +68,23 @@ export function htmlToMarkdown(html: string): string {
|
||||
}).join('\n') + '\n\n';
|
||||
});
|
||||
|
||||
// Paragraphs - convert to double newlines
|
||||
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gis, '$1\n\n');
|
||||
// Paragraphs - preserve text-align by using placeholders
|
||||
const alignedParagraphs: { [key: string]: string } = {};
|
||||
let alignIndex = 0;
|
||||
markdown = markdown.replace(/<p([^>]*)>(.*?)<\/p>/gis, (match, attrs, content) => {
|
||||
// Check for text-align in style attribute
|
||||
const alignMatch = attrs.match(/text-align:\s*(center|right)/i);
|
||||
if (alignMatch) {
|
||||
const align = alignMatch[1].toLowerCase();
|
||||
// Use double-bracket placeholder that won't be matched by HTML regex
|
||||
const placeholder = `[[ALIGN${alignIndex}]]`;
|
||||
alignedParagraphs[placeholder] = `<p style="text-align: ${align};">${content}</p>`;
|
||||
alignIndex++;
|
||||
return placeholder + '\n\n';
|
||||
}
|
||||
// No alignment, convert to plain text
|
||||
return `${content}\n\n`;
|
||||
});
|
||||
|
||||
// Line breaks
|
||||
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
||||
@@ -80,6 +95,11 @@ export function htmlToMarkdown(html: string): string {
|
||||
// Remove remaining HTML tags
|
||||
markdown = markdown.replace(/<[^>]+>/g, '');
|
||||
|
||||
// Restore aligned paragraphs
|
||||
Object.entries(alignedParagraphs).forEach(([placeholder, html]) => {
|
||||
markdown = markdown.replace(placeholder, html);
|
||||
});
|
||||
|
||||
// Clean up excessive newlines
|
||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
|
||||
@@ -36,13 +36,15 @@ export default function AppearanceGeneral() {
|
||||
friendly: { name: 'Friendly', fonts: 'Poppins + Open Sans' },
|
||||
elegant: { name: 'Elegant', fonts: 'Cormorant + Lato' },
|
||||
};
|
||||
|
||||
|
||||
const [colors, setColors] = useState({
|
||||
primary: '#1a1a1a',
|
||||
secondary: '#6b7280',
|
||||
accent: '#3b82f6',
|
||||
text: '#111827',
|
||||
background: '#ffffff',
|
||||
gradientStart: '#9333ea', // purple-600 defaults
|
||||
gradientEnd: '#3b82f6', // blue-500 defaults
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,7 +53,7 @@ export default function AppearanceGeneral() {
|
||||
// Load appearance settings
|
||||
const response = await api.get('/appearance/settings');
|
||||
const general = response.data?.general;
|
||||
|
||||
|
||||
if (general) {
|
||||
if (general.spa_mode) setSpaMode(general.spa_mode);
|
||||
if (general.spa_page) setSpaPage(general.spa_page || 0);
|
||||
@@ -70,10 +72,12 @@ export default function AppearanceGeneral() {
|
||||
accent: general.colors.accent || '#3b82f6',
|
||||
text: general.colors.text || '#111827',
|
||||
background: general.colors.background || '#ffffff',
|
||||
gradientStart: general.colors.gradientStart || '#9333ea',
|
||||
gradientEnd: general.colors.gradientEnd || '#3b82f6',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Load available pages
|
||||
const pagesResponse = await api.get('/pages/list');
|
||||
console.log('Pages API response:', pagesResponse);
|
||||
@@ -90,7 +94,7 @@ export default function AppearanceGeneral() {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
@@ -108,7 +112,7 @@ export default function AppearanceGeneral() {
|
||||
},
|
||||
colors,
|
||||
});
|
||||
|
||||
|
||||
toast.success('General settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
@@ -139,7 +143,7 @@ export default function AppearanceGeneral() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="checkout_only" id="spa-checkout" />
|
||||
<div className="space-y-1">
|
||||
@@ -151,7 +155,7 @@ export default function AppearanceGeneral() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="full" id="spa-full" />
|
||||
<div className="space-y-1">
|
||||
@@ -175,14 +179,14 @@ export default function AppearanceGeneral() {
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
This page will render the full SPA to the body element with no theme interference.
|
||||
This page will render the full SPA to the body element with no theme interference.
|
||||
The SPA Mode above determines the initial route (shop or cart). React Router handles navigation via /#/ routing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
<SettingsSection label="SPA Entry Page" htmlFor="spa-page">
|
||||
<Select
|
||||
value={spaPage.toString()}
|
||||
<Select
|
||||
value={spaPage.toString()}
|
||||
onValueChange={(value) => setSpaPage(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger id="spa-page">
|
||||
@@ -246,7 +250,7 @@ export default function AppearanceGeneral() {
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Self-hosted fonts, no external requests
|
||||
</p>
|
||||
|
||||
|
||||
{typographyMode === 'predefined' && (
|
||||
<Select value={predefinedPair} onValueChange={setPredefinedPair}>
|
||||
<SelectTrigger className="w-full min-w-[300px] [&>span]:line-clamp-none [&>span]:whitespace-normal">
|
||||
@@ -284,7 +288,7 @@ export default function AppearanceGeneral() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="custom_google" id="typo-custom" />
|
||||
<div className="space-y-1 flex-1">
|
||||
@@ -297,7 +301,7 @@ export default function AppearanceGeneral() {
|
||||
Using Google Fonts may not be GDPR compliant
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
{typographyMode === 'custom_google' && (
|
||||
<div className="space-y-3 mt-3">
|
||||
<SettingsSection label="Heading Font" htmlFor="heading-font">
|
||||
@@ -321,7 +325,7 @@ export default function AppearanceGeneral() {
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
|
||||
<div className="space-y-3 pt-4 border-t">
|
||||
<Label>Font Scale: {fontScale[0].toFixed(1)}x</Label>
|
||||
<Slider
|
||||
@@ -345,18 +349,18 @@ export default function AppearanceGeneral() {
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(colors).map(([key, value]) => (
|
||||
<SettingsSection key={key} label={key.charAt(0).toUpperCase() + key.slice(1)} htmlFor={`color-${key}`}>
|
||||
<SettingsSection key={key} label={key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')} htmlFor={`color-${key}`}>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={`color-${key}`}
|
||||
type="color"
|
||||
value={value}
|
||||
value={value as string}
|
||||
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
||||
className="w-20 h-10 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
value={value as string}
|
||||
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
||||
className="flex-1 font-mono"
|
||||
/>
|
||||
|
||||
495
admin-spa/src/routes/Appearance/Menus/MenuEditor.tsx
Normal file
495
admin-spa/src/routes/Appearance/Menus/MenuEditor.tsx
Normal file
@@ -0,0 +1,495 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Plus, GripVertical, Trash2, Link as LinkIcon, FileText, Check, AlertCircle, Home } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||
|
||||
|
||||
// Types
|
||||
interface Page {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
is_woonoow_page?: boolean;
|
||||
is_store_page?: boolean;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'page' | 'custom';
|
||||
value: string;
|
||||
target: '_self' | '_blank';
|
||||
}
|
||||
|
||||
interface MenuSettings {
|
||||
primary: MenuItem[];
|
||||
mobile: MenuItem[];
|
||||
}
|
||||
|
||||
// Sortable Item Component
|
||||
function SortableMenuItem({
|
||||
item,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
pages
|
||||
}: {
|
||||
item: MenuItem;
|
||||
onRemove: (id: string) => void;
|
||||
onUpdate: (id: string, updates: Partial<MenuItem>) => void;
|
||||
pages: Page[];
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id: item.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`bg-white border rounded-lg mb-2 shadow-sm ${isEditing ? 'ring-2 ring-primary ring-offset-1' : ''}`}
|
||||
>
|
||||
<div className="flex items-center p-3 gap-3">
|
||||
<div {...attributes} {...listeners} className="cursor-grab text-gray-400 hover:text-gray-600">
|
||||
<GripVertical className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0" onClick={() => setIsEditing(!isEditing)}>
|
||||
<div className="flex items-center gap-2 font-medium truncate">
|
||||
{item.type === 'page' ? <FileText className="w-4 h-4 text-blue-500" /> : <LinkIcon className="w-4 h-4 text-green-500" />}
|
||||
{item.type === 'page' ? (
|
||||
(() => {
|
||||
const page = pages.find(p => p.slug === item.value);
|
||||
if (page?.is_store_page) {
|
||||
return <span className="inline-flex items-center rounded-md bg-purple-50 px-2 py-1 text-[10px] font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">Store</span>;
|
||||
}
|
||||
if (item.value === '/' || page?.is_woonoow_page) {
|
||||
return <span className="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-[10px] font-medium text-green-700 ring-1 ring-inset ring-green-600/20">WooNooW</span>;
|
||||
}
|
||||
return <span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-[10px] font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">WP</span>;
|
||||
})()
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10">Custom</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{item.type === 'page' ? `Page: /${item.value}` : `URL: ${item.value}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-400 hover:text-red-500" onClick={() => onRemove(item.id)}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="p-4 border-t bg-gray-50 rounded-b-lg space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Label</Label>
|
||||
<Input
|
||||
value={item.label}
|
||||
onChange={(e) => onUpdate(item.id, { label: e.target.value })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Target</Label>
|
||||
<Select
|
||||
value={item.target}
|
||||
onValueChange={(val: any) => onUpdate(item.id, { target: val })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_self">Same Tab</SelectItem>
|
||||
<SelectItem value="_blank">New Tab</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{item.type === 'custom' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">URL</Label>
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) => onUpdate(item.id, { value: e.target.value })}
|
||||
className="h-8 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MenuEditor() {
|
||||
const [menus, setMenus] = useState<MenuSettings>({ primary: [], mobile: [] });
|
||||
const [activeTab, setActiveTab] = useState<'primary' | 'mobile'>('primary');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pages, setPages] = useState<Page[]>([]);
|
||||
const [spaPageId, setSpaPageId] = useState<number>(0);
|
||||
|
||||
// New Item State
|
||||
const [newItemType, setNewItemType] = useState<'page' | 'custom'>('page');
|
||||
const [newItemLabel, setNewItemLabel] = useState('');
|
||||
const [newItemValue, setNewItemValue] = useState('');
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [settingsRes, pagesRes] = await Promise.all([
|
||||
api.get('/appearance/settings'),
|
||||
api.get('/pages/list')
|
||||
]);
|
||||
|
||||
const settings = settingsRes.data;
|
||||
if (settings.menus) {
|
||||
setMenus(settings.menus);
|
||||
} else {
|
||||
// Default seeding if empty
|
||||
setMenus({
|
||||
primary: [
|
||||
{ id: 'home', label: 'Home', type: 'page', value: '/', target: '_self' },
|
||||
{ id: 'shop', label: 'Shop', type: 'page', value: 'shop', target: '_self' }
|
||||
],
|
||||
mobile: []
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.general?.spa_page) {
|
||||
setSpaPageId(parseInt(settings.general.spa_page));
|
||||
}
|
||||
|
||||
if (pagesRes.success) {
|
||||
setPages(pagesRes.data);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load menu data', error);
|
||||
toast.error('Failed to load menu data');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (active.id !== over?.id) {
|
||||
setMenus((prev) => {
|
||||
const list = prev[activeTab];
|
||||
const oldIndex = list.findIndex((item) => item.id === active.id);
|
||||
const newIndex = list.findIndex((item) => item.id === over?.id);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[activeTab]: arrayMove(list, oldIndex, newIndex),
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
if (!newItemLabel) {
|
||||
toast.error('Label is required');
|
||||
return;
|
||||
}
|
||||
if (!newItemValue) {
|
||||
toast.error('Destination is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem: MenuItem = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
label: newItemLabel,
|
||||
type: newItemType,
|
||||
value: newItemValue,
|
||||
target: '_self'
|
||||
};
|
||||
|
||||
setMenus(prev => ({
|
||||
...prev,
|
||||
[activeTab]: [...prev[activeTab], newItem]
|
||||
}));
|
||||
|
||||
// Reset form
|
||||
setNewItemLabel('');
|
||||
if (newItemType === 'custom') setNewItemValue('');
|
||||
toast.success('Item added');
|
||||
};
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
setMenus(prev => ({
|
||||
...prev,
|
||||
[activeTab]: prev[activeTab].filter(item => item.id !== id)
|
||||
}));
|
||||
};
|
||||
|
||||
const updateItem = (id: string, updates: Partial<MenuItem>) => {
|
||||
setMenus(prev => ({
|
||||
...prev,
|
||||
[activeTab]: prev[activeTab].map(item => item.id === id ? { ...item, ...updates } : item)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/menus', { menus });
|
||||
toast.success('Menus saved successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to save menus');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 flex justify-center"><div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Menu Editor"
|
||||
description="Manage your store's navigation menus"
|
||||
onSave={handleSave}
|
||||
isLoading={loading}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Left Col: Add Items */}
|
||||
<Card className="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Add Items</CardTitle>
|
||||
<CardDescription>Add pages or custom links</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Tabs value={newItemType} onValueChange={(v: any) => setNewItemType(v)} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="page">Page</TabsTrigger>
|
||||
<TabsTrigger value="custom">Custom URL</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
placeholder="e.g. Shop"
|
||||
value={newItemLabel}
|
||||
onChange={(e) => setNewItemLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Destination</Label>
|
||||
{newItemType === 'page' ? (
|
||||
<SearchableSelect
|
||||
value={newItemValue}
|
||||
onChange={setNewItemValue}
|
||||
options={[
|
||||
{
|
||||
value: '/',
|
||||
label: (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="flex items-center gap-2"><Home className="w-4 h-4" /> Home</span>
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">WooNooW</span>
|
||||
</div>
|
||||
),
|
||||
triggerLabel: (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="flex items-center gap-2"><Home className="w-4 h-4" /> Home</span>
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">WooNooW</span>
|
||||
</div>
|
||||
),
|
||||
searchText: 'Home'
|
||||
},
|
||||
...pages.filter(p => p.id !== spaPageId).map(page => {
|
||||
const Badge = () => {
|
||||
if (page.is_store_page) {
|
||||
return <span className="ml-2 inline-flex items-center rounded-md bg-purple-50 px-2 py-1 text-xs font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">Store</span>;
|
||||
}
|
||||
if (page.is_woonoow_page) {
|
||||
return <span className="ml-2 inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">WooNooW</span>;
|
||||
}
|
||||
return <span className="ml-2 inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">WP</span>;
|
||||
};
|
||||
|
||||
return {
|
||||
value: page.slug,
|
||||
label: (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className="truncate">{page.title}</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono truncate">/{page.slug}</span>
|
||||
</div>
|
||||
<Badge />
|
||||
</div>
|
||||
),
|
||||
triggerLabel: (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="truncate">{page.title}</span>
|
||||
<Badge />
|
||||
</div>
|
||||
),
|
||||
searchText: `${page.title} ${page.slug}`
|
||||
};
|
||||
})
|
||||
]}
|
||||
placeholder="Select a page"
|
||||
className="w-full"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="https://"
|
||||
value={newItemValue}
|
||||
onChange={(e) => setNewItemValue(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button className="w-full" variant="outline" onClick={addItem}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add to Menu
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right Col: Menu Structure */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<Tabs value={activeTab} onValueChange={(v: any) => setActiveTab(v)}>
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="primary">Primary Menu</TabsTrigger>
|
||||
<TabsTrigger value="mobile">Mobile Menu (Optional)</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="primary" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Menu Structure</CardTitle>
|
||||
<CardDescription>Drag and drop to reorder items</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={menus.primary.map(item => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{menus.primary.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 border-2 border-dashed rounded-lg">
|
||||
<AlertCircle className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No items in menu</p>
|
||||
</div>
|
||||
) : (
|
||||
menus.primary.map((item) => (
|
||||
<SortableMenuItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRemove={removeItem}
|
||||
onUpdate={updateItem}
|
||||
pages={pages}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mobile" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mobile Menu Structure</CardTitle>
|
||||
<CardDescription>
|
||||
Leave empty to use Primary Menu automatically.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={menus.mobile.map(item => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{menus.mobile.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 border-2 border-dashed rounded-lg">
|
||||
<p>Using Primary Menu</p>
|
||||
</div>
|
||||
) : (
|
||||
menus.mobile.map((item) => (
|
||||
<SortableMenuItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRemove={removeItem}
|
||||
onUpdate={updateItem}
|
||||
pages={pages}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Plus, Monitor, Smartphone, LayoutTemplate } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
import { CanvasSection } from './CanvasSection';
|
||||
import {
|
||||
HeroRenderer,
|
||||
ContentRenderer,
|
||||
ImageTextRenderer,
|
||||
FeatureGridRenderer,
|
||||
CTABannerRenderer,
|
||||
ContactFormRenderer,
|
||||
} from './section-renderers';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, any>;
|
||||
}
|
||||
|
||||
interface CanvasRendererProps {
|
||||
sections: Section[];
|
||||
selectedSectionId: string | null;
|
||||
deviceMode: 'desktop' | 'mobile';
|
||||
onSelectSection: (id: string | null) => void;
|
||||
onAddSection: (type: string, index?: number) => void;
|
||||
onDeleteSection: (id: string) => void;
|
||||
onDuplicateSection: (id: string) => void;
|
||||
onMoveSection: (id: string, direction: 'up' | 'down') => void;
|
||||
onReorderSections: (sections: Section[]) => void;
|
||||
onDeviceModeChange: (mode: 'desktop' | 'mobile') => void;
|
||||
}
|
||||
|
||||
const SECTION_TYPES = [
|
||||
{ type: 'hero', label: 'Hero', icon: LayoutTemplate },
|
||||
{ type: 'content', label: 'Content', icon: LayoutTemplate },
|
||||
{ type: 'image-text', label: 'Image + Text', icon: LayoutTemplate },
|
||||
{ type: 'feature-grid', label: 'Feature Grid', icon: LayoutTemplate },
|
||||
{ type: 'cta-banner', label: 'CTA Banner', icon: LayoutTemplate },
|
||||
{ type: 'contact-form', label: 'Contact Form', icon: LayoutTemplate },
|
||||
];
|
||||
|
||||
// Map section type to renderer component
|
||||
const SECTION_RENDERERS: Record<string, React.FC<{ section: Section; className?: string }>> = {
|
||||
'hero': HeroRenderer,
|
||||
'content': ContentRenderer,
|
||||
'image-text': ImageTextRenderer,
|
||||
'feature-grid': FeatureGridRenderer,
|
||||
'cta-banner': CTABannerRenderer,
|
||||
'contact-form': ContactFormRenderer,
|
||||
};
|
||||
|
||||
export function CanvasRenderer({
|
||||
sections,
|
||||
selectedSectionId,
|
||||
deviceMode,
|
||||
onSelectSection,
|
||||
onAddSection,
|
||||
onDeleteSection,
|
||||
onDuplicateSection,
|
||||
onMoveSection,
|
||||
onReorderSections,
|
||||
onDeviceModeChange,
|
||||
}: CanvasRendererProps) {
|
||||
const [hoveredSectionId, setHoveredSectionId] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = sections.findIndex(s => s.id === active.id);
|
||||
const newIndex = sections.findIndex(s => s.id === over.id);
|
||||
const newSections = arrayMove(sections, oldIndex, newIndex);
|
||||
onReorderSections(newSections);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasClick = (e: React.MouseEvent) => {
|
||||
// Only deselect if clicking directly on canvas background
|
||||
if (e.target === e.currentTarget) {
|
||||
onSelectSection(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-gray-100 overflow-hidden">
|
||||
{/* Device mode toggle */}
|
||||
<div className="flex items-center justify-center gap-2 py-3 bg-white border-b">
|
||||
<Button
|
||||
variant={deviceMode === 'desktop' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onDeviceModeChange('desktop')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
Desktop
|
||||
</Button>
|
||||
<Button
|
||||
variant={deviceMode === 'mobile' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onDeviceModeChange('mobile')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Smartphone className="w-4 h-4" />
|
||||
Mobile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Canvas viewport */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-6"
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto bg-white shadow-xl rounded-lg transition-all duration-300 min-h-[500px]',
|
||||
deviceMode === 'desktop' ? 'max-w-4xl' : 'max-w-sm'
|
||||
)}
|
||||
>
|
||||
{sections.length === 0 ? (
|
||||
<div className="py-24 text-center text-gray-400">
|
||||
<LayoutTemplate className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-2">No sections yet</p>
|
||||
<p className="text-sm mb-6">Add your first section to start building</p>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Section
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{SECTION_TYPES.map((type) => (
|
||||
<DropdownMenuItem
|
||||
key={type.type}
|
||||
onClick={() => onAddSection(type.type)}
|
||||
>
|
||||
<type.icon className="w-4 h-4 mr-2" />
|
||||
{type.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{/* Top Insertion Zone */}
|
||||
<InsertionZone
|
||||
index={0}
|
||||
onAdd={(type) => onAddSection(type)} // Implicitly index 0 is fine if we handle it in store, but wait store expects index.
|
||||
// Actually onAddSection in Props is (type) => void. I need to update Props too.
|
||||
// Let's check props interface above.
|
||||
/>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sections.map(s => s.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{sections.map((section, index) => {
|
||||
const Renderer = SECTION_RENDERERS[section.type];
|
||||
|
||||
return (
|
||||
<React.Fragment key={section.id}>
|
||||
<CanvasSection
|
||||
section={section}
|
||||
isSelected={selectedSectionId === section.id}
|
||||
isHovered={hoveredSectionId === section.id}
|
||||
onSelect={() => onSelectSection(section.id)}
|
||||
onHover={() => setHoveredSectionId(section.id)}
|
||||
onLeave={() => setHoveredSectionId(null)}
|
||||
onDelete={() => onDeleteSection(section.id)}
|
||||
onDuplicate={() => onDuplicateSection(section.id)}
|
||||
onMoveUp={() => onMoveSection(section.id, 'up')}
|
||||
onMoveDown={() => onMoveSection(section.id, 'down')}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < sections.length - 1}
|
||||
>
|
||||
{Renderer ? (
|
||||
<Renderer section={section} />
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
Unknown section type: {section.type}
|
||||
</div>
|
||||
)}
|
||||
</CanvasSection>
|
||||
|
||||
{/* Insertion Zone After Section */}
|
||||
<InsertionZone
|
||||
index={index + 1}
|
||||
onAdd={(type) => onAddSection(type, index + 1)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: Insertion Zone Component
|
||||
function InsertionZone({ index, onAdd }: { index: number; onAdd: (type: string) => void }) {
|
||||
return (
|
||||
<div className="group relative h-4 -my-2 z-10 flex items-center justify-center transition-all hover:h-8 hover:my-0">
|
||||
{/* Line */}
|
||||
<div className="absolute left-4 right-4 h-0.5 bg-blue-500 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
|
||||
{/* Button */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="relative z-10 w-6 h-6 rounded-full bg-blue-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all hover:scale-110 shadow-sm"
|
||||
title="Add Section Here"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{SECTION_TYPES.map((type) => (
|
||||
<DropdownMenuItem
|
||||
key={type.type}
|
||||
onClick={() => onAdd(type.type)}
|
||||
>
|
||||
<type.icon className="w-4 h-4 mr-2" />
|
||||
{type.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GripVertical, Trash2, Copy, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Section } from '../store/usePageEditorStore';
|
||||
|
||||
interface CanvasSectionProps {
|
||||
section: Section;
|
||||
children: ReactNode;
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
onSelect: () => void;
|
||||
onHover: () => void;
|
||||
onLeave: () => void;
|
||||
onDelete: () => void;
|
||||
onDuplicate: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
}
|
||||
|
||||
export function CanvasSection({
|
||||
section,
|
||||
children,
|
||||
isSelected,
|
||||
isHovered,
|
||||
onSelect,
|
||||
onHover,
|
||||
onLeave,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
}: CanvasSectionProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: section.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'relative group transition-all duration-200',
|
||||
isDragging && 'opacity-50 z-50',
|
||||
isSelected && 'ring-2 ring-blue-500 ring-offset-2',
|
||||
isHovered && !isSelected && 'ring-1 ring-blue-300'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
onMouseEnter={onHover}
|
||||
onMouseLeave={onLeave}
|
||||
>
|
||||
{/* Section content with Styles */}
|
||||
<div
|
||||
className={cn("relative overflow-hidden rounded-lg", !section.styles?.backgroundColor && "bg-white/50")}
|
||||
style={{
|
||||
backgroundColor: section.styles?.backgroundColor,
|
||||
paddingTop: section.styles?.paddingTop,
|
||||
paddingBottom: section.styles?.paddingBottom,
|
||||
}}
|
||||
>
|
||||
{/* Background Image & Overlay */}
|
||||
{section.styles?.backgroundImage && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${section.styles.backgroundImage})` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-black"
|
||||
style={{ opacity: (section.styles.backgroundOverlay || 0) / 100 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content Wrapper */}
|
||||
<div className={cn(
|
||||
"relative z-10",
|
||||
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Toolbar (Standard Interaction) */}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-10 right-0 z-50 flex items-center gap-1 bg-white shadow-lg border rounded-lg px-2 py-1 animate-in fade-in slide-in-from-bottom-2">
|
||||
{/* Label */}
|
||||
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide mr-2 px-1">
|
||||
{section.type.replace('-', ' ')}
|
||||
</span>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-4 bg-gray-200 mx-1" />
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveUp();
|
||||
}}
|
||||
disabled={!canMoveUp}
|
||||
className={cn(
|
||||
'p-1.5 rounded text-gray-500 hover:text-black hover:bg-gray-100 transition',
|
||||
!canMoveUp && 'opacity-30 cursor-not-allowed'
|
||||
)}
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveDown();
|
||||
}}
|
||||
disabled={!canMoveDown}
|
||||
className={cn(
|
||||
'p-1.5 rounded text-gray-500 hover:text-black hover:bg-gray-100 transition',
|
||||
!canMoveDown && 'opacity-30 cursor-not-allowed'
|
||||
)}
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
className="p-1.5 rounded text-gray-500 hover:text-black hover:bg-gray-100 transition"
|
||||
title="Duplicate"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-gray-200 mx-1" />
|
||||
<div className="w-px h-4 bg-gray-200 mx-1" />
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
className="p-1.5 rounded text-red-500 hover:text-red-600 hover:bg-red-50 transition"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="z-[60]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__('This action cannot be undone.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>{__('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
{__('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Border Label */}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-px left-0 bg-blue-500 text-white text-[10px] uppercase font-bold px-2 py-0.5 rounded-b-sm z-10">
|
||||
{section.type}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag Handle (Always visible on hover or select) */}
|
||||
{(isSelected || isHovered) && (
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="absolute top-1/2 -left-8 -translate-y-1/2 p-1.5 rounded text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing hover:bg-gray-100"
|
||||
title="Drag to reorder"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { FileText, Layout, Loader2 } from 'lucide-react';
|
||||
|
||||
interface PageItem {
|
||||
id?: number;
|
||||
type: 'page' | 'template';
|
||||
cpt?: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface CreatePageModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: (page: PageItem) => void;
|
||||
}
|
||||
|
||||
export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageModalProps) {
|
||||
const [pageType, setPageType] = useState<'page' | 'template'>('page');
|
||||
const [title, setTitle] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('blank');
|
||||
|
||||
// Prevent double submission
|
||||
const isSubmittingRef = useRef(false);
|
||||
|
||||
// Get site URL from WordPress config
|
||||
const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin;
|
||||
|
||||
// Fetch templates
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ['templates-presets'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/templates/presets');
|
||||
return res as { id: string; label: string; description: string; icon: string }[];
|
||||
}
|
||||
});
|
||||
|
||||
// Create page mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: { title: string; slug: string; templateId?: string }) => {
|
||||
// Guard against double submission
|
||||
if (isSubmittingRef.current) {
|
||||
throw new Error('Request already in progress');
|
||||
}
|
||||
isSubmittingRef.current = true;
|
||||
|
||||
try {
|
||||
// api.post returns JSON directly (not wrapped in { data: ... })
|
||||
const response = await api.post('/pages', {
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
templateId: data.templateId
|
||||
});
|
||||
return response; // Return response directly, not response.data
|
||||
} finally {
|
||||
// Reset after a delay to prevent race conditions
|
||||
setTimeout(() => {
|
||||
isSubmittingRef.current = false;
|
||||
}, 500);
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data?.page) {
|
||||
toast.success(__('Page created successfully'));
|
||||
onCreated({
|
||||
id: data.page.id,
|
||||
type: 'page',
|
||||
slug: data.page.slug,
|
||||
title: data.page.title,
|
||||
});
|
||||
onOpenChange(false);
|
||||
setTitle('');
|
||||
setSlug('');
|
||||
setSelectedTemplateId('blank');
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Don't show error for duplicate prevention
|
||||
if (error?.message === 'Request already in progress') {
|
||||
return;
|
||||
}
|
||||
// Extract error message from the response
|
||||
const message = error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
__('Failed to create page');
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-generate slug from title
|
||||
const handleTitleChange = (value: string) => {
|
||||
setTitle(value);
|
||||
// Auto-generate slug only if slug matches the previously auto-generated value
|
||||
const autoSlug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
if (!slug || slug === autoSlug) {
|
||||
setSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = () => {
|
||||
if (createMutation.isPending || isSubmittingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (pageType === 'page' && title && slug) {
|
||||
createMutation.mutate({ title, slug, templateId: selectedTemplateId });
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setTitle('');
|
||||
setSlug('');
|
||||
setPageType('page');
|
||||
setSelectedTemplateId('blank');
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const isDisabled = pageType === 'page' && (!title || !slug) || createMutation.isPending || isSubmittingRef.current;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Create New Page')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__('Choose what type of page you want to create.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4 px-1">
|
||||
{/* Page Type Selection */}
|
||||
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')} className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
className={`flex items-start space-x-3 p-4 border rounded-lg cursor-pointer transition-colors ${pageType === 'page' ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-accent/50'}`}
|
||||
onClick={() => setPageType('page')}
|
||||
>
|
||||
<RadioGroupItem value="page" id="page" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="page" className="flex items-center gap-2 cursor-pointer font-medium">
|
||||
<FileText className="w-4 h-4" />
|
||||
{__('Structural Page')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('Static content like About, Contact, Terms')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50 relative">
|
||||
<RadioGroupItem value="template" id="template" className="mt-1" disabled />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="template" className="flex items-center gap-2 cursor-pointer font-medium">
|
||||
<Layout className="w-4 h-4" />
|
||||
{__('CPT Template')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('Templates are auto-created for each post type')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{/* Page Details */}
|
||||
{pageType === 'page' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">{__('Page Title')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder={__('e.g., About Us')}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">{__('URL Slug')}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
placeholder={__('e.g., about-us')}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono text-primary">{siteUrl}/{slug || 'page'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>{__('Choose a Template')}</Label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`
|
||||
relative p-3 border rounded-lg cursor-pointer transition-all hover:bg-accent
|
||||
${selectedTemplateId === tpl.id ? 'border-primary ring-1 ring-primary bg-primary/5' : 'border-border'}
|
||||
`}
|
||||
onClick={() => setSelectedTemplateId(tpl.id)}
|
||||
>
|
||||
<div className="mb-2 font-medium text-sm flex items-center gap-2">
|
||||
{tpl.label}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tpl.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{templates.length === 0 && (
|
||||
<div className="col-span-4 text-center py-4 text-muted-foreground text-sm">
|
||||
{__('Loading templates...')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={createMutation.isPending}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{__('Creating...')}
|
||||
</>
|
||||
) : (
|
||||
__('Create Page')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MediaUploader } from '@/components/MediaUploader';
|
||||
import { Image as ImageIcon } from 'lucide-react';
|
||||
|
||||
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
||||
|
||||
export interface SectionProp {
|
||||
type: 'static' | 'dynamic';
|
||||
value?: any;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface InspectorFieldProps {
|
||||
fieldName: string;
|
||||
fieldLabel: string;
|
||||
fieldType: 'text' | 'textarea' | 'url' | 'image' | 'rte';
|
||||
value: SectionProp;
|
||||
onChange: (value: SectionProp) => void;
|
||||
supportsDynamic?: boolean;
|
||||
availableSources?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export function InspectorField({
|
||||
fieldName,
|
||||
fieldLabel,
|
||||
fieldType,
|
||||
value,
|
||||
onChange,
|
||||
supportsDynamic = false,
|
||||
availableSources = [],
|
||||
}: InspectorFieldProps) {
|
||||
const isDynamic = value.type === 'dynamic';
|
||||
const currentValue = isDynamic ? (value.source || '') : (value.value || '');
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
if (isDynamic) {
|
||||
onChange({ type: 'dynamic', source: newValue });
|
||||
} else {
|
||||
onChange({ type: 'static', value: newValue });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypeToggle = (dynamic: boolean) => {
|
||||
if (dynamic) {
|
||||
onChange({ type: 'dynamic', source: availableSources[0]?.value || 'post_title' });
|
||||
} else {
|
||||
onChange({ type: 'static', value: '' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={fieldName} className="text-sm font-medium">
|
||||
{fieldLabel}
|
||||
</Label>
|
||||
{supportsDynamic && availableSources.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'text-xs',
|
||||
isDynamic ? 'text-orange-500 font-medium' : 'text-gray-400'
|
||||
)}>
|
||||
{isDynamic ? '◆ Dynamic' : 'Static'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isDynamic}
|
||||
onCheckedChange={handleTypeToggle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDynamic && supportsDynamic ? (
|
||||
<Select value={currentValue} onValueChange={handleValueChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select data source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSources.map((source) => (
|
||||
<SelectItem key={source.value} value={source.value}>
|
||||
{source.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : fieldType === 'rte' ? (
|
||||
<RichTextEditor
|
||||
content={currentValue}
|
||||
onChange={handleValueChange}
|
||||
placeholder={`Enter ${fieldLabel.toLowerCase()}...`}
|
||||
/>
|
||||
) : fieldType === 'textarea' ? (
|
||||
<Textarea
|
||||
id={fieldName}
|
||||
value={currentValue}
|
||||
onChange={(e) => handleValueChange(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={fieldName}
|
||||
type={fieldType === 'url' ? 'url' : 'text'}
|
||||
value={currentValue}
|
||||
onChange={(e) => handleValueChange(e.target.value)}
|
||||
placeholder={fieldType === 'url' ? 'https://' : `Enter ${fieldLabel.toLowerCase()}`}
|
||||
className="flex-1"
|
||||
/>
|
||||
{(fieldType === 'url' || fieldType === 'image') && (
|
||||
<MediaUploader
|
||||
onSelect={(url) => handleValueChange(url)}
|
||||
type="image"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Button variant="outline" size="icon" title={__('Select Image')}>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</MediaUploader>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
Settings,
|
||||
PanelRightClose,
|
||||
PanelRight,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Palette,
|
||||
Type,
|
||||
Home
|
||||
} from 'lucide-react';
|
||||
import { InspectorField, SectionProp } from './InspectorField';
|
||||
import { InspectorRepeater } from './InspectorRepeater';
|
||||
import { MediaUploader } from '@/components/MediaUploader';
|
||||
import { SectionStyles, ElementStyle } from '../store/usePageEditorStore';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
styles?: SectionStyles;
|
||||
elementStyles?: Record<string, ElementStyle>;
|
||||
props: Record<string, SectionProp>;
|
||||
}
|
||||
|
||||
interface PageItem {
|
||||
id?: number;
|
||||
type: 'page' | 'template';
|
||||
cpt?: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
isSpaLanding?: boolean;
|
||||
}
|
||||
|
||||
interface InspectorPanelProps {
|
||||
page: PageItem | null;
|
||||
selectedSection: Section | null;
|
||||
isCollapsed: boolean;
|
||||
isTemplate: boolean;
|
||||
availableSources: { value: string; label: string }[];
|
||||
onToggleCollapse: () => void;
|
||||
onSectionPropChange: (propName: string, value: SectionProp) => void;
|
||||
onLayoutChange: (layout: string) => void;
|
||||
onColorSchemeChange: (scheme: string) => void;
|
||||
onSectionStylesChange: (styles: Partial<SectionStyles>) => void;
|
||||
onElementStylesChange: (fieldName: string, styles: Partial<ElementStyle>) => void;
|
||||
onDeleteSection: () => void;
|
||||
onSetAsSpaLanding?: () => void;
|
||||
onUnsetSpaLanding?: () => void;
|
||||
onDeletePage?: () => void;
|
||||
}
|
||||
|
||||
// Section field configurations
|
||||
const SECTION_FIELDS: Record<string, { name: string; label: string; type: 'text' | 'textarea' | 'url' | 'image' | 'rte'; dynamic?: boolean }[]> = {
|
||||
hero: [
|
||||
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
||||
{ name: 'subtitle', label: 'Subtitle', type: 'text', dynamic: true },
|
||||
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
|
||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
content: [
|
||||
{ name: 'content', label: 'Content', type: 'rte', dynamic: true },
|
||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
'image-text': [
|
||||
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
||||
{ name: 'text', label: 'Text', type: 'textarea', dynamic: true },
|
||||
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
|
||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
'feature-grid': [
|
||||
{ name: 'heading', label: 'Heading', type: 'text' },
|
||||
],
|
||||
'cta-banner': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'text', label: 'Description', type: 'text' },
|
||||
{ name: 'button_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'button_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
'contact-form': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'webhook_url', label: 'Webhook URL', type: 'url' },
|
||||
{ name: 'redirect_url', label: 'Redirect URL', type: 'url' },
|
||||
],
|
||||
};
|
||||
|
||||
const LAYOUT_OPTIONS: Record<string, { value: string; label: string }[]> = {
|
||||
hero: [
|
||||
{ value: 'default', label: 'Centered' },
|
||||
{ value: 'hero-left-image', label: 'Image Left' },
|
||||
{ value: 'hero-right-image', label: 'Image Right' },
|
||||
],
|
||||
'image-text': [
|
||||
{ value: 'image-left', label: 'Image Left' },
|
||||
{ value: 'image-right', label: 'Image Right' },
|
||||
],
|
||||
'feature-grid': [
|
||||
{ value: 'grid-2', label: '2 Columns' },
|
||||
{ value: 'grid-3', label: '3 Columns' },
|
||||
{ value: 'grid-4', label: '4 Columns' },
|
||||
],
|
||||
content: [
|
||||
{ value: 'default', label: 'Full Width' },
|
||||
{ value: 'narrow', label: 'Narrow' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
],
|
||||
};
|
||||
|
||||
const COLOR_SCHEMES = [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'primary', label: 'Primary' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
{ value: 'muted', label: 'Muted' },
|
||||
{ value: 'gradient', label: 'Gradient' },
|
||||
];
|
||||
|
||||
const STYLABLE_ELEMENTS: Record<string, { name: string; label: string; type: 'text' | 'image' }[]> = {
|
||||
hero: [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
||||
{ name: 'image', label: 'Image', type: 'image' },
|
||||
{ name: 'cta_text', label: 'Button', type: 'text' },
|
||||
],
|
||||
content: [
|
||||
{ name: 'heading', label: 'Headings', type: 'text' },
|
||||
{ name: 'text', label: 'Body Text', type: 'text' },
|
||||
{ name: 'link', label: 'Links', type: 'text' },
|
||||
{ name: 'image', label: 'Images', type: 'image' },
|
||||
{ name: 'button', label: 'Button', type: 'text' },
|
||||
{ name: 'content', label: 'Container', type: 'text' }, // Keep for backward compat or wrapper style
|
||||
],
|
||||
'image-text': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'text', label: 'Text', type: 'text' },
|
||||
{ name: 'image', label: 'Image', type: 'image' },
|
||||
{ name: 'button', label: 'Button', type: 'text' },
|
||||
],
|
||||
'feature-grid': [
|
||||
{ name: 'heading', label: 'Heading', type: 'text' },
|
||||
{ name: 'feature_item', label: 'Feature Item (Card)', type: 'text' },
|
||||
],
|
||||
'cta-banner': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'text', label: 'Description', type: 'text' },
|
||||
{ name: 'button_text', label: 'Button', type: 'text' },
|
||||
],
|
||||
'contact-form': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'button', label: 'Button', type: 'text' },
|
||||
{ name: 'fields', label: 'Input Fields', type: 'text' },
|
||||
],
|
||||
};
|
||||
|
||||
export function InspectorPanel({
|
||||
page,
|
||||
selectedSection,
|
||||
isCollapsed,
|
||||
isTemplate,
|
||||
availableSources,
|
||||
onToggleCollapse,
|
||||
onSectionPropChange,
|
||||
onLayoutChange,
|
||||
onColorSchemeChange,
|
||||
onSectionStylesChange,
|
||||
onElementStylesChange,
|
||||
onDeleteSection,
|
||||
onSetAsSpaLanding,
|
||||
onUnsetSpaLanding,
|
||||
onDeletePage,
|
||||
}: InspectorPanelProps) {
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="w-10 border-l bg-white flex flex-col items-center py-4">
|
||||
<Button variant="ghost" size="icon" onClick={onToggleCollapse} className="mb-4">
|
||||
<PanelRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedSection) {
|
||||
return (
|
||||
<div className="w-80 border-l bg-white flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-4 border-b shrink-0">
|
||||
<h3 className="font-semibold text-sm">{__('Page Settings')}</h3>
|
||||
<Button variant="ghost" size="icon" onClick={onToggleCollapse} className="h-8 w-8">
|
||||
<PanelRightClose className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4 overflow-y-auto">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<Settings className="w-4 h-4" />
|
||||
{isTemplate ? __('Template Info') : __('Page Info')}
|
||||
</div>
|
||||
<div className="space-y-4 bg-gray-50 p-3 rounded-lg border">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 uppercase tracking-wider">{__('Type')}</Label>
|
||||
<p className="text-sm font-medium">
|
||||
{isTemplate ? __('Template (Dynamic)') : __('Page (Structural)')}
|
||||
</p>
|
||||
</div>
|
||||
{page?.title && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 uppercase tracking-wider">{__('Title')}</Label>
|
||||
<p className="text-sm font-medium">{page.title}</p>
|
||||
</div>
|
||||
)}
|
||||
{page?.url && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 uppercase tracking-wider">{__('URL')}</Label>
|
||||
<a href={page.url} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-600 hover:underline flex items-center gap-1 mt-1">
|
||||
{__('View Page')}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SPA Landing Settings - Only for Pages */}
|
||||
{!isTemplate && page && (
|
||||
<div className="pt-2 border-t mt-2">
|
||||
<Label className="text-xs text-gray-500 uppercase tracking-wider block mb-2">{__('SPA Landing Page')}</Label>
|
||||
{page.isSpaLanding ? (
|
||||
<div className="space-y-2">
|
||||
<div className="bg-green-50 text-green-700 px-3 py-2 rounded-md text-sm flex items-center gap-2 border border-green-100">
|
||||
<Home className="w-4 h-4" />
|
||||
<span className="font-medium">{__('This is your SPA Landing')}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
|
||||
onClick={onUnsetSpaLanding}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{__('Unset Landing Page')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={onSetAsSpaLanding}
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
{__('Set as SPA Landing')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Danger Zone */}
|
||||
{!isTemplate && page && onDeletePage && (
|
||||
<div className="pt-2 border-t mt-2">
|
||||
<Label className="text-xs text-red-600 uppercase tracking-wider block mb-2">{__('Danger Zone')}</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
|
||||
onClick={onDeletePage}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{__('Delete This Page')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-blue-50 text-blue-800 p-3 rounded text-xs leading-relaxed">
|
||||
{__('Select any section on the canvas to edit its content and design.')}
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-80 border-l bg-white flex flex-col transition-all duration-300 shadow-xl z-30",
|
||||
isCollapsed && "w-0 overflow-hidden border-none"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b flex items-center justify-between px-4 shrink-0 bg-white">
|
||||
<span className="font-semibold text-sm truncate">
|
||||
{SECTION_FIELDS[selectedSection.type]
|
||||
? (selectedSection.type.charAt(0).toUpperCase() + selectedSection.type.slice(1)).replace('-', ' ')
|
||||
: 'Settings'}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" onClick={onToggleCollapse} className="h-8 w-8 hover:bg-gray-100">
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content Tabs (Content vs Design) */}
|
||||
<Tabs defaultValue="content" className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-4 pt-4 shrink-0 bg-white">
|
||||
<TabsList className="w-full grid grid-cols-2">
|
||||
<TabsTrigger value="content">{__('Content')}</TabsTrigger>
|
||||
<TabsTrigger value="design">{__('Design')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Content Tab */}
|
||||
<TabsContent value="content" className="p-4 space-y-6 m-0">
|
||||
{/* Structure & Presets */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Structure')}</h4>
|
||||
{LAYOUT_OPTIONS[selectedSection.type] && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Layout Variant')}</Label>
|
||||
<Select
|
||||
value={selectedSection.layoutVariant || 'default'}
|
||||
onValueChange={onLayoutChange}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{LAYOUT_OPTIONS[selectedSection.type].map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Preset Scheme')}</Label>
|
||||
<Select
|
||||
value={selectedSection.colorScheme || 'default'}
|
||||
onValueChange={onColorSchemeChange}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLOR_SCHEMES.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-gray-100" />
|
||||
|
||||
{/* Fields */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Fields')}</h4>
|
||||
{SECTION_FIELDS[selectedSection.type]?.map((field) => (
|
||||
<React.Fragment key={field.name}>
|
||||
<InspectorField
|
||||
fieldName={field.name}
|
||||
fieldLabel={field.label}
|
||||
fieldType={field.type}
|
||||
value={selectedSection.props[field.name] || { type: 'static', value: '' }}
|
||||
onChange={(val) => onSectionPropChange(field.name, val)}
|
||||
supportsDynamic={field.dynamic && isTemplate}
|
||||
availableSources={availableSources}
|
||||
/>
|
||||
{selectedSection.type === 'contact-form' && field.name === 'redirect_url' && (
|
||||
<p className="text-[10px] text-gray-500 mt-1 pl-1">
|
||||
Available shortcodes: {'{name}'}, {'{email}'}, {'{date}'}
|
||||
</p>
|
||||
)}
|
||||
{selectedSection.type === 'contact-form' && field.name === 'webhook_url' && (
|
||||
<Accordion type="single" collapsible className="w-full mt-1 border rounded-md">
|
||||
<AccordionItem value="payload" className="border-0">
|
||||
<AccordionTrigger className="text-[10px] py-1 px-2 hover:no-underline text-gray-500">
|
||||
View Payload Example
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-2 pb-2">
|
||||
<pre className="text-[10px] bg-gray-50 p-2 rounded overflow-x-auto">
|
||||
{JSON.stringify({
|
||||
"form_id": "contact_form_123",
|
||||
"fields": {
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"message": "Hello world"
|
||||
},
|
||||
"meta": {
|
||||
"url": "https://site.com/contact",
|
||||
"timestamp": 1710000000
|
||||
}
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Feature Grid Repeater */}
|
||||
{selectedSection.type === 'feature-grid' && (
|
||||
<div className="pt-4 border-t">
|
||||
<InspectorRepeater
|
||||
label={__('Features')}
|
||||
items={Array.isArray(selectedSection.props.features?.value) ? selectedSection.props.features.value : []}
|
||||
onChange={(items) => onSectionPropChange('features', { type: 'static', value: items })}
|
||||
fields={[
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'description', label: 'Description', type: 'textarea' },
|
||||
{ name: 'icon', label: 'Icon', type: 'icon' },
|
||||
]}
|
||||
itemLabelKey="title"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Design Tab */}
|
||||
<TabsContent value="design" className="p-4 space-y-6 m-0">
|
||||
{/* Background */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Background')}</h4>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Background Color')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.backgroundColor || 'transparent' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={selectedSection.styles?.backgroundColor || '#ffffff'}
|
||||
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="#FFFFFF"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
value={selectedSection.styles?.backgroundColor || ''}
|
||||
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Background Image')}</Label>
|
||||
<MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}>
|
||||
{selectedSection.styles?.backgroundImage ? (
|
||||
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50">
|
||||
<img src={selectedSection.styles.backgroundImage} alt="Background" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-white text-xs font-medium">{__('Change')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onSectionStylesChange({ backgroundImage: '' }); }}
|
||||
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal">
|
||||
<Palette className="w-6 h-6" />
|
||||
{__('Select Image')}
|
||||
</Button>
|
||||
)}
|
||||
</MediaUploader>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">{__('Overlay Opacity')}</Label>
|
||||
<span className="text-xs text-gray-500">{selectedSection.styles?.backgroundOverlay ?? 0}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[selectedSection.styles?.backgroundOverlay ?? 0]}
|
||||
max={100}
|
||||
step={5}
|
||||
onValueChange={(vals) => onSectionStylesChange({ backgroundOverlay: vals[0] })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label className="text-xs">{__('Section Height')}</Label>
|
||||
<Select
|
||||
value={selectedSection.styles?.heightPreset || 'default'}
|
||||
onValueChange={(val) => {
|
||||
// Map presets to padding values
|
||||
const paddingMap: Record<string, string> = {
|
||||
'default': '0',
|
||||
'small': '0',
|
||||
'medium': '0',
|
||||
'large': '0',
|
||||
'screen': '0',
|
||||
};
|
||||
const padding = paddingMap[val] || '4rem';
|
||||
|
||||
// If screen, we might need a specific flag, but for now lets reuse paddingTop/Bottom or add a new prop.
|
||||
// To avoid breaking schema, let's use paddingTop as the "preset carrier" or add a new styles prop if possible.
|
||||
// Since styles key is SectionStyles, let's stick to modifying paddingTop/Bottom for now as a simple preset.
|
||||
|
||||
onSectionStylesChange({
|
||||
paddingTop: padding,
|
||||
paddingBottom: padding,
|
||||
heightPreset: val // We'll add this to interface
|
||||
} as any);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Height" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="small">Small (Compact)</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="large">Large (Spacious)</SelectItem>
|
||||
<SelectItem value="screen">Full Screen</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="w-full h-px bg-gray-100" />
|
||||
|
||||
{/* Element Styles */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{__('Element Styles')}</h4>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{(STYLABLE_ELEMENTS[selectedSection.type] || []).map((field) => {
|
||||
const styles = selectedSection.elementStyles?.[field.name] || {};
|
||||
const isImage = field.type === 'image';
|
||||
|
||||
return (
|
||||
<AccordionItem key={field.name} value={field.name}>
|
||||
<AccordionTrigger className="text-xs hover:no-underline py-2">{field.label}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-2">
|
||||
{/* Common: Background Wrapper */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Background (Wrapper)')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: styles.backgroundColor || 'transparent' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={styles.backgroundColor || '#ffffff'}
|
||||
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Color (#fff)"
|
||||
className="flex-1 h-7 text-xs rounded border px-2"
|
||||
value={styles.backgroundColor || ''}
|
||||
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isImage ? (
|
||||
<>
|
||||
{/* Text Color */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Text Color')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: styles.color || '#000000' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={styles.color || '#000000'}
|
||||
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Color (#000)"
|
||||
className="flex-1 h-7 text-xs rounded border px-2"
|
||||
value={styles.color || ''}
|
||||
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Typography Group */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Typography')}</Label>
|
||||
|
||||
<Select value={styles.fontFamily || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { fontFamily: val === 'default' ? undefined : val as any })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Font Family" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="primary">Primary (Headings)</SelectItem>
|
||||
<SelectItem value="secondary">Secondary (Body)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Select value={styles.fontSize || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { fontSize: val === 'default' ? undefined : val })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Size" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default Size</SelectItem>
|
||||
<SelectItem value="text-sm">Small</SelectItem>
|
||||
<SelectItem value="text-base">Base</SelectItem>
|
||||
<SelectItem value="text-lg">Large</SelectItem>
|
||||
<SelectItem value="text-xl">XL</SelectItem>
|
||||
<SelectItem value="text-2xl">2XL</SelectItem>
|
||||
<SelectItem value="text-3xl">3XL</SelectItem>
|
||||
<SelectItem value="text-4xl">4XL</SelectItem>
|
||||
<SelectItem value="text-5xl">5XL</SelectItem>
|
||||
<SelectItem value="text-6xl">6XL</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={styles.fontWeight || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { fontWeight: val === 'default' ? undefined : val })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Weight" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default Weight</SelectItem>
|
||||
<SelectItem value="font-light">Light</SelectItem>
|
||||
<SelectItem value="font-normal">Normal</SelectItem>
|
||||
<SelectItem value="font-medium">Medium</SelectItem>
|
||||
<SelectItem value="font-semibold">Semibold</SelectItem>
|
||||
<SelectItem value="font-bold">Bold</SelectItem>
|
||||
<SelectItem value="font-extrabold">Extra Bold</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Select value={styles.textAlign || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textAlign: val === 'default' ? undefined : val as any })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default Align</SelectItem>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Link Specific Styles */}
|
||||
{field.name === 'link' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Link Styles')}</Label>
|
||||
<Select value={styles.textDecoration || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textDecoration: val === 'default' ? undefined : val as any })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Decoration" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="underline">Underline</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-400">{__('Hover Color')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: styles.hoverColor || 'transparent' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={styles.hoverColor || '#000000'}
|
||||
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Hover Color"
|
||||
className="flex-1 h-7 text-xs rounded border px-2"
|
||||
value={styles.hoverColor || ''}
|
||||
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Button/Box Specific Styles */}
|
||||
{field.name === 'button' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Box Styles')}</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-400">{__('Border Color')}</Label>
|
||||
<div className="flex items-center gap-2 h-7">
|
||||
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: styles.borderColor || 'transparent' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={styles.borderColor || '#000000'}
|
||||
onChange={(e) => onElementStylesChange(field.name, { borderColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-400">{__('Border Width')}</Label>
|
||||
<input type="text" placeholder="e.g. 1px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderWidth || ''} onChange={(e) => onElementStylesChange(field.name, { borderWidth: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-400">{__('Radius')}</Label>
|
||||
<input type="text" placeholder="e.g. 4px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderRadius || ''} onChange={(e) => onElementStylesChange(field.name, { borderRadius: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-400">{__('Padding')}</Label>
|
||||
<input type="text" placeholder="e.g. 8px 16px" className="w-full h-7 text-xs rounded border px-2" value={styles.padding || ''} onChange={(e) => onElementStylesChange(field.name, { padding: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Image Settings */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Image Fit')}</Label>
|
||||
<Select value={styles.objectFit || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { objectFit: val === 'default' ? undefined : val as any })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Object Fit" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="cover">Cover</SelectItem>
|
||||
<SelectItem value="contain">Contain</SelectItem>
|
||||
<SelectItem value="fill">Fill</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">{__('Width')}</Label>
|
||||
<input type="text" placeholder="e.g. 100%" className="w-full h-7 text-xs rounded border px-2" value={styles.width || ''} onChange={(e) => onElementStylesChange(field.name, { width: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">{__('Height')}</Label>
|
||||
<input type="text" placeholder="e.g. auto" className="w-full h-7 text-xs rounded border px-2" value={styles.height || ''} onChange={(e) => onElementStylesChange(field.name, { height: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
{/* Footer - Delete Button */}
|
||||
{
|
||||
selectedSection && (
|
||||
<div className="p-4 border-t mt-auto shrink-0 bg-gray-50/50">
|
||||
<Button variant="destructive" className="w-full" onClick={onDeleteSection}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{__('Delete Section')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Tabs >
|
||||
</div >
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Trash2, GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
|
||||
interface RepeaterFieldDef {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'textarea' | 'url' | 'image' | 'icon';
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface InspectorRepeaterProps {
|
||||
label: string;
|
||||
items: any[];
|
||||
fields: RepeaterFieldDef[];
|
||||
onChange: (items: any[]) => void;
|
||||
itemLabelKey?: string; // Key to use for the accordion header (e.g., 'title')
|
||||
}
|
||||
|
||||
// Sortable Item Component
|
||||
function SortableItem({ id, item, index, fields, itemLabelKey, onChange, onDelete }: any) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
// List of available icons for selection
|
||||
const ICON_OPTIONS = [
|
||||
'Star', 'Zap', 'Shield', 'Heart', 'Award', 'Clock', 'User', 'Settings',
|
||||
'Check', 'X', 'ArrowRight', 'Mail', 'Phone', 'MapPin', 'Briefcase',
|
||||
'Calendar', 'Camera', 'Cloud', 'Code', 'Cpu', 'CreditCard', 'Database',
|
||||
'DollarSign', 'Eye', 'File', 'Folder', 'Globe', 'Home', 'Image',
|
||||
'Layers', 'Layout', 'LifeBuoy', 'Link', 'Lock', 'MessageCircle',
|
||||
'Monitor', 'Moon', 'Music', 'Package', 'PieChart', 'Play', 'Power',
|
||||
'Printer', 'Radio', 'Search', 'Server', 'ShoppingBag', 'ShoppingCart',
|
||||
'Smartphone', 'Speaker', 'Sun', 'Tablet', 'Tag', 'Terminal', 'Tool',
|
||||
'Truck', 'Tv', 'Umbrella', 'Upload', 'Video', 'Voicemail', 'Volume2',
|
||||
'Wifi', 'Wrench'
|
||||
].sort();
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="bg-white border rounded-md mb-2">
|
||||
<AccordionItem value={`item-${index}`} className="border-0">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b bg-gray-50/50 rounded-t-md">
|
||||
<button {...attributes} {...listeners} className="cursor-grab text-gray-400 hover:text-gray-600">
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
<AccordionTrigger className="hover:no-underline py-0 flex-1 text-sm font-medium">
|
||||
{item[itemLabelKey] || `Item ${index + 1}`}
|
||||
</AccordionTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-gray-400 hover:text-red-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(index);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<AccordionContent className="p-3 space-y-3">
|
||||
{fields.map((field: RepeaterFieldDef) => (
|
||||
<div key={field.name} className="space-y-1.5">
|
||||
<Label className="text-xs text-gray-500">{field.label}</Label>
|
||||
{field.type === 'textarea' ? (
|
||||
<Textarea
|
||||
value={item[field.name] || ''}
|
||||
onChange={(e) => onChange(index, field.name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="text-xs min-h-[60px]"
|
||||
/>
|
||||
) : field.type === 'icon' ? (
|
||||
<Select
|
||||
value={item[field.name] || ''}
|
||||
onValueChange={(val) => onChange(index, field.name, val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-full">
|
||||
<SelectValue placeholder="Select an icon" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[200px]">
|
||||
{ICON_OPTIONS.map(iconName => (
|
||||
<SelectItem key={iconName} value={iconName}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{iconName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={item[field.name] || ''}
|
||||
onChange={(e) => onChange(index, field.name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InspectorRepeater({ label, items = [], fields, onChange, itemLabelKey = 'title' }: InspectorRepeaterProps) {
|
||||
// Generate simple stable IDs for sorting if items don't have them
|
||||
const itemIds = items.map((_, i) => `item-${i}`);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = itemIds.indexOf(active.id as string);
|
||||
const newIndex = itemIds.indexOf(over.id as string);
|
||||
onChange(arrayMove(items, oldIndex, newIndex));
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemChange = (index: number, fieldName: string, value: string) => {
|
||||
const newItems = [...items];
|
||||
newItems[index] = { ...newItems[index], [fieldName]: value };
|
||||
onChange(newItems);
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
const newItem: any = {};
|
||||
fields.forEach(f => newItem[f.name] = '');
|
||||
onChange([...items, newItem]);
|
||||
};
|
||||
|
||||
const handleDeleteItem = (index: number) => {
|
||||
const newItems = [...items];
|
||||
newItems.splice(index, 1);
|
||||
onChange(newItems);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{label}</Label>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleAddItem}>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={itemIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<SortableItem
|
||||
key={`item-${index}`} // Note: In a real app with IDs, use item.id
|
||||
id={`item-${index}`}
|
||||
index={index}
|
||||
item={item}
|
||||
fields={fields}
|
||||
itemLabelKey={itemLabelKey}
|
||||
onChange={handleItemChange}
|
||||
onDelete={handleDeleteItem}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</Accordion>
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="text-xs text-gray-400 text-center py-4 border border-dashed rounded-md bg-gray-50">
|
||||
No items yet. Click "Add Item" to start.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Settings, Eye, Smartphone, Monitor, ExternalLink, RefreshCw, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PageItem {
|
||||
id?: number;
|
||||
type: 'page' | 'template';
|
||||
cpt?: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface AvailableSource {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PageSettingsProps {
|
||||
page: PageItem | null;
|
||||
section: Section | null;
|
||||
sections: Section[]; // All sections for preview
|
||||
onSectionUpdate: (section: Section) => void;
|
||||
isTemplate?: boolean;
|
||||
availableSources?: AvailableSource[];
|
||||
}
|
||||
|
||||
// Section field configs
|
||||
const SECTION_FIELDS: Record<string, { name: string; type: 'text' | 'textarea' | 'url' | 'image'; dynamic?: boolean }[]> = {
|
||||
hero: [
|
||||
{ name: 'title', type: 'text', dynamic: true },
|
||||
{ name: 'subtitle', type: 'text', dynamic: true },
|
||||
{ name: 'image', type: 'image', dynamic: true },
|
||||
{ name: 'cta_text', type: 'text' },
|
||||
{ name: 'cta_url', type: 'url' },
|
||||
],
|
||||
content: [
|
||||
{ name: 'content', type: 'textarea', dynamic: true },
|
||||
],
|
||||
'image-text': [
|
||||
{ name: 'title', type: 'text', dynamic: true },
|
||||
{ name: 'text', type: 'textarea', dynamic: true },
|
||||
{ name: 'image', type: 'image', dynamic: true },
|
||||
],
|
||||
'feature-grid': [
|
||||
{ name: 'heading', type: 'text' },
|
||||
],
|
||||
'cta-banner': [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'text', type: 'text' },
|
||||
{ name: 'button_text', type: 'text' },
|
||||
{ name: 'button_url', type: 'url' },
|
||||
],
|
||||
'contact-form': [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'webhook_url', type: 'url' },
|
||||
{ name: 'redirect_url', type: 'url' },
|
||||
],
|
||||
};
|
||||
|
||||
const LAYOUT_OPTIONS: Record<string, { value: string; label: string }[]> = {
|
||||
hero: [
|
||||
{ value: 'default', label: 'Centered' },
|
||||
{ value: 'hero-left-image', label: 'Image Left' },
|
||||
{ value: 'hero-right-image', label: 'Image Right' },
|
||||
],
|
||||
'image-text': [
|
||||
{ value: 'image-left', label: 'Image Left' },
|
||||
{ value: 'image-right', label: 'Image Right' },
|
||||
],
|
||||
'feature-grid': [
|
||||
{ value: 'grid-2', label: '2 Columns' },
|
||||
{ value: 'grid-3', label: '3 Columns' },
|
||||
{ value: 'grid-4', label: '4 Columns' },
|
||||
],
|
||||
content: [
|
||||
{ value: 'default', label: 'Full Width' },
|
||||
{ value: 'narrow', label: 'Narrow' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
],
|
||||
};
|
||||
|
||||
const COLOR_SCHEMES = [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'primary', label: 'Primary' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
{ value: 'muted', label: 'Muted' },
|
||||
{ value: 'gradient', label: 'Gradient' },
|
||||
];
|
||||
|
||||
export function PageSettings({
|
||||
page,
|
||||
section,
|
||||
sections,
|
||||
onSectionUpdate,
|
||||
isTemplate = false,
|
||||
availableSources = [],
|
||||
}: PageSettingsProps) {
|
||||
const [previewMode, setPreviewMode] = useState<'desktop' | 'mobile'>('desktop');
|
||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const previewTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Debounced preview fetch
|
||||
useEffect(() => {
|
||||
if (!page || !showPreview) return;
|
||||
|
||||
// Clear existing timeout
|
||||
if (previewTimeoutRef.current) {
|
||||
clearTimeout(previewTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce preview updates
|
||||
previewTimeoutRef.current = setTimeout(async () => {
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const endpoint = page.type === 'page'
|
||||
? `/preview/page/${page.slug}`
|
||||
: `/preview/template/${page.cpt}`;
|
||||
|
||||
const response = await api.post(endpoint, { sections });
|
||||
if (response?.html) {
|
||||
setPreviewHtml(response.html);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Preview error:', error);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (previewTimeoutRef.current) {
|
||||
clearTimeout(previewTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [page, sections, showPreview]);
|
||||
|
||||
// Update iframe when HTML changes
|
||||
useEffect(() => {
|
||||
if (iframeRef.current && previewHtml) {
|
||||
const doc = iframeRef.current.contentDocument;
|
||||
if (doc) {
|
||||
doc.open();
|
||||
doc.write(previewHtml);
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}, [previewHtml]);
|
||||
|
||||
// Manual refresh
|
||||
const handleRefreshPreview = async () => {
|
||||
if (!page) return;
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const endpoint = page.type === 'page'
|
||||
? `/preview/page/${page.slug}`
|
||||
: `/preview/template/${page.cpt}`;
|
||||
|
||||
const response = await api.post(endpoint, { sections });
|
||||
if (response?.html) {
|
||||
setPreviewHtml(response.html);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Preview error:', error);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update section prop
|
||||
const updateProp = (name: string, value: any, isDynamic?: boolean) => {
|
||||
if (!section) return;
|
||||
|
||||
const newProps = { ...section.props };
|
||||
if (isDynamic) {
|
||||
newProps[name] = { type: 'dynamic', source: value };
|
||||
} else {
|
||||
newProps[name] = { type: 'static', value };
|
||||
}
|
||||
|
||||
onSectionUpdate({ ...section, props: newProps });
|
||||
};
|
||||
|
||||
// Get prop value
|
||||
const getPropValue = (name: string): string => {
|
||||
const prop = section?.props[name];
|
||||
if (!prop) return '';
|
||||
if (typeof prop === 'object') {
|
||||
return prop.type === 'dynamic' ? prop.source : prop.value || '';
|
||||
}
|
||||
return String(prop);
|
||||
};
|
||||
|
||||
// Check if prop is dynamic
|
||||
const isPropDynamic = (name: string): boolean => {
|
||||
const prop = section?.props[name];
|
||||
return typeof prop === 'object' && prop?.type === 'dynamic';
|
||||
};
|
||||
|
||||
// Render field based on type
|
||||
const renderField = (field: { name: string; type: string; dynamic?: boolean }) => {
|
||||
const value = getPropValue(field.name);
|
||||
const isDynamic = isPropDynamic(field.name);
|
||||
const fieldLabel = field.name.charAt(0).toUpperCase() + field.name.slice(1).replace('_', ' ');
|
||||
|
||||
return (
|
||||
<div key={field.name} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{fieldLabel}</Label>
|
||||
{field.dynamic && isTemplate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{isDynamic ? '◆ Dynamic' : 'Static'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isDynamic}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updateProp(field.name, 'post_title', true);
|
||||
} else {
|
||||
updateProp(field.name, '', false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDynamic && isTemplate ? (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(v) => updateProp(field.name, v, true)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={__('Select source')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSources.map(source => (
|
||||
<SelectItem key={source.value} value={source.value}>
|
||||
{source.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.type === 'textarea' ? (
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => updateProp(field.name, e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type === 'url' ? 'url' : 'text'}
|
||||
value={value}
|
||||
onChange={(e) => updateProp(field.name, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 border-l bg-white flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Page Info */}
|
||||
{page && !section && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
{isTemplate ? __('Template Settings') : __('Page Settings')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>{__('Type')}</Label>
|
||||
<p className="text-sm text-gray-600">
|
||||
{isTemplate ? __('Template (Dynamic)') : __('Page (Structural)')}
|
||||
</p>
|
||||
</div>
|
||||
{page.url && (
|
||||
<div>
|
||||
<Label>{__('URL')}</Label>
|
||||
<a
|
||||
href={page.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
{page.url}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Section Settings */}
|
||||
{section && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{__('Section Settings')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Layout Variant */}
|
||||
{LAYOUT_OPTIONS[section.type] && (
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Layout')}</Label>
|
||||
<Select
|
||||
value={section.layoutVariant || 'default'}
|
||||
onValueChange={(v) => onSectionUpdate({ ...section, layoutVariant: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LAYOUT_OPTIONS[section.type].map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color Scheme */}
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Color Scheme')}</Label>
|
||||
<Select
|
||||
value={section.colorScheme || 'default'}
|
||||
onValueChange={(v) => onSectionUpdate({ ...section, colorScheme: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLOR_SCHEMES.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{__('Content')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{SECTION_FIELDS[section.type]?.map(renderField)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Preview Panel */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
{__('Preview')}
|
||||
</span>
|
||||
{showPreview && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={handleRefreshPreview}
|
||||
disabled={previewLoading}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${previewLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Preview Mode Toggle */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Button
|
||||
variant={previewMode === 'desktop' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('desktop')}
|
||||
>
|
||||
<Monitor className="w-4 h-4 mr-1" />
|
||||
{__('Desktop')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={previewMode === 'mobile' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('mobile')}
|
||||
>
|
||||
<Smartphone className="w-4 h-4 mr-1" />
|
||||
{__('Mobile')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Preview Toggle */}
|
||||
{!showPreview ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowPreview(true)}
|
||||
disabled={!page}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
{__('Show Preview')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* Preview Iframe Container */}
|
||||
<div
|
||||
className="relative bg-gray-100 rounded-lg overflow-hidden border"
|
||||
style={{
|
||||
height: '300px',
|
||||
width: previewMode === 'mobile' ? '200px' : '100%',
|
||||
margin: previewMode === 'mobile' ? '0 auto' : undefined,
|
||||
}}
|
||||
>
|
||||
{previewLoading && (
|
||||
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="w-full h-full border-0"
|
||||
title="Page Preview"
|
||||
sandbox="allow-same-origin"
|
||||
style={{
|
||||
transform: previewMode === 'mobile' ? 'scale(0.5)' : 'scale(0.4)',
|
||||
transformOrigin: 'top left',
|
||||
width: previewMode === 'mobile' ? '400px' : '250%',
|
||||
height: previewMode === 'mobile' ? '600px' : '750px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => setShowPreview(false)}
|
||||
>
|
||||
{__('Hide Preview')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
admin-spa/src/routes/Appearance/Pages/components/PageSidebar.tsx
Normal file
104
admin-spa/src/routes/Appearance/Pages/components/PageSidebar.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FileText, Layout, Loader2, Home } from 'lucide-react';
|
||||
|
||||
interface PageItem {
|
||||
id?: number;
|
||||
type: 'page' | 'template';
|
||||
cpt?: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
has_template?: boolean;
|
||||
permalink_base?: string;
|
||||
}
|
||||
|
||||
interface PageSidebarProps {
|
||||
pages: PageItem[];
|
||||
selectedPage: PageItem | null;
|
||||
onSelectPage: (page: PageItem) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: PageSidebarProps) {
|
||||
const structuralPages = pages.filter(p => p.type === 'page');
|
||||
const templates = pages.filter(p => p.type === 'template');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-60 border-r bg-white flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-60 border-r bg-white flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4">
|
||||
{/* Structural Pages */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-2">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
{__('Structural Pages')}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{structuralPages.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic">{__('No pages yet')}</p>
|
||||
) : (
|
||||
structuralPages.map((page) => (
|
||||
<button
|
||||
key={`page-${page.id}`}
|
||||
onClick={() => onSelectPage(page)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between group',
|
||||
'hover:bg-gray-100',
|
||||
selectedPage?.id === page.id && selectedPage?.type === 'page'
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-gray-700'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{page.title}</span>
|
||||
{(page as any).isSpaLanding && (
|
||||
<span title="SPA Landing Page" className="flex-shrink-0 ml-2">
|
||||
<Home className="w-3.5 h-3.5 text-green-600" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Templates */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-2">
|
||||
<Layout className="w-3.5 h-3.5" />
|
||||
{__('Templates')}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={`template-${template.cpt}`}
|
||||
onClick={() => onSelectPage(template)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
'hover:bg-gray-100',
|
||||
selectedPage?.cpt === template.cpt && selectedPage?.type === 'template'
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-gray-700'
|
||||
)}
|
||||
>
|
||||
<span className="block">{template.title}</span>
|
||||
{template.permalink_base && (
|
||||
<span className="text-xs text-gray-400">{template.permalink_base}*</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
Plus, ChevronUp, ChevronDown, Trash2, GripVertical,
|
||||
LayoutTemplate, Type, Image, Grid3x3, Megaphone, MessageSquare,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, any>;
|
||||
}
|
||||
|
||||
interface SectionEditorProps {
|
||||
sections: Section[];
|
||||
selectedSection: Section | null;
|
||||
onSelectSection: (section: Section | null) => void;
|
||||
onAddSection: (type: string) => void;
|
||||
onDeleteSection: (id: string) => void;
|
||||
onMoveSection: (id: string, direction: 'up' | 'down') => void;
|
||||
onReorderSections: (sections: Section[]) => void;
|
||||
isTemplate: boolean;
|
||||
cpt?: string;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const SECTION_TYPES = [
|
||||
{ type: 'hero', label: 'Hero', icon: LayoutTemplate },
|
||||
{ type: 'content', label: 'Content', icon: Type },
|
||||
{ type: 'image-text', label: 'Image + Text', icon: Image },
|
||||
{ type: 'feature-grid', label: 'Feature Grid', icon: Grid3x3 },
|
||||
{ type: 'cta-banner', label: 'CTA Banner', icon: Megaphone },
|
||||
{ type: 'contact-form', label: 'Contact Form', icon: MessageSquare },
|
||||
];
|
||||
|
||||
// Sortable Section Card Component
|
||||
function SortableSectionCard({
|
||||
section,
|
||||
index,
|
||||
totalCount,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onMove,
|
||||
}: {
|
||||
section: Section;
|
||||
index: number;
|
||||
totalCount: number;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onMove: (direction: 'up' | 'down') => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: section.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const sectionType = SECTION_TYPES.find(s => s.type === section.type);
|
||||
const Icon = sectionType?.icon || LayoutTemplate;
|
||||
const hasDynamic = Object.values(section.props).some(
|
||||
p => typeof p === 'object' && p?.type === 'dynamic'
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'p-4 cursor-pointer transition-all',
|
||||
'hover:shadow-md',
|
||||
isSelected ? 'ring-2 ring-primary shadow-md' : '',
|
||||
isDragging ? 'opacity-50 shadow-lg ring-2 ring-primary/50' : ''
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing touch-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-gray-400 hover:text-gray-600" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
{sectionType?.label || section.type}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{section.layoutVariant || 'default'}
|
||||
{hasDynamic && (
|
||||
<span className="ml-2 text-primary">◆ {__('Dynamic')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onMove('up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onMove('down')}
|
||||
disabled={index === totalCount - 1}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
className="p-1.5 rounded text-red-500 hover:text-red-600 hover:bg-red-50 transition"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="z-[60]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__('This action cannot be undone.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>{__('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
{__('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionEditor({
|
||||
sections,
|
||||
selectedSection,
|
||||
onSelectSection,
|
||||
onAddSection,
|
||||
onDeleteSection,
|
||||
onMoveSection,
|
||||
onReorderSections,
|
||||
isTemplate,
|
||||
cpt,
|
||||
isLoading,
|
||||
}: SectionEditorProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = sections.findIndex(s => s.id === active.id);
|
||||
const newIndex = sections.findIndex(s => s.id === over.id);
|
||||
const newSections = arrayMove(sections, oldIndex, newIndex);
|
||||
onReorderSections(newSections);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{__('Sections')}</h2>
|
||||
{isTemplate && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-2 py-1 rounded">
|
||||
{__('Template: ')} {cpt}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sections List with Drag-and-Drop */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sections.map(s => s.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{sections.map((section, index) => (
|
||||
<SortableSectionCard
|
||||
key={section.id}
|
||||
section={section}
|
||||
index={index}
|
||||
totalCount={sections.length}
|
||||
isSelected={selectedSection?.id === section.id}
|
||||
onSelect={() => onSelectSection(section)}
|
||||
onDelete={() => onDeleteSection(section.id)}
|
||||
onMove={(direction) => onMoveSection(section.id, direction)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{sections.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<LayoutTemplate className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>{__('No sections yet. Add your first section.')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Section Button */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Add Section')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
{SECTION_TYPES.map((sectionType) => {
|
||||
const Icon = sectionType.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={sectionType.type}
|
||||
onClick={() => onAddSection(sectionType.type)}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
{sectionType.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface CTABannerRendererProps {
|
||||
section: Section;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string; btnBg: string; btnText: string }> = {
|
||||
default: { bg: '', text: 'text-gray-900', btnBg: 'bg-blue-600', btnText: 'text-white' },
|
||||
primary: { bg: 'bg-blue-600', text: 'text-white', btnBg: 'bg-white', btnText: 'text-blue-600' },
|
||||
secondary: { bg: 'bg-gray-800', text: 'text-white', btnBg: 'bg-white', btnText: 'text-gray-800' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700', btnBg: 'bg-gray-800', btnText: 'text-white' },
|
||||
gradient: { bg: 'bg-gradient-to-r from-purple-600 to-blue-500', text: 'text-white', btnBg: 'bg-white', btnText: 'text-purple-600' },
|
||||
};
|
||||
|
||||
export function CTABannerRenderer({ section, className }: CTABannerRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'primary'];
|
||||
|
||||
const title = section.props?.title?.value || 'Ready to get started?';
|
||||
const text = section.props?.text?.value || 'Join thousands of happy customers today.';
|
||||
const buttonText = section.props?.button_text?.value || 'Get Started';
|
||||
const buttonUrl = section.props?.button_url?.value || '#';
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const textStyle = getTextStyles('text');
|
||||
const btnStyle = getTextStyles('button_text');
|
||||
|
||||
return (
|
||||
<div className={cn('py-12 px-4 md:py-20 md:px-8', scheme.bg, scheme.text, className)}>
|
||||
<div className="max-w-4xl mx-auto text-center space-y-6">
|
||||
<h2
|
||||
className={cn(
|
||||
!titleStyle.classNames && "text-3xl md:text-4xl font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
className={cn(
|
||||
"max-w-2xl mx-auto",
|
||||
!textStyle.classNames && "text-lg opacity-90",
|
||||
textStyle.classNames
|
||||
)}
|
||||
style={textStyle.style}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
<button className={cn(
|
||||
'inline-flex items-center gap-2 px-8 py-4 rounded-lg font-semibold transition hover:opacity-90',
|
||||
!btnStyle.style?.backgroundColor && scheme.btnBg,
|
||||
!btnStyle.style?.color && scheme.btnText,
|
||||
btnStyle.classNames
|
||||
)}
|
||||
style={btnStyle.style}
|
||||
>
|
||||
{buttonText}
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Send, Mail, User, MessageSquare } from 'lucide-react';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface ContactFormRendererProps {
|
||||
section: Section;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string; inputBg: string; btnBg: string }> = {
|
||||
default: { bg: '', text: 'text-gray-900', inputBg: 'bg-gray-50', btnBg: 'bg-blue-600' },
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white', inputBg: 'bg-white', btnBg: 'bg-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white', inputBg: 'bg-gray-700', btnBg: 'bg-blue-500' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700', inputBg: 'bg-white', btnBg: 'bg-gray-800' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white', inputBg: 'bg-white/90', btnBg: 'bg-white' },
|
||||
};
|
||||
|
||||
export function ContactFormRenderer({ section, className }: ContactFormRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
|
||||
const title = section.props?.title?.value || 'Contact Us';
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const buttonStyleObj = getTextStyles('button');
|
||||
const fieldsStyleObj = getTextStyles('fields');
|
||||
|
||||
const buttonStyle = section.elementStyles?.button || {};
|
||||
const fieldsStyle = section.elementStyles?.fields || {};
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = () => {
|
||||
if (scheme.bg === 'wn-gradient-bg') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (scheme.bg === 'wn-secondary-bg') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className="max-w-xl mx-auto">
|
||||
<h2
|
||||
className={cn(
|
||||
"text-3xl font-bold text-center mb-8",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
|
||||
{/* Name field */}
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your Name"
|
||||
className={cn(
|
||||
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
!fieldsStyle.backgroundColor && scheme.inputBg
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.backgroundColor,
|
||||
color: fieldsStyle.color
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email field */}
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your Email"
|
||||
className={cn(
|
||||
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
!fieldsStyle.backgroundColor && scheme.inputBg
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.backgroundColor,
|
||||
color: fieldsStyle.color
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message field */}
|
||||
<div className="relative">
|
||||
<MessageSquare className="absolute left-4 top-4 w-5 h-5 text-gray-400" />
|
||||
<textarea
|
||||
placeholder="Your Message"
|
||||
rows={4}
|
||||
className={cn(
|
||||
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none',
|
||||
!fieldsStyle.backgroundColor && scheme.inputBg
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.backgroundColor,
|
||||
color: fieldsStyle.color
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 py-3 rounded-lg font-semibold transition opacity-80 cursor-not-allowed',
|
||||
!buttonStyle.backgroundColor && scheme.btnBg,
|
||||
!buttonStyle.color && (section.colorScheme === 'primary' || section.colorScheme === 'gradient' ? 'text-blue-600' : 'text-white'),
|
||||
buttonStyleObj.classNames
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: buttonStyle.backgroundColor,
|
||||
color: buttonStyle.color
|
||||
}}
|
||||
disabled
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
Send Message
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm opacity-60">
|
||||
(Form preview only - functional on frontend)
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Section } from '../../store/usePageEditorStore';
|
||||
|
||||
interface ContentRendererProps {
|
||||
section: Section;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
default: { bg: 'bg-white', text: 'text-gray-900' },
|
||||
light: { bg: 'bg-gray-50', text: 'text-gray-900' },
|
||||
dark: { bg: 'bg-gray-900', text: 'text-white' },
|
||||
blue: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
|
||||
};
|
||||
|
||||
const WIDTH_CLASSES: Record<string, string> = {
|
||||
default: 'max-w-6xl',
|
||||
narrow: 'max-w-2xl',
|
||||
medium: 'max-w-4xl',
|
||||
};
|
||||
|
||||
const fontSizeToCSS = (className?: string) => {
|
||||
switch (className) {
|
||||
case 'text-sm': return '0.875rem';
|
||||
case 'text-base': return '1rem';
|
||||
case 'text-lg': return '1.125rem';
|
||||
case 'text-xl': return '1.25rem';
|
||||
case 'text-2xl': return '1.5rem';
|
||||
case 'text-3xl': return '1.875rem';
|
||||
case 'text-4xl': return '2.25rem';
|
||||
case 'text-5xl': return '3rem';
|
||||
case 'text-6xl': return '3.75rem';
|
||||
default: return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const fontWeightToCSS = (className?: string) => {
|
||||
switch (className) {
|
||||
case 'font-light': return '300';
|
||||
case 'font-normal': return '400';
|
||||
case 'font-medium': return '500';
|
||||
case 'font-semibold': return '600';
|
||||
case 'font-bold': return '700';
|
||||
case 'font-extrabold': return '800';
|
||||
default: return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to generate scoped CSS for prose elements
|
||||
const generateScopedStyles = (sectionId: string, elementStyles: Record<string, any>) => {
|
||||
const styles: string[] = [];
|
||||
const scope = `#section-${sectionId}`;
|
||||
|
||||
// Headings (h1-h4)
|
||||
const hs = elementStyles?.heading;
|
||||
if (hs) {
|
||||
const headingRules = [
|
||||
hs.color && `color: ${hs.color} !important;`,
|
||||
hs.fontWeight && `font-weight: ${fontWeightToCSS(hs.fontWeight)} !important;`,
|
||||
hs.fontFamily && `font-family: var(--font-${hs.fontFamily}, inherit) !important;`,
|
||||
hs.fontSize && `font-size: ${fontSizeToCSS(hs.fontSize)} !important;`,
|
||||
hs.backgroundColor && `background-color: ${hs.backgroundColor} !important;`,
|
||||
// Add padding if background color is set to make it look decent
|
||||
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px; display: inline-block;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (headingRules) {
|
||||
styles.push(`${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { ${headingRules} }`);
|
||||
}
|
||||
}
|
||||
|
||||
// Body text (p, li)
|
||||
const ts = elementStyles?.text;
|
||||
if (ts) {
|
||||
const textRules = [
|
||||
ts.color && `color: ${ts.color} !important;`,
|
||||
ts.fontSize && `font-size: ${fontSizeToCSS(ts.fontSize)} !important;`,
|
||||
ts.fontWeight && `font-weight: ${fontWeightToCSS(ts.fontWeight)} !important;`,
|
||||
ts.fontFamily && `font-family: var(--font-${ts.fontFamily}, inherit) !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (textRules) {
|
||||
styles.push(`${scope} p, ${scope} li, ${scope} ul, ${scope} ol { ${textRules} }`);
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit Spacing & List Formatting Restorations
|
||||
// These ensure vertical rhythm and list styles exist even if prose defaults are overridden or missing
|
||||
styles.push(`
|
||||
${scope} p { margin-bottom: 1em; }
|
||||
${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { margin-top: 1.5em; margin-bottom: 0.5em; line-height: 1.2; }
|
||||
${scope} ul { list-style-type: disc; padding-left: 1.5em; margin-bottom: 1em; }
|
||||
${scope} ol { list-style-type: decimal; padding-left: 1.5em; margin-bottom: 1em; }
|
||||
${scope} li { margin-bottom: 0.25em; }
|
||||
${scope} img { margin-top: 1.5em; margin-bottom: 1.5em; }
|
||||
`);
|
||||
|
||||
// Links (a:not(.button))
|
||||
const ls = elementStyles?.link;
|
||||
if (ls) {
|
||||
const linkRules = [
|
||||
ls.color && `color: ${ls.color} !important;`,
|
||||
ls.textDecoration && `text-decoration: ${ls.textDecoration} !important;`,
|
||||
ls.fontSize && `font-size: ${fontSizeToCSS(ls.fontSize)} !important;`,
|
||||
ls.fontWeight && `font-weight: ${fontWeightToCSS(ls.fontWeight)} !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (linkRules) {
|
||||
styles.push(`${scope} a:not([data-button]):not(.button) { ${linkRules} }`);
|
||||
}
|
||||
if (ls.hoverColor) {
|
||||
styles.push(`${scope} a:not([data-button]):not(.button):hover { color: ${ls.hoverColor} !important; }`);
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons (a[data-button], .button)
|
||||
const bs = elementStyles?.button;
|
||||
if (bs) {
|
||||
const btnRules = [
|
||||
bs.backgroundColor && `background-color: ${bs.backgroundColor} !important;`,
|
||||
bs.color && `color: ${bs.color} !important;`,
|
||||
bs.borderRadius && `border-radius: ${bs.borderRadius} !important;`,
|
||||
bs.padding && `padding: ${bs.padding} !important;`,
|
||||
bs.fontSize && `font-size: ${fontSizeToCSS(bs.fontSize)} !important;`,
|
||||
bs.fontWeight && `font-weight: ${fontWeightToCSS(bs.fontWeight)} !important;`,
|
||||
bs.borderColor && `border: ${bs.borderWidth || '1px'} solid ${bs.borderColor} !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
// Always force text-decoration: none for buttons
|
||||
styles.push(`${scope} a[data-button], ${scope} .button { ${btnRules} display: inline-block; text-decoration: none !important; }`);
|
||||
// Add hover effect opacity or something to make it feel alive, or just keep it simple
|
||||
styles.push(`${scope} a[data-button]:hover, ${scope} .button:hover { opacity: 0.9; }`);
|
||||
}
|
||||
|
||||
// Images
|
||||
const is = elementStyles?.image;
|
||||
if (is) {
|
||||
const imgRules = [
|
||||
is.objectFit && `object-fit: ${is.objectFit} !important;`,
|
||||
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
|
||||
is.width && `width: ${is.width} !important;`,
|
||||
is.height && `height: ${is.height} !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (imgRules) {
|
||||
styles.push(`${scope} img { ${imgRules} }`);
|
||||
}
|
||||
}
|
||||
|
||||
return styles.join('\n');
|
||||
};
|
||||
|
||||
export function ContentRenderer({ section, className }: ContentRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const layout = section.layoutVariant || 'default';
|
||||
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
|
||||
|
||||
const heightPreset = section.styles?.heightPreset || 'default';
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-32',
|
||||
'screen': 'min-h-screen py-20 flex items-center',
|
||||
};
|
||||
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
|
||||
|
||||
const content = section.props?.content?.value || 'Your content goes here. Edit this in the inspector panel.';
|
||||
const isDynamic = section.props?.content?.type === 'dynamic';
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const contentStyle = getTextStyles('content');
|
||||
const headingStyle = getTextStyles('heading');
|
||||
const buttonStyle = getTextStyles('button');
|
||||
const cta_text = section.props?.cta_text?.value;
|
||||
const cta_url = section.props?.cta_url?.value;
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = () => {
|
||||
if (scheme.bg === 'wn-gradient-bg') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (scheme.bg === 'wn-secondary-bg') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`section-${section.id}`}
|
||||
className={cn(
|
||||
'px-4 md:px-8',
|
||||
heightClasses,
|
||||
!scheme.bg.startsWith('wn-') && scheme.bg,
|
||||
scheme.text,
|
||||
className
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto prose prose-lg max-w-none',
|
||||
// Default prose overrides
|
||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||
'prose-a:no-underline hover:prose-a:underline', // Establish baseline for links
|
||||
widthClass,
|
||||
scheme.text === 'text-white' && 'prose-invert',
|
||||
contentStyle.classNames // Apply font family, size, weight to container just in case
|
||||
)}
|
||||
style={{
|
||||
color: contentStyle.style.color,
|
||||
textAlign: contentStyle.style.textAlign as React.CSSProperties['textAlign'],
|
||||
'--tw-prose-headings': headingStyle.style?.color,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{isDynamic && (
|
||||
<div className="flex items-center gap-2 text-orange-400 text-sm font-medium mb-4">
|
||||
<span>◆</span>
|
||||
<span>{section.props?.content?.source || 'Dynamic Content'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: content }} />
|
||||
|
||||
{cta_text && cta_url && (
|
||||
<div className="mt-8">
|
||||
<a
|
||||
href={cta_url}
|
||||
className={cn(
|
||||
"button inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2",
|
||||
!buttonStyle.style?.backgroundColor && "bg-blue-600",
|
||||
!buttonStyle.style?.color && "text-white",
|
||||
buttonStyle.classNames
|
||||
)}
|
||||
style={buttonStyle.style}
|
||||
>
|
||||
{cta_text}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, any>;
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface FeatureGridRendererProps {
|
||||
section: Section;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string; cardBg: string }> = {
|
||||
default: { bg: '', text: 'text-gray-900', cardBg: 'bg-gray-50' },
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white', cardBg: 'bg-white/10' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white', cardBg: 'bg-white/10' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700', cardBg: 'bg-white' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white', cardBg: 'bg-white/10' },
|
||||
};
|
||||
|
||||
const GRID_CLASSES: Record<string, string> = {
|
||||
'grid-2': 'grid-cols-1 md:grid-cols-2',
|
||||
'grid-3': 'grid-cols-1 md:grid-cols-3',
|
||||
'grid-4': 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
// Default features for demo
|
||||
const DEFAULT_FEATURES = [
|
||||
{ title: 'Fast Delivery', description: 'Quick shipping to your doorstep', icon: 'Truck' },
|
||||
{ title: 'Secure Payment', description: 'Your data is always protected', icon: 'Shield' },
|
||||
{ title: 'Quality Products', description: 'Only the best for our customers', icon: 'Star' },
|
||||
];
|
||||
|
||||
export function FeatureGridRenderer({ section, className }: FeatureGridRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const layout = section.layoutVariant || 'grid-3';
|
||||
const gridClass = GRID_CLASSES[layout] || GRID_CLASSES['grid-3'];
|
||||
|
||||
const heading = section.props?.heading?.value || 'Our Features';
|
||||
const features = section.props?.features?.value || DEFAULT_FEATURES;
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const headingStyle = getTextStyles('heading');
|
||||
const featureItemStyle = getTextStyles('feature_item');
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = () => {
|
||||
if (scheme.bg === 'wn-gradient-bg') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (scheme.bg === 'wn-secondary-bg') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{heading && (
|
||||
<h2
|
||||
className={cn(
|
||||
"text-3xl md:text-4xl font-bold text-center mb-12",
|
||||
headingStyle.classNames
|
||||
)}
|
||||
style={headingStyle.style}
|
||||
>
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<div className={cn('grid gap-8', gridClass)}>
|
||||
{(Array.isArray(features) ? features : DEFAULT_FEATURES).map((feature: any, index: number) => {
|
||||
// Resolve icon from name, fallback to Star
|
||||
const IconComponent = (LucideIcons as any)[feature.icon] || LucideIcons.Star;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'p-6 rounded-xl text-center',
|
||||
!featureItemStyle.style?.backgroundColor && scheme.cardBg,
|
||||
featureItemStyle.classNames
|
||||
)}
|
||||
style={featureItemStyle.style}
|
||||
>
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<IconComponent className="w-7 h-7 text-blue-600" />
|
||||
</div>
|
||||
<h3
|
||||
className={cn(
|
||||
"mb-2",
|
||||
!featureItemStyle.style?.color && "text-lg font-semibold"
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
>
|
||||
{feature.title || `Feature ${index + 1}`}
|
||||
</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm",
|
||||
!featureItemStyle.style?.color && "opacity-80"
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
>
|
||||
{feature.description || 'Feature description goes here'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface HeroRendererProps {
|
||||
section: Section;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
default: { bg: '', text: 'text-gray-900' },
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
|
||||
};
|
||||
|
||||
export function HeroRenderer({ section, className }: HeroRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const layout = section.layoutVariant || 'default';
|
||||
|
||||
const title = section.props?.title?.value || 'Hero Title';
|
||||
const subtitle = section.props?.subtitle?.value || 'Your amazing subtitle here';
|
||||
const image = section.props?.image?.value;
|
||||
const ctaText = section.props?.cta_text?.value || 'Get Started';
|
||||
const ctaUrl = section.props?.cta_url?.value || '#';
|
||||
|
||||
// Check for dynamic placeholders
|
||||
const isDynamicTitle = section.props?.title?.type === 'dynamic';
|
||||
const isDynamicSubtitle = section.props?.subtitle?.type === 'dynamic';
|
||||
const isDynamicImage = section.props?.image?.type === 'dynamic';
|
||||
|
||||
// Element Styles
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary', // Mapping secondary to sans for now if primary is assumed default
|
||||
'font-serif': styles.fontFamily === 'primary', // Mapping primary to serif (headings) - ADJUST AS NEEDED
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
textAlign: styles.textAlign
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const subtitleStyle = getTextStyles('subtitle');
|
||||
const ctaStyle = getTextStyles('cta_text'); // For button
|
||||
|
||||
// Helper for image styles
|
||||
const imageStyle = section.elementStyles?.['image'] || {};
|
||||
|
||||
const getBackgroundStyle = () => {
|
||||
if (scheme.bg === 'wn-gradient-bg') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (scheme.bg === 'wn-secondary-bg') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (layout === 'hero-left-image' || layout === 'hero-right-image') {
|
||||
return (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
'max-w-6xl mx-auto flex items-center gap-12',
|
||||
layout === 'hero-right-image' ? 'flex-col md:flex-row-reverse' : 'flex-col md:flex-row',
|
||||
'flex-wrap md:flex-nowrap'
|
||||
)}>
|
||||
{/* Image */}
|
||||
<div className="w-full md:w-1/2">
|
||||
<div
|
||||
className="rounded-lg shadow-xl overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: imageStyle.backgroundColor,
|
||||
width: imageStyle.width || 'auto',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
>
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="w-full h-auto block"
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
height: imageStyle.height,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-64 md:h-80 bg-gray-300 flex items-center justify-center">
|
||||
<span className="text-gray-500">
|
||||
{isDynamicImage ? `◆ ${section.props?.image?.source}` : 'No Image'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="w-full md:w-1/2 space-y-4">
|
||||
<h1
|
||||
className={cn("font-bold", titleStyle.classNames || "text-3xl md:text-5xl")}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{isDynamicTitle && <span className="text-orange-400 mr-2">◆</span>}
|
||||
{title}
|
||||
</h1>
|
||||
<p
|
||||
className={cn("opacity-90", subtitleStyle.classNames || "text-lg md:text-xl")}
|
||||
style={subtitleStyle.style}
|
||||
>
|
||||
{isDynamicSubtitle && <span className="text-orange-400 mr-2">◆</span>}
|
||||
{subtitle}
|
||||
</p>
|
||||
{ctaText && (
|
||||
<button
|
||||
className={cn(
|
||||
"px-6 py-3 rounded-lg transition hover:opacity-90",
|
||||
!ctaStyle.style?.backgroundColor && "bg-white",
|
||||
!ctaStyle.style?.color && "text-gray-900",
|
||||
ctaStyle.classNames
|
||||
)}
|
||||
style={ctaStyle.style}
|
||||
>
|
||||
{ctaText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default centered layout
|
||||
return (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8 text-center', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h1
|
||||
className={cn("font-bold", titleStyle.classNames || "text-4xl md:text-6xl")}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{isDynamicTitle && <span className="text-orange-400 mr-2">◆</span>}
|
||||
{title}
|
||||
</h1>
|
||||
<p
|
||||
className={cn("opacity-90 max-w-2xl mx-auto", subtitleStyle.classNames || "text-lg md:text-2xl")}
|
||||
style={subtitleStyle.style}
|
||||
>
|
||||
{isDynamicSubtitle && <span className="text-orange-400 mr-2">◆</span>}
|
||||
{subtitle}
|
||||
</p>
|
||||
|
||||
{/* Image with Wrapper for Background */}
|
||||
<div
|
||||
className={cn("mx-auto", imageStyle.width ? "" : "max-w-3xl w-full")}
|
||||
style={{ backgroundColor: imageStyle.backgroundColor, width: imageStyle.width || 'auto', maxWidth: '100%' }}
|
||||
>
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className={cn(
|
||||
"w-full rounded-xl shadow-lg mt-8",
|
||||
!imageStyle.height && "h-auto", // Default height if not specified
|
||||
!imageStyle.objectFit && "object-cover" // Default fit if not specified
|
||||
)}
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
height: imageStyle.height,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
) : isDynamicImage ? (
|
||||
<div className="w-full h-64 bg-gray-300 rounded-xl flex items-center justify-center mt-8">
|
||||
<span className="text-gray-500">◆ {section.props?.image?.source}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{ctaText && (
|
||||
<button
|
||||
className={cn(
|
||||
"px-8 py-4 rounded-lg transition hover:opacity-90 mt-4",
|
||||
!ctaStyle.style?.backgroundColor && "bg-white",
|
||||
!ctaStyle.style?.color && "text-gray-900",
|
||||
ctaStyle.classNames || "font-semibold"
|
||||
)}
|
||||
style={ctaStyle.style}
|
||||
>
|
||||
{ctaText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Section } from '../../store/usePageEditorStore';
|
||||
|
||||
|
||||
|
||||
interface ImageTextRendererProps {
|
||||
section: Section;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
default: { bg: '', text: 'text-gray-900' },
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
|
||||
};
|
||||
|
||||
export function ImageTextRenderer({ section, className }: ImageTextRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const layout = section.layoutVariant || 'image-left';
|
||||
const isImageRight = layout === 'image-right';
|
||||
|
||||
const title = section.props?.title?.value || 'Section Title';
|
||||
const text = section.props?.text?.value || 'Your descriptive text goes here. Edit this section to add your own content.';
|
||||
const image = section.props?.image?.value;
|
||||
|
||||
const isDynamicTitle = section.props?.title?.type === 'dynamic';
|
||||
const isDynamicText = section.props?.text?.type === 'dynamic';
|
||||
const isDynamicImage = section.props?.image?.type === 'dynamic';
|
||||
|
||||
const cta_text = section.props?.cta_text?.value;
|
||||
const cta_url = section.props?.cta_url?.value;
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const textStyle = getTextStyles('text');
|
||||
const imageStyle = section.elementStyles?.['image'] || {};
|
||||
|
||||
const buttonStyle = getTextStyles('button');
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = () => {
|
||||
if (scheme.bg === 'wn-gradient-bg') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (scheme.bg === 'wn-secondary-bg') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
'max-w-6xl mx-auto flex items-center gap-12',
|
||||
isImageRight ? 'flex-col md:flex-row-reverse' : 'flex-col md:flex-row',
|
||||
'flex-wrap md:flex-nowrap'
|
||||
)}>
|
||||
{/* Image */}
|
||||
<div className="w-full md:w-1/2" style={{ backgroundColor: imageStyle.backgroundColor }}>
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="w-full h-auto rounded-xl shadow-lg"
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
width: imageStyle.width,
|
||||
maxWidth: '100%',
|
||||
height: imageStyle.height,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-64 md:h-80 bg-gray-200 rounded-xl flex items-center justify-center">
|
||||
<span className="text-gray-400">
|
||||
{isDynamicImage ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-orange-400">◆</span>
|
||||
{section.props?.image?.source}
|
||||
</span>
|
||||
) : (
|
||||
'Add Image'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="w-full md:w-1/2 space-y-4">
|
||||
<h2
|
||||
className={cn(
|
||||
"text-2xl md:text-3xl font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{isDynamicTitle && <span className="text-orange-400 mr-2">◆</span>}
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
className={cn(
|
||||
"text-lg opacity-90 leading-relaxed",
|
||||
textStyle.classNames
|
||||
)}
|
||||
style={textStyle.style}
|
||||
>
|
||||
{isDynamicText && <span className="text-orange-400 mr-2">◆</span>}
|
||||
{text}
|
||||
</p>
|
||||
|
||||
{cta_text && cta_url && (
|
||||
<div className="pt-4">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2",
|
||||
!buttonStyle.style?.backgroundColor && "bg-blue-600",
|
||||
!buttonStyle.style?.color && "text-white",
|
||||
buttonStyle.classNames
|
||||
)}
|
||||
style={buttonStyle.style}
|
||||
>
|
||||
{cta_text}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { HeroRenderer } from './HeroRenderer';
|
||||
export { ContentRenderer } from './ContentRenderer';
|
||||
export { ImageTextRenderer } from './ImageTextRenderer';
|
||||
export { FeatureGridRenderer } from './FeatureGridRenderer';
|
||||
export { CTABannerRenderer } from './CTABannerRenderer';
|
||||
export { ContactFormRenderer } from './ContactFormRenderer';
|
||||
376
admin-spa/src/routes/Appearance/Pages/index.tsx
Normal file
376
admin-spa/src/routes/Appearance/Pages/index.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Layout, Undo2, Save, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PageSidebar } from './components/PageSidebar';
|
||||
import { CanvasRenderer } from './components/CanvasRenderer';
|
||||
import { InspectorPanel } from './components/InspectorPanel';
|
||||
import { CreatePageModal } from './components/CreatePageModal';
|
||||
import { usePageEditorStore, Section } from './store/usePageEditorStore';
|
||||
|
||||
// Types
|
||||
interface PageItem {
|
||||
id?: number;
|
||||
type: 'page' | 'template';
|
||||
cpt?: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
icon?: string;
|
||||
has_template?: boolean;
|
||||
permalink_base?: string;
|
||||
isFrontPage?: boolean;
|
||||
}
|
||||
|
||||
export default function AppearancePages() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Zustand store
|
||||
const {
|
||||
currentPage,
|
||||
sections,
|
||||
selectedSectionId,
|
||||
deviceMode,
|
||||
inspectorCollapsed,
|
||||
hasUnsavedChanges,
|
||||
isLoading,
|
||||
availableSources,
|
||||
setCurrentPage,
|
||||
setSections,
|
||||
setSelectedSection,
|
||||
setDeviceMode,
|
||||
setInspectorCollapsed,
|
||||
setAvailableSources,
|
||||
setIsLoading,
|
||||
addSection,
|
||||
deleteSection,
|
||||
duplicateSection,
|
||||
moveSection,
|
||||
reorderSections,
|
||||
updateSectionProp,
|
||||
updateSectionLayout,
|
||||
updateSectionColorScheme,
|
||||
updateSectionStyles,
|
||||
updateElementStyles,
|
||||
markAsSaved,
|
||||
setAsSpaLanding,
|
||||
unsetSpaLanding,
|
||||
} = usePageEditorStore();
|
||||
|
||||
// Get selected section object
|
||||
const selectedSection = sections.find(s => s.id === selectedSectionId) || null;
|
||||
|
||||
// Fetch all pages and templates
|
||||
const { data: pages = [], isLoading: pagesLoading } = useQuery<PageItem[]>({
|
||||
queryKey: ['pages'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/pages');
|
||||
// Map API snake_case to frontend camelCase
|
||||
return response.map((p: any) => ({
|
||||
...p,
|
||||
isSpaLanding: !!p.is_spa_frontpage
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch selected page/template structure
|
||||
const { data: pageData, isLoading: pageLoading } = useQuery({
|
||||
queryKey: ['page-structure', currentPage?.type, currentPage?.slug || currentPage?.cpt],
|
||||
queryFn: async () => {
|
||||
if (!currentPage) return null;
|
||||
const endpoint = currentPage.type === 'page'
|
||||
? `/pages/${currentPage.slug}`
|
||||
: `/templates/${currentPage.cpt}`;
|
||||
const response = await api.get(endpoint);
|
||||
return response;
|
||||
},
|
||||
enabled: !!currentPage,
|
||||
});
|
||||
|
||||
// Update store when page data loads
|
||||
useEffect(() => {
|
||||
if (pageData?.structure?.sections) {
|
||||
setSections(pageData.structure.sections);
|
||||
markAsSaved();
|
||||
}
|
||||
if (pageData?.available_sources) {
|
||||
setAvailableSources(pageData.available_sources);
|
||||
}
|
||||
// Sync isFrontPage if returned from single page API (optional, but good practice)
|
||||
if (pageData?.is_front_page !== undefined && currentPage) {
|
||||
setCurrentPage({ ...currentPage, isFrontPage: !!pageData.is_front_page });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageData, setSections, markAsSaved, setAvailableSources]); // Removed currentPage from dependency to avoid loop
|
||||
|
||||
// Update loading state
|
||||
useEffect(() => {
|
||||
setIsLoading(pageLoading);
|
||||
}, [pageLoading, setIsLoading]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!currentPage) return;
|
||||
const endpoint = currentPage.type === 'page'
|
||||
? `/pages/${currentPage.slug}`
|
||||
: `/templates/${currentPage.cpt}`;
|
||||
return api.post(endpoint, { sections });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Page saved successfully'));
|
||||
markAsSaved();
|
||||
queryClient.invalidateQueries({ queryKey: ['page-structure'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to save page'));
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
return api.del(`/pages/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Page deleted successfully'));
|
||||
markAsSaved(); // Clear unsaved flag
|
||||
setCurrentPage(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to delete page'));
|
||||
},
|
||||
});
|
||||
|
||||
// Set as SPA Landing mutation
|
||||
const setSpaLandingMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
return api.post(`/pages/${id}/set-as-spa-landing`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Set as SPA Landing Page'));
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
// Update local state is handled by re-fetching pages or we can optimistic update
|
||||
if (currentPage) {
|
||||
setCurrentPage({ ...currentPage, isSpaLanding: true });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to set SPA Landing Page'));
|
||||
},
|
||||
});
|
||||
|
||||
// Unset SPA Landing mutation
|
||||
const unsetSpaLandingMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return api.post(`/pages/unset-spa-landing`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Unset SPA Landing Page'));
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
if (currentPage) {
|
||||
setCurrentPage({ ...currentPage, isSpaLanding: false });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to unset SPA Landing Page'));
|
||||
},
|
||||
});
|
||||
|
||||
// Handle page selection
|
||||
const handleSelectPage = (page: PageItem) => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (!confirm(__('You have unsaved changes. Continue?'))) return;
|
||||
}
|
||||
if (page.type === 'page') {
|
||||
setCurrentPage({
|
||||
...page,
|
||||
isSpaLanding: !!(page as any).isSpaLanding
|
||||
});
|
||||
} else {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
setSelectedSection(null);
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (pageData?.structure?.sections) {
|
||||
setSections(pageData.structure.sections);
|
||||
markAsSaved();
|
||||
toast.success(__('Changes discarded'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePage = () => {
|
||||
if (!currentPage || !currentPage.id) return;
|
||||
|
||||
if (confirm(__('Are you sure you want to delete this page? This action cannot be undone.'))) {
|
||||
deleteMutation.mutate(currentPage.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={
|
||||
cn(
|
||||
"flex flex-col bg-white transition-all duration-300",
|
||||
isFullscreen ? "fixed inset-0 z-[100] h-screen" : "h-[calc(100vh-64px)]"
|
||||
)
|
||||
} >
|
||||
{/* Header */}
|
||||
< div className="flex items-center justify-between px-6 py-3 border-b bg-white" >
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{__('Page Editor')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentPage ? currentPage.title : __('Select a page to edit')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
title={isFullscreen ? __('Exit Fullscreen') : __('Enter Fullscreen')}
|
||||
className="mr-2"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</Button>
|
||||
{hasUnsavedChanges && (
|
||||
<>
|
||||
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDiscard}
|
||||
>
|
||||
<Undo2 className="w-4 h-4 mr-2" />
|
||||
{__('Discard')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Create Page')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!hasUnsavedChanges || saveMutation.isPending}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saveMutation.isPending ? __('Saving...') : __('Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div >
|
||||
|
||||
{/* 3-Column Layout: Sidebar | Canvas | Inspector */}
|
||||
< div className="flex-1 flex overflow-hidden" >
|
||||
{/* Left Column: Pages List */}
|
||||
< PageSidebar
|
||||
pages={pages}
|
||||
selectedPage={currentPage}
|
||||
onSelectPage={handleSelectPage}
|
||||
isLoading={pagesLoading}
|
||||
/>
|
||||
|
||||
{/* Center Column: Canvas Renderer */}
|
||||
{
|
||||
currentPage ? (
|
||||
<CanvasRenderer
|
||||
sections={sections}
|
||||
selectedSectionId={selectedSectionId}
|
||||
deviceMode={deviceMode}
|
||||
onSelectSection={setSelectedSection}
|
||||
onAddSection={addSection}
|
||||
onDeleteSection={deleteSection}
|
||||
onDuplicateSection={duplicateSection}
|
||||
onMoveSection={moveSection}
|
||||
onReorderSections={reorderSections}
|
||||
onDeviceModeChange={setDeviceMode}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 bg-gray-100 flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<Layout className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg">{__('Select a page from the sidebar')}</p>
|
||||
<p className="text-sm mt-2">{__('Edit pages and templates visually')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Right Column: Inspector Panel */}
|
||||
{
|
||||
currentPage && (
|
||||
<InspectorPanel
|
||||
page={currentPage}
|
||||
selectedSection={selectedSection}
|
||||
isCollapsed={inspectorCollapsed}
|
||||
isTemplate={currentPage.type === 'template'}
|
||||
availableSources={availableSources}
|
||||
onToggleCollapse={() => setInspectorCollapsed(!inspectorCollapsed)}
|
||||
onSectionPropChange={(propName, value) => {
|
||||
if (selectedSectionId) {
|
||||
updateSectionProp(selectedSectionId, propName, value);
|
||||
}
|
||||
}}
|
||||
onLayoutChange={(layout) => {
|
||||
if (selectedSectionId) {
|
||||
updateSectionLayout(selectedSectionId, layout);
|
||||
}
|
||||
}}
|
||||
onColorSchemeChange={(scheme) => {
|
||||
if (selectedSectionId) {
|
||||
updateSectionColorScheme(selectedSectionId, scheme);
|
||||
}
|
||||
}}
|
||||
onSectionStylesChange={(styles) => {
|
||||
if (selectedSectionId) {
|
||||
updateSectionStyles(selectedSectionId, styles);
|
||||
}
|
||||
}}
|
||||
onElementStylesChange={(fieldName, styles) => {
|
||||
if (selectedSectionId) {
|
||||
updateElementStyles(selectedSectionId, fieldName, styles);
|
||||
}
|
||||
}}
|
||||
onDeleteSection={() => {
|
||||
if (selectedSectionId) {
|
||||
deleteSection(selectedSectionId);
|
||||
}
|
||||
}}
|
||||
onSetAsSpaLanding={() => {
|
||||
if (currentPage?.id) {
|
||||
setSpaLandingMutation.mutate(currentPage.id);
|
||||
}
|
||||
}}
|
||||
onUnsetSpaLanding={() => unsetSpaLandingMutation.mutate()}
|
||||
onDeletePage={handleDeletePage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
|
||||
{/* Create Page Modal */}
|
||||
< CreatePageModal
|
||||
open={showCreateModal}
|
||||
onOpenChange={setShowCreateModal}
|
||||
onCreated={(newPage) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
setCurrentPage(newPage);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Simple ID generator (replaces uuid)
|
||||
const generateId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
export interface SectionProp {
|
||||
type: 'static' | 'dynamic';
|
||||
value?: any;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface SectionStyles {
|
||||
backgroundColor?: string;
|
||||
backgroundImage?: string;
|
||||
backgroundOverlay?: number; // 0-100 opacity
|
||||
paddingTop?: string;
|
||||
paddingBottom?: string;
|
||||
contentWidth?: 'full' | 'contained';
|
||||
heightPreset?: string;
|
||||
}
|
||||
|
||||
export interface ElementStyle {
|
||||
color?: string;
|
||||
fontSize?: string;
|
||||
fontWeight?: string;
|
||||
fontFamily?: 'primary' | 'secondary';
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
|
||||
// Image specific
|
||||
objectFit?: 'cover' | 'contain' | 'fill';
|
||||
backgroundColor?: string; // Wrapper BG
|
||||
width?: string;
|
||||
height?: string;
|
||||
|
||||
// Link specific
|
||||
textDecoration?: 'none' | 'underline';
|
||||
hoverColor?: string;
|
||||
|
||||
// Button/Box specific
|
||||
borderColor?: string;
|
||||
borderWidth?: string;
|
||||
borderRadius?: string;
|
||||
padding?: string;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
styles?: SectionStyles;
|
||||
elementStyles?: Record<string, ElementStyle>;
|
||||
props: Record<string, SectionProp>;
|
||||
}
|
||||
|
||||
export interface PageItem {
|
||||
id?: number;
|
||||
type: 'page' | 'template';
|
||||
cpt?: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
isFrontPage?: boolean;
|
||||
isSpaLanding?: boolean;
|
||||
}
|
||||
|
||||
interface PageEditorState {
|
||||
// Current page/template being edited
|
||||
currentPage: PageItem | null;
|
||||
|
||||
// Sections for the current page
|
||||
sections: Section[];
|
||||
|
||||
// Selection & interaction
|
||||
selectedSectionId: string | null;
|
||||
hoveredSectionId: string | null;
|
||||
|
||||
// UI state
|
||||
deviceMode: 'desktop' | 'mobile';
|
||||
inspectorCollapsed: boolean;
|
||||
hasUnsavedChanges: boolean;
|
||||
isLoading: boolean;
|
||||
|
||||
// Available sources for dynamic fields (CPT templates)
|
||||
availableSources: { value: string; label: string }[];
|
||||
|
||||
// Actions
|
||||
setCurrentPage: (page: PageItem | null) => void;
|
||||
setSections: (sections: Section[]) => void;
|
||||
setSelectedSection: (id: string | null) => void;
|
||||
setHoveredSection: (id: string | null) => void;
|
||||
setDeviceMode: (mode: 'desktop' | 'mobile') => void;
|
||||
setInspectorCollapsed: (collapsed: boolean) => void;
|
||||
setAvailableSources: (sources: { value: string; label: string }[]) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
|
||||
// Section actions
|
||||
addSection: (type: string, index?: number) => void;
|
||||
deleteSection: (id: string) => void;
|
||||
duplicateSection: (id: string) => void;
|
||||
moveSection: (id: string, direction: 'up' | 'down') => void;
|
||||
reorderSections: (sections: Section[]) => void;
|
||||
updateSectionProp: (sectionId: string, propName: string, value: SectionProp) => void;
|
||||
updateSectionLayout: (sectionId: string, layoutVariant: string) => void;
|
||||
updateSectionColorScheme: (sectionId: string, colorScheme: string) => void;
|
||||
updateSectionStyles: (sectionId: string, styles: Partial<SectionStyles>) => void;
|
||||
updateElementStyles: (sectionId: string, fieldName: string, styles: Partial<ElementStyle>) => void;
|
||||
|
||||
// Page actions
|
||||
setAsSpaLanding: () => Promise<void>;
|
||||
unsetSpaLanding: () => Promise<void>;
|
||||
|
||||
// Persistence
|
||||
markAsChanged: () => void;
|
||||
markAsSaved: () => void;
|
||||
reset: () => void;
|
||||
savePage: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Default props for each section type
|
||||
const DEFAULT_SECTION_PROPS: Record<string, Record<string, SectionProp>> = {
|
||||
hero: {
|
||||
title: { type: 'static', value: 'Welcome to Our Site' },
|
||||
subtitle: { type: 'static', value: 'Discover amazing products and services' },
|
||||
image: { type: 'static', value: '' },
|
||||
cta_text: { type: 'static', value: 'Get Started' },
|
||||
cta_url: { type: 'static', value: '#' },
|
||||
},
|
||||
content: {
|
||||
content: { type: 'static', value: 'Add your content here. You can write rich text and format it as needed.' },
|
||||
cta_text: { type: 'static', value: '' },
|
||||
cta_url: { type: 'static', value: '' },
|
||||
},
|
||||
'image-text': {
|
||||
title: { type: 'static', value: 'Section Title' },
|
||||
text: { type: 'static', value: 'Your description text goes here. Add compelling content to engage visitors.' },
|
||||
image: { type: 'static', value: '' },
|
||||
cta_text: { type: 'static', value: '' },
|
||||
cta_url: { type: 'static', value: '' },
|
||||
},
|
||||
'feature-grid': {
|
||||
heading: { type: 'static', value: 'Our Features' },
|
||||
features: { type: 'static', value: '' },
|
||||
},
|
||||
'cta-banner': {
|
||||
title: { type: 'static', value: 'Ready to get started?' },
|
||||
text: { type: 'static', value: 'Join thousands of happy customers today.' },
|
||||
button_text: { type: 'static', value: 'Get Started' },
|
||||
button_url: { type: 'static', value: '#' },
|
||||
},
|
||||
'contact-form': {
|
||||
title: { type: 'static', value: 'Contact Us' },
|
||||
webhook_url: { type: 'static', value: '' },
|
||||
redirect_url: { type: 'static', value: '' },
|
||||
},
|
||||
};
|
||||
|
||||
// Define a SECTION_CONFIGS object based on DEFAULT_SECTION_PROPS for the new addSection logic
|
||||
const SECTION_CONFIGS: Record<string, { defaultProps: Record<string, SectionProp>; defaultStyles?: SectionStyles }> = {
|
||||
hero: { defaultProps: DEFAULT_SECTION_PROPS.hero, defaultStyles: { contentWidth: 'full' } },
|
||||
content: { defaultProps: DEFAULT_SECTION_PROPS.content, defaultStyles: { contentWidth: 'full' } },
|
||||
'image-text': { defaultProps: DEFAULT_SECTION_PROPS['image-text'], defaultStyles: { contentWidth: 'contained' } },
|
||||
'feature-grid': { defaultProps: DEFAULT_SECTION_PROPS['feature-grid'], defaultStyles: { contentWidth: 'contained' } },
|
||||
'cta-banner': { defaultProps: DEFAULT_SECTION_PROPS['cta-banner'], defaultStyles: { contentWidth: 'full' } },
|
||||
'contact-form': { defaultProps: DEFAULT_SECTION_PROPS['contact-form'], defaultStyles: { contentWidth: 'contained' } },
|
||||
};
|
||||
|
||||
|
||||
export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||
// Initial state
|
||||
currentPage: null,
|
||||
sections: [],
|
||||
selectedSectionId: null,
|
||||
hoveredSectionId: null,
|
||||
deviceMode: 'desktop',
|
||||
inspectorCollapsed: false,
|
||||
hasUnsavedChanges: false,
|
||||
isLoading: false,
|
||||
availableSources: [],
|
||||
|
||||
// Setters
|
||||
setCurrentPage: (currentPage) => set({ currentPage }),
|
||||
setSections: (sections) => set({ sections, hasUnsavedChanges: true }),
|
||||
setSelectedSection: (selectedSectionId) => set({ selectedSectionId }),
|
||||
setHoveredSection: (hoveredSectionId) => set({ hoveredSectionId }),
|
||||
setDeviceMode: (deviceMode) => set({ deviceMode }),
|
||||
setInspectorCollapsed: (inspectorCollapsed) => set({ inspectorCollapsed }),
|
||||
setAvailableSources: (availableSources) => set({ availableSources }),
|
||||
setIsLoading: (isLoading) => set({ isLoading }),
|
||||
|
||||
// Section actions
|
||||
addSection: (type, index) => {
|
||||
const { sections } = get();
|
||||
const sectionConfig = SECTION_CONFIGS[type];
|
||||
|
||||
if (!sectionConfig) return;
|
||||
|
||||
const newSection: Section = {
|
||||
id: generateId(),
|
||||
type,
|
||||
props: { ...sectionConfig.defaultProps },
|
||||
styles: { ...sectionConfig.defaultStyles }
|
||||
};
|
||||
|
||||
const newSections = [...sections];
|
||||
if (typeof index === 'number') {
|
||||
newSections.splice(index, 0, newSection);
|
||||
} else {
|
||||
newSections.push(newSection);
|
||||
}
|
||||
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
|
||||
// Select the new section
|
||||
set({ selectedSectionId: newSection.id });
|
||||
},
|
||||
|
||||
deleteSection: (id) => {
|
||||
const { sections, selectedSectionId } = get();
|
||||
const newSections = sections.filter(s => s.id !== id);
|
||||
|
||||
set({
|
||||
sections: newSections,
|
||||
hasUnsavedChanges: true,
|
||||
selectedSectionId: selectedSectionId === id ? null : selectedSectionId
|
||||
});
|
||||
},
|
||||
|
||||
duplicateSection: (id) => {
|
||||
const { sections } = get();
|
||||
const index = sections.findIndex(s => s.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const section = sections[index];
|
||||
const newSection: Section = {
|
||||
...JSON.parse(JSON.stringify(section)), // Deep clone
|
||||
id: generateId()
|
||||
};
|
||||
|
||||
const newSections = [...sections];
|
||||
newSections.splice(index + 1, 0, newSection);
|
||||
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
moveSection: (id, direction) => {
|
||||
const { sections } = get();
|
||||
const index = sections.findIndex(s => s.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
if (direction === 'up' && index > 0) {
|
||||
const newSections = [...sections];
|
||||
[newSections[index], newSections[index - 1]] = [newSections[index - 1], newSections[index]];
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
} else if (direction === 'down' && index < sections.length - 1) {
|
||||
const newSections = [...sections];
|
||||
[newSections[index], newSections[index + 1]] = [newSections[index + 1], newSections[index]];
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
}
|
||||
},
|
||||
|
||||
reorderSections: (sections) => {
|
||||
set({ sections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
updateSectionProp: (sectionId, propName, value) => {
|
||||
const { sections } = get();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
...section,
|
||||
props: {
|
||||
...section.props,
|
||||
[propName]: value
|
||||
}
|
||||
};
|
||||
});
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
updateSectionLayout: (sectionId, layoutVariant) => {
|
||||
const { sections } = get();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
...section,
|
||||
layoutVariant
|
||||
};
|
||||
});
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
updateSectionColorScheme: (sectionId, colorScheme) => {
|
||||
const { sections } = get();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
...section,
|
||||
colorScheme
|
||||
};
|
||||
});
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
updateSectionStyles: (sectionId, styles) => {
|
||||
const { sections } = get();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
...section,
|
||||
styles: {
|
||||
...section.styles,
|
||||
...styles
|
||||
}
|
||||
};
|
||||
});
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
updateElementStyles: (sectionId, fieldName, styles) => {
|
||||
const { sections } = get();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
|
||||
const newElementStyles = {
|
||||
...section.elementStyles,
|
||||
[fieldName]: {
|
||||
...(section.elementStyles?.[fieldName] || {}),
|
||||
...styles
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...section,
|
||||
elementStyles: newElementStyles
|
||||
};
|
||||
});
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
// Page Actions
|
||||
setAsSpaLanding: async () => {
|
||||
const { currentPage } = get();
|
||||
if (!currentPage || !currentPage.id) return;
|
||||
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
|
||||
// Call API to set page as SPA Landing
|
||||
await fetch(`${(window as any).WNW_API.root}/pages/${currentPage.id}/set-as-spa-landing`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-WP-Nonce': (window as any).WNW_API.nonce,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state - only update isSpaLanding, no other properties
|
||||
set({
|
||||
currentPage: {
|
||||
...currentPage,
|
||||
isSpaLanding: true
|
||||
},
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to set SPA landing page:', error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
unsetSpaLanding: async () => {
|
||||
const { currentPage } = get();
|
||||
if (!currentPage) return;
|
||||
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
|
||||
// Call API to unset SPA Landing
|
||||
await fetch(`${(window as any).WNW_API.root}/pages/unset-spa-landing`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-WP-Nonce': (window as any).WNW_API.nonce,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
set({
|
||||
currentPage: {
|
||||
...currentPage,
|
||||
isSpaLanding: false
|
||||
},
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to unset SPA landing page:', error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Persistence
|
||||
markAsChanged: () => set({ hasUnsavedChanges: true }),
|
||||
markAsSaved: () => set({ hasUnsavedChanges: false }),
|
||||
|
||||
savePage: async () => {
|
||||
const { currentPage, sections } = get();
|
||||
if (!currentPage) return;
|
||||
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
|
||||
const endpoint = currentPage.type === 'page'
|
||||
? `/pages/${currentPage.slug}`
|
||||
: `/templates/${currentPage.cpt}`;
|
||||
|
||||
await fetch(`${(window as any).WNW_API.root}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-WP-Nonce': (window as any).WNW_API.nonce,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ sections })
|
||||
});
|
||||
|
||||
set({
|
||||
hasUnsavedChanges: false,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save page:', error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
reset: () =>
|
||||
set({
|
||||
currentPage: null,
|
||||
sections: [],
|
||||
selectedSectionId: null,
|
||||
hoveredSectionId: null,
|
||||
hasUnsavedChanges: false,
|
||||
}),
|
||||
}));
|
||||
@@ -122,6 +122,15 @@ export default function MorePage() {
|
||||
|
||||
{/* Exit Fullscreen / Logout */}
|
||||
<div className=" py-6 space-y-3">
|
||||
<Button
|
||||
onClick={() => window.open(window.WNW_CONFIG?.storeUrl || '/store/', '_blank')}
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-3"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
{__('Visit Store')}
|
||||
</Button>
|
||||
|
||||
{isStandalone && (
|
||||
<Button
|
||||
onClick={() => window.location.href = window.WNW_CONFIG?.wpAdminUrl || '/wp-admin'}
|
||||
|
||||
@@ -40,6 +40,13 @@ export type ProductFormData = {
|
||||
licensing_enabled?: boolean;
|
||||
license_activation_limit?: string;
|
||||
license_duration_days?: string;
|
||||
license_activation_method?: '' | 'api' | 'oauth';
|
||||
// Subscription
|
||||
subscription_enabled?: boolean;
|
||||
subscription_period?: 'day' | 'week' | 'month' | 'year';
|
||||
subscription_interval?: string;
|
||||
subscription_trial_days?: string;
|
||||
subscription_signup_fee?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -89,6 +96,13 @@ export function ProductFormTabbed({
|
||||
const [licensingEnabled, setLicensingEnabled] = useState(initial?.licensing_enabled || false);
|
||||
const [licenseActivationLimit, setLicenseActivationLimit] = useState(initial?.license_activation_limit || '');
|
||||
const [licenseDurationDays, setLicenseDurationDays] = useState(initial?.license_duration_days || '');
|
||||
const [licenseActivationMethod, setLicenseActivationMethod] = useState<'' | 'api' | 'oauth'>(initial?.license_activation_method || '');
|
||||
// Subscription state
|
||||
const [subscriptionEnabled, setSubscriptionEnabled] = useState(initial?.subscription_enabled || false);
|
||||
const [subscriptionPeriod, setSubscriptionPeriod] = useState<'day' | 'week' | 'month' | 'year'>(initial?.subscription_period || 'month');
|
||||
const [subscriptionInterval, setSubscriptionInterval] = useState(initial?.subscription_interval || '1');
|
||||
const [subscriptionTrialDays, setSubscriptionTrialDays] = useState(initial?.subscription_trial_days || '');
|
||||
const [subscriptionSignupFee, setSubscriptionSignupFee] = useState(initial?.subscription_signup_fee || '');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Update form state when initial data changes (for edit mode)
|
||||
@@ -119,6 +133,13 @@ export function ProductFormTabbed({
|
||||
setLicensingEnabled(initial.licensing_enabled || false);
|
||||
setLicenseActivationLimit(initial.license_activation_limit || '');
|
||||
setLicenseDurationDays(initial.license_duration_days || '');
|
||||
setLicenseActivationMethod(initial.license_activation_method || '');
|
||||
// Subscription
|
||||
setSubscriptionEnabled(initial.subscription_enabled || false);
|
||||
setSubscriptionPeriod(initial.subscription_period || 'month');
|
||||
setSubscriptionInterval(initial.subscription_interval || '1');
|
||||
setSubscriptionTrialDays(initial.subscription_trial_days || '');
|
||||
setSubscriptionSignupFee(initial.subscription_signup_fee || '');
|
||||
}
|
||||
}, [initial, mode]);
|
||||
|
||||
@@ -181,6 +202,13 @@ export function ProductFormTabbed({
|
||||
licensing_enabled: licensingEnabled,
|
||||
license_activation_limit: licensingEnabled ? licenseActivationLimit : undefined,
|
||||
license_duration_days: licensingEnabled ? licenseDurationDays : undefined,
|
||||
license_activation_method: licensingEnabled ? licenseActivationMethod : undefined,
|
||||
// Subscription
|
||||
subscription_enabled: subscriptionEnabled,
|
||||
subscription_period: subscriptionEnabled ? subscriptionPeriod : undefined,
|
||||
subscription_interval: subscriptionEnabled ? subscriptionInterval : undefined,
|
||||
subscription_trial_days: subscriptionEnabled ? subscriptionTrialDays : undefined,
|
||||
subscription_signup_fee: subscriptionEnabled ? subscriptionSignupFee : undefined,
|
||||
};
|
||||
|
||||
await onSubmit(payload);
|
||||
@@ -237,6 +265,18 @@ export function ProductFormTabbed({
|
||||
setLicenseActivationLimit={setLicenseActivationLimit}
|
||||
licenseDurationDays={licenseDurationDays}
|
||||
setLicenseDurationDays={setLicenseDurationDays}
|
||||
licenseActivationMethod={licenseActivationMethod}
|
||||
setLicenseActivationMethod={setLicenseActivationMethod}
|
||||
subscriptionEnabled={subscriptionEnabled}
|
||||
setSubscriptionEnabled={setSubscriptionEnabled}
|
||||
subscriptionPeriod={subscriptionPeriod}
|
||||
setSubscriptionPeriod={setSubscriptionPeriod}
|
||||
subscriptionInterval={subscriptionInterval}
|
||||
setSubscriptionInterval={setSubscriptionInterval}
|
||||
subscriptionTrialDays={subscriptionTrialDays}
|
||||
setSubscriptionTrialDays={setSubscriptionTrialDays}
|
||||
subscriptionSignupFee={subscriptionSignupFee}
|
||||
setSubscriptionSignupFee={setSubscriptionSignupFee}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key } from 'lucide-react';
|
||||
import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key, Repeat } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getStoreCurrency } from '@/lib/currency';
|
||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||
@@ -50,6 +50,19 @@ type GeneralTabProps = {
|
||||
setLicenseActivationLimit?: (value: string) => void;
|
||||
licenseDurationDays?: string;
|
||||
setLicenseDurationDays?: (value: string) => void;
|
||||
licenseActivationMethod?: '' | 'api' | 'oauth';
|
||||
setLicenseActivationMethod?: (value: '' | 'api' | 'oauth') => void;
|
||||
// Subscription
|
||||
subscriptionEnabled?: boolean;
|
||||
setSubscriptionEnabled?: (value: boolean) => void;
|
||||
subscriptionPeriod?: 'day' | 'week' | 'month' | 'year';
|
||||
setSubscriptionPeriod?: (value: 'day' | 'week' | 'month' | 'year') => void;
|
||||
subscriptionInterval?: string;
|
||||
setSubscriptionInterval?: (value: string) => void;
|
||||
subscriptionTrialDays?: string;
|
||||
setSubscriptionTrialDays?: (value: string) => void;
|
||||
subscriptionSignupFee?: string;
|
||||
setSubscriptionSignupFee?: (value: string) => void;
|
||||
};
|
||||
|
||||
export function GeneralTab({
|
||||
@@ -84,6 +97,18 @@ export function GeneralTab({
|
||||
setLicenseActivationLimit,
|
||||
licenseDurationDays,
|
||||
setLicenseDurationDays,
|
||||
licenseActivationMethod,
|
||||
setLicenseActivationMethod,
|
||||
subscriptionEnabled,
|
||||
setSubscriptionEnabled,
|
||||
subscriptionPeriod,
|
||||
setSubscriptionPeriod,
|
||||
subscriptionInterval,
|
||||
setSubscriptionInterval,
|
||||
subscriptionTrialDays,
|
||||
setSubscriptionTrialDays,
|
||||
subscriptionSignupFee,
|
||||
setSubscriptionSignupFee,
|
||||
}: GeneralTabProps) {
|
||||
const savingsPercent =
|
||||
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
|
||||
@@ -477,6 +502,113 @@ export function GeneralTab({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{setLicenseActivationMethod && (
|
||||
<div>
|
||||
<Label className="text-xs">{__('Activation Method')}</Label>
|
||||
<Select
|
||||
value={licenseActivationMethod || 'default'}
|
||||
onValueChange={(v) => setLicenseActivationMethod(v === 'default' ? '' : v as '' | 'api' | 'oauth')}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder={__('Use Site Default')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{__('Use Site Default')}</SelectItem>
|
||||
<SelectItem value="api">{__('Simple API (license key only)')}</SelectItem>
|
||||
<SelectItem value="oauth">{__('Secure OAuth (requires login)')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{__('Override site-level activation method for this product')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Subscription option */}
|
||||
{setSubscriptionEnabled && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="subscription-enabled"
|
||||
checked={subscriptionEnabled || false}
|
||||
onCheckedChange={(checked) => setSubscriptionEnabled(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="subscription-enabled" className="cursor-pointer font-normal flex items-center gap-1">
|
||||
<Repeat className="h-3 w-3" />
|
||||
{__('Enable subscription for this product')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Subscription settings panel */}
|
||||
{subscriptionEnabled && (
|
||||
<div className="ml-6 p-3 bg-muted/50 rounded-lg space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">{__('Billing Period')}</Label>
|
||||
<Select
|
||||
value={subscriptionPeriod || 'month'}
|
||||
onValueChange={(v: any) => setSubscriptionPeriod?.(v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="day">{__('Day')}</SelectItem>
|
||||
<SelectItem value="week">{__('Week')}</SelectItem>
|
||||
<SelectItem value="month">{__('Month')}</SelectItem>
|
||||
<SelectItem value="year">{__('Year')}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">{__('Billing Interval')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
value={subscriptionInterval || '1'}
|
||||
onChange={(e) => setSubscriptionInterval?.(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{__('e.g., 1 = every month, 3 = every 3 months')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">{__('Free Trial Days')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder={__('0 = no trial')}
|
||||
value={subscriptionTrialDays || ''}
|
||||
onChange={(e) => setSubscriptionTrialDays?.(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">{__('Sign-up Fee')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={subscriptionSignupFee || ''}
|
||||
onChange={(e) => setSubscriptionSignupFee?.(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{__('One-time fee charged on first order')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -527,6 +659,6 @@ export function GeneralTab({
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import { EmailBuilder, EmailBlock, blocksToMarkdown, markdownToBlocks } from '@/
|
||||
import { CodeEditor } from '@/components/ui/code-editor';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ArrowLeft, Eye, Edit, RotateCcw, FileText } from 'lucide-react';
|
||||
import { ArrowLeft, Eye, Edit, RotateCcw, FileText, Send } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { markdownToHtml } from '@/lib/markdown-utils';
|
||||
@@ -38,12 +39,22 @@ export default function EditTemplate() {
|
||||
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
|
||||
const [activeTab, setActiveTab] = useState('preview');
|
||||
|
||||
// Fetch email customization settings
|
||||
// Send Test Email state
|
||||
const [testEmailDialogOpen, setTestEmailDialogOpen] = useState(false);
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
|
||||
// Fetch email customization settings (for non-color settings like logo, footer, social links)
|
||||
const { data: emailSettings } = useQuery({
|
||||
queryKey: ['email-settings'],
|
||||
queryFn: () => api.get('/notifications/email-settings'),
|
||||
});
|
||||
|
||||
// Fetch appearance settings for unified colors
|
||||
const { data: appearanceSettings } = useQuery({
|
||||
queryKey: ['appearance-settings'],
|
||||
queryFn: () => api.get('/appearance/settings'),
|
||||
});
|
||||
|
||||
// Fetch template
|
||||
const { data: template, isLoading, error } = useQuery({
|
||||
queryKey: ['notification-template', eventId, channelId, recipientType],
|
||||
@@ -114,6 +125,32 @@ export default function EditTemplate() {
|
||||
}
|
||||
};
|
||||
|
||||
// Send test email mutation
|
||||
const sendTestMutation = useMutation({
|
||||
mutationFn: async (email: string) => {
|
||||
return api.post(`/notifications/templates/${eventId}/${channelId}/send-test`, {
|
||||
email,
|
||||
recipient: recipientType,
|
||||
});
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
toast.success(data.message || __('Test email sent successfully'));
|
||||
setTestEmailDialogOpen(false);
|
||||
setTestEmail('');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to send test email'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSendTest = () => {
|
||||
if (!testEmail || !testEmail.includes('@')) {
|
||||
toast.error(__('Please enter a valid email address'));
|
||||
return;
|
||||
}
|
||||
sendTestMutation.mutate(testEmail);
|
||||
};
|
||||
|
||||
// Visual mode: Update blocks → Markdown (source of truth)
|
||||
const handleBlocksChange = (newBlocks: EmailBlock[]) => {
|
||||
setBlocks(newBlocks);
|
||||
@@ -288,14 +325,15 @@ export default function EditTemplate() {
|
||||
}
|
||||
});
|
||||
|
||||
// Get email settings for preview
|
||||
// Get email settings for preview - use UNIFIED appearance settings for colors
|
||||
const settings = emailSettings || {};
|
||||
const primaryColor = settings.primary_color || '#7f54b3';
|
||||
const secondaryColor = settings.secondary_color || '#7f54b3';
|
||||
const heroGradientStart = settings.hero_gradient_start || '#667eea';
|
||||
const heroGradientEnd = settings.hero_gradient_end || '#764ba2';
|
||||
const heroTextColor = settings.hero_text_color || '#ffffff';
|
||||
const buttonTextColor = settings.button_text_color || '#ffffff';
|
||||
const appearColors = appearanceSettings?.data?.general?.colors || appearanceSettings?.general?.colors || {};
|
||||
const primaryColor = appearColors.primary || '#7f54b3';
|
||||
const secondaryColor = appearColors.secondary || '#7f54b3';
|
||||
const heroGradientStart = appearColors.gradientStart || '#667eea';
|
||||
const heroGradientEnd = appearColors.gradientEnd || '#764ba2';
|
||||
const heroTextColor = '#ffffff'; // Always white on gradient
|
||||
const buttonTextColor = '#ffffff'; // Always white on primary
|
||||
const bodyBgColor = settings.body_bg_color || '#f8f8f8';
|
||||
const socialIconColor = settings.social_icon_color || 'white';
|
||||
const logoUrl = settings.logo_url || '';
|
||||
@@ -307,10 +345,11 @@ export default function EditTemplate() {
|
||||
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
|
||||
|
||||
// Generate social icons HTML with PNG images
|
||||
// Get plugin URL from config, with fallback
|
||||
const pluginUrl =
|
||||
(window as any).woonoowData?.pluginUrl ||
|
||||
(window as any).WNW_CONFIG?.pluginUrl ||
|
||||
'';
|
||||
'/wp-content/plugins/woonoow/';
|
||||
const socialIconsHtml = socialLinks.length > 0 ? `
|
||||
<div style="margin-top: 16px;">
|
||||
${socialLinks.map((link: any) => `
|
||||
@@ -414,128 +453,175 @@ export default function EditTemplate() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={template.event_label || __('Edit Template')}
|
||||
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
|
||||
onSave={handleSave}
|
||||
saveLabel={__('Save Template')}
|
||||
isLoading={false}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Determine if staff or customer based on event category
|
||||
const isStaffEvent = template.event_category === 'staff' || eventId?.includes('admin') || eventId?.includes('staff');
|
||||
const page = isStaffEvent ? 'staff' : 'customer';
|
||||
navigate(`/settings/notifications/${page}?tab=events`);
|
||||
}}
|
||||
className="gap-2"
|
||||
title={__('Back')}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{__('Back')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="gap-2"
|
||||
title={__('Reset to Default')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{__('Reset to Default')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={__('Enter notification subject')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{channelId === 'email'
|
||||
? __('Email subject line')
|
||||
: __('Push notification title')}
|
||||
</p>
|
||||
<>
|
||||
<SettingsLayout
|
||||
title={template.event_label || __('Edit Template')}
|
||||
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
|
||||
onSave={handleSave}
|
||||
saveLabel={__('Save Template')}
|
||||
isLoading={false}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Determine if staff or customer based on event category
|
||||
const isStaffEvent = template.event_category === 'staff' || eventId?.includes('admin') || eventId?.includes('staff');
|
||||
const page = isStaffEvent ? 'staff' : 'customer';
|
||||
navigate(`/settings/notifications/${page}?tab=events`);
|
||||
}}
|
||||
className="gap-2"
|
||||
title={__('Back')}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{__('Back')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="gap-2"
|
||||
title={__('Reset to Default')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{__('Reset to Default')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setTestEmailDialogOpen(true)}
|
||||
className="gap-2"
|
||||
title={__('Send Test')}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{__('Send Test')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4">
|
||||
{/* Three-tab system: Preview | Visual | Markdown */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{__('Message Body')}</Label>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||
<TabsList className="grid grid-cols-3">
|
||||
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
||||
<Eye className="h-3 w-3" />
|
||||
{__('Preview')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
|
||||
<Edit className="h-3 w-3" />
|
||||
{__('Visual')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
|
||||
<FileText className="h-3 w-3" />
|
||||
{__('Markdown')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={__('Enter notification subject')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{channelId === 'email'
|
||||
? __('Email subject line')
|
||||
: __('Push notification title')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preview Tab */}
|
||||
{activeTab === 'preview' && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={generatePreviewHTML()}
|
||||
className="w-full min-h-[600px] overflow-hidden bg-white"
|
||||
title={__('Email Preview')}
|
||||
/>
|
||||
{/* Body */}
|
||||
<div className="space-y-4">
|
||||
{/* Three-tab system: Preview | Visual | Markdown */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{__('Message Body')}</Label>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||
<TabsList className="grid grid-cols-3">
|
||||
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
||||
<Eye className="h-3 w-3" />
|
||||
{__('Preview')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
|
||||
<Edit className="h-3 w-3" />
|
||||
{__('Visual')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
|
||||
<FileText className="h-3 w-3" />
|
||||
{__('Markdown')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visual Tab */}
|
||||
{activeTab === 'visual' && (
|
||||
<div>
|
||||
<EmailBuilder
|
||||
blocks={blocks}
|
||||
onChange={handleBlocksChange}
|
||||
variables={variableKeys}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Preview Tab */}
|
||||
{activeTab === 'preview' && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={generatePreviewHTML()}
|
||||
className="w-full min-h-[600px] overflow-hidden bg-white"
|
||||
title={__('Email Preview')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Markdown Tab */}
|
||||
{activeTab === 'markdown' && (
|
||||
<div className="space-y-2">
|
||||
<CodeEditor
|
||||
value={markdownContent}
|
||||
onChange={handleMarkdownChange}
|
||||
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
||||
supportMarkdown={true}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('All changes are automatically synced between Visual and Markdown modes.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Visual Tab */}
|
||||
{activeTab === 'visual' && (
|
||||
<div>
|
||||
<EmailBuilder
|
||||
blocks={blocks}
|
||||
onChange={handleBlocksChange}
|
||||
variables={variableKeys}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Markdown Tab */}
|
||||
{activeTab === 'markdown' && (
|
||||
<div className="space-y-2">
|
||||
<CodeEditor
|
||||
value={markdownContent}
|
||||
onChange={handleMarkdownChange}
|
||||
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
||||
supportMarkdown={true}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('All changes are automatically synced between Visual and Markdown modes.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsLayout>
|
||||
|
||||
{/* Send Test Email Dialog */}
|
||||
<Dialog open={testEmailDialogOpen} onOpenChange={setTestEmailDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Send Test Email')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__('Send a test email with sample data to verify the template looks correct.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-email">{__('Email Address')}</Label>
|
||||
<Input
|
||||
id="test-email"
|
||||
type="email"
|
||||
value={testEmail}
|
||||
onChange={(e) => setTestEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('The subject will be prefixed with [TEST]')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsLayout>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setTestEmailDialogOpen(false)}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSendTest} disabled={sendTestMutation.isPending}>
|
||||
{sendTestMutation.isPending ? __('Sending...') : __('Send Test')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,190 +219,22 @@ export default function EmailCustomization() {
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Brand Colors */}
|
||||
{/* Unified Colors Notice */}
|
||||
<SettingsCard
|
||||
title={__('Brand Colors')}
|
||||
description={__('Set your primary and secondary brand colors for buttons and accents')}
|
||||
description={__('Colors for buttons, gradients, and accents in emails')}
|
||||
>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="primary_color">{__('Primary Color')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="primary_color"
|
||||
type="color"
|
||||
value={formData.primary_color}
|
||||
onChange={(e) => handleChange('primary_color', e.target.value)}
|
||||
className="w-20 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.primary_color}
|
||||
onChange={(e) => handleChange('primary_color', e.target.value)}
|
||||
placeholder="#7f54b3"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Used for primary buttons and main accents')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="secondary_color">{__('Secondary Color')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="secondary_color"
|
||||
type="color"
|
||||
value={formData.secondary_color}
|
||||
onChange={(e) => handleChange('secondary_color', e.target.value)}
|
||||
className="w-20 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.secondary_color}
|
||||
onChange={(e) => handleChange('secondary_color', e.target.value)}
|
||||
placeholder="#7f54b3"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Used for outline buttons and borders')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Hero Card Gradient */}
|
||||
<SettingsCard
|
||||
title={__('Hero Card Gradient')}
|
||||
description={__('Customize the gradient colors for hero/success card backgrounds')}
|
||||
>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hero_gradient_start">{__('Gradient Start')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="hero_gradient_start"
|
||||
type="color"
|
||||
value={formData.hero_gradient_start}
|
||||
onChange={(e) => handleChange('hero_gradient_start', e.target.value)}
|
||||
className="w-20 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.hero_gradient_start}
|
||||
onChange={(e) => handleChange('hero_gradient_start', e.target.value)}
|
||||
placeholder="#667eea"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hero_gradient_end">{__('Gradient End')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="hero_gradient_end"
|
||||
type="color"
|
||||
value={formData.hero_gradient_end}
|
||||
onChange={(e) => handleChange('hero_gradient_end', e.target.value)}
|
||||
className="w-20 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.hero_gradient_end}
|
||||
onChange={(e) => handleChange('hero_gradient_end', e.target.value)}
|
||||
placeholder="#764ba2"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hero_text_color">{__('Text Color')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="hero_text_color"
|
||||
type="color"
|
||||
value={formData.hero_text_color}
|
||||
onChange={(e) => handleChange('hero_text_color', e.target.value)}
|
||||
className="w-20 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.hero_text_color}
|
||||
onChange={(e) => handleChange('hero_text_color', e.target.value)}
|
||||
placeholder="#ffffff"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Text and heading color for hero cards (usually white)')}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-900 dark:text-blue-100">
|
||||
<strong>{__('Colors are now unified!')}</strong>{' '}
|
||||
{__('Email colors (buttons, gradients) now use the same colors as your storefront for consistent branding.')}
|
||||
</p>
|
||||
<p className="text-sm text-blue-900 dark:text-blue-100 mt-2">
|
||||
{__('To change colors, go to')}{' '}
|
||||
<a href="#/appearance/general" className="font-medium underline hover:no-underline">
|
||||
{__('Appearance → General → Colors')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="mt-4 p-6 rounded-lg text-center" style={{
|
||||
background: `linear-gradient(135deg, ${formData.hero_gradient_start} 0%, ${formData.hero_gradient_end} 100%)`
|
||||
}}>
|
||||
<h3 className="text-xl font-bold mb-2" style={{ color: formData.hero_text_color }}>{__('Preview')}</h3>
|
||||
<p className="text-sm opacity-90" style={{ color: formData.hero_text_color }}>{__('This is how your hero cards will look')}</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Button Styling */}
|
||||
<SettingsCard
|
||||
title={__('Button Styling')}
|
||||
description={__('Customize button text color and appearance')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="button_text_color">{__('Button Text Color')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="button_text_color"
|
||||
type="color"
|
||||
value={formData.button_text_color}
|
||||
onChange={(e) => handleChange('button_text_color', e.target.value)}
|
||||
className="w-20 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.button_text_color}
|
||||
onChange={(e) => handleChange('button_text_color', e.target.value)}
|
||||
placeholder="#ffffff"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Text color for buttons (usually white for dark buttons)')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Button Preview */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<button
|
||||
className="px-6 py-3 rounded-lg font-medium"
|
||||
style={{
|
||||
backgroundColor: formData.primary_color,
|
||||
color: formData.button_text_color,
|
||||
}}
|
||||
>
|
||||
{__('Primary Button')}
|
||||
</button>
|
||||
<button
|
||||
className="px-6 py-3 rounded-lg font-medium border-2"
|
||||
style={{
|
||||
borderColor: formData.secondary_color,
|
||||
color: formData.secondary_color,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{__('Secondary Button')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -540,7 +372,7 @@ export default function EmailCustomization() {
|
||||
{__('Add Social Link')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{formData.social_links.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('No social links added. Click "Add Social Link" to get started.')}
|
||||
|
||||
401
admin-spa/src/routes/Subscriptions/Detail.tsx
Normal file
401
admin-spa/src/routes/Subscriptions/Detail.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Play, Pause, XCircle, RefreshCw, Calendar, User, Package, CreditCard, Clock, FileText } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface SubscriptionOrder {
|
||||
id: number;
|
||||
subscription_id: number;
|
||||
order_id: number;
|
||||
order_type: 'parent' | 'renewal' | 'switch' | 'resubscribe';
|
||||
order_status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
id: number;
|
||||
user_id: number;
|
||||
order_id: number;
|
||||
product_id: number;
|
||||
variation_id: number | null;
|
||||
product_name: string;
|
||||
product_image: string;
|
||||
user_name: string;
|
||||
user_email: string;
|
||||
status: string;
|
||||
billing_period: string;
|
||||
billing_interval: number;
|
||||
billing_schedule: string;
|
||||
recurring_amount: string;
|
||||
start_date: string;
|
||||
trial_end_date: string | null;
|
||||
next_payment_date: string | null;
|
||||
end_date: string | null;
|
||||
last_payment_date: string | null;
|
||||
payment_method: string;
|
||||
pause_count: number;
|
||||
failed_payment_count: number;
|
||||
cancel_reason: string | null;
|
||||
created_at: string;
|
||||
can_pause: boolean;
|
||||
can_resume: boolean;
|
||||
can_cancel: boolean;
|
||||
orders: SubscriptionOrder[];
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
'pending': 'bg-yellow-100 text-yellow-800',
|
||||
'active': 'bg-green-100 text-green-800',
|
||||
'on-hold': 'bg-blue-100 text-blue-800',
|
||||
'cancelled': 'bg-gray-100 text-gray-800',
|
||||
'expired': 'bg-red-100 text-red-800',
|
||||
'pending-cancel': 'bg-orange-100 text-orange-800',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
'pending': __('Pending'),
|
||||
'active': __('Active'),
|
||||
'on-hold': __('On Hold'),
|
||||
'cancelled': __('Cancelled'),
|
||||
'expired': __('Expired'),
|
||||
'pending-cancel': __('Pending Cancel'),
|
||||
};
|
||||
|
||||
const orderTypeLabels: Record<string, string> = {
|
||||
'parent': __('Initial Order'),
|
||||
'renewal': __('Renewal'),
|
||||
'switch': __('Plan Switch'),
|
||||
'resubscribe': __('Resubscribe'),
|
||||
};
|
||||
|
||||
async function fetchSubscription(id: string) {
|
||||
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}`, {
|
||||
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to fetch subscription');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function subscriptionAction(id: number, action: string, reason?: string) {
|
||||
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': window.WNW_API.nonce,
|
||||
},
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || `Failed to ${action} subscription`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default function SubscriptionDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||
|
||||
const { data: subscription, isLoading, error } = useQuery<Subscription>({
|
||||
queryKey: ['subscription', id],
|
||||
queryFn: () => fetchSubscription(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (subscription) {
|
||||
setPageHeader(__('Subscription') + ' #' + subscription.id);
|
||||
}
|
||||
return () => clearPageHeader();
|
||||
}, [subscription, setPageHeader, clearPageHeader]);
|
||||
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: ({ action, reason }: { action: string; reason?: string }) =>
|
||||
subscriptionAction(parseInt(id!), action, reason),
|
||||
onSuccess: (_, { action }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||
toast.success(__(`Subscription ${action}d successfully`));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
if (action === 'cancel' && !confirm(__('Are you sure you want to cancel this subscription?'))) {
|
||||
return;
|
||||
}
|
||||
actionMutation.mutate({ action });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Skeleton className="h-48" />
|
||||
<Skeleton className="h-48" />
|
||||
</div>
|
||||
<Skeleton className="h-64" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !subscription) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500">{__('Failed to load subscription')}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => navigate('/subscriptions')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{__('Back to Subscriptions')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button and actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => navigate('/subscriptions')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{__('Back')}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{subscription.can_pause && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('pause')}
|
||||
disabled={actionMutation.isPending}
|
||||
>
|
||||
<Pause className="w-4 h-4 mr-2" />
|
||||
{__('Pause')}
|
||||
</Button>
|
||||
)}
|
||||
{subscription.can_resume && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('resume')}
|
||||
disabled={actionMutation.isPending}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{__('Resume')}
|
||||
</Button>
|
||||
)}
|
||||
{subscription.status === 'active' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('renew')}
|
||||
disabled={actionMutation.isPending}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{__('Renew Now')}
|
||||
</Button>
|
||||
)}
|
||||
{subscription.can_cancel && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleAction('cancel')}
|
||||
disabled={actionMutation.isPending}
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status and product info */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Subscription Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{__('Subscription Details')}</CardTitle>
|
||||
<Badge className={statusColors[subscription.status] || 'bg-gray-100'}>
|
||||
{statusLabels[subscription.status] || subscription.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{subscription.product_image ? (
|
||||
<img
|
||||
src={subscription.product_image}
|
||||
alt={subscription.product_name}
|
||||
className="w-16 h-16 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-muted rounded flex items-center justify-center">
|
||||
<Package className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-medium">{subscription.product_name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{subscription.billing_schedule}
|
||||
</p>
|
||||
<p className="text-lg font-semibold mt-1">
|
||||
{window.WNW_STORE?.currency_symbol}{subscription.recurring_amount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{__('Start Date')}</div>
|
||||
<div>{new Date(subscription.start_date).toLocaleDateString()}</div>
|
||||
</div>
|
||||
{subscription.next_payment_date && (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{__('Next Payment')}</div>
|
||||
<div>{new Date(subscription.next_payment_date).toLocaleDateString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{subscription.trial_end_date && (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{__('Trial End')}</div>
|
||||
<div>{new Date(subscription.trial_end_date).toLocaleDateString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{subscription.end_date && (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{__('End Date')}</div>
|
||||
<div>{new Date(subscription.end_date).toLocaleDateString()}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subscription.cancel_reason && (
|
||||
<div className="pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">{__('Cancel Reason')}</div>
|
||||
<div className="text-red-600">{subscription.cancel_reason}</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Customer Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{__('Customer')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-muted rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{subscription.user_name}</div>
|
||||
<div className="text-sm text-muted-foreground">{subscription.user_email}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{__('Payment Method')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4" />
|
||||
{subscription.payment_method || __('Not set')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{__('Pause Count')}</div>
|
||||
<div>{subscription.pause_count}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{__('Failed Payments')}</div>
|
||||
<div className={subscription.failed_payment_count > 0 ? 'text-red-600' : ''}>
|
||||
{subscription.failed_payment_count}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{__('Parent Order')}</div>
|
||||
<Link
|
||||
to={`/orders/${subscription.order_id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
#{subscription.order_id}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Related Orders */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{__('Related Orders')}</CardTitle>
|
||||
<CardDescription>{__('All orders associated with this subscription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{__('Order')}</TableHead>
|
||||
<TableHead>{__('Type')}</TableHead>
|
||||
<TableHead>{__('Status')}</TableHead>
|
||||
<TableHead>{__('Date')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subscription.orders?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
{__('No orders found')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
subscription.orders?.map((order) => (
|
||||
<TableRow key={order.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/orders/${order.order_id}`}
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
#{order.order_id}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{orderTypeLabels[order.order_type] || order.order_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="capitalize">{order.order_status?.replace('wc-', '')}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(order.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
332
admin-spa/src/routes/Subscriptions/index.tsx
Normal file
332
admin-spa/src/routes/Subscriptions/index.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Calendar, User, Package } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Subscription {
|
||||
id: number;
|
||||
user_id: number;
|
||||
order_id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
user_name: string;
|
||||
user_email: string;
|
||||
status: 'pending' | 'active' | 'on-hold' | 'cancelled' | 'expired' | 'pending-cancel';
|
||||
billing_schedule: string;
|
||||
recurring_amount: string;
|
||||
next_payment_date: string | null;
|
||||
created_at: string;
|
||||
can_pause: boolean;
|
||||
can_resume: boolean;
|
||||
can_cancel: boolean;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
'pending': 'bg-yellow-100 text-yellow-800',
|
||||
'active': 'bg-green-100 text-green-800',
|
||||
'on-hold': 'bg-blue-100 text-blue-800',
|
||||
'cancelled': 'bg-gray-100 text-gray-800',
|
||||
'expired': 'bg-red-100 text-red-800',
|
||||
'pending-cancel': 'bg-orange-100 text-orange-800',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
'pending': __('Pending'),
|
||||
'active': __('Active'),
|
||||
'on-hold': __('On Hold'),
|
||||
'cancelled': __('Cancelled'),
|
||||
'expired': __('Expired'),
|
||||
'pending-cancel': __('Pending Cancel'),
|
||||
};
|
||||
|
||||
async function fetchSubscriptions(params: Record<string, string>) {
|
||||
const url = new URL(window.WNW_API.root + '/subscriptions');
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to fetch subscriptions');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function subscriptionAction(id: number, action: 'cancel' | 'pause' | 'resume' | 'renew', reason?: string) {
|
||||
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': window.WNW_API.nonce,
|
||||
},
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || `Failed to ${action} subscription`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default function SubscriptionsIndex() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||
|
||||
const status = searchParams.get('status') || '';
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(__('Subscriptions'));
|
||||
return () => clearPageHeader();
|
||||
}, [setPageHeader, clearPageHeader]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['subscriptions', { status, page }],
|
||||
queryFn: () => fetchSubscriptions({ status, page: String(page), per_page: '20' }),
|
||||
});
|
||||
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: ({ id, action, reason }: { id: number; action: 'cancel' | 'pause' | 'resume' | 'renew'; reason?: string }) =>
|
||||
subscriptionAction(id, action, reason),
|
||||
onSuccess: (_, { action }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||
toast.success(__(`Subscription ${action}d successfully`));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAction = (id: number, action: 'cancel' | 'pause' | 'resume' | 'renew') => {
|
||||
if (action === 'cancel' && !confirm(__('Are you sure you want to cancel this subscription?'))) {
|
||||
return;
|
||||
}
|
||||
actionMutation.mutate({ id, action });
|
||||
};
|
||||
|
||||
const handleStatusFilter = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value === 'all') {
|
||||
params.delete('status');
|
||||
} else {
|
||||
params.set('status', value);
|
||||
}
|
||||
params.delete('page');
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const subscriptions: Subscription[] = data?.subscriptions || [];
|
||||
const total = data?.total || 0;
|
||||
const totalPages = Math.ceil(total / 20);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Select value={status || 'all'} onValueChange={handleStatusFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={__('Filter by status')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{__('All Statuses')}</SelectItem>
|
||||
<SelectItem value="active">{__('Active')}</SelectItem>
|
||||
<SelectItem value="on-hold">{__('On Hold')}</SelectItem>
|
||||
<SelectItem value="pending">{__('Pending')}</SelectItem>
|
||||
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
|
||||
<SelectItem value="expired">{__('Expired')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{__('Total')}: {total} {__('subscriptions')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[80px]">{__('ID')}</TableHead>
|
||||
<TableHead>{__('Customer')}</TableHead>
|
||||
<TableHead>{__('Product')}</TableHead>
|
||||
<TableHead>{__('Status')}</TableHead>
|
||||
<TableHead>{__('Billing')}</TableHead>
|
||||
<TableHead>{__('Next Payment')}</TableHead>
|
||||
<TableHead className="w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
[...Array(5)].map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton className="h-4 w-12" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-40" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-20" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-8" /></TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : subscriptions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Repeat className="w-8 h-8 opacity-50" />
|
||||
<p>{__('No subscriptions found')}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
subscriptions.map((sub) => (
|
||||
<TableRow key={sub.id}>
|
||||
<TableCell className="font-medium">#{sub.id}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{sub.user_name}</div>
|
||||
<div className="text-sm text-muted-foreground">{sub.user_email}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{sub.product_name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={statusColors[sub.status] || 'bg-gray-100'}>
|
||||
{statusLabels[sub.status] || sub.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
{sub.billing_schedule}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{sub.next_payment_date ? (
|
||||
<div className="text-sm">
|
||||
{new Date(sub.next_payment_date).toLocaleDateString()}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/subscriptions/${sub.id}`)}>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
{__('View Details')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{sub.can_pause && (
|
||||
<DropdownMenuItem onClick={() => handleAction(sub.id, 'pause')}>
|
||||
<Pause className="w-4 h-4 mr-2" />
|
||||
{__('Pause')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{sub.can_resume && (
|
||||
<DropdownMenuItem onClick={() => handleAction(sub.id, 'resume')}>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{__('Resume')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{sub.status === 'active' && (
|
||||
<DropdownMenuItem onClick={() => handleAction(sub.id, 'renew')}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{__('Renew Now')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{sub.can_cancel && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAction(sub.id, 'cancel')}
|
||||
className="text-red-600"
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
{__('Cancel')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('page', String(page - 1));
|
||||
setSearchParams(params);
|
||||
}}
|
||||
>
|
||||
{__('Previous')}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{__('Page')} {page} {__('of')} {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('page', String(page + 1));
|
||||
setSearchParams(params);
|
||||
}}
|
||||
>
|
||||
{__('Next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,5 +22,5 @@ module.exports = {
|
||||
borderRadius: { lg: "12px", md: "10px", sm: "8px" }
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")]
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")]
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "woonoow/woonoow",
|
||||
"type": "wordpress-plugin",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"WooNooW\\": "plugin/includes/"
|
||||
}
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
}
|
||||
}
|
||||
20
composer.lock
generated
20
composer.lock
generated
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "c8dfaf9b12dfc28774a5f4e2e71e84af",
|
||||
"packages": [],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import Wishlist from './pages/Wishlist';
|
||||
import Login from './pages/Login';
|
||||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
import OrderPay from './pages/OrderPay';
|
||||
import { DynamicPageRenderer } from './pages/DynamicPage';
|
||||
|
||||
// Create QueryClient instance
|
||||
const queryClient = new QueryClient({
|
||||
@@ -55,47 +57,83 @@ const getAppearanceSettings = () => {
|
||||
return (window as any).woonoowCustomer?.appearanceSettings || {};
|
||||
};
|
||||
|
||||
// Get initial route from data attribute (set by PHP based on SPA mode)
|
||||
// Get initial route from data attribute or derive from SPA mode
|
||||
const getInitialRoute = () => {
|
||||
const appEl = document.getElementById('woonoow-customer-app');
|
||||
const initialRoute = appEl?.getAttribute('data-initial-route');
|
||||
return initialRoute || '/shop'; // Default to shop if not specified
|
||||
if (initialRoute) return initialRoute;
|
||||
|
||||
// Derive from SPA mode if no explicit route
|
||||
const spaMode = (window as any).woonoowCustomer?.spaMode || 'full';
|
||||
if (spaMode === 'checkout_only') return '/checkout';
|
||||
return '/shop'; // Default for full mode
|
||||
};
|
||||
|
||||
// Get front page slug from config
|
||||
const getFrontPageSlug = () => {
|
||||
return (window as any).woonoowCustomer?.frontPageSlug || null;
|
||||
};
|
||||
|
||||
// Router wrapper component that uses hooks requiring Router context
|
||||
function AppRoutes() {
|
||||
const initialRoute = getInitialRoute();
|
||||
const frontPageSlug = getFrontPageSlug();
|
||||
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Routes>
|
||||
{/* Root route redirects to initial route based on SPA mode */}
|
||||
<Route path="/" element={<Navigate to={initialRoute} replace />} />
|
||||
<Routes>
|
||||
{/* License Connect - Standalone focused page without layout */}
|
||||
<Route path="/my-account/license-connect" element={<Account />} />
|
||||
|
||||
{/* Shop Routes */}
|
||||
<Route path="/shop" element={<Shop />} />
|
||||
<Route path="/product/:slug" element={<Product />} />
|
||||
{/* All other routes wrapped in BaseLayout */}
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<BaseLayout>
|
||||
<Routes>
|
||||
{/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
frontPageSlug ? (
|
||||
<DynamicPageRenderer slug={frontPageSlug} />
|
||||
) : (
|
||||
<Navigate to={initialRoute === '/' ? '/shop' : initialRoute} replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Cart & Checkout */}
|
||||
<Route path="/cart" element={<Cart />} />
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
<Route path="/order-received/:orderId" element={<ThankYou />} />
|
||||
{/* Shop Routes */}
|
||||
<Route path="/shop" element={<Shop />} />
|
||||
<Route path="/product/:slug" element={<Product />} />
|
||||
|
||||
{/* Wishlist - Public route accessible to guests */}
|
||||
<Route path="/wishlist" element={<Wishlist />} />
|
||||
{/* Cart & Checkout */}
|
||||
<Route path="/cart" element={<Cart />} />
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
<Route path="/order-received/:orderId" element={<ThankYou />} />
|
||||
<Route path="/checkout/order-received/:orderId" element={<ThankYou />} />
|
||||
<Route path="/checkout/pay/:orderId" element={<OrderPay />} />
|
||||
|
||||
{/* Login & Auth */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
{/* Wishlist - Public route accessible to guests */}
|
||||
<Route path="/wishlist" element={<Wishlist />} />
|
||||
|
||||
{/* My Account */}
|
||||
<Route path="/my-account/*" element={<Account />} />
|
||||
{/* Login & Auth */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
|
||||
{/* Fallback to initial route */}
|
||||
<Route path="*" element={<Navigate to={initialRoute} replace />} />
|
||||
</Routes>
|
||||
</BaseLayout>
|
||||
{/* My Account */}
|
||||
<Route path="/my-account/*" element={<Account />} />
|
||||
|
||||
{/* Dynamic Pages - CPT content with path base (e.g., /blog/:slug) */}
|
||||
<Route path="/:pathBase/:slug" element={<DynamicPageRenderer />} />
|
||||
|
||||
{/* Dynamic Pages - Structural pages (e.g., /about, /contact) */}
|
||||
<Route path="/:slug" element={<DynamicPageRenderer />} />
|
||||
</Routes>
|
||||
</BaseLayout>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,7 +142,7 @@ const getRouterConfig = () => {
|
||||
const config = (window as any).woonoowCustomer;
|
||||
return {
|
||||
useBrowserRouter: config?.useBrowserRouter ?? true,
|
||||
basePath: config?.basePath || '/store',
|
||||
basePath: config?.basePath ?? '/store',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -124,6 +162,24 @@ function App() {
|
||||
const appearanceSettings = getAppearanceSettings();
|
||||
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
||||
|
||||
// Inject gradient CSS variables
|
||||
React.useEffect(() => {
|
||||
// appearanceSettings is already the 'data' object from Assets.php injection
|
||||
// Structure: { general: { colors: { primary, secondary, accent, text, background, gradientStart, gradientEnd } } }
|
||||
const colors = appearanceSettings?.general?.colors;
|
||||
if (colors) {
|
||||
const root = document.documentElement;
|
||||
// Inject all color settings as CSS variables
|
||||
if (colors.primary) root.style.setProperty('--wn-primary', colors.primary);
|
||||
if (colors.secondary) root.style.setProperty('--wn-secondary', colors.secondary);
|
||||
if (colors.accent) root.style.setProperty('--wn-accent', colors.accent);
|
||||
if (colors.text) root.style.setProperty('--wn-text', colors.text);
|
||||
if (colors.background) root.style.setProperty('--wn-background', colors.background);
|
||||
if (colors.gradientStart) root.style.setProperty('--wn-gradient-start', colors.gradientStart);
|
||||
if (colors.gradientEnd) root.style.setProperty('--wn-gradient-end', colors.gradientEnd);
|
||||
}
|
||||
}, [appearanceSettings]);
|
||||
|
||||
return (
|
||||
<HelmetProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
155
customer-spa/src/components/SharedContentLayout.tsx
Normal file
155
customer-spa/src/components/SharedContentLayout.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
|
||||
interface SharedContentProps {
|
||||
// Content
|
||||
title?: string;
|
||||
text?: string; // HTML content
|
||||
|
||||
// Image
|
||||
image?: string;
|
||||
imagePosition?: 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
// Layout
|
||||
containerWidth?: 'full' | 'contained';
|
||||
|
||||
// Styles
|
||||
className?: string;
|
||||
titleStyle?: React.CSSProperties;
|
||||
titleClassName?: string;
|
||||
textStyle?: React.CSSProperties;
|
||||
textClassName?: string;
|
||||
headingStyle?: React.CSSProperties; // For prose headings override
|
||||
imageStyle?: React.CSSProperties;
|
||||
|
||||
// Pro Features (for future)
|
||||
buttons?: Array<{ text: string, url: string }>;
|
||||
buttonStyle?: { classNames?: string; style?: React.CSSProperties };
|
||||
}
|
||||
|
||||
|
||||
export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
title,
|
||||
text,
|
||||
image,
|
||||
imagePosition = 'left',
|
||||
containerWidth = 'contained',
|
||||
className,
|
||||
titleStyle,
|
||||
titleClassName,
|
||||
textStyle,
|
||||
textClassName,
|
||||
headingStyle,
|
||||
buttons,
|
||||
|
||||
imageStyle,
|
||||
buttonStyle
|
||||
}) => {
|
||||
|
||||
const hasImage = !!image;
|
||||
const isImageLeft = imagePosition === 'left';
|
||||
const isImageRight = imagePosition === 'right';
|
||||
const isImageTop = imagePosition === 'top';
|
||||
const isImageBottom = imagePosition === 'bottom';
|
||||
|
||||
// Wrapper classes
|
||||
const containerClasses = cn(
|
||||
'w-full mx-auto px-4 sm:px-6 lg:px-8',
|
||||
containerWidth === 'contained' ? 'max-w-7xl' : ''
|
||||
);
|
||||
|
||||
const gridClasses = cn(
|
||||
'mx-auto',
|
||||
hasImage && (isImageLeft || isImageRight) ? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center' : 'max-w-4xl'
|
||||
);
|
||||
|
||||
const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
|
||||
|
||||
const proseStyle = {
|
||||
...textStyle,
|
||||
'--tw-prose-headings': headingStyle?.color,
|
||||
'--tw-prose-body': textStyle?.color,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className={gridClasses}>
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
<div className={cn(
|
||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||
imageWrapperOrder,
|
||||
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
|
||||
)} style={imageStyle}>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Section Image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Side */}
|
||||
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"tracking-tight text-current mb-6",
|
||||
!titleClassName && "text-3xl font-bold sm:text-4xl",
|
||||
titleClassName
|
||||
)}
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{text && (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-lg max-w-none',
|
||||
'prose-h1:text-3xl prose-h1:font-bold prose-h1:mt-4 prose-h1:mb-2',
|
||||
'prose-h2:text-2xl prose-h2:font-bold prose-h2:mt-3 prose-h2:mb-2',
|
||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||
'prose-p:text-[var(--tw-prose-body)]',
|
||||
'text-[var(--tw-prose-body)]',
|
||||
className,
|
||||
textClassName
|
||||
)}
|
||||
style={proseStyle}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Buttons */}
|
||||
{buttons && buttons.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
{buttons.map((btn, idx) => (
|
||||
btn.text && btn.url && (
|
||||
<a
|
||||
key={idx}
|
||||
href={btn.url}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
|
||||
!buttonStyle?.style?.backgroundColor && "bg-primary",
|
||||
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
|
||||
buttonStyle?.classNames
|
||||
)}
|
||||
style={buttonStyle?.style}
|
||||
>
|
||||
{btn.text}
|
||||
</a>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
86
customer-spa/src/components/SubscriptionTimeline.tsx
Normal file
86
customer-spa/src/components/SubscriptionTimeline.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SubscriptionData {
|
||||
id: number;
|
||||
status: string;
|
||||
billing_period: string;
|
||||
billing_interval: number;
|
||||
start_date: string;
|
||||
next_payment_date: string | null;
|
||||
end_date: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
subscription: SubscriptionData;
|
||||
}
|
||||
|
||||
const SubscriptionTimeline: React.FC<Props> = ({ subscription }) => {
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const isMonth = subscription.billing_period === 'month';
|
||||
const intervalLabel = `${subscription.billing_interval} ${subscription.billing_period}${subscription.billing_interval > 1 ? 's' : ''}`;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold mb-6">Subscription Timeline</h2>
|
||||
|
||||
<div className="relative">
|
||||
{/* Connecting Line */}
|
||||
<div className="absolute top-4 left-4 right-4 h-0.5 bg-gray-200" aria-hidden="true" />
|
||||
|
||||
<div className="relative flex justify-between">
|
||||
{/* Start Node */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 rounded-full bg-green-100 border-2 border-green-500 flex items-center justify-center z-10 shrink-0">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-green-500" />
|
||||
</div>
|
||||
<div className="mt-3 text-center">
|
||||
<div className="text-sm font-medium text-gray-900">Started</div>
|
||||
<div className="text-xs text-gray-500">{formatDate(subscription.start_date)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Info (Interval) */}
|
||||
<div className="hidden sm:flex flex-col items-center justify-start pt-1">
|
||||
<div className="bg-white px-2 text-xs text-gray-400 font-medium uppercase tracking-wider">
|
||||
Every {intervalLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Payment (Active/Due) Node */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative w-8 h-8 rounded-full bg-blue-100 border-2 border-blue-600 flex items-center justify-center z-10 shrink-0 animate-pulse-ring">
|
||||
{/* Pulse Effect */}
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-20 animate-ping"></span>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-blue-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center">
|
||||
<div className="text-sm font-medium text-blue-700 font-bold">Payment Due</div>
|
||||
<div className="text-xs text-blue-600 font-medium">Now</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Future Node */}
|
||||
<div className="flex flex-col items-center opacity-60">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 border-2 border-gray-300 flex items-center justify-center z-10 shrink-0">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-gray-300" />
|
||||
</div>
|
||||
<div className="mt-3 text-center">
|
||||
<div className="text-sm font-medium text-gray-500">Next Renewal</div>
|
||||
<div className="text-xs text-gray-400">{subscription.next_payment_date ? formatDate(subscription.next_payment_date) : '...'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionTimeline;
|
||||
@@ -68,7 +68,7 @@ export function SearchableSelect({
|
||||
type="button"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between border !rounded-lg px-4 py-2 text-left bg-white hover:bg-gray-50 focus:outline-none focus:border-gray-400",
|
||||
"w-full flex items-center justify-between border rounded-lg px-4 py-2 text-left bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary/20",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -37,25 +37,35 @@ interface AppearanceSettings {
|
||||
thankyou: any;
|
||||
account: any;
|
||||
};
|
||||
menus: {
|
||||
primary: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'page' | 'custom';
|
||||
value: string;
|
||||
target: '_self' | '_blank';
|
||||
}>;
|
||||
mobile: Array<any>;
|
||||
};
|
||||
}
|
||||
|
||||
export function useAppearanceSettings() {
|
||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||
|
||||
|
||||
// Get preloaded settings from window object
|
||||
const preloadedSettings = (window as any).woonoowCustomer?.appearanceSettings;
|
||||
|
||||
|
||||
return useQuery<AppearanceSettings>({
|
||||
queryKey: ['appearance-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${apiRoot}/appearance/settings`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch appearance settings');
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
},
|
||||
@@ -68,7 +78,7 @@ export function useAppearanceSettings() {
|
||||
|
||||
export function useShopSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
|
||||
const defaultSettings = {
|
||||
layout: {
|
||||
grid_columns: '3' as string,
|
||||
@@ -93,7 +103,7 @@ export function useShopSettings() {
|
||||
show_icon: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
layout: { ...defaultSettings.layout, ...(data?.pages?.shop?.layout || {}) },
|
||||
elements: { ...defaultSettings.elements, ...(data?.pages?.shop?.elements || {}) },
|
||||
@@ -105,7 +115,7 @@ export function useShopSettings() {
|
||||
|
||||
export function useProductSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
|
||||
const defaultSettings = {
|
||||
layout: {
|
||||
image_position: 'left' as string,
|
||||
@@ -127,7 +137,7 @@ export function useProductSettings() {
|
||||
hide_if_empty: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
layout: { ...defaultSettings.layout, ...(data?.pages?.product?.layout || {}) },
|
||||
elements: { ...defaultSettings.elements, ...(data?.pages?.product?.elements || {}) },
|
||||
@@ -139,7 +149,7 @@ export function useProductSettings() {
|
||||
|
||||
export function useCartSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
|
||||
const defaultSettings = {
|
||||
layout: {
|
||||
style: 'fullwidth' as string,
|
||||
@@ -152,7 +162,7 @@ export function useCartSettings() {
|
||||
shipping_calculator: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
layout: { ...defaultSettings.layout, ...(data?.pages?.cart?.layout || {}) },
|
||||
elements: { ...defaultSettings.elements, ...(data?.pages?.cart?.elements || {}) },
|
||||
@@ -162,7 +172,7 @@ export function useCartSettings() {
|
||||
|
||||
export function useCheckoutSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
|
||||
const defaultSettings = {
|
||||
layout: {
|
||||
style: 'two-column' as string,
|
||||
@@ -178,7 +188,7 @@ export function useCheckoutSettings() {
|
||||
payment_icons: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
layout: { ...defaultSettings.layout, ...(data?.pages?.checkout?.layout || {}) },
|
||||
elements: { ...defaultSettings.elements, ...(data?.pages?.checkout?.elements || {}) },
|
||||
@@ -188,7 +198,7 @@ export function useCheckoutSettings() {
|
||||
|
||||
export function useThankYouSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
|
||||
const defaultSettings = {
|
||||
template: 'basic',
|
||||
header_visibility: 'show',
|
||||
@@ -201,7 +211,7 @@ export function useThankYouSettings() {
|
||||
related_products: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
template: data?.pages?.thankyou?.template || defaultSettings.template,
|
||||
headerVisibility: data?.pages?.thankyou?.header_visibility || defaultSettings.header_visibility,
|
||||
@@ -215,7 +225,7 @@ export function useThankYouSettings() {
|
||||
|
||||
export function useAccountSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
|
||||
const defaultSettings = {
|
||||
layout: {
|
||||
navigation_style: 'sidebar' as string,
|
||||
@@ -228,7 +238,7 @@ export function useAccountSettings() {
|
||||
account_details: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
layout: { ...defaultSettings.layout, ...(data?.pages?.account?.layout || {}) },
|
||||
elements: { ...defaultSettings.elements, ...(data?.pages?.account?.elements || {}) },
|
||||
@@ -238,7 +248,7 @@ export function useAccountSettings() {
|
||||
|
||||
export function useHeaderSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
|
||||
return {
|
||||
style: data?.header?.style ?? 'classic',
|
||||
sticky: data?.header?.sticky ?? true,
|
||||
@@ -261,7 +271,7 @@ export function useHeaderSettings() {
|
||||
|
||||
export function useFooterSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
|
||||
return {
|
||||
columns: data?.footer?.columns ?? '4',
|
||||
style: data?.footer?.style ?? 'detailed',
|
||||
@@ -293,4 +303,15 @@ export function useFooterSettings() {
|
||||
},
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export function useMenuSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
return {
|
||||
primary: data?.menus?.primary ?? [],
|
||||
mobile: data?.menus?.mobile ?? [],
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -19,114 +20,115 @@ interface WishlistItem {
|
||||
const GUEST_WISHLIST_KEY = 'woonoow_guest_wishlist';
|
||||
|
||||
export function useWishlist() {
|
||||
const [items, setItems] = useState<WishlistItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [productIds, setProductIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [guestIds, setGuestIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Check if wishlist is enabled (default true if not explicitly set to false)
|
||||
const settings = (window as any).woonoowCustomer?.settings;
|
||||
const isEnabled = settings?.wishlist_enabled !== false;
|
||||
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
|
||||
|
||||
// Load guest wishlist from localStorage
|
||||
const loadGuestWishlist = useCallback(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
|
||||
if (stored) {
|
||||
const guestIds = JSON.parse(stored) as number[];
|
||||
setProductIds(new Set(guestIds));
|
||||
// Load guest wishlist on mount
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) {
|
||||
try {
|
||||
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
|
||||
if (stored) {
|
||||
const ids = JSON.parse(stored) as number[];
|
||||
setGuestIds(new Set(ids));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load guest wishlist:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load guest wishlist:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [isLoggedIn]);
|
||||
|
||||
// Save guest wishlist to localStorage
|
||||
// Save guest wishlist helper
|
||||
const saveGuestWishlist = useCallback((ids: Set<number>) => {
|
||||
try {
|
||||
localStorage.setItem(GUEST_WISHLIST_KEY, JSON.stringify(Array.from(ids)));
|
||||
setGuestIds(ids);
|
||||
} catch (error) {
|
||||
console.error('Failed to save guest wishlist:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load wishlist on mount
|
||||
useEffect(() => {
|
||||
if (isEnabled) {
|
||||
if (isLoggedIn) {
|
||||
loadWishlist();
|
||||
} else {
|
||||
loadGuestWishlist();
|
||||
}
|
||||
}
|
||||
}, [isEnabled, isLoggedIn]);
|
||||
// Fetch wishlist items (Server)
|
||||
const { data: serverItems = [], isLoading: isServerLoading } = useQuery({
|
||||
queryKey: ['wishlist'],
|
||||
queryFn: async () => {
|
||||
return await api.get<WishlistItem[]>('/account/wishlist');
|
||||
},
|
||||
enabled: isEnabled && isLoggedIn,
|
||||
staleTime: 60 * 1000, // 1 minute cache
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const loadWishlist = useCallback(async () => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await api.get<WishlistItem[]>('/account/wishlist');
|
||||
setItems(data);
|
||||
setProductIds(new Set(data.map(item => item.product_id)));
|
||||
} catch (error) {
|
||||
console.error('Failed to load wishlist:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoggedIn]);
|
||||
// Calculate merged state
|
||||
const items = isLoggedIn ? serverItems : []; // Guest items not stored as full objects here, usually handled by fetching products by ID elsewhere
|
||||
const isLoading = isLoggedIn ? isServerLoading : false;
|
||||
|
||||
const addToWishlist = useCallback(async (productId: number) => {
|
||||
// Guest mode: store in localStorage only
|
||||
if (!isLoggedIn) {
|
||||
const newIds = new Set(productIds);
|
||||
newIds.add(productId);
|
||||
setProductIds(newIds);
|
||||
saveGuestWishlist(newIds);
|
||||
// Compute set of IDs for O(1) lookup
|
||||
const productIds = useMemo(() => {
|
||||
if (isLoggedIn) {
|
||||
return new Set(serverItems.map(item => item.product_id));
|
||||
}
|
||||
return guestIds;
|
||||
}, [isLoggedIn, serverItems, guestIds]);
|
||||
|
||||
// Mutations
|
||||
const addMutation = useMutation({
|
||||
mutationFn: async (productId: number) => {
|
||||
return await api.post('/account/wishlist', { product_id: productId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['wishlist'] });
|
||||
toast.success('Added to wishlist');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Logged in: use API
|
||||
try {
|
||||
await api.post('/account/wishlist', { product_id: productId });
|
||||
await loadWishlist(); // Reload to get full product details
|
||||
toast.success('Added to wishlist');
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const message = error?.message || 'Failed to add to wishlist';
|
||||
toast.error(message);
|
||||
return false;
|
||||
}
|
||||
}, [isLoggedIn, productIds, loadWishlist, saveGuestWishlist]);
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: async (productId: number) => {
|
||||
return await api.delete(`/account/wishlist/${productId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['wishlist'] });
|
||||
toast.success('Removed from wishlist');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to remove from wishlist');
|
||||
}
|
||||
});
|
||||
|
||||
const addToWishlist = useCallback(async (productId: number) => {
|
||||
if (!isLoggedIn) {
|
||||
const newIds = new Set(guestIds);
|
||||
newIds.add(productId);
|
||||
saveGuestWishlist(newIds);
|
||||
toast.success('Added to wishlist');
|
||||
return true;
|
||||
}
|
||||
|
||||
await addMutation.mutateAsync(productId);
|
||||
return true;
|
||||
}, [isLoggedIn, guestIds, saveGuestWishlist, addMutation]);
|
||||
|
||||
const removeFromWishlist = useCallback(async (productId: number) => {
|
||||
// Guest mode: remove from localStorage only
|
||||
if (!isLoggedIn) {
|
||||
const newIds = new Set(productIds);
|
||||
const newIds = new Set(guestIds);
|
||||
newIds.delete(productId);
|
||||
setProductIds(newIds);
|
||||
saveGuestWishlist(newIds);
|
||||
toast.success('Removed from wishlist');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Logged in: use API
|
||||
try {
|
||||
await api.delete(`/account/wishlist/${productId}`);
|
||||
setItems(items.filter(item => item.product_id !== productId));
|
||||
setProductIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(productId);
|
||||
return newSet;
|
||||
});
|
||||
toast.success('Removed from wishlist');
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error('Failed to remove from wishlist');
|
||||
return false;
|
||||
}
|
||||
}, [isLoggedIn, productIds, items, saveGuestWishlist]);
|
||||
await removeMutation.mutateAsync(productId);
|
||||
return true;
|
||||
}, [isLoggedIn, guestIds, saveGuestWishlist, removeMutation]);
|
||||
|
||||
const toggleWishlist = useCallback(async (productId: number) => {
|
||||
if (productIds.has(productId)) {
|
||||
@@ -145,12 +147,12 @@ export function useWishlist() {
|
||||
isLoading,
|
||||
isEnabled,
|
||||
isLoggedIn,
|
||||
count: items.length,
|
||||
count: productIds.size,
|
||||
productIds,
|
||||
addToWishlist,
|
||||
removeFromWishlist,
|
||||
toggleWishlist,
|
||||
isInWishlist,
|
||||
refresh: loadWishlist,
|
||||
refresh: () => queryClient.invalidateQueries({ queryKey: ['wishlist'] }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { Search, ShoppingCart, User, Menu, X, Heart } from 'lucide-react';
|
||||
import { useLayout } from '../contexts/ThemeContext';
|
||||
import { useCartStore } from '../lib/cart/store';
|
||||
import { useHeaderSettings, useFooterSettings } from '../hooks/useAppearanceSettings';
|
||||
import { useHeaderSettings, useFooterSettings, useMenuSettings } from '../hooks/useAppearanceSettings';
|
||||
import { SearchModal } from '../components/SearchModal';
|
||||
import { NewsletterForm } from '../components/NewsletterForm';
|
||||
import { LayoutWrapper } from './LayoutWrapper';
|
||||
@@ -51,6 +51,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
const { isEnabled } = useModules();
|
||||
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||
const footerSettings = useFooterSettings();
|
||||
const { primary: primaryMenu, mobile: mobileMenu } = useMenuSettings();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
@@ -74,7 +75,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
{/* Logo */}
|
||||
{headerSettings.elements.logo && (
|
||||
<div className={`flex-shrink-0 ${headerSettings.mobile_logo === 'center' ? 'max-md:mx-auto' : ''}`}>
|
||||
<Link to="/shop" className="flex items-center gap-3 group">
|
||||
<Link to="/" className="flex items-center gap-3 group">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
@@ -103,9 +104,24 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
{/* Navigation */}
|
||||
{headerSettings.elements.navigation && (
|
||||
<nav className="hidden md:flex items-center space-x-8">
|
||||
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
||||
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
|
||||
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
|
||||
{primaryMenu.length > 0 ? (
|
||||
primaryMenu.map(item => (
|
||||
item.type === 'page' ? (
|
||||
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</Link>
|
||||
) : (
|
||||
<a key={item.id} href={item.value} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</a>
|
||||
)
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{(window as any).woonoowCustomer?.frontPageSlug && (
|
||||
<Link to="/" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Home</Link>
|
||||
)}
|
||||
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
||||
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
|
||||
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
@@ -177,9 +193,13 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
<div className="md:hidden border-t py-4">
|
||||
{headerSettings.elements.navigation && (
|
||||
<nav className="flex flex-col space-y-2 mb-4">
|
||||
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link>
|
||||
<a href="/about" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">About</a>
|
||||
<a href="/contact" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Contact</a>
|
||||
{(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
|
||||
item.type === 'page' ? (
|
||||
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</Link>
|
||||
) : (
|
||||
<a key={item.id} href={item.value} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</a>
|
||||
)
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
@@ -198,9 +218,13 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
</div>
|
||||
{headerSettings.elements.navigation && (
|
||||
<nav className="flex flex-col p-4">
|
||||
<Link to="/shop" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">Shop</Link>
|
||||
<a href="/about" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">About</a>
|
||||
<a href="/contact" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">Contact</a>
|
||||
{(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
|
||||
item.type === 'page' ? (
|
||||
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} onClick={() => setMobileMenuOpen(false)} target={item.target} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">{item.label}</Link>
|
||||
) : (
|
||||
<a key={item.id} href={item.value} onClick={() => setMobileMenuOpen(false)} target={item.target} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">{item.label}</a>
|
||||
)
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
@@ -367,6 +391,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
const headerSettings = useHeaderSettings();
|
||||
const { isEnabled } = useModules();
|
||||
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||
const { primary: primaryMenu, mobile: mobileMenu } = useMenuSettings();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
@@ -381,7 +406,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
<div className={`flex flex-col items-center ${paddingClass}`}>
|
||||
{/* Logo - Centered */}
|
||||
{headerSettings.elements.logo && (
|
||||
<Link to="/shop" className="mb-4">
|
||||
<Link to="/" className="mb-4">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
@@ -404,9 +429,24 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
<nav className="hidden md:flex items-center space-x-8">
|
||||
{headerSettings.elements.navigation && (
|
||||
<>
|
||||
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
||||
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
|
||||
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
|
||||
{primaryMenu.length > 0 ? (
|
||||
primaryMenu.map(item => (
|
||||
item.type === 'page' ? (
|
||||
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</Link>
|
||||
) : (
|
||||
<a key={item.id} href={item.value} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</a>
|
||||
)
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{(window as any).woonoowCustomer?.frontPageSlug && (
|
||||
<Link to="/" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Home</Link>
|
||||
)}
|
||||
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
||||
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
|
||||
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{headerSettings.elements.search && (
|
||||
@@ -456,9 +496,13 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
<div className="md:hidden border-t py-4">
|
||||
{headerSettings.elements.navigation && (
|
||||
<nav className="flex flex-col space-y-2 mb-4">
|
||||
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link>
|
||||
<a href="/about" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">About</a>
|
||||
<a href="/contact" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Contact</a>
|
||||
{(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
|
||||
item.type === 'page' ? (
|
||||
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</Link>
|
||||
) : (
|
||||
<a key={item.id} href={item.value} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</a>
|
||||
)
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
@@ -503,6 +547,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
const headerSettings = useHeaderSettings();
|
||||
const { isEnabled } = useModules();
|
||||
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||
const { primary: primaryMenu, mobile: mobileMenu } = useMenuSettings();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
@@ -520,7 +565,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
|
||||
{headerSettings.elements.logo && (
|
||||
<div className="flex-shrink-0">
|
||||
<Link to="/shop">
|
||||
<Link to="/">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
@@ -543,7 +588,24 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
{(headerSettings.elements.navigation || hasActions) && (
|
||||
<nav className="hidden md:flex items-center space-x-8">
|
||||
{headerSettings.elements.navigation && (
|
||||
<Link to="/shop" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
||||
<>
|
||||
{primaryMenu.length > 0 ? (
|
||||
primaryMenu.map(item => (
|
||||
item.type === 'page' ? (
|
||||
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</Link>
|
||||
) : (
|
||||
<a key={item.id} href={item.value} target={item.target} className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</a>
|
||||
)
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{(window as any).woonoowCustomer?.frontPageSlug && (
|
||||
<Link to="/" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Home</Link>
|
||||
)}
|
||||
<Link to="/shop" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{headerSettings.elements.search && (
|
||||
<button
|
||||
@@ -591,7 +653,13 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
<div className="md:hidden border-t py-4">
|
||||
{headerSettings.elements.navigation && (
|
||||
<nav className="flex flex-col space-y-2 mb-4">
|
||||
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link>
|
||||
{(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
|
||||
item.type === 'page' ? (
|
||||
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</Link>
|
||||
) : (
|
||||
<a key={item.id} href={item.value} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</a>
|
||||
)
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
@@ -656,7 +724,7 @@ function LaunchLayout({ children }: BaseLayoutProps) {
|
||||
<div className="container mx-auto px-4">
|
||||
<div className={`flex items-center justify-center ${heightClass}`}>
|
||||
{headerSettings.elements.logo && (
|
||||
<Link to="/shop">
|
||||
<Link to="/">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -115,9 +115,19 @@ export default function OrderDetails() {
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Order #{order.order_number}</h1>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
|
||||
{order.status.replace('-', ' ').toUpperCase()}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{['pending', 'failed', 'on-hold'].includes(order.status) && (
|
||||
<Link
|
||||
to={`/checkout/pay/${order.id}`}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors shadow-sm"
|
||||
>
|
||||
Pay Now
|
||||
</Link>
|
||||
)}
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
|
||||
{order.status.replace('-', ' ').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 mb-6">
|
||||
|
||||
377
customer-spa/src/pages/Account/SubscriptionDetail.tsx
Normal file
377
customer-spa/src/pages/Account/SubscriptionDetail.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ArrowLeft, Repeat, Pause, Play, XCircle, Calendar, Package, CreditCard, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
|
||||
interface SubscriptionOrder {
|
||||
id: number;
|
||||
order_id: number;
|
||||
order_type: 'parent' | 'renewal' | 'switch' | 'resubscribe';
|
||||
order_status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_image: string;
|
||||
status: string;
|
||||
billing_period: string;
|
||||
billing_interval: number;
|
||||
billing_schedule: string;
|
||||
recurring_amount: string;
|
||||
start_date: string;
|
||||
trial_end_date: string | null;
|
||||
next_payment_date: string | null;
|
||||
end_date: string | null;
|
||||
last_payment_date: string | null;
|
||||
payment_method: string;
|
||||
pause_count: number;
|
||||
can_pause: boolean;
|
||||
can_resume: boolean;
|
||||
can_cancel: boolean;
|
||||
orders: SubscriptionOrder[];
|
||||
}
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
'pending': 'bg-yellow-100 text-yellow-800',
|
||||
'active': 'bg-green-100 text-green-800',
|
||||
'on-hold': 'bg-blue-100 text-blue-800',
|
||||
'cancelled': 'bg-gray-100 text-gray-800',
|
||||
'expired': 'bg-red-100 text-red-800',
|
||||
'pending-cancel': 'bg-orange-100 text-orange-800',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
'pending': 'Pending',
|
||||
'active': 'Active',
|
||||
'on-hold': 'On Hold',
|
||||
'cancelled': 'Cancelled',
|
||||
'expired': 'Expired',
|
||||
'pending-cancel': 'Pending Cancel',
|
||||
};
|
||||
|
||||
const orderTypeLabels: Record<string, string> = {
|
||||
'parent': 'Initial Order',
|
||||
'renewal': 'Renewal',
|
||||
'switch': 'Plan Switch',
|
||||
'resubscribe': 'Resubscribe',
|
||||
};
|
||||
|
||||
export default function SubscriptionDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const { data: subscription, isLoading, error } = useQuery<Subscription>({
|
||||
queryKey: ['account-subscription', id],
|
||||
queryFn: () => api.get(`/account/subscriptions/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: (action: string) => api.post(`/account/subscriptions/${id}/${action}`),
|
||||
onSuccess: (_, action) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-subscription', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['account-subscriptions'] });
|
||||
const actionLabels: Record<string, string> = {
|
||||
'pause': 'paused',
|
||||
'resume': 'resumed',
|
||||
'cancel': 'cancelled',
|
||||
};
|
||||
toast.success(`Subscription ${actionLabels[action] || action} successfully`);
|
||||
setActionLoading(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Action failed');
|
||||
setActionLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
setActionLoading(true);
|
||||
actionMutation.mutate(action);
|
||||
};
|
||||
|
||||
const handleRenew = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const response = await api.post<{ order_id: number; status: string }>(`/account/subscriptions/${id}/renew`);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['account-subscription', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['account-subscriptions'] });
|
||||
|
||||
toast.success('Renewal order created');
|
||||
|
||||
if (response.order_id) {
|
||||
// Determine destination based on functionality
|
||||
// If manual payment required or just improved UX, go to payment page
|
||||
navigate(`/order-pay/${response.order_id}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to renew');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Find pending renewal order
|
||||
const pendingRenewalOrder = subscription?.orders?.find(
|
||||
o => o.order_type === 'renewal' &&
|
||||
['pending', 'wc-pending', 'on-hold', 'wc-on-hold', 'failed', 'wc-failed'].includes(o.order_status)
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !subscription) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500">Failed to load subscription</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => navigate('/my-account/subscriptions')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Subscriptions
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SEOHead title={`Subscription #${subscription.id}`} description="Subscription details" />
|
||||
|
||||
{/* Back button */}
|
||||
<Link
|
||||
to="/my-account/subscriptions"
|
||||
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Subscriptions
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Repeat className="h-6 w-6" />
|
||||
Subscription #{subscription.id}
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Started {new Date(subscription.start_date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyles[subscription.status] || 'bg-gray-100'}`}>
|
||||
{statusLabels[subscription.status] || subscription.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Product Info Card */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{subscription.product_image ? (
|
||||
<img
|
||||
src={subscription.product_image}
|
||||
alt={subscription.product_name}
|
||||
className="w-20 h-20 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gray-100 rounded flex items-center justify-center">
|
||||
<Package className="h-10 w-10 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold">{subscription.product_name}</h2>
|
||||
<p className="text-gray-500">{subscription.billing_schedule}</p>
|
||||
<p className="text-2xl font-bold mt-2">
|
||||
{formatPrice(subscription.recurring_amount)}
|
||||
<span className="text-sm font-normal text-gray-500">
|
||||
/{subscription.billing_period}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing Details */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="font-semibold mb-4">Billing Details</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Start Date</p>
|
||||
<p className="font-medium">{new Date(subscription.start_date).toLocaleDateString()}</p>
|
||||
</div>
|
||||
{subscription.next_payment_date && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Next Payment</p>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
{new Date(subscription.next_payment_date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{subscription.trial_end_date && new Date(subscription.trial_end_date) > new Date() && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Trial Ends</p>
|
||||
<p className="font-medium text-blue-600">
|
||||
{new Date(subscription.trial_end_date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{subscription.end_date && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">End Date</p>
|
||||
<p className="font-medium">{new Date(subscription.end_date).toLocaleDateString()}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Payment Method</p>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
<CreditCard className="h-4 w-4 text-gray-400" />
|
||||
{subscription.payment_method || 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Times Paused</p>
|
||||
<p className="font-medium">{subscription.pause_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(subscription.can_pause || subscription.can_resume || subscription.can_cancel) && (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="font-semibold mb-4">Manage Subscription</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
{subscription.can_pause && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('pause')}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Pause Subscription
|
||||
</Button>
|
||||
)}
|
||||
{subscription.can_resume && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('resume')}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Resume Subscription
|
||||
</Button>
|
||||
)}
|
||||
{/* Early Renewal Button */}
|
||||
{subscription.status === 'active' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRenew}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Repeat className="h-4 w-4 mr-2" />
|
||||
Renew Early
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Pay Pending Order Button */}
|
||||
{pendingRenewalOrder && (
|
||||
<Button
|
||||
className='bg-green-600 hover:bg-green-700'
|
||||
onClick={() => navigate(`/order-pay/${pendingRenewalOrder.order_id}`)}
|
||||
>
|
||||
<CreditCard className="h-4 w-4 mr-2" />
|
||||
Pay Now (#{pendingRenewalOrder.order_id})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{subscription.can_cancel && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-500 hover:text-red-600 border-red-200 hover:border-red-300"
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Cancel Subscription
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel Subscription</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to cancel this subscription?
|
||||
You will lose access at the end of your current billing period.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep Subscription</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleAction('cancel')}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Yes, Cancel
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Orders */}
|
||||
{subscription.orders && subscription.orders.length > 0 && (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="font-semibold mb-4 flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Payment History
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{subscription.orders.map((order) => (
|
||||
<Link
|
||||
key={order.id}
|
||||
to={`/my-account/orders/${order.order_id}`}
|
||||
className="flex items-center justify-between p-3 rounded border hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">Order #{order.order_id}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-100 rounded">
|
||||
{orderTypeLabels[order.order_type] || order.order_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(order.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
customer-spa/src/pages/Account/Subscriptions.tsx
Normal file
244
customer-spa/src/pages/Account/Subscriptions.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Repeat, ChevronRight, Pause, Play, XCircle, Calendar, Package } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
|
||||
interface Subscription {
|
||||
id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_image: string;
|
||||
status: 'pending' | 'active' | 'on-hold' | 'cancelled' | 'expired' | 'pending-cancel';
|
||||
billing_schedule: string;
|
||||
recurring_amount: string;
|
||||
next_payment_date: string | null;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
can_pause: boolean;
|
||||
can_resume: boolean;
|
||||
can_cancel: boolean;
|
||||
}
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
'pending': 'bg-yellow-100 text-yellow-800',
|
||||
'active': 'bg-green-100 text-green-800',
|
||||
'on-hold': 'bg-blue-100 text-blue-800',
|
||||
'cancelled': 'bg-gray-100 text-gray-800',
|
||||
'expired': 'bg-red-100 text-red-800',
|
||||
'pending-cancel': 'bg-orange-100 text-orange-800',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
'pending': 'Pending',
|
||||
'active': 'Active',
|
||||
'on-hold': 'On Hold',
|
||||
'cancelled': 'Cancelled',
|
||||
'expired': 'Expired',
|
||||
'pending-cancel': 'Pending Cancel',
|
||||
};
|
||||
|
||||
export default function Subscriptions() {
|
||||
const queryClient = useQueryClient();
|
||||
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||
|
||||
const { data: subscriptions = [], isLoading } = useQuery<Subscription[]>({
|
||||
queryKey: ['account-subscriptions'],
|
||||
queryFn: () => api.get('/account/subscriptions'),
|
||||
});
|
||||
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: ({ id, action }: { id: number; action: string }) =>
|
||||
api.post(`/account/subscriptions/${id}/${action}`),
|
||||
onSuccess: (_, { action }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-subscriptions'] });
|
||||
const actionLabels: Record<string, string> = {
|
||||
'pause': 'paused',
|
||||
'resume': 'resumed',
|
||||
'cancel': 'cancelled',
|
||||
};
|
||||
toast.success(`Subscription ${actionLabels[action] || action} successfully`);
|
||||
setActionLoading(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Action failed');
|
||||
setActionLoading(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAction = (id: number, action: string) => {
|
||||
setActionLoading(id);
|
||||
actionMutation.mutate({ id, action });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SEOHead title="My Subscriptions" description="Manage your subscriptions" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Repeat className="h-6 w-6" />
|
||||
My Subscriptions
|
||||
</h1>
|
||||
<p className="text-gray-500">
|
||||
Manage your recurring subscriptions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{subscriptions.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border p-12 text-center">
|
||||
<Repeat className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500">You don't have any subscriptions yet.</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Purchase a subscription product to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{subscriptions.map((sub) => (
|
||||
<div key={sub.id} className="bg-white rounded-lg border overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Product Image */}
|
||||
{sub.product_image ? (
|
||||
<img
|
||||
src={sub.product_image}
|
||||
alt={sub.product_name}
|
||||
className="w-16 h-16 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-gray-100 rounded flex items-center justify-center">
|
||||
<Package className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{sub.product_name}</h3>
|
||||
<p className="text-sm text-gray-500">{sub.billing_schedule}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusStyles[sub.status] || 'bg-gray-100'}`}>
|
||||
{statusLabels[sub.status] || sub.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Amount: </span>
|
||||
<span className="font-medium">
|
||||
{formatPrice(sub.recurring_amount)}
|
||||
</span>
|
||||
</div>
|
||||
{sub.next_payment_date && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-gray-500">Next: </span>
|
||||
<span className="font-medium">
|
||||
{new Date(sub.next_payment_date).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 pt-4 border-t flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{sub.can_pause && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAction(sub.id, 'pause')}
|
||||
disabled={actionLoading === sub.id}
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-1" />
|
||||
Pause
|
||||
</Button>
|
||||
)}
|
||||
{sub.can_resume && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAction(sub.id, 'resume')}
|
||||
disabled={actionLoading === sub.id}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
)}
|
||||
{sub.can_cancel && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-600"
|
||||
disabled={actionLoading === sub.id}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel Subscription</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to cancel this subscription?
|
||||
You will lose access at the end of your current billing period.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep Subscription</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleAction(sub.id, 'cancel')}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Yes, Cancel
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/my-account/subscriptions/${sub.id}`}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||
>
|
||||
View Details
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { ReactNode, useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut, Key } from 'lucide-react';
|
||||
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut, Key, Repeat } from 'lucide-react';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { api } from '@/lib/api/client';
|
||||
import {
|
||||
@@ -50,17 +50,19 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
const allMenuItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
|
||||
{ id: 'orders', label: 'Orders', path: '/my-account/orders', icon: ShoppingBag },
|
||||
{ id: 'subscriptions', label: 'Subscriptions', path: '/my-account/subscriptions', icon: Repeat },
|
||||
{ id: 'licenses', label: 'Licenses', path: '/my-account/licenses', icon: Key },
|
||||
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
|
||||
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
||||
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
|
||||
{ id: 'licenses', label: 'Licenses', path: '/my-account/licenses', icon: Key },
|
||||
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
||||
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
|
||||
];
|
||||
|
||||
// Filter out wishlist if module disabled or settings disabled, licenses if licensing disabled
|
||||
const menuItems = allMenuItems.filter(item => {
|
||||
if (item.id === 'wishlist') return isEnabled('wishlist') && wishlistEnabled;
|
||||
if (item.id === 'licenses') return isEnabled('licensing');
|
||||
if (item.id === 'subscriptions') return isEnabled('subscription');
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ import Addresses from './Addresses';
|
||||
import Wishlist from './Wishlist';
|
||||
import AccountDetails from './AccountDetails';
|
||||
import Licenses from './Licenses';
|
||||
import LicenseConnect from './LicenseConnect';
|
||||
import Subscriptions from './Subscriptions';
|
||||
import SubscriptionDetail from './SubscriptionDetail';
|
||||
|
||||
export default function Account() {
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
@@ -17,10 +20,15 @@ export default function Account() {
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!user?.isLoggedIn) {
|
||||
const currentPath = location.pathname;
|
||||
const currentPath = location.pathname + location.search;
|
||||
return <Navigate to={`/login?redirect=${encodeURIComponent(currentPath)}`} replace />;
|
||||
}
|
||||
|
||||
// Check if this is the license-connect route (render without AccountLayout)
|
||||
if (location.pathname.includes('/license-connect')) {
|
||||
return <LicenseConnect />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<AccountLayout>
|
||||
@@ -32,6 +40,8 @@ export default function Account() {
|
||||
<Route path="addresses" element={<Addresses />} />
|
||||
<Route path="wishlist" element={<Wishlist />} />
|
||||
<Route path="licenses" element={<Licenses />} />
|
||||
<Route path="subscriptions" element={<Subscriptions />} />
|
||||
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
|
||||
<Route path="account-details" element={<AccountDetails />} />
|
||||
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
||||
</Routes>
|
||||
@@ -39,4 +49,3 @@ export default function Account() {
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
273
customer-spa/src/pages/DynamicPage/index.tsx
Normal file
273
customer-spa/src/pages/DynamicPage/index.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
// Section Components
|
||||
import { HeroSection } from './sections/HeroSection';
|
||||
import { ContentSection } from './sections/ContentSection';
|
||||
import { ImageTextSection } from './sections/ImageTextSection';
|
||||
import { FeatureGridSection } from './sections/FeatureGridSection';
|
||||
import { CTABannerSection } from './sections/CTABannerSection';
|
||||
import { ContactFormSection } from './sections/ContactFormSection';
|
||||
|
||||
// Types
|
||||
interface SectionProp {
|
||||
type: 'static' | 'dynamic';
|
||||
value?: any;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface SectionStyles {
|
||||
backgroundColor?: string;
|
||||
backgroundImage?: string;
|
||||
backgroundOverlay?: number;
|
||||
paddingTop?: string;
|
||||
paddingBottom?: string;
|
||||
contentWidth?: 'full' | 'contained';
|
||||
}
|
||||
|
||||
interface ElementStyle {
|
||||
color?: string;
|
||||
fontSize?: string;
|
||||
fontWeight?: string;
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
styles?: SectionStyles;
|
||||
elementStyles?: Record<string, ElementStyle>;
|
||||
props: Record<string, SectionProp | any>;
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
id?: number;
|
||||
type: 'page' | 'content';
|
||||
slug?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
seo?: {
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
canonical?: string;
|
||||
og_title?: string;
|
||||
og_description?: string;
|
||||
og_image?: string;
|
||||
};
|
||||
structure?: {
|
||||
sections: Section[];
|
||||
};
|
||||
post?: Record<string, any>;
|
||||
rendered?: {
|
||||
sections: Section[];
|
||||
};
|
||||
}
|
||||
|
||||
// Section renderer map
|
||||
const SECTION_COMPONENTS: Record<string, React.ComponentType<any>> = {
|
||||
hero: HeroSection,
|
||||
content: ContentSection,
|
||||
'image-text': ImageTextSection,
|
||||
'image_text': ImageTextSection,
|
||||
'feature-grid': FeatureGridSection,
|
||||
'feature_grid': FeatureGridSection,
|
||||
'cta-banner': CTABannerSection,
|
||||
'cta_banner': CTABannerSection,
|
||||
'contact-form': ContactFormSection,
|
||||
'contact_form': ContactFormSection,
|
||||
};
|
||||
|
||||
/**
|
||||
* Flatten section props by extracting .value from {type, value} objects
|
||||
* This transforms { title: { type: 'static', value: 'Hello' } }
|
||||
* into { title: 'Hello' }
|
||||
*/
|
||||
function flattenSectionProps(props: Record<string, any>): Record<string, any> {
|
||||
const flattened: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (value && typeof value === 'object' && 'type' in value && 'value' in value) {
|
||||
// This is a {type, value} prop structure
|
||||
flattened[key] = value.value;
|
||||
} else if (value && typeof value === 'object' && 'type' in value && 'source' in value) {
|
||||
// This is a dynamic prop - use source as placeholder for now
|
||||
flattened[key] = `[${value.source}]`;
|
||||
} else {
|
||||
// Regular value, pass through
|
||||
flattened[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return flattened;
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicPageRenderer
|
||||
* Renders structural pages and CPT template content
|
||||
*/
|
||||
interface DynamicPageRendererProps {
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps) {
|
||||
const { pathBase, slug: paramSlug } = useParams<{ pathBase?: string; slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
// Use prop slug if provided, otherwise use param slug
|
||||
const effectiveSlug = propSlug || paramSlug;
|
||||
|
||||
// Determine if this is a page or CPT content
|
||||
// If propSlug is provided, it's treated as a structural page (pathBase is undefined)
|
||||
const isStructuralPage = !pathBase || !!propSlug;
|
||||
const contentType = pathBase === 'blog' ? 'post' : pathBase;
|
||||
const contentSlug = effectiveSlug || '';
|
||||
|
||||
// Fetch page/content data
|
||||
const { data: pageData, isLoading, error } = useQuery<PageData>({
|
||||
queryKey: ['dynamic-page', pathBase, effectiveSlug],
|
||||
queryFn: async (): Promise<PageData> => {
|
||||
if (isStructuralPage) {
|
||||
// Fetch structural page - api.get returns JSON directly
|
||||
const response = await api.get<PageData>(`/pages/${contentSlug}`);
|
||||
return response;
|
||||
} else {
|
||||
// Fetch CPT content with template
|
||||
const response = await api.get<PageData>(`/content/${contentType}/${contentSlug}`);
|
||||
return response;
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// Handle 404
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setNotFound(true);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Get sections to render
|
||||
const sections = isStructuralPage
|
||||
? pageData?.structure?.sections || []
|
||||
: pageData?.rendered?.sections || [];
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 404 state
|
||||
if (notFound || !pageData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">404</h1>
|
||||
<p className="text-gray-600 mb-8">Page not found</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
|
||||
>
|
||||
Go Home
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* SEO */}
|
||||
<Helmet>
|
||||
<title>{pageData.seo?.meta_title || pageData.title || 'Page'}</title>
|
||||
{pageData.seo?.meta_description && (
|
||||
<meta name="description" content={pageData.seo.meta_description} />
|
||||
)}
|
||||
{pageData.seo?.canonical && (
|
||||
<link rel="canonical" href={pageData.seo.canonical} />
|
||||
)}
|
||||
{pageData.seo?.og_title && (
|
||||
<meta property="og:title" content={pageData.seo.og_title} />
|
||||
)}
|
||||
{pageData.seo?.og_description && (
|
||||
<meta property="og:description" content={pageData.seo.og_description} />
|
||||
)}
|
||||
{pageData.seo?.og_image && (
|
||||
<meta property="og:image" content={pageData.seo.og_image} />
|
||||
)}
|
||||
</Helmet>
|
||||
|
||||
{/* Render sections */}
|
||||
<div className="wn-page">
|
||||
{sections.map((section) => {
|
||||
const SectionComponent = SECTION_COMPONENTS[section.type];
|
||||
|
||||
if (!SectionComponent) {
|
||||
// Fallback for unknown section types
|
||||
return (
|
||||
<div key={section.id} className={`wn-section wn-${section.type}`}>
|
||||
<pre className="text-xs text-gray-500">
|
||||
Unknown section type: {section.type}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={section.id}
|
||||
className={`relative overflow-hidden ${!section.styles?.backgroundColor ? '' : ''}`}
|
||||
style={{
|
||||
backgroundColor: section.styles?.backgroundColor,
|
||||
paddingTop: section.styles?.paddingTop,
|
||||
paddingBottom: section.styles?.paddingBottom,
|
||||
}}
|
||||
>
|
||||
{/* Background Image & Overlay */}
|
||||
{section.styles?.backgroundImage && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${section.styles.backgroundImage})` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-black"
|
||||
style={{ opacity: (section.styles.backgroundOverlay || 0) / 100 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content Wrapper */}
|
||||
<div className={`relative z-10 ${section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'}`}>
|
||||
<SectionComponent
|
||||
id={section.id}
|
||||
section={section} // Pass full section object for components that need raw data
|
||||
layout={section.layoutVariant || 'default'}
|
||||
colorScheme={section.colorScheme || 'default'}
|
||||
styles={section.styles}
|
||||
elementStyles={section.elementStyles}
|
||||
{...flattenSectionProps(section.props || {})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty state */}
|
||||
{sections.length === 0 && (
|
||||
<div className="py-20 text-center text-gray-500">
|
||||
<p>This page has no content yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
125
customer-spa/src/pages/DynamicPage/sections/CTABannerSection.tsx
Normal file
125
customer-spa/src/pages/DynamicPage/sections/CTABannerSection.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CTABannerSectionProps {
|
||||
id: string;
|
||||
layout?: string;
|
||||
colorScheme?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
button_text?: string;
|
||||
button_url?: string;
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function CTABannerSection({
|
||||
id,
|
||||
layout = 'default',
|
||||
colorScheme = 'primary',
|
||||
title,
|
||||
text,
|
||||
button_text,
|
||||
button_url,
|
||||
elementStyles,
|
||||
styles,
|
||||
}: CTABannerSectionProps & { styles?: Record<string, any> }) {
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
borderColor: styles.borderColor,
|
||||
borderWidth: styles.borderWidth,
|
||||
borderRadius: styles.borderRadius,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const textStyle = getTextStyles('text');
|
||||
const btnStyle = getTextStyles('button_text');
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-cta-banner',
|
||||
`wn-cta-banner--${layout}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
'py-12 md:py-20',
|
||||
{
|
||||
'bg-primary text-primary-foreground': colorScheme === 'primary',
|
||||
'bg-secondary text-secondary-foreground': colorScheme === 'secondary',
|
||||
'bg-gradient-to-r from-primary to-secondary text-white': colorScheme === 'gradient',
|
||||
'bg-muted': colorScheme === 'muted',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"mx-auto px-4 text-center",
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||
)}>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"wn-cta__title mb-6",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{text && (
|
||||
<p className={cn(
|
||||
'wn-cta-banner__text mb-8 max-w-2xl mx-auto',
|
||||
!elementStyles?.text?.fontSize && "text-lg md:text-xl",
|
||||
{
|
||||
'text-white/90': colorScheme === 'primary' || colorScheme === 'gradient',
|
||||
'text-gray-600': colorScheme === 'muted',
|
||||
},
|
||||
textStyle.classNames
|
||||
)}
|
||||
style={textStyle.style}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{button_text && button_url && (
|
||||
<a
|
||||
href={button_url}
|
||||
className={cn(
|
||||
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:opacity-90',
|
||||
!btnStyle.style?.backgroundColor && {
|
||||
'bg-white': colorScheme === 'primary' || colorScheme === 'gradient',
|
||||
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
|
||||
},
|
||||
!btnStyle.style?.color && {
|
||||
'text-primary': colorScheme === 'primary' || colorScheme === 'gradient',
|
||||
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
|
||||
},
|
||||
btnStyle.classNames
|
||||
)}
|
||||
style={btnStyle.style}
|
||||
>
|
||||
{button_text}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ContactFormSectionProps {
|
||||
id: string;
|
||||
layout?: string;
|
||||
colorScheme?: string;
|
||||
title?: string;
|
||||
webhook_url?: string;
|
||||
redirect_url?: string;
|
||||
fields?: string[];
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function ContactFormSection({
|
||||
id,
|
||||
layout = 'default',
|
||||
colorScheme = 'default',
|
||||
title,
|
||||
webhook_url,
|
||||
redirect_url,
|
||||
fields = ['name', 'email', 'message'],
|
||||
elementStyles,
|
||||
styles,
|
||||
}: ContactFormSectionProps & { styles?: Record<string, any> }) {
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
borderColor: styles.borderColor,
|
||||
borderWidth: styles.borderWidth,
|
||||
borderRadius: styles.borderRadius,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const buttonStyle = getTextStyles('button');
|
||||
const fieldsStyle = getTextStyles('fields');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Submit to webhook if provided
|
||||
if (webhook_url) {
|
||||
await fetch(webhook_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect after submission
|
||||
if (redirect_url) {
|
||||
// Replace placeholders in redirect URL
|
||||
let finalUrl = redirect_url;
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
finalUrl = finalUrl.replace(`{${key}}`, encodeURIComponent(value));
|
||||
});
|
||||
window.location.href = finalUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to submit form. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-contact-form',
|
||||
`wn-scheme--${colorScheme}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
'py-12 md:py-20',
|
||||
{
|
||||
// 'bg-white': colorScheme === 'default', // Removed for global styling
|
||||
'bg-muted': colorScheme === 'muted',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"mx-auto px-4",
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||
)}>
|
||||
<div className={cn(
|
||||
'max-w-xl mx-auto',
|
||||
{
|
||||
'max-w-2xl': layout === 'wide',
|
||||
}
|
||||
)}>
|
||||
{title && (
|
||||
<h2 className={cn(
|
||||
"wn-contact__title text-center mb-12",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{fields.map((field) => {
|
||||
const fieldLabel = field.charAt(0).toUpperCase() + field.slice(1).replace('_', ' ');
|
||||
const isTextarea = field === 'message' || field === 'content';
|
||||
|
||||
return (
|
||||
<div key={field} className="wn-contact-form__field">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{fieldLabel}
|
||||
</label>
|
||||
{isTextarea ? (
|
||||
<textarea
|
||||
name={field}
|
||||
value={formData[field] || ''}
|
||||
onChange={handleChange}
|
||||
rows={5}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
fieldsStyle.classNames
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.style?.backgroundColor,
|
||||
color: fieldsStyle.style?.color,
|
||||
borderColor: fieldsStyle.style?.borderColor,
|
||||
borderRadius: fieldsStyle.style?.borderRadius,
|
||||
}}
|
||||
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={field === 'email' ? 'email' : 'text'}
|
||||
name={field}
|
||||
value={formData[field] || ''}
|
||||
onChange={handleChange}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
fieldsStyle.classNames
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.style?.backgroundColor,
|
||||
color: fieldsStyle.style?.color,
|
||||
borderColor: fieldsStyle.style?.borderColor,
|
||||
borderRadius: fieldsStyle.style?.borderRadius,
|
||||
}}
|
||||
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className={cn(
|
||||
'w-full py-3 px-6 rounded-lg font-semibold',
|
||||
'hover:opacity-90 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
!buttonStyle.style?.backgroundColor && 'bg-primary',
|
||||
!buttonStyle.style?.color && 'text-primary-foreground',
|
||||
buttonStyle.classNames
|
||||
)}
|
||||
style={buttonStyle.style}
|
||||
>
|
||||
{submitting ? 'Sending...' : 'Submit'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
259
customer-spa/src/pages/DynamicPage/sections/ContentSection.tsx
Normal file
259
customer-spa/src/pages/DynamicPage/sections/ContentSection.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
||||
|
||||
interface ContentSectionProps {
|
||||
section: {
|
||||
id: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props?: {
|
||||
content?: { value: string };
|
||||
cta_text?: { value: string };
|
||||
cta_url?: { value: string };
|
||||
};
|
||||
elementStyles?: Record<string, any>;
|
||||
styles?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
default: { bg: 'bg-white', text: 'text-gray-900' },
|
||||
light: { bg: 'bg-gray-50', text: 'text-gray-900' },
|
||||
dark: { bg: 'bg-gray-900', text: 'text-white' },
|
||||
blue: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
|
||||
};
|
||||
|
||||
const WIDTH_CLASSES: Record<string, string> = {
|
||||
default: 'max-w-screen-xl mx-auto',
|
||||
contained: 'max-w-screen-md mx-auto',
|
||||
full: 'w-full',
|
||||
};
|
||||
|
||||
const fontSizeToCSS = (className?: string) => {
|
||||
switch (className) {
|
||||
case 'text-xs': return '0.75rem';
|
||||
case 'text-sm': return '0.875rem';
|
||||
case 'text-base': return '1rem';
|
||||
case 'text-lg': return '1.125rem';
|
||||
case 'text-xl': return '1.25rem';
|
||||
case 'text-2xl': return '1.5rem';
|
||||
case 'text-3xl': return '1.875rem';
|
||||
case 'text-4xl': return '2.25rem';
|
||||
case 'text-5xl': return '3rem';
|
||||
case 'text-6xl': return '3.75rem';
|
||||
case 'text-7xl': return '4.5rem';
|
||||
case 'text-8xl': return '6rem';
|
||||
case 'text-9xl': return '8rem';
|
||||
default: return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const fontWeightToCSS = (className?: string) => {
|
||||
switch (className) {
|
||||
case 'font-thin': return '100';
|
||||
case 'font-extralight': return '200';
|
||||
case 'font-light': return '300';
|
||||
case 'font-normal': return '400';
|
||||
case 'font-medium': return '500';
|
||||
case 'font-semibold': return '600';
|
||||
case 'font-bold': return '700';
|
||||
case 'font-extrabold': return '800';
|
||||
case 'font-black': return '900';
|
||||
default: return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to generate scoped CSS for prose elements
|
||||
const generateScopedStyles = (sectionId: string, elementStyles: Record<string, any>) => {
|
||||
const styles: string[] = [];
|
||||
const scope = `#${sectionId}`; // ContentSection uses id directly on section tag
|
||||
|
||||
// Headings (h1-h4)
|
||||
const hs = elementStyles?.heading;
|
||||
if (hs) {
|
||||
const headingRules = [
|
||||
hs.color && `color: ${hs.color} !important;`,
|
||||
hs.fontWeight && `font-weight: ${fontWeightToCSS(hs.fontWeight)} !important;`,
|
||||
hs.fontFamily && `font-family: var(--font-${hs.fontFamily}, inherit) !important;`,
|
||||
hs.fontSize && `font-size: ${fontSizeToCSS(hs.fontSize)} !important;`,
|
||||
hs.backgroundColor && `background-color: ${hs.backgroundColor} !important;`,
|
||||
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px; display: inline-block;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (headingRules) {
|
||||
styles.push(`${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { ${headingRules} }`);
|
||||
}
|
||||
}
|
||||
|
||||
// Body text (p, li)
|
||||
const ts = elementStyles?.text;
|
||||
if (ts) {
|
||||
const textRules = [
|
||||
ts.color && `color: ${ts.color} !important;`,
|
||||
ts.fontSize && `font-size: ${fontSizeToCSS(ts.fontSize)} !important;`,
|
||||
ts.fontWeight && `font-weight: ${fontWeightToCSS(ts.fontWeight)} !important;`,
|
||||
ts.fontFamily && `font-family: var(--font-${ts.fontFamily}, inherit) !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (textRules) {
|
||||
styles.push(`${scope} p, ${scope} li, ${scope} ul, ${scope} ol { ${textRules} }`);
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit Spacing & List Formatting Restorations
|
||||
styles.push(`
|
||||
${scope} p { margin-bottom: 1em; }
|
||||
${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { margin-top: 1.5em; margin-bottom: 0.5em; line-height: 1.2; }
|
||||
${scope} ul { list-style-type: disc; padding-left: 1.5em; margin-bottom: 1em; }
|
||||
${scope} ol { list-style-type: decimal; padding-left: 1.5em; margin-bottom: 1em; }
|
||||
${scope} li { margin-bottom: 0.25em; }
|
||||
${scope} img { margin-top: 1.5em; margin-bottom: 1.5em; }
|
||||
`);
|
||||
|
||||
// Links (a:not(.button))
|
||||
const ls = elementStyles?.link;
|
||||
if (ls) {
|
||||
const linkRules = [
|
||||
ls.color && `color: ${ls.color} !important;`,
|
||||
ls.textDecoration && `text-decoration: ${ls.textDecoration} !important;`,
|
||||
ls.fontSize && `font-size: ${fontSizeToCSS(ls.fontSize)} !important;`,
|
||||
ls.fontWeight && `font-weight: ${fontWeightToCSS(ls.fontWeight)} !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (linkRules) {
|
||||
styles.push(`${scope} a:not([data-button]):not(.button) { ${linkRules} }`);
|
||||
}
|
||||
if (ls.hoverColor) {
|
||||
styles.push(`${scope} a:not([data-button]):not(.button):hover { color: ${ls.hoverColor} !important; }`);
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons (a[data-button], .button)
|
||||
const bs = elementStyles?.button;
|
||||
if (bs) {
|
||||
const btnRules = [
|
||||
bs.backgroundColor && `background-color: ${bs.backgroundColor} !important;`,
|
||||
bs.color && `color: ${bs.color} !important;`,
|
||||
bs.borderRadius && `border-radius: ${bs.borderRadius} !important;`,
|
||||
bs.padding && `padding: ${bs.padding} !important;`,
|
||||
bs.fontSize && `font-size: ${fontSizeToCSS(bs.fontSize)} !important;`,
|
||||
bs.fontWeight && `font-weight: ${fontWeightToCSS(bs.fontWeight)} !important;`,
|
||||
bs.borderColor && `border: ${bs.borderWidth || '1px'} solid ${bs.borderColor} !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
styles.push(`${scope} a[data-button], ${scope} .button { ${btnRules} display: inline-block; text-decoration: none !important; }`);
|
||||
styles.push(`${scope} a[data-button]:hover, ${scope} .button:hover { opacity: 0.9; }`);
|
||||
}
|
||||
|
||||
// Images
|
||||
const is = elementStyles?.image;
|
||||
if (is) {
|
||||
const imgRules = [
|
||||
is.objectFit && `object-fit: ${is.objectFit} !important;`,
|
||||
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
|
||||
is.width && `width: ${is.width} !important;`,
|
||||
is.height && `height: ${is.height} !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (imgRules) {
|
||||
styles.push(`${scope} img { ${imgRules} }`);
|
||||
}
|
||||
}
|
||||
|
||||
return styles.join('\n');
|
||||
};
|
||||
|
||||
export function ContentSection({ section }: ContentSectionProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
// Default to 'default' width if not specified
|
||||
const layout = section.layoutVariant || 'default';
|
||||
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
|
||||
|
||||
const heightPreset = section.styles?.heightPreset || 'default';
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-32',
|
||||
'screen': 'min-h-screen py-20 flex items-center',
|
||||
};
|
||||
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
|
||||
|
||||
const content = section.props?.content?.value || '';
|
||||
|
||||
// Helper to get text styles
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const contentStyle = getTextStyles('content');
|
||||
const headingStyle = getTextStyles('heading');
|
||||
const textStyle = getTextStyles('text');
|
||||
const buttonStyle = getTextStyles('button');
|
||||
|
||||
const containerWidth = section.styles?.contentWidth || 'contained';
|
||||
const cta_text = section.props?.cta_text?.value;
|
||||
const cta_url = section.props?.cta_url?.value;
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = () => {
|
||||
if (scheme.bg === 'wn-gradient-bg') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (scheme.bg === 'wn-secondary-bg') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
|
||||
<section
|
||||
id={section.id}
|
||||
className={cn(
|
||||
'wn-content',
|
||||
'px-4 md:px-8',
|
||||
heightClasses,
|
||||
!scheme.bg.startsWith('wn-') && scheme.bg,
|
||||
scheme.text
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<SharedContentLayout
|
||||
text={content}
|
||||
textStyle={textStyle.style}
|
||||
headingStyle={headingStyle.style}
|
||||
containerWidth={containerWidth as any}
|
||||
className={contentStyle.classNames}
|
||||
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
|
||||
buttonStyle={{
|
||||
classNames: buttonStyle.classNames,
|
||||
style: buttonStyle.style
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
|
||||
interface FeatureItem {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface FeatureGridSectionProps {
|
||||
id: string;
|
||||
layout?: string;
|
||||
colorScheme?: string;
|
||||
heading?: string;
|
||||
items?: FeatureItem[];
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function FeatureGridSection({
|
||||
id,
|
||||
layout = 'grid-3',
|
||||
colorScheme = 'default',
|
||||
heading,
|
||||
items = [],
|
||||
features = [],
|
||||
elementStyles,
|
||||
styles,
|
||||
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any> }) {
|
||||
// Use items or features (priority to items if both exist, but usually only one comes from props)
|
||||
const listItems = items.length > 0 ? items : features;
|
||||
const gridCols = {
|
||||
'grid-2': 'md:grid-cols-2',
|
||||
'grid-3': 'md:grid-cols-3',
|
||||
'grid-4': 'md:grid-cols-2 lg:grid-cols-4',
|
||||
}[layout] || 'md:grid-cols-3';
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
borderColor: styles.borderColor,
|
||||
borderWidth: styles.borderWidth,
|
||||
borderRadius: styles.borderRadius,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const headingStyle = getTextStyles('heading');
|
||||
const featureItemStyle = getTextStyles('feature_item');
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-feature-grid',
|
||||
`wn-feature-grid--${layout}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
'py-12 md:py-24',
|
||||
{
|
||||
// 'bg-white': colorScheme === 'default', // Removed for global styling
|
||||
'bg-muted': colorScheme === 'muted',
|
||||
'bg-primary text-primary-foreground': colorScheme === 'primary',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"mx-auto px-4",
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||
)}>
|
||||
{heading && (
|
||||
<h2
|
||||
className={cn(
|
||||
"wn-features__heading text-center mb-12",
|
||||
!elementStyles?.heading?.fontSize && "text-3xl md:text-4xl",
|
||||
!elementStyles?.heading?.fontWeight && "font-bold",
|
||||
headingStyle.classNames
|
||||
)}
|
||||
style={headingStyle.style}
|
||||
>
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<div className={cn('grid gap-8', gridCols)}>
|
||||
{listItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'wn-feature-grid__item',
|
||||
'p-6 rounded-xl',
|
||||
!featureItemStyle.style?.backgroundColor && {
|
||||
'bg-white shadow-lg': colorScheme !== 'primary',
|
||||
'bg-white/10': colorScheme === 'primary',
|
||||
},
|
||||
featureItemStyle.classNames
|
||||
)}
|
||||
style={featureItemStyle.style}
|
||||
>
|
||||
{item.icon && (() => {
|
||||
const IconComponent = (LucideIcons as any)[item.icon];
|
||||
if (!IconComponent) return null;
|
||||
return (
|
||||
<div className="wn-feature-grid__icon mb-4 inline-block p-3 rounded-full bg-primary/10 text-primary">
|
||||
<IconComponent className="w-8 h-8" />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{item.title && (
|
||||
<h3
|
||||
className={cn(
|
||||
"wn-feature-grid__item-title mb-3",
|
||||
!featureItemStyle.classNames && "text-xl font-semibold"
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{item.description && (
|
||||
<p className={cn(
|
||||
'wn-feature-grid__item-desc',
|
||||
!featureItemStyle.style?.color && {
|
||||
'text-gray-600': colorScheme !== 'primary',
|
||||
'text-white/80': colorScheme === 'primary',
|
||||
}
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
239
customer-spa/src/pages/DynamicPage/sections/HeroSection.tsx
Normal file
239
customer-spa/src/pages/DynamicPage/sections/HeroSection.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface HeroSectionProps {
|
||||
id: string;
|
||||
layout?: string;
|
||||
colorScheme?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
image?: string;
|
||||
cta_text?: string;
|
||||
cta_url?: string;
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function HeroSection({
|
||||
id,
|
||||
layout = 'default',
|
||||
colorScheme = 'default',
|
||||
title,
|
||||
subtitle,
|
||||
image,
|
||||
cta_text,
|
||||
cta_url,
|
||||
elementStyles,
|
||||
styles,
|
||||
}: HeroSectionProps & { styles?: Record<string, any> }) {
|
||||
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
|
||||
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
|
||||
const isCentered = layout === 'centered' || layout === 'default';
|
||||
|
||||
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage;
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
borderColor: styles.borderColor,
|
||||
borderWidth: styles.borderWidth,
|
||||
borderRadius: styles.borderRadius,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const subtitleStyle = getTextStyles('subtitle');
|
||||
const ctaStyle = getTextStyles('cta_text');
|
||||
|
||||
const imageStyle = elementStyles?.['image'] || {};
|
||||
|
||||
// Determine height classes
|
||||
const heightPreset = styles?.heightPreset || 'default';
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-24', // Original Default
|
||||
'small': 'py-8 md:py-16',
|
||||
'medium': 'py-16 md:py-32',
|
||||
'large': 'py-24 md:py-48',
|
||||
'screen': 'min-h-screen py-20 flex items-center',
|
||||
};
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = () => {
|
||||
if (hasCustomBackground) return undefined;
|
||||
if (colorScheme === 'gradient') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (colorScheme === 'secondary') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isDynamicScheme = ['primary', 'secondary', 'gradient'].includes(colorScheme) && !hasCustomBackground;
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-hero',
|
||||
`wn-hero--${layout}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
'relative overflow-hidden',
|
||||
isDynamicScheme && 'text-white',
|
||||
colorScheme === 'muted' && !hasCustomBackground && 'bg-muted',
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
'mx-auto px-4',
|
||||
heightClasses,
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container',
|
||||
{
|
||||
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
|
||||
'text-center': isCentered,
|
||||
}
|
||||
)}>
|
||||
{/* Image - Left */}
|
||||
{image && isImageLeft && (
|
||||
<div className="w-full md:w-1/2">
|
||||
<div
|
||||
className="rounded-lg shadow-xl overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: imageStyle.backgroundColor,
|
||||
width: imageStyle.width || 'auto',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Hero image'}
|
||||
className="w-full h-auto block"
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
height: imageStyle.height,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn(
|
||||
'wn-hero__content',
|
||||
{
|
||||
'w-full md:w-1/2': isImageLeft || isImageRight,
|
||||
'max-w-3xl mx-auto': isCentered,
|
||||
}
|
||||
)}>
|
||||
{title && (
|
||||
<h1
|
||||
className={cn(
|
||||
"wn-hero__title mb-6 leading-tight",
|
||||
!elementStyles?.title?.fontSize && "text-4xl md:text-5xl lg:text-6xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{subtitle && (
|
||||
<p
|
||||
className={cn(
|
||||
"wn-hero__subtitle text-opacity-80 mb-8",
|
||||
!elementStyles?.subtitle?.fontSize && "text-lg md:text-xl",
|
||||
subtitleStyle.classNames
|
||||
)}
|
||||
style={subtitleStyle.style}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Centered Image */}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-12 mx-auto rounded-lg shadow-xl overflow-hidden",
|
||||
imageStyle.width ? "" : "max-w-4xl"
|
||||
)}
|
||||
style={{ backgroundColor: imageStyle.backgroundColor, width: imageStyle.width || 'auto', maxWidth: '100%' }}
|
||||
>
|
||||
{image && isCentered && (
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Hero image'}
|
||||
className={cn(
|
||||
"w-full rounded-[inherit]",
|
||||
!imageStyle.height && "h-auto",
|
||||
!imageStyle.objectFit && "object-cover"
|
||||
)}
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
height: imageStyle.height,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cta_text && cta_url && (
|
||||
<a
|
||||
href={cta_url}
|
||||
className={cn(
|
||||
"wn-hero__cta inline-block px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors mt-8",
|
||||
!ctaStyle.style?.backgroundColor && "bg-primary",
|
||||
!ctaStyle.style?.color && "text-primary-foreground",
|
||||
ctaStyle.classNames
|
||||
)}
|
||||
style={ctaStyle.style}
|
||||
>
|
||||
{cta_text}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image - Right */}
|
||||
{image && isImageRight && (
|
||||
<div className="w-full md:w-1/2">
|
||||
<div
|
||||
className="rounded-lg shadow-xl overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: imageStyle.backgroundColor,
|
||||
width: imageStyle.width || 'auto',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Hero image'}
|
||||
className="w-full h-auto block"
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
height: imageStyle.height,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
104
customer-spa/src/pages/DynamicPage/sections/ImageTextSection.tsx
Normal file
104
customer-spa/src/pages/DynamicPage/sections/ImageTextSection.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
||||
|
||||
interface ImageTextSectionProps {
|
||||
id: string;
|
||||
layout?: string;
|
||||
colorScheme?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
image?: string;
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function ImageTextSection({
|
||||
id,
|
||||
layout = 'image-left',
|
||||
colorScheme = 'default',
|
||||
title,
|
||||
text,
|
||||
image,
|
||||
cta_text,
|
||||
cta_url,
|
||||
elementStyles,
|
||||
styles,
|
||||
}: ImageTextSectionProps & { styles?: Record<string, any>, cta_text?: string, cta_url?: string }) {
|
||||
const isImageLeft = layout === 'image-left' || layout === 'left';
|
||||
const isImageRight = layout === 'image-right' || layout === 'right';
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
borderColor: styles.borderColor,
|
||||
borderWidth: styles.borderWidth,
|
||||
borderRadius: styles.borderRadius,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const textStyle = getTextStyles('text');
|
||||
const buttonStyle = getTextStyles('button');
|
||||
|
||||
const imageStyle = elementStyles?.['image'] || {};
|
||||
|
||||
// Height preset support
|
||||
const heightPreset = styles?.heightPreset || 'default';
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-24',
|
||||
'small': 'py-8 md:py-16',
|
||||
'medium': 'py-16 md:py-32',
|
||||
'large': 'py-24 md:py-48',
|
||||
'screen': 'min-h-screen py-20 flex items-center',
|
||||
};
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-image-text',
|
||||
`wn-scheme--${colorScheme}`,
|
||||
heightClasses,
|
||||
{
|
||||
'bg-muted': colorScheme === 'muted',
|
||||
'bg-primary/5': colorScheme === 'primary',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<SharedContentLayout
|
||||
title={title}
|
||||
text={text}
|
||||
image={image}
|
||||
imagePosition={isImageRight ? 'right' : 'left'}
|
||||
containerWidth={styles?.contentWidth === 'full' ? 'full' : 'contained'}
|
||||
titleStyle={titleStyle.style}
|
||||
titleClassName={titleStyle.classNames}
|
||||
textStyle={textStyle.style}
|
||||
textClassName={textStyle.classNames}
|
||||
imageStyle={{
|
||||
backgroundColor: imageStyle.backgroundColor,
|
||||
objectFit: imageStyle.objectFit,
|
||||
}}
|
||||
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
|
||||
buttonStyle={{
|
||||
classNames: buttonStyle.classNames,
|
||||
style: buttonStyle.style
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
254
customer-spa/src/pages/OrderPay/index.tsx
Normal file
254
customer-spa/src/pages/OrderPay/index.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { toast } from 'sonner';
|
||||
import SubscriptionTimeline from '../../components/SubscriptionTimeline';
|
||||
|
||||
// Define types based on CheckoutController response
|
||||
// Define types based on CheckoutController response
|
||||
interface BaseResponse {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OrderDetailsResponse extends BaseResponse {
|
||||
id: number;
|
||||
number: string;
|
||||
status: string;
|
||||
created_via: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
currency_symbol: string;
|
||||
items: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
qty: number;
|
||||
total: number;
|
||||
image?: string;
|
||||
}>;
|
||||
available_gateways: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
}>;
|
||||
subscription?: {
|
||||
id: number;
|
||||
status: string;
|
||||
billing_period: string;
|
||||
billing_interval: number;
|
||||
start_date: string;
|
||||
next_payment_date: string | null;
|
||||
end_date: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface PaymentResponse extends BaseResponse {
|
||||
redirect?: string;
|
||||
messages?: string;
|
||||
}
|
||||
|
||||
const OrderPay: React.FC = () => {
|
||||
const { orderId } = useParams<{ orderId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const orderKey = searchParams.get('key');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [order, setOrder] = useState<OrderDetailsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedGateway, setSelectedGateway] = useState<string>('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (orderId) {
|
||||
fetchOrder();
|
||||
}
|
||||
}, [orderId]);
|
||||
|
||||
const fetchOrder = async () => {
|
||||
try {
|
||||
const endpoint = `/checkout/order/${orderId}${orderKey ? `?key=${orderKey}` : ''}`;
|
||||
const response = await api.get<OrderDetailsResponse>(endpoint);
|
||||
|
||||
if (response.error) {
|
||||
toast.error(response.error);
|
||||
return;
|
||||
}
|
||||
if (response.ok) {
|
||||
setOrder(response as OrderDetailsResponse);
|
||||
if (response.available_gateways?.length > 0) {
|
||||
setSelectedGateway(response.available_gateways[0].id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch order', error);
|
||||
toast.error('Failed to load order details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePayment = async () => {
|
||||
if (!selectedGateway) {
|
||||
toast.error('Please select a payment method');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await api.post<PaymentResponse>(`/checkout/pay-order/${orderId}`, {
|
||||
payment_method: selectedGateway,
|
||||
key: orderKey
|
||||
});
|
||||
|
||||
if (response.ok && response.redirect) {
|
||||
window.location.href = response.redirect;
|
||||
} else {
|
||||
toast.error(response.error || 'Payment failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payment error', error);
|
||||
toast.error('An error occurred while processing payment');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to format price with proper currency locale
|
||||
const formatPrice = (amount: number, currency: string) => {
|
||||
try {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
} catch (e) {
|
||||
// Fallback
|
||||
return `${currency} ${amount.toFixed(0)}`;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-center">Loading order details...</div>;
|
||||
if (!order) return <div className="p-8 text-center text-red-500">Order not found</div>;
|
||||
|
||||
const isRenewal = order.created_via === 'subscription_renewal';
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Complete Payment</h1>
|
||||
|
||||
{order.subscription && (
|
||||
<SubscriptionTimeline subscription={order.subscription} />
|
||||
)}
|
||||
|
||||
{isRenewal && !order.subscription && (
|
||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-blue-700">
|
||||
This is a payment for your <span className="font-bold">subscription renewal</span>.
|
||||
Completing this payment will extend your subscription period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Order Summary <span className="text-gray-400 font-normal">#{order.number}</span></h2>
|
||||
<div className="space-y-4">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.id} className="flex justify-between items-center border-b pb-4 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-4">
|
||||
{item.image ? (
|
||||
<img src={item.image} alt={item.name} className="w-16 h-16 object-cover rounded bg-gray-100" />
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-gray-100 rounded flex items-center justify-center text-gray-400">
|
||||
<span className="text-xs">No img</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-lg">{item.name}</div>
|
||||
<div className="text-sm text-gray-500">Qty: {item.qty}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-medium text-lg">
|
||||
{formatPrice(item.total, order.currency)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<div className="flex justify-between items-center text-gray-600 mb-2">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(order.total, order.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center font-bold text-xl mt-4">
|
||||
<span>Total to Pay</span>
|
||||
<span className="text-primary">{formatPrice(order.total, order.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Select Payment Method</h2>
|
||||
|
||||
{order.available_gateways.length === 0 ? (
|
||||
<div className="p-4 bg-yellow-50 text-yellow-700 rounded-md border border-yellow-200">
|
||||
No payment methods are available for this order. Please contact support.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 mb-6">
|
||||
{order.available_gateways.map((gateway) => (
|
||||
<label
|
||||
key={gateway.id}
|
||||
className={`flex items-start p-4 border rounded-lg cursor-pointer transition-all ${selectedGateway === gateway.id
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment_method"
|
||||
value={gateway.id}
|
||||
checked={selectedGateway === gateway.id}
|
||||
onChange={(e) => setSelectedGateway(e.target.value)}
|
||||
className="h-4 w-4 text-primary border-gray-300 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<span className="block font-medium text-gray-900 text-base">
|
||||
{gateway.title || gateway.id}
|
||||
</span>
|
||||
{gateway.description && (
|
||||
<span className="block text-gray-500 mt-1">
|
||||
{gateway.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handlePayment}
|
||||
disabled={processing || !selectedGateway}
|
||||
className="w-full bg-primary text-white py-4 px-6 rounded-lg font-bold text-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||
>
|
||||
{processing ? 'Processing Payment...' : `Pay ${formatPrice(order.total, order.currency)}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderPay;
|
||||
@@ -98,25 +98,47 @@ export default function Product() {
|
||||
if (!v.attributes) return false;
|
||||
|
||||
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
|
||||
const normalizedValue = attrValue.toLowerCase().trim();
|
||||
const normalizedSelectedValue = attrValue.toLowerCase().trim();
|
||||
const attrNameLower = attrName.toLowerCase();
|
||||
|
||||
// Check all attribute keys in variation (case-insensitive)
|
||||
for (const [vKey, vValue] of Object.entries(v.attributes)) {
|
||||
const vKeyLower = vKey.toLowerCase();
|
||||
const attrNameLower = attrName.toLowerCase();
|
||||
// Find the attribute definition to get the slug
|
||||
const attrDef = product.attributes?.find((a: any) => a.name === attrName);
|
||||
const attrSlug = attrDef?.slug || attrNameLower.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
|
||||
if (vKeyLower === `attribute_${attrNameLower}` ||
|
||||
vKeyLower === `attribute_pa_${attrNameLower}` ||
|
||||
vKeyLower === attrNameLower) {
|
||||
// Try to find a matching key in the variation attributes
|
||||
let variationValue: string | undefined = undefined;
|
||||
|
||||
const varValueNormalized = String(vValue).toLowerCase().trim();
|
||||
if (varValueNormalized === normalizedValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check for common WooCommerce attribute key formats
|
||||
// 1. Check strict slug format (attribute_7-days-...)
|
||||
if (`attribute_${attrSlug}` in v.attributes) {
|
||||
variationValue = v.attributes[`attribute_${attrSlug}`];
|
||||
}
|
||||
// 2. Check pa_ format (attribute_pa_color)
|
||||
else if (`attribute_pa_${attrSlug}` in v.attributes) {
|
||||
variationValue = v.attributes[`attribute_pa_${attrSlug}`];
|
||||
}
|
||||
// 3. Fallback to name-based checks (legacy)
|
||||
else if (`attribute_${attrNameLower}` in v.attributes) {
|
||||
variationValue = v.attributes[`attribute_${attrNameLower}`];
|
||||
} else if (`attribute_pa_${attrNameLower}` in v.attributes) {
|
||||
variationValue = v.attributes[`attribute_pa_${attrNameLower}`];
|
||||
} else if (attrNameLower in v.attributes) {
|
||||
variationValue = v.attributes[attrNameLower];
|
||||
}
|
||||
|
||||
return false;
|
||||
// If key is undefined/missing in variation, it means "Any" -> Match
|
||||
if (variationValue === undefined || variationValue === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If empty string, it also means "Any" -> Match
|
||||
const normalizedVarValue = String(variationValue).toLowerCase().trim();
|
||||
if (normalizedVarValue === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise, values must match
|
||||
return normalizedVarValue === normalizedSelectedValue;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,11 +203,36 @@ export default function Product() {
|
||||
}
|
||||
}
|
||||
|
||||
// Construct variation params using keys from the matched variation
|
||||
// but filling in values from user selection (handles "Any" variations with empty values)
|
||||
let variation_params: Record<string, string> = {};
|
||||
if (product.type === 'variable' && selectedVariation?.attributes) {
|
||||
// Get keys from the variation's attributes (these are the correct WooCommerce keys)
|
||||
Object.keys(selectedVariation.attributes).forEach(key => {
|
||||
// Key format is like "attribute_7-days-auto-closing-variation-plan"
|
||||
// Extract the slug part after "attribute_"
|
||||
const slug = key.replace(/^attribute_/, '');
|
||||
|
||||
// Find the matching user-selected value by attribute name
|
||||
const attrDef = product.attributes?.find((a: any) =>
|
||||
a.slug === slug || a.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') === slug
|
||||
);
|
||||
|
||||
if (attrDef && selectedAttributes[attrDef.name]) {
|
||||
variation_params[key] = selectedAttributes[attrDef.name];
|
||||
} else {
|
||||
// Fallback to stored value if no user selection
|
||||
variation_params[key] = selectedVariation.attributes[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.post(apiClient.endpoints.cart.add, {
|
||||
product_id: product.id,
|
||||
quantity,
|
||||
variation_id: selectedVariation?.id || 0,
|
||||
variation: variation_params,
|
||||
});
|
||||
|
||||
addItem({
|
||||
@@ -320,8 +367,8 @@ export default function Product() {
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
|
||||
? 'bg-primary w-6'
|
||||
: 'bg-gray-300 hover:bg-gray-400'
|
||||
? 'bg-primary w-6'
|
||||
: 'bg-gray-300 hover:bg-gray-400'
|
||||
}`}
|
||||
aria-label={`View image ${index + 1}`}
|
||||
/>
|
||||
@@ -354,8 +401,8 @@ export default function Product() {
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img
|
||||
? 'border-primary ring-4 ring-primary ring-offset-2'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
? 'border-primary ring-4 ring-primary ring-offset-2'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
@@ -446,8 +493,8 @@ export default function Product() {
|
||||
key={optIndex}
|
||||
onClick={() => handleAttributeChange(attr.name, option)}
|
||||
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
|
||||
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
|
||||
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
@@ -503,8 +550,8 @@ export default function Product() {
|
||||
<button
|
||||
onClick={() => product && toggleWishlist(product.id)}
|
||||
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${product && isInWishlist(product.id)
|
||||
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
|
||||
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
|
||||
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
|
||||
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<Heart className={`h-5 w-5 ${product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function Shop() {
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
className="w-full !pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
|
||||
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);
|
||||
@@ -42,6 +42,13 @@ class AppearanceController {
|
||||
'callback' => [__CLASS__, 'save_footer'],
|
||||
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Save menu settings
|
||||
register_rest_route(self::API_NAMESPACE, '/appearance/menus', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'save_menus'],
|
||||
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Save page-specific settings
|
||||
register_rest_route(self::API_NAMESPACE, '/appearance/pages/(?P<page>[a-zA-Z0-9_-]+)', [
|
||||
@@ -73,7 +80,11 @@ class AppearanceController {
|
||||
* Get all appearance settings
|
||||
*/
|
||||
public static function get_settings(WP_REST_Request $request) {
|
||||
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
|
||||
$stored = get_option(self::OPTION_KEY, []);
|
||||
$defaults = self::get_default_settings();
|
||||
|
||||
// Merge stored with defaults to ensure all fields exist (recursive)
|
||||
$settings = array_replace_recursive($defaults, $stored);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
@@ -85,8 +96,12 @@ class AppearanceController {
|
||||
* Save general settings
|
||||
*/
|
||||
public static function save_general(WP_REST_Request $request) {
|
||||
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
|
||||
$settings = get_option(self::OPTION_KEY, []);
|
||||
$defaults = self::get_default_settings();
|
||||
$settings = array_replace_recursive($defaults, $settings);
|
||||
|
||||
$colors = $request->get_param('colors') ?? [];
|
||||
|
||||
$general_data = [
|
||||
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
|
||||
'spa_page' => absint($request->get_param('spaPage') ?? 0),
|
||||
@@ -101,11 +116,13 @@ class AppearanceController {
|
||||
'scale' => floatval($request->get_param('typography')['scale'] ?? 1.0),
|
||||
],
|
||||
'colors' => [
|
||||
'primary' => sanitize_hex_color($request->get_param('colors')['primary'] ?? '#1a1a1a'),
|
||||
'secondary' => sanitize_hex_color($request->get_param('colors')['secondary'] ?? '#6b7280'),
|
||||
'accent' => sanitize_hex_color($request->get_param('colors')['accent'] ?? '#3b82f6'),
|
||||
'text' => sanitize_hex_color($request->get_param('colors')['text'] ?? '#111827'),
|
||||
'background' => sanitize_hex_color($request->get_param('colors')['background'] ?? '#ffffff'),
|
||||
'primary' => sanitize_hex_color($colors['primary'] ?? '#1a1a1a'),
|
||||
'secondary' => sanitize_hex_color($colors['secondary'] ?? '#6b7280'),
|
||||
'accent' => sanitize_hex_color($colors['accent'] ?? '#3b82f6'),
|
||||
'text' => sanitize_hex_color($colors['text'] ?? '#111827'),
|
||||
'background' => sanitize_hex_color($colors['background'] ?? '#ffffff'),
|
||||
'gradientStart' => sanitize_hex_color($colors['gradientStart'] ?? '#9333ea'),
|
||||
'gradientEnd' => sanitize_hex_color($colors['gradientEnd'] ?? '#3b82f6'),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -230,6 +247,44 @@ class AppearanceController {
|
||||
'data' => $footer_data,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save menu settings
|
||||
*/
|
||||
public static function save_menus(WP_REST_Request $request) {
|
||||
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
|
||||
|
||||
$menus = $request->get_param('menus') ?? [];
|
||||
|
||||
// Sanitize menus
|
||||
$sanitized_menus = [
|
||||
'primary' => [],
|
||||
'mobile' => [], // Optional separate mobile menu
|
||||
];
|
||||
|
||||
foreach (['primary', 'mobile'] as $location) {
|
||||
if (isset($menus[$location]) && is_array($menus[$location])) {
|
||||
foreach ($menus[$location] as $item) {
|
||||
$sanitized_menus[$location][] = [
|
||||
'id' => sanitize_text_field($item['id'] ?? uniqid()),
|
||||
'label' => sanitize_text_field($item['label'] ?? ''),
|
||||
'type' => sanitize_text_field($item['type'] ?? 'page'), // page, custom
|
||||
'value' => sanitize_text_field($item['value'] ?? ''), // slug or url
|
||||
'target' => sanitize_text_field($item['target'] ?? '_self'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$settings['menus'] = $sanitized_menus;
|
||||
update_option(self::OPTION_KEY, $settings);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'Menu settings saved successfully',
|
||||
'data' => $sanitized_menus,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save page-specific settings
|
||||
@@ -389,11 +444,23 @@ class AppearanceController {
|
||||
'sort_order' => 'ASC',
|
||||
]);
|
||||
|
||||
$pages_list = array_map(function($page) {
|
||||
$store_pages = [
|
||||
(int) get_option('woocommerce_shop_page_id'),
|
||||
(int) get_option('woocommerce_cart_page_id'),
|
||||
(int) get_option('woocommerce_checkout_page_id'),
|
||||
(int) get_option('woocommerce_myaccount_page_id'),
|
||||
];
|
||||
|
||||
$pages_list = array_map(function($page) use ($store_pages) {
|
||||
$is_woonoow = !empty(get_post_meta($page->ID, '_wn_page_structure', true));
|
||||
$is_store = in_array((int)$page->ID, $store_pages, true);
|
||||
|
||||
return [
|
||||
'id' => $page->ID,
|
||||
'title' => $page->post_title,
|
||||
'slug' => $page->post_name,
|
||||
'is_woonoow_page' => $is_woonoow,
|
||||
'is_store_page' => $is_store,
|
||||
];
|
||||
}, $pages);
|
||||
|
||||
@@ -427,6 +494,8 @@ class AppearanceController {
|
||||
'accent' => '#3b82f6',
|
||||
'text' => '#111827',
|
||||
'background' => '#ffffff',
|
||||
'gradientStart' => '#9333ea',
|
||||
'gradientEnd' => '#3b82f6',
|
||||
],
|
||||
],
|
||||
'header' => [
|
||||
@@ -458,6 +527,14 @@ class AppearanceController {
|
||||
],
|
||||
'social_links' => [],
|
||||
],
|
||||
'menus' => [
|
||||
'primary' => [
|
||||
['id' => 'home', 'label' => 'Home', 'type' => 'page', 'value' => '/', 'target' => '_self'],
|
||||
['id' => 'shop', 'label' => 'Shop', 'type' => 'page', 'value' => 'shop', 'target' => '_self'],
|
||||
],
|
||||
// Fallback for mobile if empty is to use primary
|
||||
'mobile' => [],
|
||||
],
|
||||
'pages' => [
|
||||
'shop' => [
|
||||
'layout' => [
|
||||
|
||||
@@ -8,6 +8,9 @@ class Menu {
|
||||
add_action('admin_head', [__CLASS__, 'localize_wc_menus'], 999);
|
||||
// Add link to standalone admin in admin bar
|
||||
add_action('admin_bar_menu', [__CLASS__, 'add_admin_bar_link'], 100);
|
||||
|
||||
// Add custom state for SPA Front Page
|
||||
add_filter('display_post_states', [__CLASS__, 'add_spa_page_state'], 10, 2);
|
||||
}
|
||||
public static function register() {
|
||||
add_menu_page(
|
||||
@@ -133,4 +136,23 @@ class Menu {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add "WooNooW SPA Page" state to the pages list
|
||||
*
|
||||
* @param array $states Array of post states.
|
||||
* @param \WP_Post $post Current post object.
|
||||
* @return array Modified post states.
|
||||
*/
|
||||
public static function add_spa_page_state($states, $post) {
|
||||
$settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
|
||||
|
||||
if ((int)$post->ID === (int)$spa_frontpage_id) {
|
||||
$states['spa_frontpage'] = __('WooNooW Front Page', 'woonoow');
|
||||
} elseif (!empty(get_post_meta($post->ID, '_wn_page_structure', true))) {
|
||||
$states['woonoow_page'] = __('WooNooW Page', 'woonoow');
|
||||
}
|
||||
return $states;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace WooNooW\Api;
|
||||
|
||||
use WP_Error;
|
||||
@@ -8,40 +9,44 @@ use WC_Product;
|
||||
use WC_Shipping_Zones;
|
||||
use WC_Shipping_Rate;
|
||||
|
||||
if (!defined('ABSPATH')) { exit; }
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class CheckoutController {
|
||||
class CheckoutController
|
||||
{
|
||||
|
||||
/**
|
||||
* Register REST routes for checkout quote & submit
|
||||
*/
|
||||
public static function register() {
|
||||
public static function register()
|
||||
{
|
||||
$namespace = 'woonoow/v1';
|
||||
register_rest_route($namespace, '/checkout/quote', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [ new self(), 'quote' ],
|
||||
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ], // consider nonce check later
|
||||
'callback' => [new self(), 'quote'],
|
||||
'permission_callback' => [\WooNooW\Api\Permissions::class, 'anon_or_wp_nonce'], // consider nonce check later
|
||||
]);
|
||||
register_rest_route($namespace, '/checkout/submit', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [ new self(), 'submit' ],
|
||||
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ], // consider capability/nonce
|
||||
'callback' => [new self(), 'submit'],
|
||||
'permission_callback' => [\WooNooW\Api\Permissions::class, 'anon_or_wp_nonce'], // consider capability/nonce
|
||||
]);
|
||||
register_rest_route($namespace, '/checkout/fields', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [ new self(), 'get_fields' ],
|
||||
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
|
||||
'callback' => [new self(), 'get_fields'],
|
||||
'permission_callback' => [\WooNooW\Api\Permissions::class, 'anon_or_wp_nonce'],
|
||||
]);
|
||||
// Public countries endpoint for customer checkout form
|
||||
register_rest_route($namespace, '/countries', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [ new self(), 'get_countries' ],
|
||||
'callback' => [new self(), 'get_countries'],
|
||||
'permission_callback' => '__return_true', // Public - needed for checkout
|
||||
]);
|
||||
// Public order view endpoint for thank you page
|
||||
register_rest_route($namespace, '/checkout/order/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [ new self(), 'get_order' ],
|
||||
'callback' => [new self(), 'get_order'],
|
||||
'permission_callback' => '__return_true', // Public, validated via order_key
|
||||
'args' => [
|
||||
'key' => [
|
||||
@@ -53,8 +58,14 @@ class CheckoutController {
|
||||
// Get available shipping rates for given address
|
||||
register_rest_route($namespace, '/checkout/shipping-rates', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [ new self(), 'get_shipping_rates' ],
|
||||
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
|
||||
'callback' => [new self(), 'get_shipping_rates'],
|
||||
'permission_callback' => [\WooNooW\Api\Permissions::class, 'anon_or_wp_nonce'],
|
||||
]);
|
||||
// Process payment for an existing order (e.g. renewal)
|
||||
register_rest_route($namespace, '/checkout/pay-order/(?P<id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [new self(), 'pay_order'],
|
||||
'permission_callback' => '__return_true', // Validated via order key/owner in method
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -68,7 +79,8 @@ class CheckoutController {
|
||||
* shipping_method: "flat_rate:1" | "free_shipping:3" | ...
|
||||
* }
|
||||
*/
|
||||
public function quote(WP_REST_Request $r): array {
|
||||
public function quote(WP_REST_Request $r): array
|
||||
{
|
||||
$__t0 = microtime(true);
|
||||
$payload = $this->sanitize_payload($r);
|
||||
|
||||
@@ -162,7 +174,8 @@ class CheckoutController {
|
||||
* Validates access via order_key (for guests) or logged-in customer ID
|
||||
* GET /checkout/order/{id}?key=wc_order_xxx
|
||||
*/
|
||||
public function get_order(WP_REST_Request $r): array {
|
||||
public function get_order(WP_REST_Request $r): array
|
||||
{
|
||||
$order_id = absint($r['id']);
|
||||
$order_key = sanitize_text_field($r->get_param('key') ?? '');
|
||||
|
||||
@@ -175,9 +188,12 @@ class CheckoutController {
|
||||
return ['error' => __('Order not found', 'woonoow')];
|
||||
}
|
||||
|
||||
// Validate access: order_key must match OR user must be logged in and own the order
|
||||
// Validate access: order_key must match OR user must be logged in and own the order (or be admin)
|
||||
$valid_key = $order_key && hash_equals($order->get_order_key(), $order_key);
|
||||
$valid_owner = is_user_logged_in() && get_current_user_id() === $order->get_customer_id();
|
||||
$valid_owner = is_user_logged_in() && (
|
||||
get_current_user_id() === $order->get_customer_id() ||
|
||||
current_user_can('manage_woocommerce')
|
||||
);
|
||||
|
||||
if (!$valid_key && !$valid_owner) {
|
||||
return ['error' => __('Unauthorized access to order', 'woonoow')];
|
||||
@@ -197,7 +213,7 @@ class CheckoutController {
|
||||
'image' => $product ? wp_get_attachment_image_url($product->get_image_id(), 'thumbnail') : null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// Build shipping lines
|
||||
$shipping_lines = [];
|
||||
foreach ($order->get_shipping_methods() as $shipping_item) {
|
||||
@@ -208,16 +224,16 @@ class CheckoutController {
|
||||
'total' => wc_price($shipping_item->get_total()),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// Get tracking info from order meta (various plugins use different keys)
|
||||
$tracking_number = $order->get_meta('_tracking_number')
|
||||
?: $order->get_meta('_wc_shipment_tracking_items')
|
||||
$tracking_number = $order->get_meta('_tracking_number')
|
||||
?: $order->get_meta('_wc_shipment_tracking_items')
|
||||
?: $order->get_meta('_rajaongkir_awb_number')
|
||||
?: '';
|
||||
$tracking_url = $order->get_meta('_tracking_url')
|
||||
$tracking_url = $order->get_meta('_tracking_url')
|
||||
?: $order->get_meta('_rajaongkir_tracking_url')
|
||||
?: '';
|
||||
|
||||
|
||||
// Check for shipment tracking plugin format (array of tracking items)
|
||||
if (is_array($tracking_number) && !empty($tracking_number)) {
|
||||
$first_tracking = reset($tracking_number);
|
||||
@@ -230,6 +246,7 @@ class CheckoutController {
|
||||
'id' => $order->get_id(),
|
||||
'number' => $order->get_order_number(),
|
||||
'status' => $order->get_status(),
|
||||
'created_via' => $order->get_created_via(),
|
||||
'subtotal' => (float) $order->get_subtotal(),
|
||||
'discount_total' => (float) $order->get_discount_total(),
|
||||
'shipping_total' => (float) $order->get_shipping_total(),
|
||||
@@ -249,6 +266,28 @@ class CheckoutController {
|
||||
'phone' => $order->get_billing_phone(),
|
||||
],
|
||||
'items' => $items,
|
||||
'subscription' => $this->get_subscription_for_response($order),
|
||||
'available_gateways' => $this->get_available_gateways_for_order($order),
|
||||
];
|
||||
}
|
||||
|
||||
private function get_subscription_for_response($order)
|
||||
{
|
||||
if (!class_exists('\WooNooW\Modules\Subscription\SubscriptionManager')) {
|
||||
return null;
|
||||
}
|
||||
$sub = \WooNooW\Modules\Subscription\SubscriptionManager::get_by_order_id($order->get_id());
|
||||
if (!$sub) return null;
|
||||
|
||||
return [
|
||||
'id' => (int) $sub->id,
|
||||
'status' => $sub->status,
|
||||
'billing_period' => $sub->billing_period,
|
||||
'billing_interval' => (int) $sub->billing_interval,
|
||||
'start_date' => $sub->start_date,
|
||||
'next_payment_date' => $sub->next_payment_date,
|
||||
'end_date' => $sub->end_date,
|
||||
'recurring_amount' => (float) $sub->recurring_amount,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -263,7 +302,8 @@ class CheckoutController {
|
||||
* payment_method: "cod" | "bacs" | ...
|
||||
* }
|
||||
*/
|
||||
public function submit(WP_REST_Request $r): array {
|
||||
public function submit(WP_REST_Request $r): array
|
||||
{
|
||||
$__t0 = microtime(true);
|
||||
$payload = $this->sanitize_payload($r);
|
||||
|
||||
@@ -281,11 +321,11 @@ class CheckoutController {
|
||||
if (is_user_logged_in()) {
|
||||
$user_id = get_current_user_id();
|
||||
$order->set_customer_id($user_id);
|
||||
|
||||
|
||||
// Update user's billing information from checkout data
|
||||
if (!empty($payload['billing'])) {
|
||||
$billing = $payload['billing'];
|
||||
|
||||
|
||||
// Update first name and last name
|
||||
if (!empty($billing['first_name'])) {
|
||||
update_user_meta($user_id, 'first_name', sanitize_text_field($billing['first_name']));
|
||||
@@ -295,12 +335,12 @@ class CheckoutController {
|
||||
update_user_meta($user_id, 'last_name', sanitize_text_field($billing['last_name']));
|
||||
update_user_meta($user_id, 'billing_last_name', sanitize_text_field($billing['last_name']));
|
||||
}
|
||||
|
||||
|
||||
// Update billing phone
|
||||
if (!empty($billing['phone'])) {
|
||||
update_user_meta($user_id, 'billing_phone', sanitize_text_field($billing['phone']));
|
||||
}
|
||||
|
||||
|
||||
// Update billing email
|
||||
if (!empty($billing['email'])) {
|
||||
update_user_meta($user_id, 'billing_email', sanitize_email($billing['email']));
|
||||
@@ -310,20 +350,20 @@ class CheckoutController {
|
||||
// Guest checkout - check if auto-register is enabled
|
||||
$customer_settings = \WooNooW\Compat\CustomerSettingsProvider::get_settings();
|
||||
$auto_register = $customer_settings['auto_register_members'] ?? false;
|
||||
|
||||
|
||||
if ($auto_register && !empty($payload['billing']['email'])) {
|
||||
$email = sanitize_email($payload['billing']['email']);
|
||||
|
||||
|
||||
// Check if user already exists
|
||||
$existing_user = get_user_by('email', $email);
|
||||
|
||||
|
||||
if ($existing_user) {
|
||||
// User exists - link order to them
|
||||
$order->set_customer_id($existing_user->ID);
|
||||
} else {
|
||||
// Create new user account
|
||||
$password = wp_generate_password(12, true, true);
|
||||
|
||||
|
||||
$userdata = [
|
||||
'user_login' => $email,
|
||||
'user_email' => $email,
|
||||
@@ -333,24 +373,24 @@ class CheckoutController {
|
||||
'display_name' => trim((sanitize_text_field($payload['billing']['first_name'] ?? '') . ' ' . sanitize_text_field($payload['billing']['last_name'] ?? ''))) ?: $email,
|
||||
'role' => 'customer', // WooCommerce customer role
|
||||
];
|
||||
|
||||
|
||||
$new_user_id = wp_insert_user($userdata);
|
||||
|
||||
|
||||
if (!is_wp_error($new_user_id)) {
|
||||
// Link order to new user
|
||||
$order->set_customer_id($new_user_id);
|
||||
|
||||
|
||||
// Store temp password in user meta for email template
|
||||
// The real password is already set via wp_insert_user
|
||||
update_user_meta($new_user_id, '_woonoow_temp_password', $password);
|
||||
|
||||
|
||||
// AUTO-LOGIN: Set authentication cookie so user is logged in after page reload
|
||||
wp_set_auth_cookie($new_user_id, true);
|
||||
wp_set_current_user($new_user_id);
|
||||
|
||||
|
||||
// Set WooCommerce customer billing data
|
||||
$customer = new \WC_Customer($new_user_id);
|
||||
|
||||
|
||||
if (!empty($payload['billing']['first_name'])) $customer->set_billing_first_name(sanitize_text_field($payload['billing']['first_name']));
|
||||
if (!empty($payload['billing']['last_name'])) $customer->set_billing_last_name(sanitize_text_field($payload['billing']['last_name']));
|
||||
if (!empty($payload['billing']['email'])) $customer->set_billing_email(sanitize_email($payload['billing']['email']));
|
||||
@@ -360,9 +400,9 @@ class CheckoutController {
|
||||
if (!empty($payload['billing']['state'])) $customer->set_billing_state(sanitize_text_field($payload['billing']['state']));
|
||||
if (!empty($payload['billing']['postcode'])) $customer->set_billing_postcode(sanitize_text_field($payload['billing']['postcode']));
|
||||
if (!empty($payload['billing']['country'])) $customer->set_billing_country(sanitize_text_field($payload['billing']['country']));
|
||||
|
||||
|
||||
$customer->save();
|
||||
|
||||
|
||||
// Send new account email (WooCommerce will handle this automatically via hook)
|
||||
do_action('woocommerce_created_customer', $new_user_id, $userdata, $password);
|
||||
}
|
||||
@@ -430,12 +470,12 @@ class CheckoutController {
|
||||
// Fallback: use shipping_cost directly from frontend
|
||||
// This handles API-based shipping like Rajaongkir where WC zones don't apply
|
||||
$item = new \WC_Order_Item_Shipping();
|
||||
|
||||
|
||||
// Parse method ID from shipping_method (format: "method_id:instance_id" or "method_id:instance_id:variant")
|
||||
$parts = explode(':', $payload['shipping_method']);
|
||||
$method_id = $parts[0] ?? 'shipping';
|
||||
$instance_id = isset($parts[1]) ? (int)$parts[1] : 0;
|
||||
|
||||
|
||||
$item->set_props([
|
||||
'method_title' => sanitize_text_field($payload['shipping_title'] ?? 'Shipping'),
|
||||
'method_id' => sanitize_text_field($method_id),
|
||||
@@ -479,12 +519,73 @@ class CheckoutController {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process payment for an existing order
|
||||
* POST /checkout/pay-order/{id}
|
||||
*/
|
||||
public function pay_order(WP_REST_Request $r): array
|
||||
{
|
||||
$order_id = absint($r['id']);
|
||||
$order = wc_get_order($order_id);
|
||||
|
||||
if (!$order) {
|
||||
return ['error' => __('Order not found', 'woonoow')];
|
||||
}
|
||||
|
||||
// Validate access
|
||||
$key = $r->get_param('key'); // optional if logged in
|
||||
$valid_key = $key && hash_equals($order->get_order_key(), $key);
|
||||
$valid_owner = is_user_logged_in() && (
|
||||
get_current_user_id() === $order->get_customer_id() ||
|
||||
current_user_can('manage_woocommerce')
|
||||
);
|
||||
|
||||
if (!$valid_key && !$valid_owner) {
|
||||
return ['error' => __('Unauthorized access', 'woonoow')];
|
||||
}
|
||||
|
||||
if ($order->is_paid()) {
|
||||
return ['error' => __('Order already paid', 'woonoow')];
|
||||
}
|
||||
|
||||
$payment_method = wc_clean($r->get_param('payment_method'));
|
||||
if (empty($payment_method)) {
|
||||
return ['error' => __('Payment method required', 'woonoow')];
|
||||
}
|
||||
|
||||
// Update payment method
|
||||
$available = WC()->payment_gateways()->get_available_payment_gateways();
|
||||
if (!isset($available[$payment_method])) {
|
||||
return ['error' => __('Invalid payment method', 'woonoow')];
|
||||
}
|
||||
|
||||
$gateway = $available[$payment_method];
|
||||
$order->set_payment_method($gateway);
|
||||
$order->save();
|
||||
|
||||
// Process payment
|
||||
$result = $gateway->process_payment($order_id);
|
||||
|
||||
if (isset($result['result']) && $result['result'] === 'success') {
|
||||
return [
|
||||
'ok' => true,
|
||||
'redirect' => $result['redirect'] ?? $order->get_checkout_order_received_url(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'error' => __('Payment failed', 'woonoow') . (isset($result['result']) ? ': ' . $result['result'] : ''),
|
||||
'messages' => wc_get_notices('error'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get checkout fields with all filters applied
|
||||
* Accepts: { items: [...], is_digital_only?: bool }
|
||||
* Returns fields with required, hidden, etc. based on addons + cart context
|
||||
*/
|
||||
public function get_fields(WP_REST_Request $r): array {
|
||||
public function get_fields(WP_REST_Request $r): array
|
||||
{
|
||||
$json = $r->get_json_params();
|
||||
$items = isset($json['items']) && is_array($json['items']) ? $json['items'] : [];
|
||||
$is_digital_only = isset($json['is_digital_only']) ? (bool) $json['is_digital_only'] : false;
|
||||
@@ -504,7 +605,7 @@ class CheckoutController {
|
||||
foreach ($fieldset as $key => $field) {
|
||||
// Check if field should be hidden
|
||||
$hidden = false;
|
||||
|
||||
|
||||
// Hide shipping fields if digital only (your existing logic)
|
||||
if ($is_digital_only && $fieldset_key === 'shipping') {
|
||||
$hidden = true;
|
||||
@@ -534,7 +635,7 @@ class CheckoutController {
|
||||
'priority' => $field['priority'] ?? 10,
|
||||
'options' => $field['options'] ?? null, // For select fields
|
||||
'custom' => !in_array($key, $this->get_standard_field_keys()), // Flag custom fields
|
||||
'autocomplete'=> $field['autocomplete'] ?? '',
|
||||
'autocomplete' => $field['autocomplete'] ?? '',
|
||||
'validate' => $field['validate'] ?? [],
|
||||
// New fields for dynamic rendering
|
||||
'input_class' => $field['input_class'] ?? [],
|
||||
@@ -549,7 +650,7 @@ class CheckoutController {
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
usort($formatted, function($a, $b) {
|
||||
usort($formatted, function ($a, $b) {
|
||||
return $a['priority'] <=> $b['priority'];
|
||||
});
|
||||
|
||||
@@ -564,7 +665,8 @@ class CheckoutController {
|
||||
* Get list of standard WooCommerce field keys
|
||||
* Plugins can extend this list via the 'woonoow_standard_checkout_field_keys' filter
|
||||
*/
|
||||
private function get_standard_field_keys(): array {
|
||||
private function get_standard_field_keys(): array
|
||||
{
|
||||
$keys = [
|
||||
'billing_first_name',
|
||||
'billing_last_name',
|
||||
@@ -588,7 +690,7 @@ class CheckoutController {
|
||||
'shipping_postcode',
|
||||
'order_comments',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Filter the list of standard checkout field keys.
|
||||
* Plugins can add their own field keys to be recognized as "standard" (not custom).
|
||||
@@ -600,14 +702,19 @@ class CheckoutController {
|
||||
|
||||
/** ----------------- Helpers ----------------- **/
|
||||
|
||||
private function accurate_quote_via_wc_cart(array $payload): array {
|
||||
if (!WC()->customer) { WC()->customer = new \WC_Customer(get_current_user_id(), true); }
|
||||
if (!WC()->cart) { WC()->cart = new \WC_Cart(); }
|
||||
private function accurate_quote_via_wc_cart(array $payload): array
|
||||
{
|
||||
if (!WC()->customer) {
|
||||
WC()->customer = new \WC_Customer(get_current_user_id(), true);
|
||||
}
|
||||
if (!WC()->cart) {
|
||||
WC()->cart = new \WC_Cart();
|
||||
}
|
||||
|
||||
// Address context for taxes/shipping rules - set temporarily without saving to user profile
|
||||
$ship = !empty($payload['shipping']) ? $payload['shipping'] : $payload['billing'];
|
||||
if (!empty($payload['billing'])) {
|
||||
foreach (['country','state','postcode','city','address_1','address_2'] as $k) {
|
||||
foreach (['country', 'state', 'postcode', 'city', 'address_1', 'address_2'] as $k) {
|
||||
$setter = 'set_billing_' . $k;
|
||||
if (method_exists(WC()->customer, $setter) && isset($payload['billing'][$k])) {
|
||||
WC()->customer->{$setter}(wc_clean($payload['billing'][$k]));
|
||||
@@ -615,7 +722,7 @@ class CheckoutController {
|
||||
}
|
||||
}
|
||||
if (!empty($ship)) {
|
||||
foreach (['country','state','postcode','city','address_1','address_2'] as $k) {
|
||||
foreach (['country', 'state', 'postcode', 'city', 'address_1', 'address_2'] as $k) {
|
||||
$setter = 'set_shipping_' . $k;
|
||||
if (method_exists(WC()->customer, $setter) && isset($ship[$k])) {
|
||||
WC()->customer->{$setter}(wc_clean($ship[$k]));
|
||||
@@ -685,7 +792,8 @@ class CheckoutController {
|
||||
];
|
||||
}
|
||||
|
||||
private function sanitize_payload(WP_REST_Request $r): array {
|
||||
private function sanitize_payload(WP_REST_Request $r): array
|
||||
{
|
||||
$json = $r->get_json_params();
|
||||
|
||||
$items = isset($json['items']) && is_array($json['items']) ? $json['items'] : [];
|
||||
@@ -704,7 +812,7 @@ class CheckoutController {
|
||||
];
|
||||
}, $items),
|
||||
'billing' => $billing,
|
||||
'shipping'=> $shipping,
|
||||
'shipping' => $shipping,
|
||||
'coupons' => $coupons,
|
||||
'shipping_method' => isset($json['shipping_method']) ? wc_clean($json['shipping_method']) : null,
|
||||
'payment_method' => isset($json['payment_method']) ? wc_clean($json['payment_method']) : null,
|
||||
@@ -716,7 +824,8 @@ class CheckoutController {
|
||||
];
|
||||
}
|
||||
|
||||
private function load_product(array $line) {
|
||||
private function load_product(array $line)
|
||||
{
|
||||
$pid = (int)($line['variation_id'] ?? 0) ?: (int)($line['product_id'] ?? 0);
|
||||
if (!$pid) {
|
||||
return new WP_Error('bad_item', __('Invalid product id', 'woonoow'));
|
||||
@@ -728,8 +837,9 @@ class CheckoutController {
|
||||
return $product;
|
||||
}
|
||||
|
||||
private function only_address_fields(array $src): array {
|
||||
$keys = ['first_name','last_name','company','address_1','address_2','city','state','postcode','country','email','phone'];
|
||||
private function only_address_fields(array $src): array
|
||||
{
|
||||
$keys = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
|
||||
$out = [];
|
||||
foreach ($keys as $k) {
|
||||
if (isset($src[$k])) $out[$k] = wc_clean(wp_unslash($src[$k]));
|
||||
@@ -737,7 +847,8 @@ class CheckoutController {
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function estimate_shipping(array $address, ?string $chosen_method): float {
|
||||
private function estimate_shipping(array $address, ?string $chosen_method): float
|
||||
{
|
||||
$country = wc_clean($address['country'] ?? '');
|
||||
$postcode = wc_clean($address['postcode'] ?? '');
|
||||
$state = wc_clean($address['state'] ?? '');
|
||||
@@ -745,12 +856,14 @@ class CheckoutController {
|
||||
|
||||
$cache_key = 'wnw_ship_' . md5(json_encode([$country, $state, $postcode, $city, (string) $chosen_method]));
|
||||
$cached = wp_cache_get($cache_key, 'woonoow');
|
||||
if ($cached !== false) { return (float) $cached; }
|
||||
if ($cached !== false) {
|
||||
return (float) $cached;
|
||||
}
|
||||
|
||||
if (!$country) return 0.0;
|
||||
|
||||
$packages = [[
|
||||
'destination' => compact('country','state','postcode','city'),
|
||||
'destination' => compact('country', 'state', 'postcode', 'city'),
|
||||
'contents_cost' => 0, // not exact in v0
|
||||
'contents' => [],
|
||||
'applied_coupons' => [],
|
||||
@@ -778,7 +891,8 @@ class CheckoutController {
|
||||
return $cost;
|
||||
}
|
||||
|
||||
private function find_shipping_rate_for_order(WC_Order $order, string $chosen) {
|
||||
private function find_shipping_rate_for_order(WC_Order $order, string $chosen)
|
||||
{
|
||||
$shipping = $order->get_address('shipping');
|
||||
$packages = [[
|
||||
'destination' => [
|
||||
@@ -810,12 +924,13 @@ class CheckoutController {
|
||||
* Get countries and states for checkout form
|
||||
* Public endpoint - no authentication required
|
||||
*/
|
||||
public function get_countries(): array {
|
||||
public function get_countries(): array
|
||||
{
|
||||
$wc_countries = WC()->countries;
|
||||
|
||||
|
||||
// Get allowed selling countries
|
||||
$allowed = $wc_countries->get_allowed_countries();
|
||||
|
||||
|
||||
// Format for frontend
|
||||
$countries = [];
|
||||
foreach ($allowed as $code => $name) {
|
||||
@@ -824,7 +939,7 @@ class CheckoutController {
|
||||
'name' => $name,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// Get states for all allowed countries
|
||||
$states = [];
|
||||
foreach (array_keys($allowed) as $country_code) {
|
||||
@@ -833,10 +948,10 @@ class CheckoutController {
|
||||
$states[$country_code] = $country_states;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get default country
|
||||
$default_country = $wc_countries->get_base_country();
|
||||
|
||||
|
||||
return [
|
||||
'countries' => $countries,
|
||||
'states' => $states,
|
||||
@@ -849,16 +964,17 @@ class CheckoutController {
|
||||
* POST /checkout/shipping-rates
|
||||
* Body: { shipping: { country, state, city, postcode, destination_id? }, items: [...] }
|
||||
*/
|
||||
public function get_shipping_rates(WP_REST_Request $r): array {
|
||||
public function get_shipping_rates(WP_REST_Request $r): array
|
||||
{
|
||||
$payload = $r->get_json_params();
|
||||
$shipping = $payload['shipping'] ?? [];
|
||||
$items = $payload['items'] ?? [];
|
||||
|
||||
|
||||
$country = wc_clean($shipping['country'] ?? '');
|
||||
$state = wc_clean($shipping['state'] ?? '');
|
||||
$city = wc_clean($shipping['city'] ?? '');
|
||||
$postcode = wc_clean($shipping['postcode'] ?? '');
|
||||
|
||||
|
||||
if (empty($country)) {
|
||||
return [
|
||||
'ok' => true,
|
||||
@@ -866,10 +982,10 @@ class CheckoutController {
|
||||
'message' => 'Country is required',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// Trigger hook for plugins to set session data (e.g., Rajaongkir destination_id)
|
||||
do_action('woonoow/shipping/before_calculate', $shipping, $items);
|
||||
|
||||
|
||||
// Set customer location for shipping calculation
|
||||
if (WC()->customer) {
|
||||
WC()->customer->set_shipping_country($country);
|
||||
@@ -877,7 +993,7 @@ class CheckoutController {
|
||||
WC()->customer->set_shipping_city($city);
|
||||
WC()->customer->set_shipping_postcode($postcode);
|
||||
}
|
||||
|
||||
|
||||
// Build package for shipping calculation
|
||||
$contents = [];
|
||||
$contents_cost = 0;
|
||||
@@ -893,7 +1009,7 @@ class CheckoutController {
|
||||
];
|
||||
$contents_cost += $price * $qty;
|
||||
}
|
||||
|
||||
|
||||
$package = [
|
||||
'destination' => [
|
||||
'country' => $country,
|
||||
@@ -906,7 +1022,7 @@ class CheckoutController {
|
||||
'applied_coupons' => [],
|
||||
'user' => ['ID' => get_current_user_id()],
|
||||
];
|
||||
|
||||
|
||||
// Get matching shipping zone
|
||||
$zone = WC_Shipping_Zones::get_zone_matching_package($package);
|
||||
if (!$zone) {
|
||||
@@ -916,11 +1032,11 @@ class CheckoutController {
|
||||
'message' => 'No shipping zone matches your location',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// Get enabled shipping methods from zone
|
||||
$methods = $zone->get_shipping_methods(true);
|
||||
$rates = [];
|
||||
|
||||
|
||||
foreach ($methods as $method) {
|
||||
// Check if method has rates (some methods like live rate need to calculate)
|
||||
if (method_exists($method, 'get_rates_for_package')) {
|
||||
@@ -938,14 +1054,14 @@ class CheckoutController {
|
||||
// Fallback for simple methods
|
||||
$method_id = $method->id . ':' . $method->get_instance_id();
|
||||
$cost = 0;
|
||||
|
||||
|
||||
// Try to get cost from method
|
||||
if (isset($method->cost)) {
|
||||
$cost = (float) $method->cost;
|
||||
} elseif (method_exists($method, 'get_option')) {
|
||||
$cost = (float) $method->get_option('cost', 0);
|
||||
}
|
||||
|
||||
|
||||
$rates[] = [
|
||||
'id' => $method_id,
|
||||
'label' => $method->get_title(),
|
||||
@@ -955,11 +1071,34 @@ class CheckoutController {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'rates' => $rates,
|
||||
'zone_name' => $zone->get_zone_name(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function get_available_gateways_for_order(WC_Order $order): array
|
||||
{
|
||||
// Mock cart for gateways that check cart total
|
||||
if (!WC()->cart) {
|
||||
WC()->initialize_cart();
|
||||
}
|
||||
// We can't easily bake the order into the cart, but many gateways just check 'needs_payment'
|
||||
// or country.
|
||||
|
||||
$gateways = WC()->payment_gateways()->get_available_payment_gateways();
|
||||
$results = [];
|
||||
|
||||
foreach ($gateways as $gateway) {
|
||||
$results[] = [
|
||||
'id' => $gateway->id,
|
||||
'title' => $gateway->get_title() ?: $gateway->method_title ?: ucfirst($gateway->id), // Fallbacks
|
||||
'description' => $gateway->get_description(),
|
||||
'icon' => $gateway->get_icon(),
|
||||
];
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace WooNooW\Api\Controllers;
|
||||
|
||||
use WP_REST_Controller;
|
||||
@@ -9,21 +10,23 @@ use WP_Error;
|
||||
* Cart Controller
|
||||
* Handles cart operations via REST API
|
||||
*/
|
||||
class CartController extends WP_REST_Controller {
|
||||
|
||||
class CartController extends WP_REST_Controller
|
||||
{
|
||||
|
||||
/**
|
||||
* Register routes
|
||||
*/
|
||||
public function register_routes() {
|
||||
public function register_routes()
|
||||
{
|
||||
$namespace = 'woonoow/v1';
|
||||
|
||||
|
||||
// Get cart
|
||||
register_rest_route($namespace, '/cart', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_cart'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
|
||||
// Add to cart
|
||||
register_rest_route($namespace, '/cart/add', [
|
||||
'methods' => 'POST',
|
||||
@@ -48,7 +51,7 @@ class CartController extends WP_REST_Controller {
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
// Update cart item
|
||||
register_rest_route($namespace, '/cart/update', [
|
||||
'methods' => 'POST',
|
||||
@@ -66,7 +69,7 @@ class CartController extends WP_REST_Controller {
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
// Remove from cart
|
||||
register_rest_route($namespace, '/cart/remove', [
|
||||
'methods' => 'POST',
|
||||
@@ -79,14 +82,14 @@ class CartController extends WP_REST_Controller {
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
// Clear cart
|
||||
register_rest_route($namespace, '/cart/clear', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'clear_cart'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
|
||||
// Apply coupon
|
||||
register_rest_route($namespace, '/cart/apply-coupon', [
|
||||
'methods' => 'POST',
|
||||
@@ -100,7 +103,7 @@ class CartController extends WP_REST_Controller {
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
// Remove coupon
|
||||
register_rest_route($namespace, '/cart/remove-coupon', [
|
||||
'methods' => 'POST',
|
||||
@@ -115,25 +118,26 @@ class CartController extends WP_REST_Controller {
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get cart contents
|
||||
*/
|
||||
public function get_cart($request) {
|
||||
public function get_cart($request)
|
||||
{
|
||||
if (!function_exists('WC')) {
|
||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
||||
}
|
||||
|
||||
|
||||
// Ensure cart is initialized
|
||||
if (is_null(WC()->cart)) {
|
||||
wc_load_cart();
|
||||
}
|
||||
|
||||
|
||||
$cart = WC()->cart;
|
||||
|
||||
|
||||
// Calculate totals to ensure discounts are computed
|
||||
$cart->calculate_totals();
|
||||
|
||||
|
||||
// Format coupons with discount amounts
|
||||
$coupons_with_discounts = [];
|
||||
foreach ($cart->get_applied_coupons() as $coupon_code) {
|
||||
@@ -145,7 +149,7 @@ class CartController extends WP_REST_Controller {
|
||||
'type' => $coupon->get_discount_type(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response([
|
||||
'items' => $this->format_cart_items($cart->get_cart()),
|
||||
'totals' => [
|
||||
@@ -167,42 +171,107 @@ class CartController extends WP_REST_Controller {
|
||||
'item_count' => $cart->get_cart_contents_count(),
|
||||
], 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add product to cart
|
||||
*/
|
||||
public function add_to_cart($request) {
|
||||
public function add_to_cart($request)
|
||||
{
|
||||
if (!function_exists('WC')) {
|
||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
||||
}
|
||||
|
||||
|
||||
// Ensure cart is initialized
|
||||
if (is_null(WC()->cart)) {
|
||||
wc_load_cart();
|
||||
}
|
||||
|
||||
|
||||
$product_id = $request->get_param('product_id');
|
||||
$quantity = $request->get_param('quantity') ?: 1;
|
||||
$variation_id = $request->get_param('variation_id') ?: 0;
|
||||
|
||||
$variation = $request->get_param('variation') ?: [];
|
||||
|
||||
// TEMPORARY DEBUG: Return early to confirm this code is reached
|
||||
if ($product_id == 512) {
|
||||
return new WP_REST_Response([
|
||||
'debug' => true,
|
||||
'message' => 'CartController reached',
|
||||
'product_id' => $product_id,
|
||||
'variation_id' => $variation_id,
|
||||
'variation' => $variation,
|
||||
], 200);
|
||||
}
|
||||
|
||||
// Validate product
|
||||
$product = wc_get_product($product_id);
|
||||
if (!$product) {
|
||||
return new WP_Error('invalid_product', 'Product not found', ['status' => 404]);
|
||||
}
|
||||
|
||||
|
||||
// Check stock
|
||||
if (!$product->is_in_stock()) {
|
||||
return new WP_Error('out_of_stock', 'Product is out of stock', ['status' => 400]);
|
||||
}
|
||||
|
||||
// Add to cart
|
||||
$cart_item_key = WC()->cart->add_to_cart($product_id, $quantity, $variation_id);
|
||||
|
||||
if (!$cart_item_key) {
|
||||
return new WP_Error('add_to_cart_failed', 'Failed to add product to cart', ['status' => 400]);
|
||||
|
||||
// For variable products with a variation_id, handle attributes properly
|
||||
// This ensures correct attribute keys even if frontend sends wrong/empty data
|
||||
if ($variation_id > 0) {
|
||||
$variation_product = wc_get_product($variation_id);
|
||||
if ($variation_product && $variation_product->is_type('variation')) {
|
||||
// Get the actual attributes stored on the variation
|
||||
$stored_attributes = $variation_product->get_variation_attributes();
|
||||
|
||||
// Merge: use stored attributes as base, but fill in empty values from frontend
|
||||
// This handles variations created with "Any X" option (empty values)
|
||||
$frontend_variation = $request->get_param('variation') ?: [];
|
||||
|
||||
foreach ($stored_attributes as $key => $value) {
|
||||
if ($value === '' && isset($frontend_variation[$key]) && $frontend_variation[$key] !== '') {
|
||||
// Stored value is empty ("Any"), use frontend value
|
||||
$stored_attributes[$key] = sanitize_text_field($frontend_variation[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
$variation = $stored_attributes;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// DEBUG: Log what we're passing to add_to_cart
|
||||
error_log('[CartController] product_id: ' . $product_id);
|
||||
error_log('[CartController] quantity: ' . $quantity);
|
||||
error_log('[CartController] variation_id: ' . $variation_id);
|
||||
error_log('[CartController] variation: ' . json_encode($variation));
|
||||
error_log('[CartController] frontend_variation (raw): ' . json_encode($request->get_param('variation')));
|
||||
|
||||
// Add to cart
|
||||
$cart_item_key = WC()->cart->add_to_cart($product_id, $quantity, $variation_id, $variation);
|
||||
|
||||
if (!$cart_item_key) {
|
||||
// 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([
|
||||
'success' => true,
|
||||
'cart_item_key' => $cart_item_key,
|
||||
@@ -210,166 +279,172 @@ class CartController extends WP_REST_Controller {
|
||||
'cart' => $this->get_cart($request)->data,
|
||||
], 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update cart item quantity
|
||||
*/
|
||||
public function update_cart_item($request) {
|
||||
public function update_cart_item($request)
|
||||
{
|
||||
if (!function_exists('WC')) {
|
||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
||||
}
|
||||
|
||||
|
||||
// Ensure cart is initialized
|
||||
if (is_null(WC()->cart)) {
|
||||
wc_load_cart();
|
||||
}
|
||||
|
||||
|
||||
$cart_item_key = $request->get_param('cart_item_key');
|
||||
$quantity = $request->get_param('quantity');
|
||||
|
||||
|
||||
// Validate cart item
|
||||
$cart = WC()->cart->get_cart();
|
||||
if (!isset($cart[$cart_item_key])) {
|
||||
return new WP_Error('invalid_cart_item', 'Cart item not found', ['status' => 404]);
|
||||
}
|
||||
|
||||
|
||||
// Update quantity
|
||||
$updated = WC()->cart->set_quantity($cart_item_key, $quantity);
|
||||
|
||||
|
||||
if (!$updated) {
|
||||
return new WP_Error('update_failed', 'Failed to update cart item', ['status' => 400]);
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'Cart updated successfully',
|
||||
'cart' => $this->get_cart($request)->data,
|
||||
], 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove item from cart
|
||||
*/
|
||||
public function remove_from_cart($request) {
|
||||
public function remove_from_cart($request)
|
||||
{
|
||||
if (!function_exists('WC')) {
|
||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
||||
}
|
||||
|
||||
|
||||
// Ensure cart is initialized
|
||||
if (is_null(WC()->cart)) {
|
||||
wc_load_cart();
|
||||
}
|
||||
|
||||
|
||||
$cart_item_key = $request->get_param('cart_item_key');
|
||||
|
||||
|
||||
// Validate cart item
|
||||
$cart = WC()->cart->get_cart();
|
||||
if (!isset($cart[$cart_item_key])) {
|
||||
return new WP_Error('invalid_cart_item', 'Cart item not found', ['status' => 404]);
|
||||
}
|
||||
|
||||
|
||||
// Remove item
|
||||
$removed = WC()->cart->remove_cart_item($cart_item_key);
|
||||
|
||||
|
||||
if (!$removed) {
|
||||
return new WP_Error('remove_failed', 'Failed to remove cart item', ['status' => 400]);
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'Item removed from cart',
|
||||
'cart' => $this->get_cart($request)->data,
|
||||
], 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clear cart
|
||||
*/
|
||||
public function clear_cart($request) {
|
||||
public function clear_cart($request)
|
||||
{
|
||||
if (!function_exists('WC')) {
|
||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
||||
}
|
||||
|
||||
|
||||
// Ensure cart is initialized
|
||||
if (is_null(WC()->cart)) {
|
||||
wc_load_cart();
|
||||
}
|
||||
|
||||
|
||||
WC()->cart->empty_cart();
|
||||
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'Cart cleared successfully',
|
||||
], 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Apply coupon
|
||||
*/
|
||||
public function apply_coupon($request) {
|
||||
public function apply_coupon($request)
|
||||
{
|
||||
if (!function_exists('WC')) {
|
||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
||||
}
|
||||
|
||||
|
||||
// Ensure cart is initialized
|
||||
if (is_null(WC()->cart)) {
|
||||
wc_load_cart();
|
||||
}
|
||||
|
||||
|
||||
$coupon_code = $request->get_param('coupon_code');
|
||||
|
||||
|
||||
// Apply coupon
|
||||
$applied = WC()->cart->apply_coupon($coupon_code);
|
||||
|
||||
|
||||
if (!$applied) {
|
||||
return new WP_Error('coupon_failed', 'Failed to apply coupon', ['status' => 400]);
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'Coupon applied successfully',
|
||||
'cart' => $this->get_cart($request)->data,
|
||||
], 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove coupon
|
||||
*/
|
||||
public function remove_coupon($request) {
|
||||
public function remove_coupon($request)
|
||||
{
|
||||
if (!function_exists('WC')) {
|
||||
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
|
||||
}
|
||||
|
||||
|
||||
// Ensure cart is initialized
|
||||
if (is_null(WC()->cart)) {
|
||||
wc_load_cart();
|
||||
}
|
||||
|
||||
|
||||
$coupon_code = $request->get_param('coupon_code');
|
||||
|
||||
|
||||
// Remove coupon
|
||||
$removed = WC()->cart->remove_coupon($coupon_code);
|
||||
|
||||
|
||||
if (!$removed) {
|
||||
return new WP_Error('coupon_remove_failed', 'Failed to remove coupon', ['status' => 400]);
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'Coupon removed successfully',
|
||||
'cart' => $this->get_cart($request)->data,
|
||||
], 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Format cart items for response
|
||||
*/
|
||||
private function format_cart_items($cart_items) {
|
||||
private function format_cart_items($cart_items)
|
||||
{
|
||||
$formatted = [];
|
||||
|
||||
|
||||
foreach ($cart_items as $cart_item_key => $cart_item) {
|
||||
$product = $cart_item['data'];
|
||||
|
||||
|
||||
$formatted[] = [
|
||||
'key' => $cart_item_key,
|
||||
'product_id' => $cart_item['product_id'],
|
||||
@@ -385,7 +460,7 @@ class CartController extends WP_REST_Controller {
|
||||
'downloadable' => $product->is_downloadable(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Licenses API Controller
|
||||
*
|
||||
@@ -17,91 +18,111 @@ use WP_Error;
|
||||
use WooNooW\Core\ModuleRegistry;
|
||||
use WooNooW\Modules\Licensing\LicenseManager;
|
||||
|
||||
class LicensesController {
|
||||
|
||||
class LicensesController
|
||||
{
|
||||
|
||||
/**
|
||||
* Register REST routes
|
||||
*/
|
||||
public static function register_routes() {
|
||||
public static function register_routes()
|
||||
{
|
||||
// Check if module is enabled
|
||||
if (!ModuleRegistry::is_enabled('licensing')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Admin routes
|
||||
register_rest_route('woonoow/v1', '/licenses', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_licenses'],
|
||||
'permission_callback' => function() {
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_license'],
|
||||
'permission_callback' => function() {
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'revoke_license'],
|
||||
'permission_callback' => function() {
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)/activations', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_activations'],
|
||||
'permission_callback' => function() {
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
// Customer routes
|
||||
register_rest_route('woonoow/v1', '/account/licenses', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_customer_licenses'],
|
||||
'permission_callback' => function() {
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
register_rest_route('woonoow/v1', '/account/licenses/(?P<id>\d+)/deactivate', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'customer_deactivate'],
|
||||
'permission_callback' => function() {
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
// Public API routes (for software validation)
|
||||
register_rest_route('woonoow/v1', '/licenses/validate', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'validate_license'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
|
||||
register_rest_route('woonoow/v1', '/licenses/activate', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'activate_license'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
|
||||
register_rest_route('woonoow/v1', '/licenses/deactivate', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'deactivate_license'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
// OAuth endpoints for license-connect
|
||||
register_rest_route('woonoow/v1', '/licenses/oauth/validate', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'oauth_validate'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/licenses/oauth/confirm', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'oauth_confirm'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all licenses (admin)
|
||||
*/
|
||||
public static function get_licenses(WP_REST_Request $request) {
|
||||
public static function get_licenses(WP_REST_Request $request)
|
||||
{
|
||||
$args = [
|
||||
'search' => $request->get_param('search'),
|
||||
'status' => $request->get_param('status'),
|
||||
@@ -110,14 +131,14 @@ class LicensesController {
|
||||
'limit' => $request->get_param('per_page') ?: 50,
|
||||
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 50),
|
||||
];
|
||||
|
||||
|
||||
$result = LicenseManager::get_all_licenses($args);
|
||||
|
||||
|
||||
// Enrich with product and user info
|
||||
foreach ($result['licenses'] as &$license) {
|
||||
$license = self::enrich_license($license);
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response([
|
||||
'licenses' => $result['licenses'],
|
||||
'total' => $result['total'],
|
||||
@@ -125,167 +146,282 @@ class LicensesController {
|
||||
'per_page' => $args['limit'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get single license (admin)
|
||||
*/
|
||||
public static function get_license(WP_REST_Request $request) {
|
||||
public static function get_license(WP_REST_Request $request)
|
||||
{
|
||||
$license = LicenseManager::get_license($request->get_param('id'));
|
||||
|
||||
|
||||
if (!$license) {
|
||||
return new WP_Error('not_found', __('License not found', 'woonoow'), ['status' => 404]);
|
||||
}
|
||||
|
||||
|
||||
$license = self::enrich_license($license);
|
||||
$license['activations'] = LicenseManager::get_activations($license['id']);
|
||||
|
||||
|
||||
return new WP_REST_Response($license);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Revoke license (admin)
|
||||
*/
|
||||
public static function revoke_license(WP_REST_Request $request) {
|
||||
public static function revoke_license(WP_REST_Request $request)
|
||||
{
|
||||
$result = LicenseManager::revoke($request->get_param('id'));
|
||||
|
||||
|
||||
if (!$result) {
|
||||
return new WP_Error('revoke_failed', __('Failed to revoke license', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response(['success' => true]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get activations for license (admin)
|
||||
*/
|
||||
public static function get_activations(WP_REST_Request $request) {
|
||||
public static function get_activations(WP_REST_Request $request)
|
||||
{
|
||||
$activations = LicenseManager::get_activations($request->get_param('id'));
|
||||
return new WP_REST_Response($activations);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get customer's licenses
|
||||
*/
|
||||
public static function get_customer_licenses(WP_REST_Request $request) {
|
||||
public static function get_customer_licenses(WP_REST_Request $request)
|
||||
{
|
||||
$user_id = get_current_user_id();
|
||||
$licenses = LicenseManager::get_user_licenses($user_id);
|
||||
|
||||
|
||||
// Enrich each license
|
||||
foreach ($licenses as &$license) {
|
||||
$license = self::enrich_license($license);
|
||||
$license['activations'] = LicenseManager::get_activations($license['id']);
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response($licenses);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Customer deactivate their own activation
|
||||
*/
|
||||
public static function customer_deactivate(WP_REST_Request $request) {
|
||||
public static function customer_deactivate(WP_REST_Request $request)
|
||||
{
|
||||
$user_id = get_current_user_id();
|
||||
$license = LicenseManager::get_license($request->get_param('id'));
|
||||
|
||||
|
||||
if (!$license || $license['user_id'] != $user_id) {
|
||||
return new WP_Error('not_found', __('License not found', 'woonoow'), ['status' => 404]);
|
||||
}
|
||||
|
||||
|
||||
$data = $request->get_json_params();
|
||||
$result = LicenseManager::deactivate(
|
||||
$license['license_key'],
|
||||
$data['activation_id'] ?? null,
|
||||
$data['machine_id'] ?? null
|
||||
);
|
||||
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate license (public API)
|
||||
*/
|
||||
public static function validate_license(WP_REST_Request $request) {
|
||||
public static function validate_license(WP_REST_Request $request)
|
||||
{
|
||||
$data = $request->get_json_params();
|
||||
|
||||
|
||||
if (empty($data['license_key'])) {
|
||||
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
|
||||
}
|
||||
|
||||
|
||||
$result = LicenseManager::validate($data['license_key']);
|
||||
return new WP_REST_Response($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Activate license (public API)
|
||||
*/
|
||||
public static function activate_license(WP_REST_Request $request) {
|
||||
public static function activate_license(WP_REST_Request $request)
|
||||
{
|
||||
$data = $request->get_json_params();
|
||||
|
||||
|
||||
if (empty($data['license_key'])) {
|
||||
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
|
||||
}
|
||||
|
||||
|
||||
$activation_data = [
|
||||
'domain' => $data['domain'] ?? null,
|
||||
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
'machine_id' => $data['machine_id'] ?? null,
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
|
||||
'return_url' => $data['return_url'] ?? null,
|
||||
'activation_token' => $data['activation_token'] ?? null,
|
||||
];
|
||||
|
||||
|
||||
$result = LicenseManager::activate($data['license_key'], $activation_data);
|
||||
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Deactivate license (public API)
|
||||
*/
|
||||
public static function deactivate_license(WP_REST_Request $request) {
|
||||
public static function deactivate_license(WP_REST_Request $request)
|
||||
{
|
||||
$data = $request->get_json_params();
|
||||
|
||||
|
||||
if (empty($data['license_key'])) {
|
||||
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
|
||||
}
|
||||
|
||||
|
||||
$result = LicenseManager::deactivate(
|
||||
$data['license_key'],
|
||||
$data['activation_id'] ?? null,
|
||||
$data['machine_id'] ?? null
|
||||
);
|
||||
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Enrich license with product and user info
|
||||
*/
|
||||
private static function enrich_license($license) {
|
||||
private static function enrich_license($license)
|
||||
{
|
||||
// Add product info
|
||||
$product = wc_get_product($license['product_id']);
|
||||
$license['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'woonoow');
|
||||
|
||||
|
||||
// Add user info
|
||||
$user = get_userdata($license['user_id']);
|
||||
$license['user_email'] = $user ? $user->user_email : '';
|
||||
$license['user_name'] = $user ? $user->display_name : __('Unknown User', 'woonoow');
|
||||
|
||||
|
||||
// Add computed fields
|
||||
$license['is_expired'] = $license['expires_at'] && strtotime($license['expires_at']) < time();
|
||||
$license['activations_remaining'] = $license['activation_limit'] > 0
|
||||
$license['activations_remaining'] = $license['activation_limit'] > 0
|
||||
? max(0, $license['activation_limit'] - $license['activation_count'])
|
||||
: -1;
|
||||
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +226,26 @@ class NotificationsController {
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
],
|
||||
]);
|
||||
|
||||
// POST /woonoow/v1/notifications/templates/:eventId/:channelId/send-test
|
||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/templates/(?P<eventId>[a-zA-Z0-9_-]+)/(?P<channelId>[a-zA-Z0-9_-]+)/send-test', [
|
||||
[
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'send_test_email'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'args' => [
|
||||
'email' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_email',
|
||||
],
|
||||
'recipient' => [
|
||||
'default' => 'customer',
|
||||
'type' => 'string',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -931,4 +951,411 @@ class NotificationsController {
|
||||
'per_page' => $per_page,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send test email for a notification template
|
||||
*
|
||||
* @param WP_REST_Request $request Request object
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function send_test_email(WP_REST_Request $request) {
|
||||
$event_id = $request->get_param('eventId');
|
||||
$channel_id = $request->get_param('channelId');
|
||||
$recipient_type = $request->get_param('recipient') ?? 'customer';
|
||||
$to_email = $request->get_param('email');
|
||||
|
||||
// Validate email
|
||||
if (!is_email($to_email)) {
|
||||
return new \WP_Error(
|
||||
'invalid_email',
|
||||
__('Invalid email address', 'woonoow'),
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
|
||||
// Only support email channel for test
|
||||
if ($channel_id !== 'email') {
|
||||
return new \WP_Error(
|
||||
'unsupported_channel',
|
||||
__('Test sending is only available for email channel', 'woonoow'),
|
||||
['status' => 400]
|
||||
);
|
||||
}
|
||||
|
||||
// Get template
|
||||
$template = TemplateProvider::get_template($event_id, $channel_id, $recipient_type);
|
||||
|
||||
if (!$template) {
|
||||
return new \WP_Error(
|
||||
'template_not_found',
|
||||
__('Template not found', 'woonoow'),
|
||||
['status' => 404]
|
||||
);
|
||||
}
|
||||
|
||||
// Build sample data for variables
|
||||
$sample_data = $this->get_sample_data_for_event($event_id);
|
||||
|
||||
// Replace variables in subject and body
|
||||
$subject = '[TEST] ' . $this->replace_variables($template['subject'] ?? '', $sample_data);
|
||||
$body_markdown = $this->replace_variables($template['body'] ?? '', $sample_data);
|
||||
|
||||
// Render email using EmailRenderer
|
||||
$email_renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
|
||||
|
||||
// We need to manually render since we're not triggering a real event
|
||||
$html = $this->render_test_email($body_markdown, $subject, $sample_data);
|
||||
|
||||
// Set content type to HTML
|
||||
$headers = ['Content-Type: text/html; charset=UTF-8'];
|
||||
|
||||
// Send email
|
||||
$sent = wp_mail($to_email, $subject, $html, $headers);
|
||||
|
||||
if (!$sent) {
|
||||
return new \WP_Error(
|
||||
'send_failed',
|
||||
__('Failed to send test email. Check your mail server configuration.', 'woonoow'),
|
||||
['status' => 500]
|
||||
);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => sprintf(__('Test email sent to %s', 'woonoow'), $to_email),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sample data for an event type
|
||||
*
|
||||
* @param string $event_id
|
||||
* @return array
|
||||
*/
|
||||
private function get_sample_data_for_event($event_id) {
|
||||
$base_data = [
|
||||
'site_name' => get_bloginfo('name'),
|
||||
'store_name' => get_bloginfo('name'),
|
||||
'store_url' => home_url(),
|
||||
'shop_url' => get_permalink(wc_get_page_id('shop')),
|
||||
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
|
||||
'support_email' => get_option('admin_email'),
|
||||
'current_year' => date('Y'),
|
||||
'customer_name' => 'John Doe',
|
||||
'customer_first_name' => 'John',
|
||||
'customer_last_name' => 'Doe',
|
||||
'customer_email' => 'john@example.com',
|
||||
'customer_phone' => '+1 234 567 8900',
|
||||
'login_url' => wp_login_url(),
|
||||
];
|
||||
|
||||
// Order-related events
|
||||
if (strpos($event_id, 'order') !== false) {
|
||||
$base_data = array_merge($base_data, [
|
||||
'order_number' => '12345',
|
||||
'order_id' => '12345',
|
||||
'order_date' => date('F j, Y'),
|
||||
'order_total' => wc_price(129.99),
|
||||
'order_subtotal' => wc_price(109.99),
|
||||
'order_tax' => wc_price(10.00),
|
||||
'order_shipping' => wc_price(10.00),
|
||||
'order_discount' => wc_price(0),
|
||||
'order_status' => 'Processing',
|
||||
'order_url' => '#',
|
||||
'payment_method' => 'Credit Card',
|
||||
'payment_status' => 'Paid',
|
||||
'payment_date' => date('F j, Y'),
|
||||
'transaction_id' => 'TXN123456789',
|
||||
'shipping_method' => 'Standard Shipping',
|
||||
'estimated_delivery' => date('F j', strtotime('+3 days')) . '-' . date('j', strtotime('+5 days')),
|
||||
'completion_date' => date('F j, Y'),
|
||||
'billing_address' => '123 Main St, City, State 12345, Country',
|
||||
'shipping_address' => '123 Main St, City, State 12345, Country',
|
||||
'tracking_number' => 'TRACK123456',
|
||||
'tracking_url' => '#',
|
||||
'shipping_carrier' => 'Standard Carrier',
|
||||
'payment_retry_url' => '#',
|
||||
'review_url' => '#',
|
||||
'order_items' => $this->get_sample_order_items_html(),
|
||||
'order_items_table' => $this->get_sample_order_items_html(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Customer account events
|
||||
if (strpos($event_id, 'customer') !== false || strpos($event_id, 'account') !== false) {
|
||||
$base_data = array_merge($base_data, [
|
||||
'customer_username' => 'johndoe',
|
||||
'user_temp_password' => 'SamplePass123',
|
||||
'reset_link' => '#',
|
||||
'reset_key' => 'abc123xyz',
|
||||
'user_login' => 'johndoe',
|
||||
'user_email' => 'john@example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
return $base_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sample order items HTML
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_sample_order_items_html() {
|
||||
return '<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
|
||||
<thead>
|
||||
<tr style="background: #f5f5f5;">
|
||||
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #ddd;">Product</th>
|
||||
<th style="padding: 12px; text-align: center; border-bottom: 2px solid #ddd;">Qty</th>
|
||||
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #eee;">
|
||||
<strong>Sample Product</strong><br>
|
||||
<span style="color: #666; font-size: 13px;">Size: M, Color: Blue</span>
|
||||
</td>
|
||||
<td style="padding: 12px; text-align: center; border-bottom: 1px solid #eee;">2</td>
|
||||
<td style="padding: 12px; text-align: right; border-bottom: 1px solid #eee;">' . wc_price(59.98) . '</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #eee;">
|
||||
<strong>Another Product</strong><br>
|
||||
<span style="color: #666; font-size: 13px;">Option: Standard</span>
|
||||
</td>
|
||||
<td style="padding: 12px; text-align: center; border-bottom: 1px solid #eee;">1</td>
|
||||
<td style="padding: 12px; text-align: right; border-bottom: 1px solid #eee;">' . wc_price(50.01) . '</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace variables in text
|
||||
*
|
||||
* @param string $text
|
||||
* @param array $variables
|
||||
* @return string
|
||||
*/
|
||||
private function replace_variables($text, $variables) {
|
||||
foreach ($variables as $key => $value) {
|
||||
$text = str_replace('{' . $key . '}', $value, $text);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render test email HTML
|
||||
*
|
||||
* @param string $body_markdown
|
||||
* @param string $subject
|
||||
* @param array $variables
|
||||
* @return string
|
||||
*/
|
||||
private function render_test_email($body_markdown, $subject, $variables) {
|
||||
// Parse cards
|
||||
$content = $this->parse_cards_for_test($body_markdown);
|
||||
|
||||
// Get appearance settings for colors
|
||||
$appearance = get_option('woonoow_appearance_settings', []);
|
||||
$colors = $appearance['general']['colors'] ?? [];
|
||||
$primary_color = $colors['primary'] ?? '#7f54b3';
|
||||
$secondary_color = $colors['secondary'] ?? '#7f54b3';
|
||||
$hero_gradient_start = $colors['gradientStart'] ?? '#667eea';
|
||||
$hero_gradient_end = $colors['gradientEnd'] ?? '#764ba2';
|
||||
|
||||
// Get email settings for branding
|
||||
$email_settings = get_option('woonoow_email_settings', []);
|
||||
$logo_url = $email_settings['logo_url'] ?? '';
|
||||
$header_text = $email_settings['header_text'] ?? $variables['store_name'];
|
||||
$footer_text = $email_settings['footer_text'] ?? sprintf('© %s %s. All rights reserved.', date('Y'), $variables['store_name']);
|
||||
$footer_text = str_replace('{current_year}', date('Y'), $footer_text);
|
||||
|
||||
// Build header
|
||||
if (!empty($logo_url)) {
|
||||
$header = sprintf(
|
||||
'<a href="%s"><img src="%s" alt="%s" style="max-width: 200px; max-height: 60px;"></a>',
|
||||
esc_url($variables['store_url']),
|
||||
esc_url($logo_url),
|
||||
esc_attr($variables['store_name'])
|
||||
);
|
||||
} else {
|
||||
$header = sprintf(
|
||||
'<a href="%s" style="font-size: 24px; font-weight: 700; color: #333; text-decoration: none;">%s</a>',
|
||||
esc_url($variables['store_url']),
|
||||
esc_html($header_text)
|
||||
);
|
||||
}
|
||||
|
||||
// Build full HTML
|
||||
$html = '<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>' . esc_html($subject) . '</title>
|
||||
<style>
|
||||
body { font-family: "Inter", Arial, sans-serif; background: #f8f8f8; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; }
|
||||
.header { padding: 32px; text-align: center; background: #f8f8f8; }
|
||||
.card-gutter { padding: 0 16px; }
|
||||
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; padding: 32px 40px; width: 100%; box-sizing: border-box; }
|
||||
.card-hero { background: linear-gradient(135deg, ' . esc_attr($hero_gradient_start) . ' 0%, ' . esc_attr($hero_gradient_end) . ' 100%); color: #ffffff; }
|
||||
.card-hero * { color: #ffffff !important; }
|
||||
.card-success { background-color: #f0fdf4; }
|
||||
.card-info { background-color: #f0f7ff; }
|
||||
.card-warning { background-color: #fff8e1; }
|
||||
.card-basic { background: none; padding: 0; }
|
||||
h1, h2, h3 { margin-top: 0; color: #333; }
|
||||
p { font-size: 16px; line-height: 1.6; color: #555; margin-bottom: 16px; }
|
||||
.button { display: inline-block; background: ' . esc_attr($primary_color) . '; color: #ffffff !important; padding: 14px 28px; border-radius: 6px; text-decoration: none; font-weight: 600; }
|
||||
.button-outline { display: inline-block; background: transparent; color: ' . esc_attr($secondary_color) . ' !important; padding: 12px 26px; border: 2px solid ' . esc_attr($secondary_color) . '; border-radius: 6px; text-decoration: none; font-weight: 600; }
|
||||
.footer { padding: 32px; text-align: center; color: #888; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">' . $header . '</div>
|
||||
<div class="card-gutter">' . $content . '</div>
|
||||
<div class="footer"><p>' . nl2br(esc_html($footer_text)) . '</p></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cards for test email
|
||||
*
|
||||
* @param string $content
|
||||
* @return string
|
||||
*/
|
||||
private function parse_cards_for_test($content) {
|
||||
// Parse [card:type] syntax
|
||||
$content = preg_replace_callback(
|
||||
'/\[card:(\w+)\](.*?)\[\/card\]/s',
|
||||
function($matches) {
|
||||
$type = $matches[1];
|
||||
$card_content = $this->markdown_to_html($matches[2]);
|
||||
return '<div class="card card-' . esc_attr($type) . '">' . $card_content . '</div>';
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
// Parse [card type="..."] syntax
|
||||
$content = preg_replace_callback(
|
||||
'/\[card([^\]]*)\](.*?)\[\/card\]/s',
|
||||
function($matches) {
|
||||
$attrs = $matches[1];
|
||||
$card_content = $this->markdown_to_html($matches[2]);
|
||||
$type = 'default';
|
||||
if (preg_match('/type=["\']([^"\']+)["\']/', $attrs, $type_match)) {
|
||||
$type = $type_match[1];
|
||||
}
|
||||
return '<div class="card card-' . esc_attr($type) . '">' . $card_content . '</div>';
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
// Parse buttons - new [button:style](url)Text[/button] syntax
|
||||
$content = preg_replace_callback(
|
||||
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
|
||||
function($matches) {
|
||||
$style = $matches[1];
|
||||
$url = $matches[2];
|
||||
$text = trim($matches[3]);
|
||||
$class = $style === 'outline' ? 'button-outline' : 'button';
|
||||
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($text) . '</a></p>';
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
// Parse buttons - old [button url="..." style="..."]Text[/button] syntax
|
||||
$content = preg_replace_callback(
|
||||
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=["\'](\w+)["\'])?\]([^\[]+)\[\/button\]/',
|
||||
function($matches) {
|
||||
$url = $matches[1];
|
||||
$style = $matches[2] ?? 'solid';
|
||||
$text = trim($matches[3]);
|
||||
$class = $style === 'outline' ? 'button-outline' : 'button';
|
||||
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($text) . '</a></p>';
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
// If no cards found, wrap in default card
|
||||
if (strpos($content, '<div class="card') === false) {
|
||||
$content = '<div class="card">' . $this->markdown_to_html($content) . '</div>';
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic markdown to HTML conversion
|
||||
*
|
||||
* @param string $text
|
||||
* @return string
|
||||
*/
|
||||
private function markdown_to_html($text) {
|
||||
// Parse buttons FIRST - new [button:style](url)Text[/button] syntax
|
||||
$text = preg_replace_callback(
|
||||
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
|
||||
function($matches) {
|
||||
$style = $matches[1];
|
||||
$url = $matches[2];
|
||||
$btn_text = trim($matches[3]);
|
||||
$class = $style === 'outline' ? 'button-outline' : 'button';
|
||||
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($btn_text) . '</a></p>';
|
||||
},
|
||||
$text
|
||||
);
|
||||
|
||||
// Parse buttons - old [button url="..."] syntax
|
||||
$text = preg_replace_callback(
|
||||
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=[\'"](\\w+)[\'"])?\]([^\[]+)\[\/button\]/',
|
||||
function($matches) {
|
||||
$url = $matches[1];
|
||||
$style = $matches[2] ?? 'solid';
|
||||
$btn_text = trim($matches[3]);
|
||||
$class = $style === 'outline' ? 'button-outline' : 'button';
|
||||
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($btn_text) . '</a></p>';
|
||||
},
|
||||
$text
|
||||
);
|
||||
|
||||
// Headers
|
||||
$text = preg_replace('/^### (.+)$/m', '<h3>$1</h3>', $text);
|
||||
$text = preg_replace('/^## (.+)$/m', '<h2>$1</h2>', $text);
|
||||
$text = preg_replace('/^# (.+)$/m', '<h1>$1</h1>', $text);
|
||||
|
||||
// Bold
|
||||
$text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
|
||||
|
||||
// Italic
|
||||
$text = preg_replace('/\*(.+?)\*/', '<em>$1</em>', $text);
|
||||
|
||||
// Links (but not button syntax - already handled above)
|
||||
$text = preg_replace('/\[(?!button)([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $text);
|
||||
|
||||
// List items
|
||||
$text = preg_replace('/^- (.+)$/m', '<li>$1</li>', $text);
|
||||
$text = preg_replace('/(<li>.*<\/li>)/s', '<ul>$1</ul>', $text);
|
||||
|
||||
// Paragraphs - wrap lines that aren't already wrapped
|
||||
$lines = explode("\n", $text);
|
||||
$result = [];
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (empty($line)) continue;
|
||||
if (!preg_match('/^<(h[1-6]|ul|li|div|p|table|tr|td|th)/', $line)) {
|
||||
$line = '<p>' . $line . '</p>';
|
||||
}
|
||||
$result[] = $line;
|
||||
}
|
||||
|
||||
return implode("\n", $result);
|
||||
}
|
||||
}
|
||||
|
||||
833
includes/Api/PagesController.php
Normal file
833
includes/Api/PagesController.php
Normal file
@@ -0,0 +1,833 @@
|
||||
<?php
|
||||
namespace WooNooW\Api;
|
||||
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_Error;
|
||||
use WooNooW\Frontend\PlaceholderRenderer;
|
||||
|
||||
use WooNooW\Frontend\PageSSR;
|
||||
use WooNooW\Templates\TemplateRegistry;
|
||||
|
||||
/**
|
||||
* Pages Controller
|
||||
* REST API for page structures and CPT templates
|
||||
*/
|
||||
class PagesController
|
||||
{
|
||||
/**
|
||||
* Register API routes
|
||||
*/
|
||||
public static function register_routes()
|
||||
{
|
||||
$namespace = 'woonoow/v1';
|
||||
|
||||
// Unset SPA Landing (Must be before generic slug route)
|
||||
register_rest_route($namespace, '/pages/unset-spa-landing', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'unset_spa_landing'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// List all pages and templates
|
||||
register_rest_route($namespace, '/pages', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_pages'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
// Get/Save page structure (structural pages)
|
||||
register_rest_route($namespace, '/pages/(?P<slug>[a-zA-Z0-9_-]+)', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_page'],
|
||||
'permission_callback' => '__return_true',
|
||||
],
|
||||
[
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'save_page'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Get template presets (Must be before generic template cpt route)
|
||||
register_rest_route($namespace, '/templates/presets', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_template_presets'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
// Get/Save CPT templates
|
||||
register_rest_route($namespace, '/templates/(?P<cpt>[a-zA-Z0-9_-]+)', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_template'],
|
||||
'permission_callback' => '__return_true',
|
||||
],
|
||||
[
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'save_template'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Get post with template applied (for SPA rendering)
|
||||
register_rest_route($namespace, '/content/(?P<type>[a-zA-Z0-9_-]+)/(?P<slug>[a-zA-Z0-9_-]+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_content_with_template'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
|
||||
|
||||
// Create new page
|
||||
register_rest_route($namespace, '/pages', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'create_page'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Preview page (render HTML for iframe)
|
||||
register_rest_route($namespace, '/preview/page/(?P<slug>[a-zA-Z0-9_-]+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'render_page_preview'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Preview template (render HTML for iframe)
|
||||
register_rest_route($namespace, '/preview/template/(?P<cpt>[a-zA-Z0-9_-]+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'render_template_preview'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Set page as SPA Landing (shown at SPA root route)
|
||||
register_rest_route($namespace, '/pages/(?P<id>\d+)/set-as-spa-landing', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'set_as_spa_landing'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
// Delete page
|
||||
register_rest_route($namespace, '/pages/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_page'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
]);
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Check admin permission
|
||||
*/
|
||||
public static function check_admin_permission()
|
||||
{
|
||||
return current_user_can('manage_woocommerce');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available template presets
|
||||
*/
|
||||
public static function get_template_presets()
|
||||
{
|
||||
return new WP_REST_Response(TemplateRegistry::get_templates(), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all editable pages (and templates)
|
||||
*/
|
||||
public static function get_pages()
|
||||
{
|
||||
$result = [];
|
||||
|
||||
// Get SPA settings
|
||||
$settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
|
||||
|
||||
// Get structural pages (pages with WooNooW structure)
|
||||
$pages = get_posts([
|
||||
'post_type' => 'page',
|
||||
'posts_per_page' => -1,
|
||||
'meta_query' => [
|
||||
[
|
||||
'key' => '_wn_page_structure',
|
||||
'compare' => 'EXISTS',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$result[] = [
|
||||
'id' => $page->ID,
|
||||
'type' => 'page',
|
||||
'slug' => $page->post_name,
|
||||
'title' => $page->post_title,
|
||||
'url' => get_permalink($page),
|
||||
'icon' => 'page',
|
||||
'is_spa_frontpage' => (int)$page->ID === (int)$spa_frontpage_id,
|
||||
];
|
||||
}
|
||||
|
||||
// Get CPT templates
|
||||
$cpts = self::get_editable_post_types();
|
||||
foreach ($cpts as $cpt => $label) {
|
||||
$template = get_option("wn_template_{$cpt}", null);
|
||||
|
||||
$result[] = [
|
||||
'type' => 'template',
|
||||
'cpt' => $cpt,
|
||||
'title' => "{$label} Template",
|
||||
'icon' => 'template',
|
||||
'permalink_base' => self::get_cpt_permalink_base($cpt),
|
||||
'has_template' => !empty($template),
|
||||
];
|
||||
}
|
||||
|
||||
return new WP_REST_Response($result, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page structure by slug
|
||||
*/
|
||||
public static function get_page(WP_REST_Request $request)
|
||||
{
|
||||
$slug = $request->get_param('slug');
|
||||
|
||||
// Find page by slug
|
||||
$page = get_page_by_path($slug);
|
||||
if (!$page) {
|
||||
return new WP_Error('not_found', 'Page not found', ['status' => 404]);
|
||||
}
|
||||
|
||||
$structure = get_post_meta($page->ID, '_wn_page_structure', true);
|
||||
|
||||
// Get SPA settings
|
||||
$settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
|
||||
|
||||
// Get SEO data (Yoast/Rank Math)
|
||||
$seo = self::get_seo_data($page->ID);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'id' => $page->ID,
|
||||
'type' => 'page',
|
||||
'slug' => $page->post_name,
|
||||
'title' => $page->post_title,
|
||||
'seo' => $seo,
|
||||
'is_spa_frontpage' => (int)$page->ID === (int)$spa_frontpage_id,
|
||||
'structure' => $structure ?: ['sections' => []],
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save page structure
|
||||
*/
|
||||
public static function save_page(WP_REST_Request $request)
|
||||
{
|
||||
$slug = $request->get_param('slug');
|
||||
$body = $request->get_json_params();
|
||||
|
||||
// Find page by slug
|
||||
$page = get_page_by_path($slug);
|
||||
if (!$page) {
|
||||
return new WP_Error('not_found', 'Page not found', ['status' => 404]);
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
$structure = $body['sections'] ?? null;
|
||||
if ($structure === null) {
|
||||
return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]);
|
||||
}
|
||||
|
||||
// Save structure
|
||||
$save_data = [
|
||||
'type' => 'page',
|
||||
'sections' => $structure,
|
||||
'updated_at' => current_time('mysql'),
|
||||
];
|
||||
|
||||
update_post_meta($page->ID, '_wn_page_structure', $save_data);
|
||||
|
||||
// Invalidate SSR cache
|
||||
delete_transient("wn_ssr_page_{$page->ID}");
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'page' => [
|
||||
'id' => $page->ID,
|
||||
'slug' => $page->post_name,
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CPT template
|
||||
*/
|
||||
public static function get_template(WP_REST_Request $request)
|
||||
{
|
||||
$cpt = $request->get_param('cpt');
|
||||
|
||||
// Validate CPT exists
|
||||
if (!post_type_exists($cpt) && $cpt !== 'post') {
|
||||
return new WP_Error('invalid_cpt', 'Invalid post type', ['status' => 400]);
|
||||
}
|
||||
|
||||
$template = get_option("wn_template_{$cpt}", null);
|
||||
$cpt_obj = get_post_type_object($cpt);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'type' => 'template',
|
||||
'cpt' => $cpt,
|
||||
'title' => $cpt_obj ? $cpt_obj->labels->singular_name . ' Template' : ucfirst($cpt) . ' Template',
|
||||
'permalink_base' => self::get_cpt_permalink_base($cpt),
|
||||
'available_sources' => self::get_available_sources($cpt),
|
||||
'structure' => $template ?: ['sections' => []],
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save CPT template
|
||||
*/
|
||||
public static function save_template(WP_REST_Request $request)
|
||||
{
|
||||
$cpt = $request->get_param('cpt');
|
||||
$body = $request->get_json_params();
|
||||
|
||||
// Validate CPT exists
|
||||
if (!post_type_exists($cpt) && $cpt !== 'post') {
|
||||
return new WP_Error('invalid_cpt', 'Invalid post type', ['status' => 400]);
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
$structure = $body['sections'] ?? null;
|
||||
if ($structure === null) {
|
||||
return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]);
|
||||
}
|
||||
|
||||
// Save template
|
||||
$save_data = [
|
||||
'type' => 'template',
|
||||
'cpt' => $cpt,
|
||||
'sections' => $structure,
|
||||
'updated_at' => current_time('mysql'),
|
||||
];
|
||||
|
||||
update_option("wn_template_{$cpt}", $save_data);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'template' => [
|
||||
'cpt' => $cpt,
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content with template applied (for SPA rendering)
|
||||
*/
|
||||
public static function get_content_with_template(WP_REST_Request $request)
|
||||
{
|
||||
$type = $request->get_param('type');
|
||||
$slug = $request->get_param('slug');
|
||||
|
||||
// Handle structural pages
|
||||
if ($type === 'page') {
|
||||
return self::get_page($request);
|
||||
}
|
||||
|
||||
// For CPT items, get post and apply template
|
||||
$post = get_page_by_path($slug, OBJECT, $type);
|
||||
if (!$post) {
|
||||
// Try with post type 'post' if type is 'blog'
|
||||
if ($type === 'blog') {
|
||||
$post = get_page_by_path($slug, OBJECT, 'post');
|
||||
$type = 'post';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$post) {
|
||||
return new WP_Error('not_found', 'Content not found', ['status' => 404]);
|
||||
}
|
||||
|
||||
// Get template for this CPT
|
||||
$template = get_option("wn_template_{$type}", null);
|
||||
|
||||
// Build post data
|
||||
$post_data = PlaceholderRenderer::build_post_data($post);
|
||||
|
||||
// Get SEO data
|
||||
$seo = self::get_seo_data($post->ID);
|
||||
|
||||
// If template exists, resolve placeholders
|
||||
$rendered_sections = [];
|
||||
if ($template && !empty($template['sections'])) {
|
||||
foreach ($template['sections'] as $section) {
|
||||
$resolved_section = $section;
|
||||
$resolved_section['props'] = PageSSR::resolve_props($section['props'] ?? [], $post_data);
|
||||
$rendered_sections[] = $resolved_section;
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'type' => 'content',
|
||||
'cpt' => $type,
|
||||
'post' => $post_data,
|
||||
'seo' => $seo,
|
||||
'template' => $template ?: ['sections' => []],
|
||||
'rendered' => [
|
||||
'sections' => $rendered_sections,
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set page as SPA Landing (the page shown at SPA root route)
|
||||
* This does NOT affect WordPress page_on_front setting.
|
||||
*/
|
||||
public static function set_as_spa_landing(WP_REST_Request $request) {
|
||||
$id = (int)$request->get_param('id');
|
||||
|
||||
// Verify the page exists
|
||||
$page = get_post($id);
|
||||
if (!$page || $page->post_type !== 'page') {
|
||||
return new WP_Error('invalid_page', 'Page not found', ['status' => 404]);
|
||||
}
|
||||
|
||||
// Update WooNooW SPA settings - set this page as the SPA frontpage
|
||||
$settings = get_option('woonoow_appearance_settings', []);
|
||||
if (!isset($settings['general'])) {
|
||||
$settings['general'] = [];
|
||||
}
|
||||
$settings['general']['spa_frontpage'] = $id;
|
||||
|
||||
update_option('woonoow_appearance_settings', $settings);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'id' => $id,
|
||||
'message' => 'SPA Landing page set successfully'
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unset SPA Landing (the page shown at SPA root route)
|
||||
* After unsetting, SPA will redirect to /shop or /checkout based on mode
|
||||
*/
|
||||
public static function unset_spa_landing(WP_REST_Request $request) {
|
||||
// Update WooNooW SPA settings - clear the SPA frontpage
|
||||
$settings = get_option('woonoow_appearance_settings', []);
|
||||
if (isset($settings['general'])) {
|
||||
$settings['general']['spa_frontpage'] = 0;
|
||||
}
|
||||
update_option('woonoow_appearance_settings', $settings);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'SPA Landing page unset. Root will now redirect to shop/checkout.'
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new page
|
||||
*/
|
||||
public static function create_page(WP_REST_Request $request)
|
||||
{
|
||||
$body = $request->get_json_params();
|
||||
|
||||
$title = sanitize_text_field($body['title'] ?? '');
|
||||
$slug = sanitize_title($body['slug'] ?? $title);
|
||||
|
||||
if (empty($title)) {
|
||||
return new WP_Error('invalid_data', 'Title is required', ['status' => 400]);
|
||||
}
|
||||
|
||||
// Check if page already exists
|
||||
if (get_page_by_path($slug)) {
|
||||
return new WP_Error('exists', 'Page with this slug already exists', ['status' => 400]);
|
||||
}
|
||||
|
||||
// Create page
|
||||
$page_id = wp_insert_post([
|
||||
'post_type' => 'page',
|
||||
'post_title' => $title,
|
||||
'post_name' => $slug,
|
||||
'post_status' => 'publish',
|
||||
]);
|
||||
|
||||
if (is_wp_error($page_id)) {
|
||||
return $page_id;
|
||||
}
|
||||
|
||||
// Initialize empty structure
|
||||
$structure = [
|
||||
'type' => 'page',
|
||||
'sections' => [],
|
||||
'created_at' => current_time('mysql'),
|
||||
];
|
||||
|
||||
// Apply template if provided
|
||||
$template_id = $body['templateId'] ?? null;
|
||||
if ($template_id) {
|
||||
$template = TemplateRegistry::get_template($template_id);
|
||||
if ($template) {
|
||||
$structure['sections'] = $template['sections'];
|
||||
}
|
||||
}
|
||||
|
||||
update_post_meta($page_id, '_wn_page_structure', $structure);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'page' => [
|
||||
'id' => $page_id,
|
||||
'slug' => $slug,
|
||||
'title' => $title,
|
||||
'url' => get_permalink($page_id),
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete page
|
||||
*/
|
||||
public static function delete_page(WP_REST_Request $request) {
|
||||
$id = (int)$request->get_param('id');
|
||||
|
||||
$page = get_post($id);
|
||||
if (!$page || $page->post_type !== 'page') {
|
||||
return new WP_Error('not_found', 'Page not found', ['status' => 404]);
|
||||
}
|
||||
|
||||
// Check if it's the SPA front page
|
||||
$settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
|
||||
|
||||
if ((int)$id === (int)$spa_frontpage_id) {
|
||||
// Unset SPA frontpage if deleting it
|
||||
if (isset($settings['general'])) {
|
||||
$settings['general']['spa_frontpage'] = 0;
|
||||
update_option('woonoow_appearance_settings', $settings);
|
||||
}
|
||||
}
|
||||
|
||||
$deleted = wp_delete_post($id, true); // Force delete
|
||||
|
||||
if (!$deleted) {
|
||||
return new WP_Error('delete_failed', 'Failed to delete page', ['status' => 500]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'id' => $id,
|
||||
'message' => 'Page deleted successfully'
|
||||
], 200);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helper Methods
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get editable post types for templates
|
||||
*/
|
||||
private static function get_editable_post_types()
|
||||
{
|
||||
$types = [
|
||||
'post' => 'Blog Post',
|
||||
];
|
||||
|
||||
// Get public custom post types
|
||||
$custom_types = get_post_types([
|
||||
'public' => true,
|
||||
'_builtin' => false,
|
||||
], 'objects');
|
||||
|
||||
foreach ($custom_types as $type) {
|
||||
// Skip WooCommerce types (handled separately)
|
||||
if (in_array($type->name, ['product', 'shop_order', 'shop_coupon'])) {
|
||||
continue;
|
||||
}
|
||||
$types[$type->name] = $type->labels->singular_name;
|
||||
}
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permalink base for a CPT
|
||||
*/
|
||||
private static function get_cpt_permalink_base($cpt)
|
||||
{
|
||||
if ($cpt === 'post') {
|
||||
// Get blog permalink structure
|
||||
$struct = get_option('permalink_structure');
|
||||
if (strpos($struct, '%postname%') !== false) {
|
||||
return '/blog/';
|
||||
}
|
||||
return '/';
|
||||
}
|
||||
|
||||
$obj = get_post_type_object($cpt);
|
||||
if ($obj && isset($obj->rewrite['slug'])) {
|
||||
return '/' . $obj->rewrite['slug'] . '/';
|
||||
}
|
||||
|
||||
return '/' . $cpt . '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available dynamic sources for a CPT
|
||||
*/
|
||||
private static function get_available_sources($cpt)
|
||||
{
|
||||
$sources = [
|
||||
['value' => 'post_title', 'label' => 'Title'],
|
||||
['value' => 'post_content', 'label' => 'Content'],
|
||||
['value' => 'post_excerpt', 'label' => 'Excerpt'],
|
||||
['value' => 'post_featured_image', 'label' => 'Featured Image'],
|
||||
['value' => 'post_author', 'label' => 'Author'],
|
||||
['value' => 'post_date', 'label' => 'Date'],
|
||||
['value' => 'post_url', 'label' => 'Permalink'],
|
||||
];
|
||||
|
||||
// Add taxonomy sources
|
||||
$taxonomies = get_object_taxonomies($cpt, 'objects');
|
||||
foreach ($taxonomies as $tax) {
|
||||
if ($tax->public) {
|
||||
$sources[] = [
|
||||
'value' => $tax->name,
|
||||
'label' => $tax->labels->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Add related posts source
|
||||
$sources[] = ['value' => 'related_posts', 'label' => 'Related Posts'];
|
||||
|
||||
return $sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SEO data for a post (Yoast/Rank Math compatible)
|
||||
*/
|
||||
private static function get_seo_data($post_id)
|
||||
{
|
||||
$seo = [];
|
||||
|
||||
// Try Yoast
|
||||
$seo['meta_title'] = get_post_meta($post_id, '_yoast_wpseo_title', true) ?: get_the_title($post_id);
|
||||
$seo['meta_description'] = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
|
||||
$seo['canonical'] = get_post_meta($post_id, '_yoast_wpseo_canonical', true) ?: get_permalink($post_id);
|
||||
$seo['og_title'] = get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true);
|
||||
$seo['og_description'] = get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true);
|
||||
$seo['og_image'] = get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true);
|
||||
|
||||
// Try Rank Math if Yoast not available
|
||||
if (empty($seo['meta_description'])) {
|
||||
$seo['meta_description'] = get_post_meta($post_id, 'rank_math_description', true);
|
||||
}
|
||||
|
||||
return $seo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render page preview HTML (for editor iframe)
|
||||
*/
|
||||
public static function render_page_preview(WP_REST_Request $request)
|
||||
{
|
||||
$slug = $request->get_param('slug');
|
||||
$body = $request->get_json_params();
|
||||
|
||||
// Get sections from POST body (unsaved changes)
|
||||
$sections = $body['sections'] ?? [];
|
||||
|
||||
// Find page for title
|
||||
$page = get_page_by_path($slug);
|
||||
$title = $page ? $page->post_title : 'Preview';
|
||||
|
||||
// Render HTML
|
||||
$html = self::render_preview_html($title, $sections, 'page');
|
||||
|
||||
// Return as HTML response
|
||||
return new WP_REST_Response([
|
||||
'html' => $html,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render template preview HTML (for editor iframe)
|
||||
*/
|
||||
public static function render_template_preview(WP_REST_Request $request)
|
||||
{
|
||||
$cpt = $request->get_param('cpt');
|
||||
$body = $request->get_json_params();
|
||||
|
||||
// Get sections from POST body
|
||||
$sections = $body['sections'] ?? [];
|
||||
|
||||
// Get sample post for dynamic placeholders
|
||||
$sample_post = null;
|
||||
if ($cpt && $cpt !== 'page') {
|
||||
$posts = get_posts([
|
||||
'post_type' => $cpt,
|
||||
'posts_per_page' => 1,
|
||||
'post_status' => 'publish',
|
||||
]);
|
||||
if (!empty($posts)) {
|
||||
$sample_post = $posts[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve placeholders if sample post exists
|
||||
$resolved_sections = $sections;
|
||||
if ($sample_post) {
|
||||
$post_data = PlaceholderRenderer::build_post_data($sample_post);
|
||||
$resolved_sections = [];
|
||||
foreach ($sections as $section) {
|
||||
$resolved_section = $section;
|
||||
$resolved_section['props'] = PageSSR::resolve_props($section['props'] ?? [], $post_data);
|
||||
$resolved_sections[] = $resolved_section;
|
||||
}
|
||||
}
|
||||
|
||||
$cpt_obj = get_post_type_object($cpt);
|
||||
$title = $cpt_obj ? $cpt_obj->labels->singular_name . ' Preview' : 'Template Preview';
|
||||
|
||||
// Render HTML
|
||||
$html = self::render_preview_html($title, $resolved_sections, 'template', $sample_post);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'html' => $html,
|
||||
'sample_post' => $sample_post ? [
|
||||
'id' => $sample_post->ID,
|
||||
'title' => $sample_post->post_title,
|
||||
] : null,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Render preview HTML document
|
||||
*/
|
||||
private static function render_preview_html($title, $sections, $type, $sample_post = null)
|
||||
{
|
||||
// Get site URL for assets
|
||||
$plugin_url = plugins_url('', dirname(dirname(__FILE__)));
|
||||
|
||||
// Start output buffering
|
||||
ob_start();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html <?php language_attributes(); ?>>
|
||||
<head>
|
||||
<meta charset="<?php bloginfo('charset'); ?>">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?php echo esc_html($title); ?> - Preview</title>
|
||||
<style>
|
||||
/* Reset and base styles */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1f2937;
|
||||
background: #fff;
|
||||
}
|
||||
img { max-width: 100%; height: auto; }
|
||||
|
||||
/* Section base */
|
||||
.wn-section { padding: 4rem 1rem; }
|
||||
.wn-container { max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
/* Color schemes */
|
||||
.wn-scheme-default { background: #fff; color: #1f2937; }
|
||||
.wn-scheme-primary { background: #3b82f6; color: #fff; }
|
||||
.wn-scheme-secondary { background: #1f2937; color: #fff; }
|
||||
.wn-scheme-muted { background: #f3f4f6; color: #1f2937; }
|
||||
.wn-scheme-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Hero section */
|
||||
.wn-hero { text-align: center; padding: 6rem 1rem; }
|
||||
.wn-hero h1 { font-size: 2.5rem; font-weight: 800; margin-bottom: 1rem; }
|
||||
.wn-hero p { font-size: 1.25rem; opacity: 0.9; margin-bottom: 2rem; }
|
||||
.wn-hero .wn-btn {
|
||||
display: inline-block; padding: 0.75rem 1.5rem;
|
||||
background: currentColor; color: inherit;
|
||||
border-radius: 0.5rem; text-decoration: none;
|
||||
filter: invert(1); font-weight: 600;
|
||||
}
|
||||
|
||||
/* Content section */
|
||||
.wn-content { padding: 3rem 1rem; }
|
||||
.wn-content.wn-narrow .wn-container { max-width: 720px; }
|
||||
.wn-content.wn-medium .wn-container { max-width: 960px; }
|
||||
|
||||
/* Image + Text */
|
||||
.wn-image-text { display: flex; gap: 3rem; align-items: center; flex-wrap: wrap; }
|
||||
.wn-image-text .wn-image { flex: 1; min-width: 300px; }
|
||||
.wn-image-text .wn-text { flex: 1; min-width: 300px; }
|
||||
.wn-image-text.wn-image-right { flex-direction: row-reverse; }
|
||||
|
||||
/* Feature grid */
|
||||
.wn-features { display: grid; gap: 2rem; }
|
||||
.wn-features.wn-grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.wn-features.wn-grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.wn-features.wn-grid-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
@media (max-width: 768px) {
|
||||
.wn-features { grid-template-columns: 1fr; }
|
||||
}
|
||||
.wn-feature { text-align: center; padding: 1.5rem; }
|
||||
.wn-feature-icon { font-size: 2rem; margin-bottom: 1rem; }
|
||||
|
||||
/* CTA Banner */
|
||||
.wn-cta { text-align: center; padding: 4rem 1rem; }
|
||||
.wn-cta h2 { font-size: 2rem; margin-bottom: 1rem; }
|
||||
|
||||
/* Contact form */
|
||||
.wn-contact form { max-width: 500px; margin: 0 auto; }
|
||||
.wn-contact input, .wn-contact textarea {
|
||||
width: 100%; padding: 0.75rem; margin-bottom: 1rem;
|
||||
border: 1px solid #d1d5db; border-radius: 0.375rem;
|
||||
}
|
||||
.wn-contact button {
|
||||
width: 100%; padding: 0.75rem; background: #3b82f6;
|
||||
color: #fff; border: none; border-radius: 0.375rem;
|
||||
cursor: pointer; font-weight: 600;
|
||||
}
|
||||
|
||||
/* Preview indicator */
|
||||
.wn-preview-indicator {
|
||||
position: fixed; top: 0; left: 0; right: 0;
|
||||
background: #f59e0b; color: #000; text-align: center;
|
||||
padding: 0.5rem; font-size: 0.875rem; font-weight: 500;
|
||||
z-index: 9999;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wn-preview-indicator">
|
||||
🔍 Preview Mode <?php if ($sample_post): ?>(Using: <?php echo esc_html($sample_post->post_title); ?>)<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<main style="padding-top: 2.5rem;">
|
||||
<?php
|
||||
foreach ($sections as $section) {
|
||||
echo PageSSR::render_section($section, $sample_post ? PlaceholderRenderer::build_post_data($sample_post) : []);
|
||||
}
|
||||
|
||||
if (empty($sections)) {
|
||||
echo '<div style="text-align:center; padding:4rem; color:#9ca3af;">';
|
||||
echo '<p>No sections added yet.</p>';
|
||||
echo '<p>Add sections in the editor to see preview.</p>';
|
||||
echo '</div>';
|
||||
}
|
||||
?>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ use WooNooW\Api\ModuleSettingsController;
|
||||
use WooNooW\Api\CampaignsController;
|
||||
use WooNooW\Api\DocsController;
|
||||
use WooNooW\Api\LicensesController;
|
||||
use WooNooW\Api\SubscriptionsController;
|
||||
use WooNooW\Frontend\ShopController;
|
||||
use WooNooW\Frontend\CartController as FrontendCartController;
|
||||
use WooNooW\Frontend\AccountController;
|
||||
@@ -35,6 +36,7 @@ use WooNooW\Frontend\HookBridge;
|
||||
use WooNooW\Api\Controllers\SettingsController;
|
||||
use WooNooW\Api\Controllers\CartController as ApiCartController;
|
||||
use WooNooW\Admin\AppearanceController;
|
||||
use WooNooW\Api\PagesController;
|
||||
|
||||
class Routes {
|
||||
public static function init() {
|
||||
@@ -162,6 +164,9 @@ class Routes {
|
||||
// Licenses controller (licensing module)
|
||||
LicensesController::register_routes();
|
||||
|
||||
// Subscriptions controller (subscription module)
|
||||
SubscriptionsController::register_routes();
|
||||
|
||||
// Modules controller
|
||||
$modules_controller = new ModulesController();
|
||||
$modules_controller->register_routes();
|
||||
@@ -181,6 +186,9 @@ class Routes {
|
||||
AddressController::register_routes();
|
||||
WishlistController::register_routes();
|
||||
HookBridge::register_routes();
|
||||
|
||||
// Pages and templates controller
|
||||
PagesController::register_routes();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
476
includes/Api/SubscriptionsController.php
Normal file
476
includes/Api/SubscriptionsController.php
Normal file
@@ -0,0 +1,476 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Subscriptions API Controller
|
||||
*
|
||||
* REST API endpoints for subscription management.
|
||||
*
|
||||
* @package WooNooW\Api
|
||||
*/
|
||||
|
||||
namespace WooNooW\Api;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_Error;
|
||||
use WooNooW\Core\ModuleRegistry;
|
||||
use WooNooW\Modules\Subscription\SubscriptionManager;
|
||||
|
||||
class SubscriptionsController
|
||||
{
|
||||
|
||||
/**
|
||||
* Register REST routes
|
||||
*/
|
||||
public static function register_routes()
|
||||
{
|
||||
// Check if module is enabled
|
||||
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin routes
|
||||
register_rest_route('woonoow/v1', '/subscriptions', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_subscriptions'],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_subscription'],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)', [
|
||||
'methods' => 'PUT',
|
||||
'callback' => [__CLASS__, 'update_subscription'],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)/cancel', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'cancel_subscription'],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)/renew', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'renew_subscription'],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)/pause', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'pause_subscription'],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)/resume', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'resume_subscription'],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
// Customer routes
|
||||
register_rest_route('woonoow/v1', '/account/subscriptions', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_customer_subscriptions'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_customer_subscription'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\d+)/cancel', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'customer_cancel'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\d+)/pause', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'customer_pause'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\d+)/resume', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'customer_resume'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\d+)/renew', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'customer_renew'],
|
||||
'permission_callback' => function () {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all subscriptions (admin)
|
||||
*/
|
||||
public static function get_subscriptions(WP_REST_Request $request)
|
||||
{
|
||||
$args = [
|
||||
'status' => $request->get_param('status'),
|
||||
'product_id' => $request->get_param('product_id'),
|
||||
'user_id' => $request->get_param('user_id'),
|
||||
'limit' => $request->get_param('per_page') ?: 20,
|
||||
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 20),
|
||||
];
|
||||
|
||||
$subscriptions = SubscriptionManager::get_all($args);
|
||||
$total = SubscriptionManager::count(['status' => $args['status']]);
|
||||
|
||||
// Enrich with product and user info
|
||||
$enriched = [];
|
||||
foreach ($subscriptions as $subscription) {
|
||||
$enriched[] = self::enrich_subscription($subscription);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'subscriptions' => $enriched,
|
||||
'total' => $total,
|
||||
'page' => $request->get_param('page') ?: 1,
|
||||
'per_page' => $args['limit'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single subscription (admin)
|
||||
*/
|
||||
public static function get_subscription(WP_REST_Request $request)
|
||||
{
|
||||
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||
|
||||
if (!$subscription) {
|
||||
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||
}
|
||||
|
||||
$enriched = self::enrich_subscription($subscription);
|
||||
$enriched['orders'] = SubscriptionManager::get_orders($subscription->id);
|
||||
|
||||
return new WP_REST_Response($enriched);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update subscription (admin)
|
||||
*/
|
||||
public static function update_subscription(WP_REST_Request $request)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||
|
||||
if (!$subscription) {
|
||||
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||
}
|
||||
|
||||
$data = $request->get_json_params();
|
||||
$allowed_fields = ['status', 'next_payment_date', 'end_date', 'billing_period', 'billing_interval'];
|
||||
|
||||
$update_data = [];
|
||||
$format = [];
|
||||
|
||||
foreach ($allowed_fields as $field) {
|
||||
if (isset($data[$field])) {
|
||||
$update_data[$field] = $data[$field];
|
||||
$format[] = is_numeric($data[$field]) ? '%d' : '%s';
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($update_data)) {
|
||||
return new WP_Error('no_data', __('No valid fields to update', 'woonoow'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$table = $wpdb->prefix . 'woonoow_subscriptions';
|
||||
$updated = $wpdb->update($table, $update_data, ['id' => $subscription->id], $format, ['%d']);
|
||||
|
||||
if ($updated === false) {
|
||||
return new WP_Error('update_failed', __('Failed to update subscription', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel subscription (admin)
|
||||
*/
|
||||
public static function cancel_subscription(WP_REST_Request $request)
|
||||
{
|
||||
$data = $request->get_json_params();
|
||||
$reason = $data['reason'] ?? 'Cancelled by admin';
|
||||
|
||||
$result = SubscriptionManager::cancel($request->get_param('id'), $reason);
|
||||
|
||||
if (!$result) {
|
||||
return new WP_Error('cancel_failed', __('Failed to cancel subscription', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew subscription (admin - force immediate renewal)
|
||||
*/
|
||||
public static function renew_subscription(WP_REST_Request $request)
|
||||
{
|
||||
$result = SubscriptionManager::renew($request->get_param('id'));
|
||||
|
||||
if (!$result) {
|
||||
return new WP_Error('renew_failed', __('Failed to process renewal', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(['success' => true, 'order_id' => $result['order_id']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause subscription (admin)
|
||||
*/
|
||||
public static function pause_subscription(WP_REST_Request $request)
|
||||
{
|
||||
$result = SubscriptionManager::pause($request->get_param('id'));
|
||||
|
||||
if (!$result) {
|
||||
return new WP_Error('pause_failed', __('Failed to pause subscription', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume subscription (admin)
|
||||
*/
|
||||
public static function resume_subscription(WP_REST_Request $request)
|
||||
{
|
||||
$result = SubscriptionManager::resume($request->get_param('id'));
|
||||
|
||||
if (!$result) {
|
||||
return new WP_Error('resume_failed', __('Failed to resume subscription', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer's subscriptions
|
||||
*/
|
||||
public static function get_customer_subscriptions(WP_REST_Request $request)
|
||||
{
|
||||
$user_id = get_current_user_id();
|
||||
$subscriptions = SubscriptionManager::get_by_user($user_id, [
|
||||
'status' => $request->get_param('status'),
|
||||
'limit' => $request->get_param('per_page') ?: 20,
|
||||
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 20),
|
||||
]);
|
||||
|
||||
// Enrich each subscription
|
||||
$enriched = [];
|
||||
foreach ($subscriptions as $subscription) {
|
||||
$enriched[] = self::enrich_subscription($subscription);
|
||||
}
|
||||
|
||||
return new WP_REST_Response($enriched);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer's subscription detail
|
||||
*/
|
||||
public static function get_customer_subscription(WP_REST_Request $request)
|
||||
{
|
||||
$user_id = get_current_user_id();
|
||||
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||
|
||||
if (!$subscription || $subscription->user_id != $user_id) {
|
||||
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||
}
|
||||
|
||||
$enriched = self::enrich_subscription($subscription);
|
||||
$enriched['orders'] = SubscriptionManager::get_orders($subscription->id);
|
||||
|
||||
return new WP_REST_Response($enriched);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer cancel their own subscription
|
||||
*/
|
||||
public static function customer_cancel(WP_REST_Request $request)
|
||||
{
|
||||
// Check if customer cancellation is allowed
|
||||
$settings = ModuleRegistry::get_settings('subscription');
|
||||
if (empty($settings['allow_customer_cancel'])) {
|
||||
return new WP_Error('not_allowed', __('Customer cancellation is not allowed', 'woonoow'), ['status' => 403]);
|
||||
}
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||
|
||||
if (!$subscription || $subscription->user_id != $user_id) {
|
||||
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||
}
|
||||
|
||||
$data = $request->get_json_params();
|
||||
$reason = $data['reason'] ?? 'Cancelled by customer';
|
||||
|
||||
$result = SubscriptionManager::cancel($subscription->id, $reason);
|
||||
|
||||
if (!$result) {
|
||||
return new WP_Error('cancel_failed', __('Failed to cancel subscription', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer pause their own subscription
|
||||
*/
|
||||
public static function customer_pause(WP_REST_Request $request)
|
||||
{
|
||||
// Check if customer pause is allowed
|
||||
$settings = ModuleRegistry::get_settings('subscription');
|
||||
if (empty($settings['allow_customer_pause'])) {
|
||||
return new WP_Error('not_allowed', __('Customer pause is not allowed', 'woonoow'), ['status' => 403]);
|
||||
}
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||
|
||||
if (!$subscription || $subscription->user_id != $user_id) {
|
||||
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||
}
|
||||
|
||||
$result = SubscriptionManager::pause($subscription->id);
|
||||
|
||||
if (!$result) {
|
||||
return new WP_Error('pause_failed', __('Failed to pause subscription. Maximum pauses may have been reached.', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer resume their own subscription
|
||||
*/
|
||||
public static function customer_resume(WP_REST_Request $request)
|
||||
{
|
||||
$user_id = get_current_user_id();
|
||||
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||
|
||||
if (!$subscription || $subscription->user_id != $user_id) {
|
||||
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||
}
|
||||
|
||||
$result = SubscriptionManager::resume($subscription->id);
|
||||
|
||||
if (!$result) {
|
||||
return new WP_Error('resume_failed', __('Failed to resume subscription', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer renew their own subscription (Early Renewal)
|
||||
*/
|
||||
public static function customer_renew(WP_REST_Request $request)
|
||||
{
|
||||
$user_id = get_current_user_id();
|
||||
$subscription = SubscriptionManager::get($request->get_param('id'));
|
||||
|
||||
if (!$subscription || $subscription->user_id != $user_id) {
|
||||
return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]);
|
||||
}
|
||||
|
||||
// Check if subscription is active (for early renewal) or on-hold with no pending payment
|
||||
if ($subscription->status !== 'active' && $subscription->status !== 'on-hold') {
|
||||
return new WP_Error('not_allowed', __('Only active subscriptions can be renewed early', 'woonoow'), ['status' => 403]);
|
||||
}
|
||||
|
||||
// Trigger renewal
|
||||
$result = SubscriptionManager::renew($subscription->id);
|
||||
|
||||
// SubscriptionManager::renew returns array (success) or false (failed)
|
||||
if (!$result) {
|
||||
return new WP_Error('renew_failed', __('Failed to create renewal order', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'order_id' => $result['order_id'],
|
||||
'status' => $result['status']
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich subscription with product and user info
|
||||
*/
|
||||
private static function enrich_subscription($subscription)
|
||||
{
|
||||
$enriched = (array) $subscription;
|
||||
|
||||
// Add product info
|
||||
$product_id = $subscription->variation_id ?: $subscription->product_id;
|
||||
$product = wc_get_product($product_id);
|
||||
$enriched['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'woonoow');
|
||||
$enriched['product_image'] = $product ? wp_get_attachment_url($product->get_image_id()) : '';
|
||||
|
||||
// Add user info
|
||||
$user = get_userdata($subscription->user_id);
|
||||
$enriched['user_email'] = $user ? $user->user_email : '';
|
||||
$enriched['user_name'] = $user ? $user->display_name : __('Unknown User', 'woonoow');
|
||||
|
||||
// Add computed fields
|
||||
$enriched['is_active'] = $subscription->status === 'active';
|
||||
$enriched['can_pause'] = $subscription->status === 'active';
|
||||
$enriched['can_resume'] = $subscription->status === 'on-hold';
|
||||
$enriched['can_cancel'] = in_array($subscription->status, ['active', 'on-hold', 'pending']);
|
||||
|
||||
// Format billing info
|
||||
$period_labels = [
|
||||
'day' => __('day', 'woonoow'),
|
||||
'week' => __('week', 'woonoow'),
|
||||
'month' => __('month', 'woonoow'),
|
||||
'year' => __('year', 'woonoow'),
|
||||
];
|
||||
$interval = $subscription->billing_interval > 1 ? $subscription->billing_interval . ' ' : '';
|
||||
$period = $period_labels[$subscription->billing_period] ?? $subscription->billing_period;
|
||||
if ($subscription->billing_interval > 1) {
|
||||
$period .= 's'; // Pluralize
|
||||
}
|
||||
$enriched['billing_schedule'] = sprintf(__('Every %s%s', 'woonoow'), $interval, $period);
|
||||
|
||||
return $enriched;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ if ( ! defined('ABSPATH') ) exit;
|
||||
*/
|
||||
class NavigationRegistry {
|
||||
const NAV_OPTION = 'wnw_nav_tree';
|
||||
const NAV_VERSION = '1.0.9'; // Added Help menu
|
||||
const NAV_VERSION = '1.3.0'; // Added Subscriptions section
|
||||
|
||||
/**
|
||||
* Initialize hooks
|
||||
@@ -132,6 +132,8 @@ class NavigationRegistry {
|
||||
// Future: Drafts, Recurring, etc.
|
||||
],
|
||||
],
|
||||
// Subscriptions - only if module enabled
|
||||
...self::get_subscriptions_section(),
|
||||
[
|
||||
'key' => 'products',
|
||||
'label' => __('Products', 'woonoow'),
|
||||
@@ -169,6 +171,8 @@ class NavigationRegistry {
|
||||
'icon' => 'palette',
|
||||
'children' => [
|
||||
['label' => __('General', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/general'],
|
||||
['label' => __('Pages', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/pages'],
|
||||
['label' => __('Menus', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/menus'],
|
||||
['label' => __('Header', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/header'],
|
||||
['label' => __('Footer', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/footer'],
|
||||
['label' => __('Shop', 'woonoow'), 'mode' => 'spa', 'path' => '/appearance/shop'],
|
||||
@@ -240,6 +244,30 @@ class NavigationRegistry {
|
||||
return $children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscriptions navigation section
|
||||
* Returns empty array if module is not enabled
|
||||
*
|
||||
* @return array Subscriptions section or empty array
|
||||
*/
|
||||
private static function get_subscriptions_section(): array {
|
||||
if (!\WooNooW\Core\ModuleRegistry::is_enabled('subscription')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
'key' => 'subscriptions',
|
||||
'label' => __('Subscriptions', 'woonoow'),
|
||||
'path' => '/subscriptions',
|
||||
'icon' => 'repeat',
|
||||
'children' => [
|
||||
['label' => __('All Subscriptions', 'woonoow'), 'mode' => 'spa', 'path' => '/subscriptions', 'exact' => true],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the complete navigation tree
|
||||
*
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Module Registry
|
||||
*
|
||||
@@ -10,14 +11,16 @@
|
||||
|
||||
namespace WooNooW\Core;
|
||||
|
||||
class ModuleRegistry {
|
||||
|
||||
class ModuleRegistry
|
||||
{
|
||||
|
||||
/**
|
||||
* Get built-in modules
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function get_builtin_modules() {
|
||||
private static function get_builtin_modules()
|
||||
{
|
||||
$modules = [
|
||||
'newsletter' => [
|
||||
'id' => 'newsletter',
|
||||
@@ -68,6 +71,7 @@ class ModuleRegistry {
|
||||
'category' => 'products',
|
||||
'icon' => 'refresh-cw',
|
||||
'default_enabled' => false,
|
||||
'has_settings' => true,
|
||||
'features' => [
|
||||
__('Recurring billing', 'woonoow'),
|
||||
__('Subscription management', 'woonoow'),
|
||||
@@ -91,19 +95,20 @@ class ModuleRegistry {
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get addon modules from AddonRegistry
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function get_addon_modules() {
|
||||
private static function get_addon_modules()
|
||||
{
|
||||
$addons = apply_filters('woonoow/addon_registry', []);
|
||||
$modules = [];
|
||||
|
||||
|
||||
foreach ($addons as $addon_id => $addon) {
|
||||
$modules[$addon_id] = [
|
||||
'id' => $addon_id,
|
||||
@@ -120,31 +125,33 @@ class ModuleRegistry {
|
||||
'settings_component' => $addon['settings_component'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all modules (built-in + addons)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_all_modules() {
|
||||
public static function get_all_modules()
|
||||
{
|
||||
$builtin = self::get_builtin_modules();
|
||||
$addons = self::get_addon_modules();
|
||||
|
||||
|
||||
return array_merge($builtin, $addons);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get categories dynamically from registered modules
|
||||
*
|
||||
* @return array Associative array of category_id => label
|
||||
*/
|
||||
public static function get_categories() {
|
||||
public static function get_categories()
|
||||
{
|
||||
$all_modules = self::get_all_modules();
|
||||
$categories = [];
|
||||
|
||||
|
||||
// Extract unique categories from modules
|
||||
foreach ($all_modules as $module) {
|
||||
$cat = $module['category'] ?? 'other';
|
||||
@@ -152,27 +159,28 @@ class ModuleRegistry {
|
||||
$categories[$cat] = self::get_category_label($cat);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Sort by predefined order
|
||||
$order = ['marketing', 'customers', 'products', 'shipping', 'payments', 'analytics', 'other'];
|
||||
uksort($categories, function($a, $b) use ($order) {
|
||||
uksort($categories, function ($a, $b) use ($order) {
|
||||
$pos_a = array_search($a, $order);
|
||||
$pos_b = array_search($b, $order);
|
||||
if ($pos_a === false) $pos_a = 999;
|
||||
if ($pos_b === false) $pos_b = 999;
|
||||
return $pos_a - $pos_b;
|
||||
});
|
||||
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get human-readable label for category
|
||||
*
|
||||
* @param string $category Category ID
|
||||
* @return string
|
||||
*/
|
||||
private static function get_category_label($category) {
|
||||
private static function get_category_label($category)
|
||||
{
|
||||
$labels = [
|
||||
'marketing' => __('Marketing & Sales', 'woonoow'),
|
||||
'customers' => __('Customer Experience', 'woonoow'),
|
||||
@@ -182,19 +190,20 @@ class ModuleRegistry {
|
||||
'analytics' => __('Analytics & Reports', 'woonoow'),
|
||||
'other' => __('Other Extensions', 'woonoow'),
|
||||
];
|
||||
|
||||
|
||||
return $labels[$category] ?? ucfirst($category);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Group modules by category
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_grouped_modules() {
|
||||
public static function get_grouped_modules()
|
||||
{
|
||||
$all_modules = self::get_all_modules();
|
||||
$grouped = [];
|
||||
|
||||
|
||||
foreach ($all_modules as $module) {
|
||||
$cat = $module['category'] ?? 'other';
|
||||
if (!isset($grouped[$cat])) {
|
||||
@@ -202,18 +211,19 @@ class ModuleRegistry {
|
||||
}
|
||||
$grouped[$cat][] = $module;
|
||||
}
|
||||
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get enabled modules
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_enabled_modules() {
|
||||
public static function get_enabled_modules()
|
||||
{
|
||||
$enabled = get_option('woonoow_enabled_modules', null);
|
||||
|
||||
|
||||
// First time - use defaults
|
||||
if ($enabled === null) {
|
||||
$modules = self::get_all_modules();
|
||||
@@ -225,89 +235,93 @@ class ModuleRegistry {
|
||||
}
|
||||
update_option('woonoow_enabled_modules', $enabled);
|
||||
}
|
||||
|
||||
|
||||
return $enabled;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if a module is enabled
|
||||
*
|
||||
* @param string $module_id
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled($module_id) {
|
||||
public static function is_enabled($module_id)
|
||||
{
|
||||
$enabled = self::get_enabled_modules();
|
||||
return in_array($module_id, $enabled);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Enable a module
|
||||
*
|
||||
* @param string $module_id
|
||||
* @return bool
|
||||
*/
|
||||
public static function enable($module_id) {
|
||||
public static function enable($module_id)
|
||||
{
|
||||
$modules = self::get_all_modules();
|
||||
|
||||
|
||||
if (!isset($modules[$module_id])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
$enabled = self::get_enabled_modules();
|
||||
|
||||
|
||||
if (!in_array($module_id, $enabled)) {
|
||||
$enabled[] = $module_id;
|
||||
update_option('woonoow_enabled_modules', $enabled);
|
||||
|
||||
|
||||
// Clear navigation cache when module is toggled
|
||||
if (class_exists('\WooNooW\Compat\NavigationRegistry')) {
|
||||
\WooNooW\Compat\NavigationRegistry::flush();
|
||||
}
|
||||
|
||||
|
||||
do_action('woonoow/module/enabled', $module_id);
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Disable a module
|
||||
*
|
||||
* @param string $module_id
|
||||
* @return bool
|
||||
*/
|
||||
public static function disable($module_id) {
|
||||
public static function disable($module_id)
|
||||
{
|
||||
$enabled = self::get_enabled_modules();
|
||||
|
||||
|
||||
if (in_array($module_id, $enabled)) {
|
||||
$enabled = array_diff($enabled, [$module_id]);
|
||||
update_option('woonoow_enabled_modules', array_values($enabled));
|
||||
|
||||
|
||||
// Clear navigation cache when module is toggled
|
||||
if (class_exists('\WooNooW\Compat\NavigationRegistry')) {
|
||||
\WooNooW\Compat\NavigationRegistry::flush();
|
||||
}
|
||||
|
||||
|
||||
do_action('woonoow/module/disabled', $module_id);
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get modules by category
|
||||
*
|
||||
* @param string $category
|
||||
* @return array
|
||||
*/
|
||||
public static function get_by_category($category) {
|
||||
public static function get_by_category($category)
|
||||
{
|
||||
$modules = self::get_all_modules();
|
||||
$enabled = self::get_enabled_modules();
|
||||
|
||||
|
||||
$result = [];
|
||||
foreach ($modules as $module) {
|
||||
if ($module['category'] === $category) {
|
||||
@@ -315,23 +329,63 @@ class ModuleRegistry {
|
||||
$result[] = $module;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all modules with enabled status
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_all_with_status() {
|
||||
public static function get_all_with_status()
|
||||
{
|
||||
$modules = self::get_all_modules();
|
||||
$enabled = self::get_enabled_modules();
|
||||
|
||||
foreach ($modules as $id => $module) {
|
||||
$modules[$id]['enabled'] = in_array($id, $enabled);
|
||||
|
||||
foreach ($modules as $id => &$module) {
|
||||
$module['enabled'] = in_array($id, $enabled);
|
||||
}
|
||||
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module settings
|
||||
*
|
||||
* @param string $module_id
|
||||
* @return array
|
||||
*/
|
||||
public static function get_settings($module_id)
|
||||
{
|
||||
$settings = get_option("woonoow_module_{$module_id}_settings", []);
|
||||
|
||||
// Apply defaults from schema if available
|
||||
$schema = apply_filters('woonoow/module_settings_schema', []);
|
||||
|
||||
if (isset($schema[$module_id])) {
|
||||
$defaults = self::get_schema_defaults($schema[$module_id]);
|
||||
$settings = wp_parse_args($settings, $defaults);
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default values from schema
|
||||
*
|
||||
* @param array $schema
|
||||
* @return array
|
||||
*/
|
||||
private static function get_schema_defaults($schema)
|
||||
{
|
||||
$defaults = [];
|
||||
|
||||
foreach ($schema as $key => $field) {
|
||||
if (isset($field['default'])) {
|
||||
$defaults[$key] = $field['default'];
|
||||
}
|
||||
}
|
||||
|
||||
return $defaults;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Email Renderer
|
||||
*
|
||||
@@ -9,70 +10,78 @@
|
||||
|
||||
namespace WooNooW\Core\Notifications;
|
||||
|
||||
class EmailRenderer {
|
||||
|
||||
|
||||
|
||||
class EmailRenderer
|
||||
{
|
||||
|
||||
/**
|
||||
* Instance
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
|
||||
/**
|
||||
* Get instance
|
||||
*/
|
||||
public static function instance() {
|
||||
public static function instance()
|
||||
{
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render email
|
||||
*
|
||||
* @param string $event_id Event ID (order_placed, order_processing, etc.)
|
||||
* @param string $recipient_type Recipient type (staff, customer)
|
||||
* @param mixed $data Order, Product, or Customer object
|
||||
* @param WC_Order|WC_Product|WC_Customer|mixed $data Order, Product, or Customer object
|
||||
* @param array $extra_data Additional data
|
||||
* @return array|null ['to', 'subject', 'body']
|
||||
*/
|
||||
public function render($event_id, $recipient_type, $data, $extra_data = []) {
|
||||
public function render($event_id, $recipient_type, $data, $extra_data = [])
|
||||
{
|
||||
// Get template settings
|
||||
$template_settings = $this->get_template_settings($event_id, $recipient_type);
|
||||
|
||||
|
||||
if (!$template_settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Get recipient email
|
||||
$to = $this->get_recipient_email($recipient_type, $data);
|
||||
|
||||
|
||||
if (!$to) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailRenderer] Failed to get recipient email for event: ' . $event_id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Get variables
|
||||
$variables = $this->get_variables($event_id, $data, $extra_data);
|
||||
|
||||
|
||||
// Replace variables in subject and content
|
||||
$subject = $this->replace_variables($template_settings['subject'], $variables);
|
||||
$content = $this->replace_variables($template_settings['body'], $variables);
|
||||
|
||||
|
||||
// Parse cards in content
|
||||
$content = $this->parse_cards($content);
|
||||
|
||||
|
||||
// Get HTML template
|
||||
$template_path = $this->get_design_template();
|
||||
|
||||
|
||||
// Render final HTML
|
||||
$html = $this->render_html($template_path, $content, $subject, $variables);
|
||||
|
||||
|
||||
return [
|
||||
'to' => $to,
|
||||
'subject' => $subject,
|
||||
'body' => $html,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get template settings
|
||||
*
|
||||
@@ -80,30 +89,31 @@ class EmailRenderer {
|
||||
* @param string $recipient_type
|
||||
* @return array|null
|
||||
*/
|
||||
private function get_template_settings($event_id, $recipient_type) {
|
||||
private function get_template_settings($event_id, $recipient_type)
|
||||
{
|
||||
// Get saved template (with recipient_type for proper default template lookup)
|
||||
$template = TemplateProvider::get_template($event_id, 'email', $recipient_type);
|
||||
|
||||
|
||||
if (!$template) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
|
||||
|
||||
// Get design template preference
|
||||
$settings = get_option('woonoow_notification_settings', []);
|
||||
$design = $settings['email_design_template'] ?? 'modern';
|
||||
|
||||
|
||||
return [
|
||||
'subject' => $template['subject'] ?? '',
|
||||
'body' => $template['body'] ?? '',
|
||||
'design' => $design,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get recipient email
|
||||
*
|
||||
@@ -111,23 +121,58 @@ class EmailRenderer {
|
||||
* @param mixed $data
|
||||
* @return string|null
|
||||
*/
|
||||
private function get_recipient_email($recipient_type, $data) {
|
||||
private function get_recipient_email($recipient_type, $data)
|
||||
{
|
||||
if ($recipient_type === 'staff') {
|
||||
return get_option('admin_email');
|
||||
}
|
||||
|
||||
|
||||
// Customer
|
||||
if ($data instanceof \WC_Order) {
|
||||
return $data->get_billing_email();
|
||||
}
|
||||
|
||||
|
||||
if ($data instanceof \WC_Customer) {
|
||||
return $data->get_email();
|
||||
}
|
||||
|
||||
|
||||
if ($data instanceof \WP_User) {
|
||||
return $data->user_email;
|
||||
}
|
||||
|
||||
// Handle array data (e.g. subscription notifications)
|
||||
if (is_array($data)) {
|
||||
// Check for customer object in array
|
||||
if (isset($data['customer'])) {
|
||||
if ($data['customer'] instanceof \WP_User) {
|
||||
return $data['customer']->user_email;
|
||||
}
|
||||
if ($data['customer'] instanceof \WC_Customer) {
|
||||
return $data['customer']->get_email();
|
||||
}
|
||||
}
|
||||
|
||||
// Check for direct email in data
|
||||
if (isset($data['email']) && is_email($data['email'])) {
|
||||
return $data['email'];
|
||||
}
|
||||
|
||||
// Check for user_id
|
||||
if (isset($data['user_id'])) {
|
||||
$user = get_user_by('id', $data['user_id']);
|
||||
if ($user) {
|
||||
return $user->user_email;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailRenderer] Could not determine recipient email for type: ' . $recipient_type);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get variables for template
|
||||
*
|
||||
@@ -136,7 +181,8 @@ class EmailRenderer {
|
||||
* @param array $extra_data
|
||||
* @return array
|
||||
*/
|
||||
private function get_variables($event_id, $data, $extra_data = []) {
|
||||
private function get_variables($event_id, $data, $extra_data = [])
|
||||
{
|
||||
$variables = [
|
||||
'site_name' => get_bloginfo('name'),
|
||||
'site_title' => get_bloginfo('name'),
|
||||
@@ -147,12 +193,12 @@ class EmailRenderer {
|
||||
'support_email' => get_option('admin_email'),
|
||||
'current_year' => date('Y'),
|
||||
];
|
||||
|
||||
|
||||
// Order variables
|
||||
if ($data instanceof \WC_Order) {
|
||||
if ($data instanceof WC_Order) {
|
||||
// Calculate estimated delivery (3-5 business days from now)
|
||||
$estimated_delivery = date('F j', strtotime('+3 days')) . '-' . date('j', strtotime('+5 days'));
|
||||
|
||||
|
||||
// Completion date (for completed orders)
|
||||
$completion_date = '';
|
||||
if ($data->get_date_completed()) {
|
||||
@@ -160,13 +206,13 @@ class EmailRenderer {
|
||||
} else {
|
||||
$completion_date = date('F j, Y'); // Fallback to today
|
||||
}
|
||||
|
||||
|
||||
// Payment date
|
||||
$payment_date = '';
|
||||
if ($data->get_date_paid()) {
|
||||
$payment_date = $data->get_date_paid()->date('F j, Y');
|
||||
}
|
||||
|
||||
|
||||
$variables = array_merge($variables, [
|
||||
'order_number' => $data->get_order_number(),
|
||||
'order_id' => $data->get_id(),
|
||||
@@ -202,7 +248,7 @@ class EmailRenderer {
|
||||
'tracking_url' => $data->get_meta('_tracking_url') ?: '#',
|
||||
'shipping_carrier' => $data->get_meta('_shipping_carrier') ?: 'Standard Shipping',
|
||||
]);
|
||||
|
||||
|
||||
// Order items table
|
||||
$items_html = '<table class="order-details" style="width: 100%; border-collapse: collapse;">';
|
||||
$items_html .= '<thead><tr>';
|
||||
@@ -210,7 +256,7 @@ class EmailRenderer {
|
||||
$items_html .= '<th style="text-align: center; padding: 12px 0; border-bottom: 1px solid #e5e5e5;">Qty</th>';
|
||||
$items_html .= '<th style="text-align: right; padding: 12px 0; border-bottom: 1px solid #e5e5e5;">Price</th>';
|
||||
$items_html .= '</tr></thead><tbody>';
|
||||
|
||||
|
||||
foreach ($data->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
$items_html .= '<tr>';
|
||||
@@ -228,16 +274,16 @@ class EmailRenderer {
|
||||
);
|
||||
$items_html .= '</tr>';
|
||||
}
|
||||
|
||||
|
||||
$items_html .= '</tbody></table>';
|
||||
|
||||
|
||||
// Both naming conventions for compatibility
|
||||
$variables['order_items'] = $items_html;
|
||||
$variables['order_items_table'] = $items_html;
|
||||
}
|
||||
|
||||
|
||||
// Product variables
|
||||
if ($data instanceof \WC_Product) {
|
||||
if ($data instanceof WC_Product) {
|
||||
$variables = array_merge($variables, [
|
||||
'product_id' => $data->get_id(),
|
||||
'product_name' => $data->get_name(),
|
||||
@@ -248,27 +294,27 @@ class EmailRenderer {
|
||||
'stock_status' => $data->get_stock_status(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
// Customer variables
|
||||
if ($data instanceof \WC_Customer) {
|
||||
if ($data instanceof WC_Customer) {
|
||||
// Get temp password from user meta (stored during auto-registration)
|
||||
$user_temp_password = get_user_meta($data->get_id(), '_woonoow_temp_password', true);
|
||||
|
||||
|
||||
// Generate login URL (pointing to SPA login instead of wp-login)
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||
|
||||
|
||||
if ($spa_page_id) {
|
||||
$spa_url = get_permalink($spa_page_id);
|
||||
// Use path format for BrowserRouter, hash format for HashRouter
|
||||
$login_url = $use_browser_router
|
||||
$login_url = $use_browser_router
|
||||
? trailingslashit($spa_url) . 'login'
|
||||
: $spa_url . '#/login';
|
||||
} else {
|
||||
$login_url = wp_login_url();
|
||||
}
|
||||
|
||||
|
||||
$variables = array_merge($variables, [
|
||||
'customer_id' => $data->get_id(),
|
||||
'customer_name' => $data->get_display_name(),
|
||||
@@ -282,31 +328,72 @@ class EmailRenderer {
|
||||
'shop_url' => get_permalink(wc_get_page_id('shop')),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
// Subscription variables (passed as array)
|
||||
if (is_array($data) && isset($data['subscription'])) {
|
||||
$sub = $data['subscription'];
|
||||
// subscription object usually has: id, user_id, product_id, status, ...
|
||||
|
||||
$sub_variables = [
|
||||
'subscription_id' => $sub->id ?? '',
|
||||
'subscription_status' => isset($sub->status) ? ucfirst($sub->status) : '',
|
||||
'billing_period' => isset($sub->billing_period) ? ucfirst($sub->billing_period) : '',
|
||||
'recurring_amount' => isset($sub->recurring_amount) ? wc_price($sub->recurring_amount) : '',
|
||||
'next_payment_date' => isset($sub->next_payment_date) ? date('F j, Y', strtotime($sub->next_payment_date)) : 'N/A',
|
||||
'end_date' => isset($sub->end_date) ? date('F j, Y', strtotime($sub->end_date)) : 'N/A',
|
||||
'cancel_reason' => $data['reason'] ?? '',
|
||||
'failed_count' => $data['failed_count'] ?? 0,
|
||||
'payment_link' => $data['payment_link'] ?? '',
|
||||
];
|
||||
|
||||
// Get product name if not already set
|
||||
if (!isset($variables['product_name']) && isset($data['product']) && $data['product'] instanceof \WC_Product) {
|
||||
$sub_variables['product_name'] = $data['product']->get_name();
|
||||
$sub_variables['product_url'] = get_permalink($data['product']->get_id());
|
||||
}
|
||||
|
||||
// Get customer details if not already set
|
||||
if (!isset($variables['customer_name']) && isset($data['customer']) && $data['customer'] instanceof \WP_User) {
|
||||
$user = $data['customer'];
|
||||
$sub_variables['customer_name'] = $user->display_name;
|
||||
$sub_variables['customer_email'] = $user->user_email;
|
||||
}
|
||||
|
||||
$variables = array_merge($variables, $sub_variables);
|
||||
} else if (is_array($data) && isset($data['customer']) && $data['customer'] instanceof \WP_User) {
|
||||
// Basic user data passed in array without subscription (e.g. generalized notification)
|
||||
$user = $data['customer'];
|
||||
$variables = array_merge($variables, [
|
||||
'customer_name' => $user->display_name,
|
||||
'customer_email' => $user->user_email,
|
||||
]);
|
||||
}
|
||||
|
||||
// Merge extra data
|
||||
$variables = array_merge($variables, $extra_data);
|
||||
|
||||
|
||||
return apply_filters('woonoow_email_variables', $variables, $event_id, $data);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse [card] tags and convert to HTML
|
||||
*
|
||||
* @param string $content
|
||||
* @return string
|
||||
*/
|
||||
private function parse_cards($content) {
|
||||
private function parse_cards($content)
|
||||
{
|
||||
// Use a single unified regex to match BOTH syntaxes in document order
|
||||
// This ensures cards are rendered in the order they appear
|
||||
$combined_pattern = '/\[card(?::(\w+)|([^\]]*)?)\](.*?)\[\/card\]/s';
|
||||
|
||||
|
||||
preg_match_all($combined_pattern, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||||
|
||||
|
||||
if (empty($matches)) {
|
||||
// No cards found, wrap entire content in a single card
|
||||
return $this->render_card($content, []);
|
||||
}
|
||||
|
||||
|
||||
$html = '';
|
||||
foreach ($matches as $match) {
|
||||
// Determine which syntax was matched
|
||||
@@ -314,7 +401,7 @@ class EmailRenderer {
|
||||
$new_syntax_type = !empty($match[1][0]) ? $match[1][0] : null; // [card:type] format
|
||||
$old_syntax_attrs = $match[2][0] ?? ''; // [card type="..."] format
|
||||
$card_content = $match[3][0];
|
||||
|
||||
|
||||
if ($new_syntax_type) {
|
||||
// NEW syntax [card:type]
|
||||
$attributes = ['type' => $new_syntax_type];
|
||||
@@ -322,42 +409,43 @@ class EmailRenderer {
|
||||
// OLD syntax [card type="..."] or [card]
|
||||
$attributes = $this->parse_card_attributes($old_syntax_attrs);
|
||||
}
|
||||
|
||||
|
||||
$html .= $this->render_card($card_content, $attributes);
|
||||
$html .= $this->render_card_spacing();
|
||||
}
|
||||
|
||||
|
||||
// Remove last spacing
|
||||
$html = preg_replace('/<table[^>]*class="card-spacing"[^>]*>.*?<\/table>\s*$/s', '', $html);
|
||||
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse card attributes from [card ...] tag
|
||||
*
|
||||
* @param string $attr_string
|
||||
* @return array
|
||||
*/
|
||||
private function parse_card_attributes($attr_string) {
|
||||
private function parse_card_attributes($attr_string)
|
||||
{
|
||||
$attributes = [
|
||||
'type' => 'default',
|
||||
'bg' => null,
|
||||
];
|
||||
|
||||
|
||||
// Parse type="highlight"
|
||||
if (preg_match('/type=["\']([^"\']+)["\']/', $attr_string, $match)) {
|
||||
$attributes['type'] = $match[1];
|
||||
}
|
||||
|
||||
|
||||
// Parse bg="url"
|
||||
if (preg_match('/bg=["\']([^"\']+)["\']/', $attr_string, $match)) {
|
||||
$attributes['bg'] = $match[1];
|
||||
}
|
||||
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render a single card
|
||||
*
|
||||
@@ -365,25 +453,28 @@ class EmailRenderer {
|
||||
* @param array $attributes
|
||||
* @return string
|
||||
*/
|
||||
private function render_card($content, $attributes) {
|
||||
private function render_card($content, $attributes)
|
||||
{
|
||||
$type = $attributes['type'] ?? 'default';
|
||||
$bg = $attributes['bg'] ?? null;
|
||||
|
||||
|
||||
// Parse markdown in content
|
||||
$content = MarkdownParser::parse($content);
|
||||
|
||||
|
||||
// Get email customization settings for colors
|
||||
$email_settings = get_option('woonoow_email_settings', []);
|
||||
$primary_color = $email_settings['primary_color'] ?? '#7f54b3';
|
||||
$secondary_color = $email_settings['secondary_color'] ?? '#7f54b3';
|
||||
$button_text_color = $email_settings['button_text_color'] ?? '#ffffff';
|
||||
$hero_gradient_start = $email_settings['hero_gradient_start'] ?? '#667eea';
|
||||
$hero_gradient_end = $email_settings['hero_gradient_end'] ?? '#764ba2';
|
||||
$hero_text_color = $email_settings['hero_text_color'] ?? '#ffffff';
|
||||
|
||||
// Use unified colors from Appearance > General > Colors
|
||||
$appearance = get_option('woonoow_appearance_settings', []);
|
||||
$colors = $appearance['general']['colors'] ?? [];
|
||||
$primary_color = $colors['primary'] ?? '#7f54b3';
|
||||
$secondary_color = $colors['secondary'] ?? '#7f54b3';
|
||||
$button_text_color = '#ffffff'; // Always white on primary buttons
|
||||
$hero_gradient_start = $colors['gradientStart'] ?? '#667eea';
|
||||
$hero_gradient_end = $colors['gradientEnd'] ?? '#764ba2';
|
||||
$hero_text_color = '#ffffff'; // Always white on gradient
|
||||
|
||||
// Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility
|
||||
// Helper function to generate button HTML
|
||||
$generateButtonHtml = function($url, $style, $text) use ($primary_color, $secondary_color, $button_text_color) {
|
||||
$generateButtonHtml = function ($url, $style, $text) use ($primary_color, $secondary_color, $button_text_color) {
|
||||
if ($style === 'outline') {
|
||||
// Outline button - transparent background with border
|
||||
$button_style = sprintf(
|
||||
@@ -399,7 +490,7 @@ class EmailRenderer {
|
||||
esc_attr($button_text_color)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Use table-based button for better email client compatibility
|
||||
return sprintf(
|
||||
'<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="margin: 16px auto;"><tr><td align="center"><a href="%s" style="%s">%s</a></td></tr></table>',
|
||||
@@ -408,11 +499,11 @@ class EmailRenderer {
|
||||
esc_html($text)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// NEW FORMAT: [button:style](url)Text[/button]
|
||||
$content = preg_replace_callback(
|
||||
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
|
||||
function($matches) use ($generateButtonHtml) {
|
||||
function ($matches) use ($generateButtonHtml) {
|
||||
$style = $matches[1]; // solid or outline
|
||||
$url = $matches[2];
|
||||
$text = trim($matches[3]);
|
||||
@@ -420,11 +511,11 @@ class EmailRenderer {
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
|
||||
// OLD FORMAT: [button url="..." style="solid|outline"]Text[/button]
|
||||
$content = preg_replace_callback(
|
||||
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=["\'](solid|outline)["\'])?\]([^\[]+)\[\/button\]/',
|
||||
function($matches) use ($generateButtonHtml) {
|
||||
function ($matches) use ($generateButtonHtml) {
|
||||
$url = $matches[1];
|
||||
$style = $matches[2] ?? 'solid';
|
||||
$text = trim($matches[3]);
|
||||
@@ -432,15 +523,15 @@ class EmailRenderer {
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
|
||||
$class = 'card';
|
||||
$style = 'width: 100%; background-color: #ffffff; border-radius: 8px;';
|
||||
$content_style = 'padding: 32px 40px;';
|
||||
|
||||
|
||||
// Add type class and styling
|
||||
if ($type !== 'default') {
|
||||
$class .= ' card-' . esc_attr($type);
|
||||
|
||||
|
||||
// Apply gradient and text color for hero cards
|
||||
if ($type === 'hero') {
|
||||
$style .= sprintf(
|
||||
@@ -449,7 +540,7 @@ class EmailRenderer {
|
||||
esc_attr($hero_gradient_end)
|
||||
);
|
||||
$content_style .= sprintf(' color: %s;', esc_attr($hero_text_color));
|
||||
|
||||
|
||||
// Add inline color to all headings and paragraphs for email client compatibility
|
||||
$content = preg_replace(
|
||||
'/<(h[1-6]|p)([^>]*)>/',
|
||||
@@ -470,13 +561,13 @@ class EmailRenderer {
|
||||
$style .= ' background-color: #fff8e1;';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add background image
|
||||
if ($bg) {
|
||||
$class .= ' card-bg';
|
||||
$style .= ' background-image: url(' . esc_url($bg) . ');';
|
||||
}
|
||||
|
||||
|
||||
return sprintf(
|
||||
'<table role="presentation" class="%s" border="0" cellpadding="0" cellspacing="0" style="%s">
|
||||
<tr>
|
||||
@@ -491,18 +582,19 @@ class EmailRenderer {
|
||||
$content
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render card spacing
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function render_card_spacing() {
|
||||
private function render_card_spacing()
|
||||
{
|
||||
return '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr><td class="card-spacing" style="height: 24px; font-size: 24px; line-height: 24px;"> </td></tr>
|
||||
</table>';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Replace variables in text
|
||||
*
|
||||
@@ -510,34 +602,36 @@ class EmailRenderer {
|
||||
* @param array $variables
|
||||
* @return string
|
||||
*/
|
||||
private function replace_variables($text, $variables) {
|
||||
private function replace_variables($text, $variables)
|
||||
{
|
||||
foreach ($variables as $key => $value) {
|
||||
$text = str_replace('{' . $key . '}', $value, $text);
|
||||
}
|
||||
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get design template path
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_design_template() {
|
||||
private function get_design_template()
|
||||
{
|
||||
// Use single base template (theme-agnostic)
|
||||
$template_path = WOONOOW_PATH . 'templates/emails/base.html';
|
||||
|
||||
|
||||
// Allow filtering template path
|
||||
$template_path = apply_filters('woonoow_email_template', $template_path);
|
||||
|
||||
|
||||
// Fallback to base if custom template doesn't exist
|
||||
if (!file_exists($template_path)) {
|
||||
$template_path = WOONOOW_PATH . 'templates/emails/base.html';
|
||||
}
|
||||
|
||||
|
||||
return $template_path;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render HTML email
|
||||
*
|
||||
@@ -547,29 +641,30 @@ class EmailRenderer {
|
||||
* @param array $variables All variables
|
||||
* @return string
|
||||
*/
|
||||
private function render_html($template_path, $content, $subject, $variables) {
|
||||
private function render_html($template_path, $content, $subject, $variables)
|
||||
{
|
||||
if (!file_exists($template_path)) {
|
||||
// Fallback to plain HTML
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
// Load template
|
||||
$html = file_get_contents($template_path);
|
||||
|
||||
|
||||
// Get email customization settings
|
||||
$email_settings = get_option('woonoow_email_settings', []);
|
||||
|
||||
|
||||
// Email body background
|
||||
$body_bg = '#f8f8f8';
|
||||
|
||||
|
||||
// Email header (logo or text)
|
||||
$logo_url = $email_settings['logo_url'] ?? '';
|
||||
|
||||
|
||||
// Fallback to site icon if no logo set
|
||||
if (empty($logo_url) && has_site_icon()) {
|
||||
$logo_url = get_site_icon_url(200);
|
||||
}
|
||||
|
||||
|
||||
if (!empty($logo_url)) {
|
||||
$header = sprintf(
|
||||
'<a href="%s"><img src="%s" alt="%s" style="max-width: 200px; max-height: 60px;"></a>',
|
||||
@@ -586,17 +681,17 @@ class EmailRenderer {
|
||||
esc_html($header_text)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Email footer with {current_year} variable support
|
||||
$footer_text = !empty($email_settings['footer_text']) ? $email_settings['footer_text'] : sprintf(
|
||||
'© %s %s. All rights reserved.',
|
||||
date('Y'),
|
||||
$variables['store_name']
|
||||
);
|
||||
|
||||
|
||||
// Replace {current_year} with actual year
|
||||
$footer_text = str_replace('{current_year}', date('Y'), $footer_text);
|
||||
|
||||
|
||||
// Social icons with PNG images
|
||||
$social_html = '';
|
||||
if (!empty($email_settings['social_links']) && is_array($email_settings['social_links'])) {
|
||||
@@ -615,13 +710,13 @@ class EmailRenderer {
|
||||
}
|
||||
$social_html .= '</div>';
|
||||
}
|
||||
|
||||
|
||||
$footer = sprintf(
|
||||
'<p style="font-family: \'Inter\', Arial, sans-serif; font-size: 13px; line-height: 1.5; color: #888888; margin: 0 0 8px 0; text-align: center;">%s</p>%s',
|
||||
nl2br(esc_html($footer_text)),
|
||||
$social_html
|
||||
);
|
||||
|
||||
|
||||
// Replace placeholders
|
||||
$html = str_replace('{{email_subject}}', esc_html($subject), $html);
|
||||
$html = str_replace('{{email_body_bg}}', esc_attr($body_bg), $html);
|
||||
@@ -631,15 +726,15 @@ class EmailRenderer {
|
||||
$html = str_replace('{{store_name}}', esc_html($variables['store_name']), $html);
|
||||
$html = str_replace('{{store_url}}', esc_url($variables['store_url']), $html);
|
||||
$html = str_replace('{{current_year}}', date('Y'), $html);
|
||||
|
||||
|
||||
// Replace all other variables
|
||||
foreach ($variables as $key => $value) {
|
||||
$html = str_replace('{{' . $key . '}}', $value, $html);
|
||||
}
|
||||
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get social icon URL
|
||||
*
|
||||
@@ -647,7 +742,8 @@ class EmailRenderer {
|
||||
* @param string $color 'white' or 'black'
|
||||
* @return string
|
||||
*/
|
||||
private function get_social_icon_url($platform, $color = 'white') {
|
||||
private function get_social_icon_url($platform, $color = 'white')
|
||||
{
|
||||
// Use plugin URL constant if available, otherwise calculate from file path
|
||||
if (defined('WOONOOW_URL')) {
|
||||
$plugin_url = WOONOOW_URL;
|
||||
|
||||
375
includes/Core/Notifications/TemplateProvider.bak.php
Normal file
375
includes/Core/Notifications/TemplateProvider.bak.php
Normal file
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
/**
|
||||
* Notification Template Provider
|
||||
*
|
||||
* Manages notification templates for all channels.
|
||||
*
|
||||
* @package WooNooW\Core\Notifications
|
||||
*/
|
||||
|
||||
namespace WooNooW\Core\Notifications;
|
||||
|
||||
use WooNooW\Email\DefaultTemplates as EmailDefaultTemplates;
|
||||
|
||||
class TemplateProvider {
|
||||
|
||||
/**
|
||||
* Option key for storing templates
|
||||
*/
|
||||
const OPTION_KEY = 'woonoow_notification_templates';
|
||||
|
||||
/**
|
||||
* Get all templates
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_templates() {
|
||||
$templates = get_option(self::OPTION_KEY, []);
|
||||
|
||||
// Merge with defaults
|
||||
$defaults = self::get_default_templates();
|
||||
|
||||
return array_merge($defaults, $templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template for specific event and channel
|
||||
*
|
||||
* @param string $event_id Event ID
|
||||
* @param string $channel_id Channel ID
|
||||
* @param string $recipient_type Recipient type ('customer' or 'staff')
|
||||
* @return array|null
|
||||
*/
|
||||
public static function get_template($event_id, $channel_id, $recipient_type = 'customer') {
|
||||
$templates = self::get_templates();
|
||||
|
||||
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
|
||||
|
||||
if (isset($templates[$key])) {
|
||||
return $templates[$key];
|
||||
}
|
||||
|
||||
// Return default if exists
|
||||
$defaults = self::get_default_templates();
|
||||
|
||||
if (isset($defaults[$key])) {
|
||||
return $defaults[$key];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save template
|
||||
*
|
||||
* @param string $event_id Event ID
|
||||
* @param string $channel_id Channel ID
|
||||
* @param array $template Template data
|
||||
* @param string $recipient_type Recipient type ('customer' or 'staff')
|
||||
* @return bool
|
||||
*/
|
||||
public static function save_template($event_id, $channel_id, $template, $recipient_type = 'customer') {
|
||||
$templates = get_option(self::OPTION_KEY, []);
|
||||
|
||||
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
|
||||
|
||||
$templates[$key] = [
|
||||
'event_id' => $event_id,
|
||||
'channel_id' => $channel_id,
|
||||
'recipient_type' => $recipient_type,
|
||||
'subject' => $template['subject'] ?? '',
|
||||
'body' => $template['body'] ?? '',
|
||||
'variables' => $template['variables'] ?? [],
|
||||
'updated_at' => current_time('mysql'),
|
||||
];
|
||||
|
||||
return update_option(self::OPTION_KEY, $templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete template (revert to default)
|
||||
*
|
||||
* @param string $event_id Event ID
|
||||
* @param string $channel_id Channel ID
|
||||
* @param string $recipient_type Recipient type ('customer' or 'staff')
|
||||
* @return bool
|
||||
*/
|
||||
public static function delete_template($event_id, $channel_id, $recipient_type = 'customer') {
|
||||
$templates = get_option(self::OPTION_KEY, []);
|
||||
|
||||
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
|
||||
|
||||
if (isset($templates[$key])) {
|
||||
unset($templates[$key]);
|
||||
return update_option(self::OPTION_KEY, $templates);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WooCommerce email template content
|
||||
*
|
||||
* @param string $email_id WooCommerce email ID
|
||||
* @return array|null
|
||||
*/
|
||||
private static function get_wc_email_template($email_id) {
|
||||
if (!function_exists('WC')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mailer = \WC()->mailer();
|
||||
$emails = $mailer->get_emails();
|
||||
|
||||
if (isset($emails[$email_id])) {
|
||||
$email = $emails[$email_id];
|
||||
return [
|
||||
'subject' => $email->get_subject(),
|
||||
'heading' => $email->get_heading(),
|
||||
'enabled' => $email->is_enabled(),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default templates
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_default_templates() {
|
||||
$templates = [];
|
||||
|
||||
// Get all events from EventRegistry (single source of truth)
|
||||
$all_events = EventRegistry::get_all_events();
|
||||
|
||||
// Get email templates from DefaultTemplates
|
||||
$allEmailTemplates = EmailDefaultTemplates::get_all_templates();
|
||||
|
||||
foreach ($all_events as $event) {
|
||||
$event_id = $event['id'];
|
||||
$recipient_type = $event['recipient_type'];
|
||||
// Get template body from the new clean markdown source
|
||||
$body = $allEmailTemplates[$recipient_type][$event_id] ?? '';
|
||||
$subject = EmailDefaultTemplates::get_default_subject($recipient_type, $event_id);
|
||||
|
||||
// If template doesn't exist, create a simple fallback
|
||||
if (empty($body)) {
|
||||
$body = "[card]\n\n## Notification\n\nYou have a new notification about {$event_id}.\n\n[/card]";
|
||||
$subject = __('Notification from {store_name}', 'woonoow');
|
||||
}
|
||||
|
||||
$templates["{$recipient_type}_{$event_id}_email"] = [
|
||||
'event_id' => $event_id,
|
||||
'channel_id' => 'email',
|
||||
'recipient_type' => $recipient_type,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'variables' => self::get_variables_for_event($event_id),
|
||||
];
|
||||
}
|
||||
|
||||
// Add push notification templates
|
||||
$templates['staff_order_placed_push'] = [
|
||||
'event_id' => 'order_placed',
|
||||
'channel_id' => 'push',
|
||||
'recipient_type' => 'staff',
|
||||
'subject' => __('New Order #{order_number}', 'woonoow'),
|
||||
'body' => __('New order from {customer_name} - {order_total}', 'woonoow'),
|
||||
'variables' => self::get_order_variables(),
|
||||
];
|
||||
$templates['customer_order_processing_push'] = [
|
||||
'event_id' => 'order_processing',
|
||||
'channel_id' => 'push',
|
||||
'recipient_type' => 'customer',
|
||||
'subject' => __('Order Processing', 'woonoow'),
|
||||
'body' => __('Your order #{order_number} is being processed', 'woonoow'),
|
||||
'variables' => self::get_order_variables(),
|
||||
];
|
||||
$templates['customer_order_completed_push'] = [
|
||||
'event_id' => 'order_completed',
|
||||
'channel_id' => 'push',
|
||||
'recipient_type' => 'customer',
|
||||
'subject' => __('Order Completed', 'woonoow'),
|
||||
'body' => __('Your order #{order_number} has been completed!', 'woonoow'),
|
||||
'variables' => self::get_order_variables(),
|
||||
];
|
||||
$templates['staff_order_cancelled_push'] = [
|
||||
'event_id' => 'order_cancelled',
|
||||
'channel_id' => 'push',
|
||||
'recipient_type' => 'staff',
|
||||
'subject' => __('Order Cancelled', 'woonoow'),
|
||||
'body' => __('Order #{order_number} has been cancelled', 'woonoow'),
|
||||
'variables' => self::get_order_variables(),
|
||||
];
|
||||
$templates['customer_order_refunded_push'] = [
|
||||
'event_id' => 'order_refunded',
|
||||
'channel_id' => 'push',
|
||||
'recipient_type' => 'customer',
|
||||
'subject' => __('Order Refunded', 'woonoow'),
|
||||
'body' => __('Your order #{order_number} has been refunded', 'woonoow'),
|
||||
'variables' => self::get_order_variables(),
|
||||
];
|
||||
$templates['staff_low_stock_push'] = [
|
||||
'event_id' => 'low_stock',
|
||||
'channel_id' => 'push',
|
||||
'recipient_type' => 'staff',
|
||||
'subject' => __('Low Stock Alert', 'woonoow'),
|
||||
'body' => __('{product_name} is running low on stock', 'woonoow'),
|
||||
'variables' => self::get_product_variables(),
|
||||
];
|
||||
$templates['staff_out_of_stock_push'] = [
|
||||
'event_id' => 'out_of_stock',
|
||||
'channel_id' => 'push',
|
||||
'recipient_type' => 'staff',
|
||||
'subject' => __('Out of Stock Alert', 'woonoow'),
|
||||
'body' => __('{product_name} is now out of stock', 'woonoow'),
|
||||
'variables' => self::get_product_variables(),
|
||||
];
|
||||
$templates['customer_new_customer_push'] = [
|
||||
'event_id' => 'new_customer',
|
||||
'channel_id' => 'push',
|
||||
'recipient_type' => 'customer',
|
||||
'subject' => __('Welcome!', 'woonoow'),
|
||||
'body' => __('Welcome to {store_name}, {customer_name}!', 'woonoow'),
|
||||
'variables' => self::get_customer_variables(),
|
||||
];
|
||||
$templates['customer_customer_note_push'] = [
|
||||
'event_id' => 'customer_note',
|
||||
'channel_id' => 'push',
|
||||
'recipient_type' => 'customer',
|
||||
'subject' => __('Order Note Added', 'woonoow'),
|
||||
'body' => __('A note has been added to order #{order_number}', 'woonoow'),
|
||||
'variables' => self::get_order_variables(),
|
||||
];
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variables for a specific event
|
||||
*
|
||||
* @param string $event_id Event ID
|
||||
* @return array
|
||||
*/
|
||||
private static function get_variables_for_event($event_id) {
|
||||
// Product events
|
||||
if (in_array($event_id, ['low_stock', 'out_of_stock'])) {
|
||||
return self::get_product_variables();
|
||||
}
|
||||
|
||||
// Customer events (but not order-related)
|
||||
if ($event_id === 'new_customer') {
|
||||
return self::get_customer_variables();
|
||||
}
|
||||
|
||||
// Subscription events
|
||||
if (strpos($event_id, 'subscription_') === 0) {
|
||||
return self::get_subscription_variables();
|
||||
}
|
||||
|
||||
// All other events are order-related
|
||||
return self::get_order_variables();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available order variables
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_order_variables() {
|
||||
return [
|
||||
'order_number' => __('Order Number', 'woonoow'),
|
||||
'order_total' => __('Order Total', 'woonoow'),
|
||||
'order_status' => __('Order Status', 'woonoow'),
|
||||
'order_date' => __('Order Date', 'woonoow'),
|
||||
'order_url' => __('Order URL', 'woonoow'),
|
||||
'order_items_list' => __('Order Items (formatted list)', 'woonoow'),
|
||||
'order_items_table' => __('Order Items (formatted table)', 'woonoow'),
|
||||
'payment_method' => __('Payment Method', 'woonoow'),
|
||||
'payment_url' => __('Payment URL (for pending payments)', 'woonoow'),
|
||||
'shipping_method' => __('Shipping Method', 'woonoow'),
|
||||
'tracking_number' => __('Tracking Number', 'woonoow'),
|
||||
'refund_amount' => __('Refund Amount', 'woonoow'),
|
||||
'customer_name' => __('Customer Name', 'woonoow'),
|
||||
'customer_email' => __('Customer Email', 'woonoow'),
|
||||
'customer_phone' => __('Customer Phone', 'woonoow'),
|
||||
'billing_address' => __('Billing Address', 'woonoow'),
|
||||
'shipping_address' => __('Shipping Address', 'woonoow'),
|
||||
'store_name' => __('Store Name', 'woonoow'),
|
||||
'store_url' => __('Store URL', 'woonoow'),
|
||||
'store_email' => __('Store Email', 'woonoow'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available product variables
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_product_variables() {
|
||||
return [
|
||||
'product_name' => __('Product Name', 'woonoow'),
|
||||
'product_sku' => __('Product SKU', 'woonoow'),
|
||||
'product_url' => __('Product URL', 'woonoow'),
|
||||
'stock_quantity' => __('Stock Quantity', 'woonoow'),
|
||||
'store_name' => __('Store Name', 'woonoow'),
|
||||
'store_url' => __('Store URL', 'woonoow'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available customer variables
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_customer_variables() {
|
||||
return [
|
||||
'customer_name' => __('Customer Name', 'woonoow'),
|
||||
'customer_email' => __('Customer Email', 'woonoow'),
|
||||
'customer_phone' => __('Customer Phone', 'woonoow'),
|
||||
'store_name' => __('Store Name', 'woonoow'),
|
||||
'store_url' => __('Store URL', 'woonoow'),
|
||||
'store_email' => __('Store Email', 'woonoow'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available subscription variables
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_subscription_variables() {
|
||||
return [
|
||||
'subscription_id' => __('Subscription ID', 'woonoow'),
|
||||
'subscription_status' => __('Subscription Status', 'woonoow'),
|
||||
'product_name' => __('Product Name', 'woonoow'),
|
||||
'billing_period' => __('Billing Period (e.g., Monthly)', 'woonoow'),
|
||||
'recurring_amount' => __('Recurring Amount', 'woonoow'),
|
||||
'next_payment_date' => __('Next Payment Date', 'woonoow'),
|
||||
'end_date' => __('Subscription End Date', 'woonoow'),
|
||||
'cancel_reason' => __('Cancellation Reason', 'woonoow'),
|
||||
'customer_name' => __('Customer Name', 'woonoow'),
|
||||
'customer_email' => __('Customer Email', 'woonoow'),
|
||||
'store_name' => __('Store Name', 'woonoow'),
|
||||
'store_url' => __('Store URL', 'woonoow'),
|
||||
'my_account_url' => __('My Account URL', 'woonoow'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace variables in template
|
||||
*
|
||||
* @param string $content Content with variables
|
||||
* @param array $data Data to replace variables
|
||||
* @return string
|
||||
*/
|
||||
public static function replace_variables($content, $data) {
|
||||
foreach ($data as $key => $value) {
|
||||
$content = str_replace('{' . $key . '}', $value, $content);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
@@ -107,32 +107,6 @@ class TemplateProvider {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WooCommerce email template content
|
||||
*
|
||||
* @param string $email_id WooCommerce email ID
|
||||
* @return array|null
|
||||
*/
|
||||
private static function get_wc_email_template($email_id) {
|
||||
if (!function_exists('WC')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mailer = WC()->mailer();
|
||||
$emails = $mailer->get_emails();
|
||||
|
||||
if (isset($emails[$email_id])) {
|
||||
$email = $emails[$email_id];
|
||||
return [
|
||||
'subject' => $email->get_subject(),
|
||||
'heading' => $email->get_heading(),
|
||||
'enabled' => $email->is_enabled(),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default templates
|
||||
*
|
||||
@@ -264,6 +238,11 @@ class TemplateProvider {
|
||||
return self::get_customer_variables();
|
||||
}
|
||||
|
||||
// Subscription events
|
||||
if (strpos($event_id, 'subscription_') === 0) {
|
||||
return self::get_subscription_variables();
|
||||
}
|
||||
|
||||
// All other events are order-related
|
||||
return self::get_order_variables();
|
||||
}
|
||||
@@ -330,6 +309,29 @@ class TemplateProvider {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available subscription variables
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_subscription_variables() {
|
||||
return [
|
||||
'subscription_id' => __('Subscription ID', 'woonoow'),
|
||||
'subscription_status' => __('Subscription Status', 'woonoow'),
|
||||
'product_name' => __('Product Name', 'woonoow'),
|
||||
'billing_period' => __('Billing Period (e.g., Monthly)', 'woonoow'),
|
||||
'recurring_amount' => __('Recurring Amount', 'woonoow'),
|
||||
'next_payment_date' => __('Next Payment Date', 'woonoow'),
|
||||
'end_date' => __('Subscription End Date', 'woonoow'),
|
||||
'cancel_reason' => __('Cancellation Reason', 'woonoow'),
|
||||
'customer_name' => __('Customer Name', 'woonoow'),
|
||||
'customer_email' => __('Customer Email', 'woonoow'),
|
||||
'store_name' => __('Store Name', 'woonoow'),
|
||||
'store_url' => __('Store URL', 'woonoow'),
|
||||
'my_account_url' => __('My Account URL', 'woonoow'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace variables in template
|
||||
*
|
||||
|
||||
@@ -144,11 +144,11 @@ class Assets {
|
||||
$theme_settings = array_replace_recursive($default_settings, $spa_settings);
|
||||
|
||||
// Get appearance settings and preload them
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
if (empty($appearance_settings)) {
|
||||
// Use defaults from AppearanceController
|
||||
$appearance_settings = \WooNooW\Admin\AppearanceController::get_default_settings();
|
||||
}
|
||||
$stored_settings = get_option('woonoow_appearance_settings', []);
|
||||
$default_appearance = \WooNooW\Admin\AppearanceController::get_default_settings();
|
||||
|
||||
// Merge stored settings with defaults to ensure new fields (like gradient colors) exist
|
||||
$appearance_settings = array_replace_recursive($default_appearance, $stored_settings);
|
||||
|
||||
// Get WooCommerce currency settings
|
||||
$currency_settings = [
|
||||
@@ -198,12 +198,23 @@ class Assets {
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$spa_page = $spa_page_id ? get_post($spa_page_id) : null;
|
||||
|
||||
// Check if SPA page is set as WordPress frontpage
|
||||
// Check if SPA Entry Page is set as WordPress frontpage
|
||||
$frontpage_id = (int) get_option('page_on_front');
|
||||
$is_spa_frontpage = $frontpage_id && $spa_page_id && $frontpage_id === (int) $spa_page_id;
|
||||
$is_spa_wp_frontpage = $frontpage_id && $spa_page_id && $frontpage_id === (int) $spa_page_id;
|
||||
|
||||
// If SPA is frontpage, base path is /, otherwise use page slug
|
||||
$base_path = $is_spa_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
|
||||
// Get SPA Landing page (explicit setting, separate from Entry Page)
|
||||
// This determines what content to show at the SPA root route "/"
|
||||
$spa_frontpage_id = $appearance_settings['general']['spa_frontpage'] ?? 0;
|
||||
$front_page_slug = '';
|
||||
if ($spa_frontpage_id) {
|
||||
$spa_frontpage = get_post($spa_frontpage_id);
|
||||
if ($spa_frontpage) {
|
||||
$front_page_slug = $spa_frontpage->post_name;
|
||||
}
|
||||
}
|
||||
|
||||
// If SPA Entry Page is WP frontpage, base path is /, otherwise use Entry Page slug
|
||||
$base_path = $is_spa_wp_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
|
||||
|
||||
// Check if BrowserRouter is enabled (default: true for SEO)
|
||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||
@@ -223,6 +234,8 @@ class Assets {
|
||||
'appearanceSettings' => $appearance_settings,
|
||||
'basePath' => $base_path,
|
||||
'useBrowserRouter' => $use_browser_router,
|
||||
'frontPageSlug' => $front_page_slug,
|
||||
'spaMode' => $appearance_settings['general']['spa_mode'] ?? 'full',
|
||||
];
|
||||
|
||||
?>
|
||||
@@ -270,11 +283,11 @@ class Assets {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get Customer SPA settings
|
||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
|
||||
// Get SPA mode from appearance settings (the correct source)
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||
|
||||
// If disabled, don't load
|
||||
// If disabled, only load for pages with shortcodes
|
||||
if ($mode === 'disabled') {
|
||||
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
|
||||
if (function_exists('is_shop') && is_shop()) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace WooNooW\Frontend;
|
||||
|
||||
use WP_REST_Request;
|
||||
@@ -79,7 +80,8 @@ class CartController
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'update_cart'],
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
return true;
|
||||
},
|
||||
'args' => [
|
||||
'cart_item_key' => [
|
||||
'required' => true,
|
||||
@@ -97,7 +99,8 @@ class CartController
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'remove_from_cart'],
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
return true;
|
||||
},
|
||||
'args' => [
|
||||
'cart_item_key' => [
|
||||
'required' => true,
|
||||
@@ -111,7 +114,8 @@ class CartController
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'apply_coupon'],
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
return true;
|
||||
},
|
||||
'args' => [
|
||||
'coupon_code' => [
|
||||
'required' => true,
|
||||
@@ -125,7 +129,8 @@ class CartController
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'clear_cart'],
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
return true;
|
||||
},
|
||||
]);
|
||||
|
||||
// Remove coupon
|
||||
@@ -133,7 +138,8 @@ class CartController
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'remove_coupon'],
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
return true;
|
||||
},
|
||||
'args' => [
|
||||
'coupon_code' => [
|
||||
'required' => true,
|
||||
@@ -227,6 +233,12 @@ class CartController
|
||||
|
||||
if (!empty($value)) {
|
||||
$variation_attributes[$meta_key] = $value;
|
||||
} else {
|
||||
// Value is empty ("Any" variation) - check if frontend sent value in 'variation' param
|
||||
$frontend_variation = $request->get_param('variation');
|
||||
if (is_array($frontend_variation) && isset($frontend_variation[$meta_key]) && !empty($frontend_variation[$meta_key])) {
|
||||
$variation_attributes[$meta_key] = sanitize_text_field($frontend_variation[$meta_key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
491
includes/Frontend/PageSSR.php
Normal file
491
includes/Frontend/PageSSR.php
Normal file
@@ -0,0 +1,491 @@
|
||||
<?php
|
||||
namespace WooNooW\Frontend;
|
||||
|
||||
/**
|
||||
* Page SSR (Server-Side Rendering)
|
||||
* Renders page sections as static HTML for search engine crawlers
|
||||
*/
|
||||
class PageSSR
|
||||
{
|
||||
/**
|
||||
* Render page structure to HTML
|
||||
*
|
||||
* @param array $structure Page structure with sections
|
||||
* @param array|null $post_data Post data for dynamic placeholders
|
||||
* @return string Rendered HTML
|
||||
*/
|
||||
public static function render($structure, $post_data = null)
|
||||
{
|
||||
if (empty($structure) || empty($structure['sections'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = '';
|
||||
|
||||
foreach ($structure['sections'] as $section) {
|
||||
$html .= self::render_section($section, $post_data);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single section to HTML
|
||||
*
|
||||
* @param array $section Section data
|
||||
* @param array|null $post_data Post data for placeholders
|
||||
* @return string Section HTML
|
||||
*/
|
||||
public static function render_section($section, $post_data = null)
|
||||
{
|
||||
$type = $section['type'] ?? 'content';
|
||||
$props = $section['props'] ?? [];
|
||||
$layout = $section['layoutVariant'] ?? 'default';
|
||||
$color_scheme = $section['colorScheme'] ?? 'default';
|
||||
|
||||
// Resolve all props (replace dynamic placeholders with actual values)
|
||||
$resolved_props = self::resolve_props($props, $post_data);
|
||||
|
||||
// Generate section ID for anchor links
|
||||
$section_id = $section['id'] ?? 'section-' . uniqid();
|
||||
|
||||
$element_styles = $section['elementStyles'] ?? [];
|
||||
$styles = $section['styles'] ?? []; // Section wrapper styles (bg, overlay)
|
||||
|
||||
// Render based on section type
|
||||
$method = 'render_' . str_replace('-', '_', $type);
|
||||
if (method_exists(__CLASS__, $method)) {
|
||||
return self::$method($resolved_props, $layout, $color_scheme, $section_id, $element_styles, $styles);
|
||||
}
|
||||
|
||||
// Fallback: generic section wrapper
|
||||
return self::render_generic($resolved_props, $type, $section_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve props - replace dynamic placeholders with actual values
|
||||
*
|
||||
* @param array $props Section props
|
||||
* @param array|null $post_data Post data
|
||||
* @return array Resolved props with actual values
|
||||
*/
|
||||
public static function resolve_props($props, $post_data = null)
|
||||
{
|
||||
$resolved = [];
|
||||
|
||||
foreach ($props as $key => $prop) {
|
||||
if (!is_array($prop)) {
|
||||
$resolved[$key] = $prop;
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = $prop['type'] ?? 'static';
|
||||
|
||||
if ($type === 'static') {
|
||||
$resolved[$key] = $prop['value'] ?? '';
|
||||
} elseif ($type === 'dynamic' && $post_data) {
|
||||
$source = $prop['source'] ?? '';
|
||||
$resolved[$key] = PlaceholderRenderer::get_value($source, $post_data);
|
||||
} else {
|
||||
$resolved[$key] = $prop['value'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Section Renderers
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Helper to generate style attribute string
|
||||
*/
|
||||
private static function generate_style_attr($styles) {
|
||||
if (empty($styles)) return '';
|
||||
|
||||
$css = [];
|
||||
if (!empty($styles['color'])) $css[] = "color: {$styles['color']}";
|
||||
if (!empty($styles['backgroundColor'])) $css[] = "background-color: {$styles['backgroundColor']}";
|
||||
if (!empty($styles['fontSize'])) $css[] = "font-size: {$styles['fontSize']}"; // Note: assumes value has unit or is handled by class, but inline style works for specific values
|
||||
// Add more mapping if needed, or rely on frontend to send valid CSS values
|
||||
|
||||
return empty($css) ? '' : 'style="' . implode(';', $css) . '"';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Hero section
|
||||
*/
|
||||
public static function render_hero($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||
{
|
||||
$title = esc_html($props['title'] ?? '');
|
||||
$subtitle = esc_html($props['subtitle'] ?? '');
|
||||
$image = esc_url($props['image'] ?? '');
|
||||
$cta_text = esc_html($props['cta_text'] ?? '');
|
||||
$cta_url = esc_url($props['cta_url'] ?? '');
|
||||
|
||||
// Section Styles (Background & Spacing)
|
||||
$bg_color = $section_styles['backgroundColor'] ?? '';
|
||||
$bg_image = $section_styles['backgroundImage'] ?? '';
|
||||
$overlay_opacity = $section_styles['backgroundOverlay'] ?? 0;
|
||||
$pt = $section_styles['paddingTop'] ?? '';
|
||||
$pb = $section_styles['paddingBottom'] ?? '';
|
||||
$height_preset = $section_styles['heightPreset'] ?? '';
|
||||
|
||||
$section_css = "";
|
||||
if ($bg_color) $section_css .= "background-color: {$bg_color};";
|
||||
if ($bg_image) $section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
|
||||
if ($pt) $section_css .= "padding-top: {$pt};";
|
||||
if ($pb) $section_css .= "padding-bottom: {$pb};";
|
||||
if ($height_preset === 'screen') $section_css .= "min-height: 100vh; display: flex; align-items: center;";
|
||||
|
||||
$section_attr = $section_css ? "style=\"{$section_css}\"" : "";
|
||||
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-hero wn-hero--{$layout} wn-scheme--{$color_scheme}\" {$section_attr}>";
|
||||
|
||||
// Overlay
|
||||
if ($overlay_opacity > 0) {
|
||||
$opacity = $overlay_opacity / 100;
|
||||
$html .= "<div class=\"wn-hero__overlay\" style=\"background-color: rgba(0,0,0,{$opacity}); position: absolute; inset: 0;\"></div>";
|
||||
}
|
||||
|
||||
// Element Styles
|
||||
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
|
||||
$subtitle_style = self::generate_style_attr($element_styles['subtitle'] ?? []);
|
||||
$cta_style = self::generate_style_attr($element_styles['cta_text'] ?? []); // Button
|
||||
|
||||
// Image (if not background)
|
||||
if ($image && !$bg_image && $layout !== 'default') {
|
||||
$html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-hero__image\" />";
|
||||
}
|
||||
|
||||
$html .= '<div class="wn-hero__content" style="position: relative; z-index: 10;">';
|
||||
if ($title) {
|
||||
$html .= "<h1 class=\"wn-hero__title\" {$title_style}>{$title}</h1>";
|
||||
}
|
||||
if ($subtitle) {
|
||||
$html .= "<p class=\"wn-hero__subtitle\" {$subtitle_style}>{$subtitle}</p>";
|
||||
}
|
||||
if ($cta_text && $cta_url) {
|
||||
$html .= "<a href=\"{$cta_url}\" class=\"wn-hero__cta\" {$cta_style}>{$cta_text}</a>";
|
||||
}
|
||||
$html .= '</div>';
|
||||
$html .= '</section>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Universal Row Renderer (Shared logic for Content & ImageText)
|
||||
*/
|
||||
private static function render_universal_row($props, $layout, $color_scheme, $element_styles, $options = []) {
|
||||
$title = esc_html($props['title']['value'] ?? ($props['title'] ?? ''));
|
||||
$text = $props['text']['value'] ?? ($props['text'] ?? ($props['content']['value'] ?? ($props['content'] ?? ''))); // Handle both props/values
|
||||
$image = esc_url($props['image']['value'] ?? ($props['image'] ?? ''));
|
||||
|
||||
// Options
|
||||
$has_image = !empty($image);
|
||||
$image_pos = $layout ?: 'left';
|
||||
|
||||
// Element Styles
|
||||
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
|
||||
$text_style = self::generate_style_attr($element_styles['text'] ?? ($element_styles['content'] ?? []));
|
||||
|
||||
// Wrapper Classes
|
||||
$wrapper_class = "wn-max-w-7xl wn-mx-auto wn-px-4";
|
||||
$grid_class = "wn-mx-auto";
|
||||
|
||||
if ($has_image && in_array($image_pos, ['left', 'right', 'image-left', 'image-right'])) {
|
||||
$grid_class .= " wn-grid wn-grid-cols-1 wn-lg-grid-cols-2 wn-gap-12 wn-items-center";
|
||||
} else {
|
||||
$grid_class .= " wn-max-w-4xl";
|
||||
}
|
||||
|
||||
$html = "<div class=\"{$wrapper_class}\">";
|
||||
$html .= "<div class=\"{$grid_class}\">";
|
||||
|
||||
// Image Output
|
||||
$image_html = "";
|
||||
if ($current_pos_right = ($image_pos === 'right' || $image_pos === 'image-right')) {
|
||||
$order_class = 'wn-lg-order-last';
|
||||
} else {
|
||||
$order_class = 'wn-lg-order-first';
|
||||
}
|
||||
|
||||
if ($has_image) {
|
||||
$image_html = "<div class=\"wn-relative wn-w-full wn-aspect-[4/3] wn-rounded-2xl wn-overflow-hidden wn-shadow-lg {$order_class}\">";
|
||||
$image_html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-absolute wn-inset-0 wn-w-full wn-h-full wn-object-cover\" />";
|
||||
$image_html .= "</div>";
|
||||
}
|
||||
|
||||
// Content Output
|
||||
$content_html = "<div class=\"wn-flex wn-flex-col\">";
|
||||
if ($title) {
|
||||
$content_html .= "<h2 class=\"wn-text-3xl wn-font-bold wn-mb-6\" {$title_style}>{$title}</h2>";
|
||||
}
|
||||
if ($text) {
|
||||
// Apply prose classes similar to React
|
||||
$content_html .= "<div class=\"wn-prose wn-prose-lg wn-max-w-none\" {$text_style}>{$text}</div>";
|
||||
}
|
||||
$content_html .= "</div>";
|
||||
|
||||
// Render based on order (Grid handles order via CSS classes for left/right, but fallback for DOM order)
|
||||
if ($has_image) {
|
||||
// For grid layout, we output both. CSS order handles visual.
|
||||
$html .= $image_html . $content_html;
|
||||
} else {
|
||||
$html .= $content_html;
|
||||
}
|
||||
|
||||
$html .= "</div></div>";
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Content section (for post body, rich text)
|
||||
*/
|
||||
public static function render_content($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||
{
|
||||
$content = $props['content'] ?? '';
|
||||
// Apply WordPress content filters (shortcodes, autop, etc.)
|
||||
$content = apply_filters('the_content', $content);
|
||||
// Normalize prop structure for universal renderer if needed
|
||||
if (is_string($props['content'])) {
|
||||
$props['content'] = ['value' => $content];
|
||||
} else {
|
||||
$props['content']['value'] = $content;
|
||||
}
|
||||
|
||||
// Section Styles (Background)
|
||||
$bg_color = $section_styles['backgroundColor'] ?? '';
|
||||
$padding = $section_styles['paddingTop'] ?? '';
|
||||
$height_preset = $section_styles['heightPreset'] ?? '';
|
||||
|
||||
$css = "";
|
||||
if($bg_color) $css .= "background-color:{$bg_color};";
|
||||
|
||||
// Height Logic
|
||||
if ($height_preset === 'screen') {
|
||||
$css .= "min-height: 100vh; display: flex; align-items: center;";
|
||||
$padding = '5rem'; // Default padding for screen to avoid edge collision
|
||||
} elseif ($height_preset === 'small') {
|
||||
$padding = '2rem';
|
||||
} elseif ($height_preset === 'large') {
|
||||
$padding = '8rem';
|
||||
} elseif ($height_preset === 'medium') {
|
||||
$padding = '4rem';
|
||||
}
|
||||
|
||||
if($padding) $css .= "padding:{$padding} 0;";
|
||||
|
||||
$style_attr = $css ? "style=\"{$css}\"" : "";
|
||||
|
||||
$inner_html = self::render_universal_row($props, 'left', $color_scheme, $element_styles);
|
||||
|
||||
return "<section id=\"{$id}\" class=\"wn-section wn-content wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Image + Text section
|
||||
*/
|
||||
public static function render_image_text($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
|
||||
{
|
||||
$bg_color = $section_styles['backgroundColor'] ?? '';
|
||||
$padding = $section_styles['paddingTop'] ?? '';
|
||||
$height_preset = $section_styles['heightPreset'] ?? '';
|
||||
|
||||
$css = "";
|
||||
if($bg_color) $css .= "background-color:{$bg_color};";
|
||||
|
||||
// Height Logic
|
||||
if ($height_preset === 'screen') {
|
||||
$css .= "min-height: 100vh; display: flex; align-items: center;";
|
||||
$padding = '5rem';
|
||||
} elseif ($height_preset === 'small') {
|
||||
$padding = '2rem';
|
||||
} elseif ($height_preset === 'large') {
|
||||
$padding = '8rem';
|
||||
} elseif ($height_preset === 'medium') {
|
||||
$padding = '4rem';
|
||||
}
|
||||
|
||||
if($padding) $css .= "padding:{$padding} 0;";
|
||||
$style_attr = $css ? "style=\"{$css}\"" : "";
|
||||
|
||||
$inner_html = self::render_universal_row($props, $layout, $color_scheme, $element_styles);
|
||||
|
||||
return "<section id=\"{$id}\" class=\"wn-section wn-image-text wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Feature Grid section
|
||||
*/
|
||||
public static function render_feature_grid($props, $layout, $color_scheme, $id, $element_styles = [])
|
||||
{
|
||||
$heading = esc_html($props['heading'] ?? '');
|
||||
$items = $props['items'] ?? [];
|
||||
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-feature-grid wn-feature-grid--{$layout} wn-scheme--{$color_scheme}\">";
|
||||
|
||||
if ($heading) {
|
||||
$html .= "<h2 class=\"wn-feature-grid__heading\">{$heading}</h2>";
|
||||
}
|
||||
|
||||
// Feature Item Styles (Card)
|
||||
$item_style_attr = self::generate_style_attr($element_styles['feature_item'] ?? []); // BG, Border, Shadow handled by CSS classes mostly, but colors here
|
||||
$item_bg = $element_styles['feature_item']['backgroundColor'] ?? '';
|
||||
|
||||
$html .= '<div class="wn-feature-grid__items">';
|
||||
foreach ($items as $item) {
|
||||
$item_title = esc_html($item['title'] ?? '');
|
||||
$item_desc = esc_html($item['description'] ?? '');
|
||||
$item_icon = esc_html($item['icon'] ?? '');
|
||||
|
||||
// Allow overriding item specific style if needed, but for now global
|
||||
$html .= "<div class=\"wn-feature-grid__item\" {$item_style_attr}>";
|
||||
|
||||
// Render Icon SVG
|
||||
if ($item_icon) {
|
||||
$icon_svg = self::get_icon_svg($item_icon);
|
||||
if ($icon_svg) {
|
||||
$html .= "<div class=\"wn-feature-grid__icon\">{$icon_svg}</div>";
|
||||
}
|
||||
}
|
||||
|
||||
if ($item_title) {
|
||||
// Feature title style
|
||||
$f_title_style = self::generate_style_attr($element_styles['feature_title'] ?? []);
|
||||
$html .= "<h3 class=\"wn-feature-grid__item-title\" {$f_title_style}>{$item_title}</h3>";
|
||||
}
|
||||
if ($item_desc) {
|
||||
// Feature description style
|
||||
$f_desc_style = self::generate_style_attr($element_styles['feature_description'] ?? []);
|
||||
$html .= "<p class=\"wn-feature-grid__item-desc\" {$f_desc_style}>{$item_desc}</p>";
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
$html .= '</section>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render CTA Banner section
|
||||
*/
|
||||
public static function render_cta_banner($props, $layout, $color_scheme, $id, $element_styles = [])
|
||||
{
|
||||
$title = esc_html($props['title'] ?? '');
|
||||
$text = esc_html($props['text'] ?? '');
|
||||
$button_text = esc_html($props['button_text'] ?? '');
|
||||
$button_url = esc_url($props['button_url'] ?? '');
|
||||
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-cta-banner wn-cta-banner--{$layout} wn-scheme--{$color_scheme}\">";
|
||||
$html .= '<div class="wn-cta-banner__content">';
|
||||
|
||||
if ($title) {
|
||||
$html .= "<h2 class=\"wn-cta-banner__title\">{$title}</h2>";
|
||||
}
|
||||
if ($text) {
|
||||
$html .= "<p class=\"wn-cta-banner__text\">{$text}</p>";
|
||||
}
|
||||
if ($button_text && $button_url) {
|
||||
$html .= "<a href=\"{$button_url}\" class=\"wn-cta-banner__button\">{$button_text}</a>";
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
$html .= '</section>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Contact Form section
|
||||
*/
|
||||
public static function render_contact_form($props, $layout, $color_scheme, $id, $element_styles = [])
|
||||
{
|
||||
$title = esc_html($props['title'] ?? '');
|
||||
$webhook_url = esc_url($props['webhook_url'] ?? '');
|
||||
$redirect_url = esc_url($props['redirect_url'] ?? '');
|
||||
$fields = $props['fields'] ?? ['name', 'email', 'message'];
|
||||
|
||||
// Extract styles
|
||||
$btn_bg = $element_styles['button']['backgroundColor'] ?? '';
|
||||
$btn_color = $element_styles['button']['color'] ?? '';
|
||||
$field_bg = $element_styles['fields']['backgroundColor'] ?? '';
|
||||
$field_color = $element_styles['fields']['color'] ?? '';
|
||||
|
||||
$btn_style = "";
|
||||
if ($btn_bg) $btn_style .= "background-color: {$btn_bg};";
|
||||
if ($btn_color) $btn_style .= "color: {$btn_color};";
|
||||
$btn_attr = $btn_style ? "style=\"{$btn_style}\"" : "";
|
||||
|
||||
$field_style = "";
|
||||
if ($field_bg) $field_style .= "background-color: {$field_bg};";
|
||||
if ($field_color) $field_style .= "color: {$field_color};";
|
||||
$field_attr = $field_style ? "style=\"{$field_style}\"" : "";
|
||||
|
||||
$html = "<section id=\"{$id}\" class=\"wn-section wn-contact-form wn-scheme--{$color_scheme}\">";
|
||||
|
||||
if ($title) {
|
||||
$html .= "<h2 class=\"wn-contact-form__title\">{$title}</h2>";
|
||||
}
|
||||
|
||||
// Form is rendered but won't work for bots (they just see the structure)
|
||||
$html .= '<form class="wn-contact-form__form" method="post">';
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$field_label = ucfirst(str_replace('_', ' ', $field));
|
||||
$html .= '<div class="wn-contact-form__field">';
|
||||
$html .= "<label>{$field_label}</label>";
|
||||
if ($field === 'message') {
|
||||
$html .= "<textarea name=\"{$field}\" placeholder=\"{$field_label}\" {$field_attr}></textarea>";
|
||||
} else {
|
||||
$html .= "<input type=\"text\" name=\"{$field}\" placeholder=\"{$field_label}\" {$field_attr} />";
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
$html .= "<button type=\"submit\" {$btn_attr}>Submit</button>";
|
||||
$html .= '</form>';
|
||||
$html .= '</section>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get SVG for known icons
|
||||
*/
|
||||
private static function get_icon_svg($name) {
|
||||
$icons = [
|
||||
'Star' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
|
||||
'Zap' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
|
||||
'Shield' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
|
||||
'Heart' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
|
||||
'Award' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/></svg>',
|
||||
'Clock' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
|
||||
'Truck' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg>',
|
||||
'User' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
|
||||
'Settings' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
|
||||
];
|
||||
|
||||
return $icons[$name] ?? $icons['Star'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic section fallback
|
||||
*/
|
||||
public static function render_generic($props, $type, $id)
|
||||
{
|
||||
$content = '';
|
||||
foreach ($props as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$content .= "<div class=\"wn-{$type}__{$key}\">" . wp_kses_post($value) . "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
return "<section id=\"{$id}\" class=\"wn-section wn-{$type}\">{$content}</section>";
|
||||
}
|
||||
}
|
||||
213
includes/Frontend/PlaceholderRenderer.php
Normal file
213
includes/Frontend/PlaceholderRenderer.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
namespace WooNooW\Frontend;
|
||||
|
||||
/**
|
||||
* Placeholder Renderer
|
||||
* Resolves dynamic placeholders to actual post/CPT data
|
||||
*/
|
||||
class PlaceholderRenderer
|
||||
{
|
||||
/**
|
||||
* Get value for a dynamic placeholder source
|
||||
*
|
||||
* @param string $source Placeholder source (e.g., 'post_title', 'post_content')
|
||||
* @param array $post_data Post data array
|
||||
* @return mixed Resolved value
|
||||
*/
|
||||
public static function get_value($source, $post_data)
|
||||
{
|
||||
if (empty($source) || empty($post_data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Standard post fields
|
||||
switch ($source) {
|
||||
case 'post_title':
|
||||
case 'title':
|
||||
return $post_data['title'] ?? $post_data['post_title'] ?? '';
|
||||
|
||||
case 'post_content':
|
||||
case 'content':
|
||||
return $post_data['content'] ?? $post_data['post_content'] ?? '';
|
||||
|
||||
case 'post_excerpt':
|
||||
case 'excerpt':
|
||||
return $post_data['excerpt'] ?? $post_data['post_excerpt'] ?? '';
|
||||
|
||||
case 'post_featured_image':
|
||||
case 'featured_image':
|
||||
return $post_data['featured_image'] ??
|
||||
$post_data['thumbnail'] ??
|
||||
$post_data['_thumbnail_url'] ?? '';
|
||||
|
||||
case 'post_author':
|
||||
case 'author':
|
||||
return $post_data['author'] ?? $post_data['post_author'] ?? '';
|
||||
|
||||
case 'post_date':
|
||||
case 'date':
|
||||
return $post_data['date'] ?? $post_data['post_date'] ?? '';
|
||||
|
||||
case 'post_categories':
|
||||
case 'categories':
|
||||
return $post_data['categories'] ?? [];
|
||||
|
||||
case 'post_tags':
|
||||
case 'tags':
|
||||
return $post_data['tags'] ?? [];
|
||||
|
||||
case 'post_url':
|
||||
case 'url':
|
||||
case 'permalink':
|
||||
return $post_data['url'] ?? $post_data['permalink'] ?? '';
|
||||
}
|
||||
|
||||
// Check for custom meta fields (format: {cpt}_field_{name})
|
||||
if (strpos($source, '_field_') !== false) {
|
||||
$parts = explode('_field_', $source);
|
||||
$field_name = end($parts);
|
||||
|
||||
// Try to get from meta array
|
||||
if (isset($post_data['meta'][$field_name])) {
|
||||
return $post_data['meta'][$field_name];
|
||||
}
|
||||
|
||||
// Try direct field access
|
||||
if (isset($post_data[$field_name])) {
|
||||
return $post_data[$field_name];
|
||||
}
|
||||
}
|
||||
|
||||
// Check for direct key match
|
||||
if (isset($post_data[$source])) {
|
||||
return $post_data[$source];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build post data array from WP_Post object
|
||||
*
|
||||
* @param \WP_Post|int $post Post object or ID
|
||||
* @return array Post data array
|
||||
*/
|
||||
public static function build_post_data($post)
|
||||
{
|
||||
if (is_numeric($post)) {
|
||||
$post = get_post($post);
|
||||
}
|
||||
|
||||
if (!$post || !($post instanceof \WP_Post)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = [
|
||||
'id' => $post->ID,
|
||||
'title' => $post->post_title,
|
||||
'content' => apply_filters('the_content', $post->post_content),
|
||||
'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30),
|
||||
'date' => get_the_date('', $post),
|
||||
'date_iso' => get_the_date('c', $post),
|
||||
'url' => get_permalink($post),
|
||||
'slug' => $post->post_name,
|
||||
'type' => $post->post_type,
|
||||
];
|
||||
|
||||
// Author
|
||||
$author_id = $post->post_author;
|
||||
$data['author'] = get_the_author_meta('display_name', $author_id);
|
||||
$data['author_url'] = get_author_posts_url($author_id);
|
||||
|
||||
// Featured image
|
||||
$thumbnail_id = get_post_thumbnail_id($post);
|
||||
if ($thumbnail_id) {
|
||||
$data['featured_image'] = get_the_post_thumbnail_url($post, 'large');
|
||||
$data['featured_image_id'] = $thumbnail_id;
|
||||
}
|
||||
|
||||
// Taxonomies
|
||||
$taxonomies = get_object_taxonomies($post->post_type);
|
||||
foreach ($taxonomies as $taxonomy) {
|
||||
$terms = get_the_terms($post, $taxonomy);
|
||||
if ($terms && !is_wp_error($terms)) {
|
||||
$data[$taxonomy] = array_map(function($term) {
|
||||
return [
|
||||
'id' => $term->term_id,
|
||||
'name' => $term->name,
|
||||
'slug' => $term->slug,
|
||||
'url' => get_term_link($term),
|
||||
];
|
||||
}, $terms);
|
||||
}
|
||||
}
|
||||
|
||||
// Shortcuts for common taxonomies
|
||||
if (isset($data['category'])) {
|
||||
$data['categories'] = $data['category'];
|
||||
}
|
||||
if (isset($data['post_tag'])) {
|
||||
$data['tags'] = $data['post_tag'];
|
||||
}
|
||||
|
||||
// Custom meta fields
|
||||
$meta = get_post_meta($post->ID);
|
||||
if ($meta) {
|
||||
$data['meta'] = [];
|
||||
foreach ($meta as $key => $values) {
|
||||
// Skip internal meta keys
|
||||
if (strpos($key, '_') === 0) {
|
||||
continue;
|
||||
}
|
||||
$data['meta'][$key] = count($values) === 1 ? $values[0] : $values;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related posts
|
||||
*
|
||||
* @param int $post_id Current post ID
|
||||
* @param int $count Number of related posts
|
||||
* @param string $post_type Post type
|
||||
* @return array Related posts data
|
||||
*/
|
||||
public static function get_related_posts($post_id, $count = 3, $post_type = 'post')
|
||||
{
|
||||
// Get categories of current post
|
||||
$categories = get_the_category($post_id);
|
||||
$category_ids = wp_list_pluck($categories, 'term_id');
|
||||
|
||||
$args = [
|
||||
'post_type' => $post_type,
|
||||
'posts_per_page' => $count,
|
||||
'post__not_in' => [$post_id],
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
];
|
||||
|
||||
if (!empty($category_ids)) {
|
||||
$args['category__in'] = $category_ids;
|
||||
}
|
||||
|
||||
$query = new \WP_Query($args);
|
||||
$related = [];
|
||||
|
||||
foreach ($query->posts as $post) {
|
||||
$related[] = [
|
||||
'id' => $post->ID,
|
||||
'title' => $post->post_title,
|
||||
'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 20),
|
||||
'url' => get_permalink($post),
|
||||
'featured_image' => get_the_post_thumbnail_url($post, 'medium'),
|
||||
'date' => get_the_date('', $post),
|
||||
];
|
||||
}
|
||||
|
||||
wp_reset_postdata();
|
||||
|
||||
return $related;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace WooNooW\Frontend;
|
||||
|
||||
use WP_REST_Request;
|
||||
@@ -9,14 +10,16 @@ use WP_Error;
|
||||
* Shop Controller - Customer-facing product catalog API
|
||||
* Handles product listing, search, and categories for customer-spa
|
||||
*/
|
||||
class ShopController {
|
||||
|
||||
class ShopController
|
||||
{
|
||||
|
||||
/**
|
||||
* Register REST API routes
|
||||
*/
|
||||
public static function register_routes() {
|
||||
public static function register_routes()
|
||||
{
|
||||
$namespace = 'woonoow/v1';
|
||||
|
||||
|
||||
// Get products (public)
|
||||
register_rest_route($namespace, '/shop/products', [
|
||||
'methods' => 'GET',
|
||||
@@ -53,7 +56,7 @@ class ShopController {
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
// Get single product (public)
|
||||
register_rest_route($namespace, '/shop/products/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
@@ -61,20 +64,20 @@ class ShopController {
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'id' => [
|
||||
'validate_callback' => function($param) {
|
||||
'validate_callback' => function ($param) {
|
||||
return is_numeric($param);
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
// Get categories (public)
|
||||
register_rest_route($namespace, '/shop/categories', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_categories'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
|
||||
// Search products (public)
|
||||
register_rest_route($namespace, '/shop/search', [
|
||||
'methods' => 'GET',
|
||||
@@ -88,11 +91,12 @@ class ShopController {
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get products list
|
||||
*/
|
||||
public static function get_products(WP_REST_Request $request) {
|
||||
public static function get_products(WP_REST_Request $request)
|
||||
{
|
||||
$page = $request->get_param('page');
|
||||
$per_page = $request->get_param('per_page');
|
||||
$category = $request->get_param('category');
|
||||
@@ -102,7 +106,7 @@ class ShopController {
|
||||
$slug = $request->get_param('slug');
|
||||
$include = $request->get_param('include');
|
||||
$exclude = $request->get_param('exclude');
|
||||
|
||||
|
||||
$args = [
|
||||
'post_type' => 'product',
|
||||
'post_status' => 'publish',
|
||||
@@ -111,25 +115,25 @@ class ShopController {
|
||||
'orderby' => $orderby,
|
||||
'order' => $order,
|
||||
];
|
||||
|
||||
|
||||
// Add slug filter (for single product lookup)
|
||||
if (!empty($slug)) {
|
||||
$args['name'] = $slug;
|
||||
}
|
||||
|
||||
|
||||
// Add include filter (specific product IDs)
|
||||
if (!empty($include)) {
|
||||
$ids = array_map('intval', explode(',', $include));
|
||||
$args['post__in'] = $ids;
|
||||
$args['orderby'] = 'post__in'; // Maintain order of IDs
|
||||
}
|
||||
|
||||
|
||||
// Add exclude filter (exclude specific product IDs)
|
||||
if (!empty($exclude)) {
|
||||
$ids = array_map('intval', explode(',', $exclude));
|
||||
$args['post__not_in'] = $ids;
|
||||
}
|
||||
|
||||
|
||||
// Add category filter
|
||||
if (!empty($category)) {
|
||||
// Check if category is numeric (ID) or string (slug)
|
||||
@@ -142,23 +146,23 @@ class ShopController {
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// Add search
|
||||
if (!empty($search)) {
|
||||
$args['s'] = $search;
|
||||
}
|
||||
|
||||
|
||||
$query = new \WP_Query($args);
|
||||
|
||||
|
||||
// Check if this is a single product request (by slug)
|
||||
$is_single = !empty($slug);
|
||||
|
||||
|
||||
$products = [];
|
||||
if ($query->have_posts()) {
|
||||
while ($query->have_posts()) {
|
||||
$query->the_post();
|
||||
$product = wc_get_product(get_the_ID());
|
||||
|
||||
|
||||
if ($product) {
|
||||
// Return detailed data for single product requests
|
||||
$products[] = self::format_product($product, $is_single);
|
||||
@@ -166,7 +170,7 @@ class ShopController {
|
||||
}
|
||||
wp_reset_postdata();
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response([
|
||||
'products' => $products,
|
||||
'total' => $query->found_posts,
|
||||
@@ -175,34 +179,36 @@ class ShopController {
|
||||
'per_page' => $per_page,
|
||||
], 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get single product
|
||||
*/
|
||||
public static function get_product(WP_REST_Request $request) {
|
||||
public static function get_product(WP_REST_Request $request)
|
||||
{
|
||||
$product_id = $request->get_param('id');
|
||||
$product = wc_get_product($product_id);
|
||||
|
||||
|
||||
if (!$product) {
|
||||
return new WP_Error('product_not_found', 'Product not found', ['status' => 404]);
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response(self::format_product($product, true), 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get categories
|
||||
*/
|
||||
public static function get_categories(WP_REST_Request $request) {
|
||||
public static function get_categories(WP_REST_Request $request)
|
||||
{
|
||||
$terms = get_terms([
|
||||
'taxonomy' => 'product_cat',
|
||||
'hide_empty' => true,
|
||||
]);
|
||||
|
||||
|
||||
if (is_wp_error($terms)) {
|
||||
return new WP_Error('categories_error', 'Failed to get categories', ['status' => 500]);
|
||||
}
|
||||
|
||||
|
||||
$categories = [];
|
||||
foreach ($terms as $term) {
|
||||
$thumbnail_id = get_term_meta($term->term_id, 'thumbnail_id', true);
|
||||
@@ -214,45 +220,47 @@ class ShopController {
|
||||
'image' => $thumbnail_id ? wp_get_attachment_url($thumbnail_id) : '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response($categories, 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Search products
|
||||
*/
|
||||
public static function search_products(WP_REST_Request $request) {
|
||||
public static function search_products(WP_REST_Request $request)
|
||||
{
|
||||
$search = $request->get_param('s');
|
||||
|
||||
|
||||
$args = [
|
||||
'post_type' => 'product',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => 10,
|
||||
's' => $search,
|
||||
];
|
||||
|
||||
|
||||
$query = new \WP_Query($args);
|
||||
|
||||
|
||||
$products = [];
|
||||
if ($query->have_posts()) {
|
||||
while ($query->have_posts()) {
|
||||
$query->the_post();
|
||||
$product = wc_get_product(get_the_ID());
|
||||
|
||||
|
||||
if ($product) {
|
||||
$products[] = self::format_product($product);
|
||||
}
|
||||
}
|
||||
wp_reset_postdata();
|
||||
}
|
||||
|
||||
|
||||
return new WP_REST_Response($products, 200);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Format product data for API response
|
||||
*/
|
||||
private static function format_product($product, $detailed = false) {
|
||||
private static function format_product($product, $detailed = false)
|
||||
{
|
||||
$data = [
|
||||
'id' => $product->get_id(),
|
||||
'name' => $product->get_name(),
|
||||
@@ -272,18 +280,18 @@ class ShopController {
|
||||
'virtual' => $product->is_virtual(),
|
||||
'downloadable' => $product->is_downloadable(),
|
||||
];
|
||||
|
||||
|
||||
// Add detailed info if requested
|
||||
if ($detailed) {
|
||||
$data['description'] = $product->get_description();
|
||||
$data['short_description'] = $product->get_short_description();
|
||||
$data['sku'] = $product->get_sku();
|
||||
$data['tags'] = wp_get_post_terms($product->get_id(), 'product_tag', ['fields' => 'names']);
|
||||
|
||||
|
||||
// Gallery images
|
||||
$gallery_ids = $product->get_gallery_image_ids();
|
||||
$data['gallery'] = array_map('wp_get_attachment_url', $gallery_ids);
|
||||
|
||||
|
||||
// Images array (featured + gallery) for frontend
|
||||
$images = [];
|
||||
if ($data['image']) {
|
||||
@@ -291,39 +299,41 @@ class ShopController {
|
||||
}
|
||||
$images = array_merge($images, $data['gallery']);
|
||||
$data['images'] = $images;
|
||||
|
||||
|
||||
// Attributes and Variations for variable products
|
||||
if ($product->is_type('variable')) {
|
||||
$data['attributes'] = self::get_product_attributes($product);
|
||||
$data['variations'] = self::get_product_variations($product);
|
||||
}
|
||||
|
||||
|
||||
// Related products
|
||||
$related_ids = wc_get_related_products($product->get_id(), 4);
|
||||
$data['related_products'] = array_map(function($id) {
|
||||
$data['related_products'] = array_map(function ($id) {
|
||||
$related = wc_get_product($id);
|
||||
return $related ? self::format_product($related) : null;
|
||||
}, $related_ids);
|
||||
$data['related_products'] = array_filter($data['related_products']);
|
||||
}
|
||||
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get product attributes
|
||||
*/
|
||||
private static function get_product_attributes($product) {
|
||||
private static function get_product_attributes($product)
|
||||
{
|
||||
$attributes = [];
|
||||
|
||||
|
||||
foreach ($product->get_attributes() as $attribute) {
|
||||
$attribute_data = [
|
||||
'name' => wc_attribute_label($attribute->get_name()),
|
||||
'slug' => sanitize_title($attribute->get_name()),
|
||||
'options' => [],
|
||||
'visible' => $attribute->get_visible(),
|
||||
'variation' => $attribute->get_variation(),
|
||||
];
|
||||
|
||||
|
||||
// Get attribute options
|
||||
if ($attribute->is_taxonomy()) {
|
||||
$terms = wc_get_product_terms($product->get_id(), $attribute->get_name(), ['fields' => 'names']);
|
||||
@@ -331,39 +341,29 @@ class ShopController {
|
||||
} else {
|
||||
$attribute_data['options'] = $attribute->get_options();
|
||||
}
|
||||
|
||||
|
||||
$attributes[] = $attribute_data;
|
||||
}
|
||||
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get product variations
|
||||
*/
|
||||
private static function get_product_variations($product) {
|
||||
private static function get_product_variations($product)
|
||||
{
|
||||
$variations = [];
|
||||
|
||||
|
||||
foreach ($product->get_available_variations() as $variation) {
|
||||
$variation_obj = wc_get_product($variation['variation_id']);
|
||||
|
||||
|
||||
if ($variation_obj) {
|
||||
// Get attributes directly from post meta (most reliable)
|
||||
$attributes = [];
|
||||
// Use attributes directly from WooCommerce's get_available_variations()
|
||||
// This correctly handles custom attributes, taxonomy attributes, and "Any" selections
|
||||
$attributes = $variation['attributes'];
|
||||
$variation_id = $variation['variation_id'];
|
||||
|
||||
// Query all post meta for this variation
|
||||
global $wpdb;
|
||||
$meta_rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT meta_key, meta_value FROM {$wpdb->postmeta}
|
||||
WHERE post_id = %d AND meta_key LIKE 'attribute_%%'",
|
||||
$variation_id
|
||||
));
|
||||
|
||||
foreach ($meta_rows as $row) {
|
||||
$attributes[$row->meta_key] = $row->meta_value;
|
||||
}
|
||||
|
||||
|
||||
$variations[] = [
|
||||
'id' => $variation_id,
|
||||
'attributes' => $attributes,
|
||||
@@ -376,7 +376,7 @@ class ShopController {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return $variations;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace WooNooW\Frontend;
|
||||
|
||||
use WooNooW\Frontend\PageSSR;
|
||||
use WooNooW\Frontend\PlaceholderRenderer;
|
||||
|
||||
/**
|
||||
* Template Override
|
||||
* Overrides WooCommerce templates to use WooNooW SPA
|
||||
@@ -15,29 +19,32 @@ class TemplateOverride
|
||||
{
|
||||
// Register rewrite rules for BrowserRouter SEO (must be on 'init')
|
||||
add_action('init', [__CLASS__, 'register_spa_rewrite_rules']);
|
||||
|
||||
|
||||
// Flush rewrite rules when relevant settings change
|
||||
add_action('update_option_woonoow_appearance_settings', function($old_value, $new_value) {
|
||||
add_action('update_option_woonoow_appearance_settings', function ($old_value, $new_value) {
|
||||
$old_general = $old_value['general'] ?? [];
|
||||
$new_general = $new_value['general'] ?? [];
|
||||
|
||||
|
||||
// Only flush if spa_mode, spa_page, or use_browser_router changed
|
||||
$needs_flush =
|
||||
$needs_flush =
|
||||
($old_general['spa_mode'] ?? '') !== ($new_general['spa_mode'] ?? '') ||
|
||||
($old_general['spa_page'] ?? '') !== ($new_general['spa_page'] ?? '') ||
|
||||
($old_general['use_browser_router'] ?? true) !== ($new_general['use_browser_router'] ?? true);
|
||||
|
||||
|
||||
if ($needs_flush) {
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
}, 10, 2);
|
||||
|
||||
|
||||
// Redirect WooCommerce pages to SPA routes early (before template loads)
|
||||
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
|
||||
|
||||
|
||||
// Serve SPA directly for frontpage routes (priority 1 = very early, before WC)
|
||||
add_action('template_redirect', [__CLASS__, 'serve_spa_for_frontpage_routes'], 1);
|
||||
|
||||
|
||||
// Serve SSR for bots on pages/CPT with WooNooW structure (priority 2 = after frontpage check)
|
||||
add_action('template_redirect', [__CLASS__, 'maybe_serve_ssr_for_bots'], 2);
|
||||
|
||||
// Hook to wp_loaded with priority 10 (BEFORE WooCommerce's priority 20)
|
||||
// This ensures we process add-to-cart before WooCommerce does
|
||||
add_action('wp_loaded', [__CLASS__, 'intercept_add_to_cart'], 10);
|
||||
@@ -68,7 +75,7 @@ class TemplateOverride
|
||||
add_action('get_header', [__CLASS__, 'remove_theme_header']);
|
||||
add_action('get_footer', [__CLASS__, 'remove_theme_footer']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Register rewrite rules for BrowserRouter SEO
|
||||
* Catches all SPA routes and serves the SPA page
|
||||
@@ -77,25 +84,25 @@ class TemplateOverride
|
||||
{
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
|
||||
|
||||
// Check if BrowserRouter is enabled (default: true for new installs)
|
||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||
|
||||
|
||||
if (!$spa_page_id || !$use_browser_router) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$spa_page = get_post($spa_page_id);
|
||||
if (!$spa_page) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$spa_slug = $spa_page->post_name;
|
||||
|
||||
|
||||
// Check if SPA page is set as WordPress frontpage
|
||||
$frontpage_id = (int) get_option('page_on_front');
|
||||
$is_spa_frontpage = $frontpage_id && $frontpage_id === (int) $spa_page_id;
|
||||
|
||||
|
||||
if ($is_spa_frontpage) {
|
||||
// When SPA is frontpage, add root-level routes
|
||||
// /shop, /shop/* → SPA page
|
||||
@@ -109,28 +116,33 @@ class TemplateOverride
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=shop/$matches[1]',
|
||||
'top'
|
||||
);
|
||||
|
||||
|
||||
// /product/* → SPA page
|
||||
add_rewrite_rule(
|
||||
'^product/(.*)$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=product/$matches[1]',
|
||||
'top'
|
||||
);
|
||||
|
||||
|
||||
// /cart → SPA page
|
||||
add_rewrite_rule(
|
||||
'^cart/?$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=cart',
|
||||
'top'
|
||||
);
|
||||
|
||||
// /checkout → SPA page
|
||||
|
||||
// /checkout, /checkout/* → SPA page
|
||||
add_rewrite_rule(
|
||||
'^checkout/?$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=checkout',
|
||||
'top'
|
||||
);
|
||||
|
||||
add_rewrite_rule(
|
||||
'^checkout/(.*)$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=checkout/$matches[1]',
|
||||
'top'
|
||||
);
|
||||
|
||||
// /my-account, /my-account/* → SPA page
|
||||
add_rewrite_rule(
|
||||
'^my-account/?$',
|
||||
@@ -142,7 +154,7 @@ class TemplateOverride
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=my-account/$matches[1]',
|
||||
'top'
|
||||
);
|
||||
|
||||
|
||||
// /login, /register, /reset-password → SPA page
|
||||
add_rewrite_rule(
|
||||
'^login/?$',
|
||||
@@ -159,8 +171,33 @@ class TemplateOverride
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=reset-password',
|
||||
'top'
|
||||
);
|
||||
|
||||
// /order-pay/* → SPA page
|
||||
add_rewrite_rule(
|
||||
'^order-pay/(.*)$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=order-pay/$matches[1]',
|
||||
'top'
|
||||
);
|
||||
|
||||
// /order-pay/* → SPA page
|
||||
add_rewrite_rule(
|
||||
'^order-pay/(.*)$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=order-pay/$matches[1]',
|
||||
'top'
|
||||
);
|
||||
|
||||
// /order-pay/* → SPA page (moved to checkout/pay/ in new structure)
|
||||
// Removed direct order-pay rule to favor checkout subpath
|
||||
|
||||
} else {
|
||||
// Rewrite /slug/anything to serve the SPA page
|
||||
// Rewrite /slug to serve the SPA page (base URL)
|
||||
add_rewrite_rule(
|
||||
'^' . preg_quote($spa_slug, '/') . '/?$',
|
||||
'index.php?page_id=' . $spa_page_id,
|
||||
'top'
|
||||
);
|
||||
|
||||
// Rewrite /slug/anything to serve the SPA page with path
|
||||
// React Router handles the path after that
|
||||
add_rewrite_rule(
|
||||
'^' . preg_quote($spa_slug, '/') . '/(.*)$',
|
||||
@@ -168,9 +205,9 @@ class TemplateOverride
|
||||
'top'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Register query var for the SPA path
|
||||
add_filter('query_vars', function($vars) {
|
||||
add_filter('query_vars', function ($vars) {
|
||||
$vars[] = 'woonoow_spa_path';
|
||||
return $vars;
|
||||
});
|
||||
@@ -236,32 +273,32 @@ class TemplateOverride
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||
|
||||
|
||||
// Only redirect when SPA mode is 'full'
|
||||
if ($spa_mode !== 'full') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!$spa_page_id) {
|
||||
return; // No SPA page configured
|
||||
}
|
||||
|
||||
|
||||
// Skip if SPA is set as frontpage (serve_spa_for_frontpage_routes handles it)
|
||||
$frontpage_id = (int) get_option('page_on_front');
|
||||
if ($frontpage_id && $frontpage_id === (int) $spa_page_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Already on SPA page, don't redirect
|
||||
global $post;
|
||||
if ($post && $post->ID == $spa_page_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$spa_url = trailingslashit(get_permalink($spa_page_id));
|
||||
|
||||
|
||||
// Helper function to build route URL based on router type
|
||||
$build_route = function($path) use ($spa_url, $use_browser_router) {
|
||||
$build_route = function ($path) use ($spa_url, $use_browser_router) {
|
||||
if ($use_browser_router) {
|
||||
// Path format: /store/cart
|
||||
return $spa_url . ltrim($path, '/');
|
||||
@@ -269,13 +306,13 @@ class TemplateOverride
|
||||
// Hash format: /store/#/cart
|
||||
return rtrim($spa_url, '/') . '#/' . ltrim($path, '/');
|
||||
};
|
||||
|
||||
|
||||
// Check which WC page we're on and redirect
|
||||
if (is_shop()) {
|
||||
wp_redirect($build_route('shop'), 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
if (is_product()) {
|
||||
// Use get_queried_object() which returns the WP_Post, then get slug
|
||||
$product_post = get_queried_object();
|
||||
@@ -285,23 +322,53 @@ class TemplateOverride
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (is_cart()) {
|
||||
wp_redirect($build_route('cart'), 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
if (is_checkout() && !is_order_received_page()) {
|
||||
// Check for order-pay endpoint
|
||||
if (is_wc_endpoint_url('order-pay')) {
|
||||
global $wp;
|
||||
$order_id = $wp->query_vars['order-pay'];
|
||||
wp_redirect($build_route('order-pay/' . $order_id), 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
wp_redirect($build_route('checkout'), 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
if (is_account_page()) {
|
||||
wp_redirect($build_route('my-account'), 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Redirect structural pages with WooNooW sections to SPA
|
||||
if (is_singular('page') && $post) {
|
||||
// Skip the SPA page itself and frontpage
|
||||
if ($post->ID == $spa_page_id || $post->ID == $frontpage_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if page has WooNooW structure
|
||||
$structure = get_post_meta($post->ID, '_wn_page_structure', true);
|
||||
if (!empty($structure) && !empty($structure['sections'])) {
|
||||
// Redirect to SPA with page slug route
|
||||
$page_slug = $post->post_name;
|
||||
wp_redirect($build_route($page_slug), 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve SPA template directly for frontpage SPA routes
|
||||
* When SPA page is set as WordPress frontpage, intercept known routes
|
||||
* and serve the SPA template directly (bypasses WooCommerce templates)
|
||||
*/
|
||||
/**
|
||||
* Serve SPA template directly for frontpage SPA routes
|
||||
* When SPA page is set as WordPress frontpage, intercept known routes
|
||||
@@ -313,23 +380,34 @@ class TemplateOverride
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||
|
||||
|
||||
// Only run in full SPA mode
|
||||
if ($spa_mode !== 'full' || !$spa_page_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if SPA page is set as WordPress frontpage
|
||||
$frontpage_id = (int) get_option('page_on_front');
|
||||
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
|
||||
return; // SPA is not frontpage, let normal routing handle it
|
||||
}
|
||||
|
||||
// Get the current request path
|
||||
|
||||
// Get the current request path relative to site root
|
||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
$home_path = parse_url(home_url(), PHP_URL_PATH);
|
||||
|
||||
// Normalize request URI for subdirectory installs
|
||||
if ($home_path && $home_path !== '/') {
|
||||
$home_path = rtrim($home_path, '/');
|
||||
if (strpos($request_uri, $home_path) === 0) {
|
||||
$request_uri = substr($request_uri, strlen($home_path));
|
||||
if (empty($request_uri)) $request_uri = '/';
|
||||
}
|
||||
}
|
||||
|
||||
$path = parse_url($request_uri, PHP_URL_PATH);
|
||||
$path = '/' . trim($path, '/');
|
||||
|
||||
|
||||
// Define SPA routes that should be intercepted when SPA is frontpage
|
||||
$spa_routes = [
|
||||
'/', // Frontpage itself
|
||||
@@ -339,45 +417,68 @@ class TemplateOverride
|
||||
'/my-account', // Account page
|
||||
'/login', // Login page
|
||||
'/register', // Register page
|
||||
'/register', // Register page
|
||||
'/reset-password', // Password reset
|
||||
'/order-pay', // Order pay page
|
||||
];
|
||||
|
||||
|
||||
// Check for exact matches or path prefixes
|
||||
$should_serve_spa = false;
|
||||
|
||||
|
||||
// Check exact matches
|
||||
if (in_array($path, $spa_routes)) {
|
||||
$should_serve_spa = true;
|
||||
}
|
||||
|
||||
|
||||
// Check path prefixes (for sub-routes)
|
||||
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
|
||||
$prefix_routes = ['/shop/', '/my-account/', '/product/', '/checkout/'];
|
||||
foreach ($prefix_routes as $prefix) {
|
||||
if (strpos($path, $prefix) === 0) {
|
||||
$should_serve_spa = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check for structural pages with WooNooW sections
|
||||
if (!$should_serve_spa && !empty($path) && $path !== '/') {
|
||||
// Try to find a page by slug matching the path
|
||||
$slug = trim($path, '/');
|
||||
|
||||
// Handle nested slugs (get the last part as the page slug)
|
||||
if (strpos($slug, '/') !== false) {
|
||||
$slug_parts = explode('/', $slug);
|
||||
$slug = end($slug_parts);
|
||||
}
|
||||
|
||||
$page = get_page_by_path($slug);
|
||||
if ($page) {
|
||||
// Check if this page has WooNooW structure
|
||||
$structure = get_post_meta($page->ID, '_wn_page_structure', true);
|
||||
if (!empty($structure) && !empty($structure['sections'])) {
|
||||
$should_serve_spa = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not a SPA route
|
||||
if (!$should_serve_spa) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Prevent caching for dynamic SPA content
|
||||
nocache_headers();
|
||||
|
||||
|
||||
// Load the SPA template directly and exit
|
||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||
if (file_exists($spa_template)) {
|
||||
// Set up minimal WordPress environment for the template
|
||||
status_header(200);
|
||||
|
||||
|
||||
// Define constant to tell Assets to load unconditionally
|
||||
if (!defined('WOONOOW_SERVE_SPA')) {
|
||||
define('WOONOOW_SERVE_SPA', true);
|
||||
}
|
||||
|
||||
|
||||
// Include the SPA template
|
||||
include $spa_template;
|
||||
exit;
|
||||
@@ -390,8 +491,8 @@ class TemplateOverride
|
||||
*/
|
||||
public static function disable_canonical_redirect($redirect_url, $requested_url)
|
||||
{
|
||||
$settings = get_option('woonoow_customer_spa_settings', []);
|
||||
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
|
||||
$settings = get_option('woonoow_appearance_settings', []);
|
||||
$mode = isset($settings['general']['spa_mode']) ? $settings['general']['spa_mode'] : 'disabled';
|
||||
|
||||
// Only disable redirects in full SPA mode
|
||||
if ($mode !== 'full') {
|
||||
@@ -399,6 +500,7 @@ class TemplateOverride
|
||||
}
|
||||
|
||||
// Check if this is a SPA route
|
||||
// We include /product/ and standard endpoints
|
||||
$spa_routes = ['/product/', '/cart', '/checkout', '/my-account'];
|
||||
|
||||
foreach ($spa_routes as $route) {
|
||||
@@ -419,12 +521,12 @@ class TemplateOverride
|
||||
// Check spa_mode from appearance settings FIRST
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||
|
||||
|
||||
// If SPA is disabled, return original template immediately
|
||||
if ($spa_mode === 'disabled') {
|
||||
return $template;
|
||||
}
|
||||
|
||||
|
||||
// Check if current page is a designated SPA page
|
||||
if (self::is_spa_page()) {
|
||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||
@@ -443,7 +545,7 @@ class TemplateOverride
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For spa_mode = 'checkout_only'
|
||||
if ($spa_mode === 'checkout_only') {
|
||||
if (is_checkout() || is_order_received_page() || is_account_page() || is_cart()) {
|
||||
@@ -566,7 +668,7 @@ class TemplateOverride
|
||||
private static function is_spa_page()
|
||||
{
|
||||
global $post;
|
||||
|
||||
|
||||
// Get SPA settings from appearance
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
@@ -648,4 +750,285 @@ class TemplateOverride
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if current request is from a bot/crawler
|
||||
* Used to serve SSR content for SEO instead of SPA redirect
|
||||
*
|
||||
* @return bool True if request is from a known bot
|
||||
*/
|
||||
public static function is_bot()
|
||||
{
|
||||
// Get User-Agent
|
||||
$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
if (empty($user_agent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert to lowercase for case-insensitive matching
|
||||
$user_agent = strtolower($user_agent);
|
||||
|
||||
// Known bot patterns
|
||||
$bot_patterns = [
|
||||
// Search engine crawlers
|
||||
'googlebot',
|
||||
'bingbot',
|
||||
'slurp', // Yahoo
|
||||
'duckduckbot',
|
||||
'baiduspider',
|
||||
'yandexbot',
|
||||
'sogou',
|
||||
'exabot',
|
||||
|
||||
// Generic patterns
|
||||
'crawler',
|
||||
'spider',
|
||||
'robot',
|
||||
'scraper',
|
||||
|
||||
// Social media bots (for link previews)
|
||||
'facebookexternalhit',
|
||||
'twitterbot',
|
||||
'linkedinbot',
|
||||
'whatsapp',
|
||||
'slackbot',
|
||||
'telegrambot',
|
||||
'discordbot',
|
||||
|
||||
// Other known bots
|
||||
'applebot',
|
||||
'semrushbot',
|
||||
'ahrefsbot',
|
||||
'mj12bot',
|
||||
'dotbot',
|
||||
'petalbot',
|
||||
'bytespider',
|
||||
|
||||
// Prerender services
|
||||
'prerender',
|
||||
'headlesschrome',
|
||||
];
|
||||
|
||||
// Check if User-Agent contains any bot pattern
|
||||
foreach ($bot_patterns as $pattern) {
|
||||
if (strpos($user_agent, $pattern) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve SSR content for bots
|
||||
* Renders page structure as static HTML for search engine indexing
|
||||
*
|
||||
* @param int $page_id Page ID to render
|
||||
* @param string $type 'page' or 'template'
|
||||
* @param \WP_Post|null $post_obj Post object for template rendering (CPT items)
|
||||
*/
|
||||
public static function serve_ssr_content($page_id, $type = 'page', $post_obj = null)
|
||||
{
|
||||
// Generate cache key
|
||||
$cache_id = $post_obj ? $post_obj->ID : $page_id;
|
||||
$cache_key = "wn_ssr_{$type}_{$cache_id}";
|
||||
|
||||
// Check cache TTL (default 1 hour, filterable)
|
||||
$cache_ttl = apply_filters('woonoow_ssr_cache_ttl', HOUR_IN_SECONDS);
|
||||
|
||||
// Try to get cached content
|
||||
$cached = get_transient($cache_key);
|
||||
if ($cached !== false) {
|
||||
echo $cached;
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get page structure
|
||||
if ($type === 'page') {
|
||||
$structure = get_post_meta($page_id, '_wn_page_structure', true);
|
||||
} else {
|
||||
// CPT template - type is the post_type like 'post', 'portfolio', etc.
|
||||
$structure = get_option("wn_template_{$type}", null);
|
||||
}
|
||||
|
||||
if (empty($structure) || empty($structure['sections'])) {
|
||||
return false; // No structure, let normal WP handle it
|
||||
}
|
||||
|
||||
// Render using PageSSR
|
||||
$post_data = null;
|
||||
if ($post_obj && $type !== 'page') {
|
||||
$placeholder_renderer = new PlaceholderRenderer();
|
||||
$post_data = $placeholder_renderer->build_post_data($post_obj);
|
||||
}
|
||||
|
||||
$ssr = new PageSSR();
|
||||
$html = $ssr->render($structure['sections'], $post_data);
|
||||
|
||||
if (empty($html)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get page title
|
||||
$title = $type === 'page' ? get_the_title($page_id) : '';
|
||||
if ($post_obj) {
|
||||
$title = get_the_title($post_obj);
|
||||
}
|
||||
|
||||
// SEO data
|
||||
$seo_title = $title . ' - ' . get_bloginfo('name');
|
||||
$seo_description = '';
|
||||
|
||||
// Try to get Yoast/Rank Math SEO data
|
||||
if ($type === 'page') {
|
||||
$seo_title = get_post_meta($page_id, '_yoast_wpseo_title', true) ?:
|
||||
get_post_meta($page_id, 'rank_math_title', true) ?: $seo_title;
|
||||
$seo_description = get_post_meta($page_id, '_yoast_wpseo_metadesc', true) ?:
|
||||
get_post_meta($page_id, 'rank_math_description', true) ?: '';
|
||||
} elseif ($post_obj) {
|
||||
$seo_title = get_post_meta($post_obj->ID, '_yoast_wpseo_title', true) ?:
|
||||
get_post_meta($post_obj->ID, 'rank_math_title', true) ?: $seo_title;
|
||||
$seo_description = get_post_meta($post_obj->ID, '_yoast_wpseo_metadesc', true) ?:
|
||||
get_post_meta($post_obj->ID, 'rank_math_description', true) ?:
|
||||
wp_trim_words(wp_strip_all_tags($post_obj->post_content), 30);
|
||||
}
|
||||
|
||||
// Output SSR HTML - start output buffering for caching
|
||||
ob_start();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html <?php language_attributes(); ?>>
|
||||
|
||||
<head>
|
||||
<meta charset="<?php bloginfo('charset'); ?>">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?php echo esc_html($seo_title); ?></title>
|
||||
<?php if ($seo_description): ?>
|
||||
<meta name="description" content="<?php echo esc_attr($seo_description); ?>">
|
||||
<?php endif; ?>
|
||||
<link rel="canonical" href="<?php echo esc_url(get_permalink($post_obj ?: $page_id)); ?>">
|
||||
<meta property="og:title" content="<?php echo esc_attr($seo_title); ?>">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="<?php echo esc_url(get_permalink($post_obj ?: $page_id)); ?>">
|
||||
<?php if ($seo_description): ?>
|
||||
<meta property="og:description" content="<?php echo esc_attr($seo_description); ?>">
|
||||
<?php endif; ?>
|
||||
<style>
|
||||
/* Minimal SSR styles for bots */
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wn-ssr {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.wn-section {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.wn-section h1,
|
||||
.wn-section h2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.wn-section p {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wn-section img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.wn-hero {
|
||||
background: #f5f5f5;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wn-cta-banner {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wn-cta-banner a {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wn-feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.wn-feature-item {
|
||||
padding: 20px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
<?php wp_head(); ?>
|
||||
</head>
|
||||
|
||||
<body <?php body_class('wn-ssr-page'); ?>>
|
||||
<div class="wn-ssr">
|
||||
<?php echo $html; ?>
|
||||
</div>
|
||||
<?php wp_footer(); ?>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<?php
|
||||
// Get buffered output
|
||||
$output = ob_get_clean();
|
||||
|
||||
// Cache the output for bots (uses cache TTL from filter)
|
||||
set_transient($cache_key, $output, $cache_ttl);
|
||||
|
||||
// Output and exit
|
||||
echo $output;
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle SSR for structural pages and CPT items when bot detected
|
||||
* Should be called from template_redirect hook
|
||||
*/
|
||||
public static function maybe_serve_ssr_for_bots()
|
||||
{
|
||||
// Only serve SSR for bots
|
||||
if (!self::is_bot()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a page with WooNooW structure
|
||||
if (is_singular('page')) {
|
||||
$page_id = get_queried_object_id();
|
||||
$structure = get_post_meta($page_id, '_wn_page_structure', true);
|
||||
|
||||
if (!empty($structure) && !empty($structure['sections'])) {
|
||||
self::serve_ssr_content($page_id, 'page');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for CPT items with templates
|
||||
$post_type = get_post_type();
|
||||
if ($post_type && is_singular() && $post_type !== 'page') {
|
||||
$template = get_option("wn_template_{$post_type}", null);
|
||||
|
||||
if (!empty($template) && !empty($template['sections'])) {
|
||||
$post_obj = get_queried_object();
|
||||
self::serve_ssr_content($post_obj->ID, $post_type, $post_obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* License Manager
|
||||
*
|
||||
@@ -13,53 +14,57 @@ if (!defined('ABSPATH')) exit;
|
||||
|
||||
use WooNooW\Core\ModuleRegistry;
|
||||
|
||||
class LicenseManager {
|
||||
|
||||
class LicenseManager
|
||||
{
|
||||
|
||||
private static $table_name = 'woonoow_licenses';
|
||||
private static $activations_table = 'woonoow_license_activations';
|
||||
|
||||
|
||||
/**
|
||||
* Initialize
|
||||
*/
|
||||
public static function init() {
|
||||
public static function init()
|
||||
{
|
||||
// Only initialize if module is enabled
|
||||
if (!ModuleRegistry::is_enabled('licensing')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Hook into order completion - multiple hooks to catch all scenarios
|
||||
add_action('woocommerce_order_status_completed', [__CLASS__, 'generate_licenses_for_order']);
|
||||
add_action('woocommerce_order_status_processing', [__CLASS__, 'generate_licenses_for_order']);
|
||||
add_action('woocommerce_payment_complete', [__CLASS__, 'generate_licenses_for_order']);
|
||||
|
||||
|
||||
// Also hook into thank you page for COD/pending orders (with lower priority)
|
||||
add_action('woocommerce_thankyou', [__CLASS__, 'maybe_generate_on_thankyou'], 10);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Maybe generate licenses on thank you page (for COD and pending orders)
|
||||
*/
|
||||
public static function maybe_generate_on_thankyou($order_id) {
|
||||
public static function maybe_generate_on_thankyou($order_id)
|
||||
{
|
||||
if (!$order_id) return;
|
||||
|
||||
|
||||
$order = wc_get_order($order_id);
|
||||
if (!$order) return;
|
||||
|
||||
|
||||
// Only generate for orders that didn't already get licenses via status hooks
|
||||
// Check if it's a virtual-only order that might skip payment completion
|
||||
$needs_payment = $order->needs_payment();
|
||||
$is_virtual = self::is_virtual_order($order);
|
||||
|
||||
|
||||
// Generate if: virtual order OR already paid (processing/completed)
|
||||
if ($is_virtual || in_array($order->get_status(), ['processing', 'completed'])) {
|
||||
self::generate_licenses_for_order($order_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if order contains only virtual items
|
||||
*/
|
||||
private static function is_virtual_order($order) {
|
||||
private static function is_virtual_order($order)
|
||||
{
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if ($product && !$product->is_virtual()) {
|
||||
@@ -68,19 +73,20 @@ class LicenseManager {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create database tables
|
||||
*/
|
||||
public static function create_tables() {
|
||||
public static function create_tables()
|
||||
{
|
||||
global $wpdb;
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
|
||||
$licenses_table = $wpdb->prefix . self::$table_name;
|
||||
$activations_table = $wpdb->prefix . self::$activations_table;
|
||||
|
||||
|
||||
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
|
||||
|
||||
|
||||
// Create licenses table - dbDelta requires each CREATE TABLE to be called separately
|
||||
$sql_licenses = "CREATE TABLE $licenses_table (
|
||||
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
@@ -102,9 +108,9 @@ class LicenseManager {
|
||||
KEY user_id (user_id),
|
||||
KEY status (status)
|
||||
) $charset_collate;";
|
||||
|
||||
|
||||
dbDelta($sql_licenses);
|
||||
|
||||
|
||||
// Create activations table
|
||||
$sql_activations = "CREATE TABLE $activations_table (
|
||||
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
@@ -120,44 +126,45 @@ class LicenseManager {
|
||||
KEY license_id (license_id),
|
||||
KEY status (status)
|
||||
) $charset_collate;";
|
||||
|
||||
|
||||
dbDelta($sql_activations);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate licenses for completed order
|
||||
*/
|
||||
public static function generate_licenses_for_order($order_id) {
|
||||
public static function generate_licenses_for_order($order_id)
|
||||
{
|
||||
$order = wc_get_order($order_id);
|
||||
if (!$order) return;
|
||||
|
||||
|
||||
foreach ($order->get_items() as $item_id => $item) {
|
||||
$product_id = $item->get_product_id();
|
||||
$product = wc_get_product($product_id);
|
||||
|
||||
|
||||
if (!$product) continue;
|
||||
|
||||
|
||||
// Check if product has licensing enabled
|
||||
$licensing_enabled = get_post_meta($product_id, '_woonoow_licensing_enabled', true);
|
||||
if ($licensing_enabled !== 'yes') continue;
|
||||
|
||||
|
||||
// Check if license already exists for this order item
|
||||
if (self::license_exists_for_order_item($item_id)) continue;
|
||||
|
||||
|
||||
// Get activation limit from product or default
|
||||
$activation_limit = (int) get_post_meta($product_id, '_woonoow_license_activation_limit', true);
|
||||
if ($activation_limit <= 0) {
|
||||
$activation_limit = (int) get_option('woonoow_licensing_default_activation_limit', 1);
|
||||
}
|
||||
|
||||
|
||||
// Get expiry from product or default
|
||||
$expiry_days = (int) get_post_meta($product_id, '_woonoow_license_expiry_days', true);
|
||||
if ($expiry_days <= 0 && get_option('woonoow_licensing_license_expiry_enabled', false)) {
|
||||
$expiry_days = (int) get_option('woonoow_licensing_default_expiry_days', 365);
|
||||
}
|
||||
|
||||
|
||||
$expires_at = $expiry_days > 0 ? gmdate('Y-m-d H:i:s', strtotime("+$expiry_days days")) : null;
|
||||
|
||||
|
||||
// Generate license for each quantity
|
||||
$quantity = $item->get_quantity();
|
||||
for ($i = 0; $i < $quantity; $i++) {
|
||||
@@ -172,29 +179,31 @@ class LicenseManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if license already exists for order item
|
||||
*/
|
||||
public static function license_exists_for_order_item($order_item_id) {
|
||||
public static function license_exists_for_order_item($order_item_id)
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
|
||||
return (bool) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM $table WHERE order_item_id = %d",
|
||||
$order_item_id
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a new license
|
||||
*/
|
||||
public static function create_license($data) {
|
||||
public static function create_license($data)
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
|
||||
$license_key = self::generate_license_key();
|
||||
|
||||
|
||||
$wpdb->insert($table, [
|
||||
'license_key' => $license_key,
|
||||
'product_id' => $data['product_id'],
|
||||
@@ -205,24 +214,25 @@ class LicenseManager {
|
||||
'expires_at' => $data['expires_at'] ?? null,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
|
||||
$license_id = $wpdb->insert_id;
|
||||
|
||||
|
||||
do_action('woonoow/license/created', $license_id, $license_key, $data);
|
||||
|
||||
|
||||
return [
|
||||
'id' => $license_id,
|
||||
'license_key' => $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');
|
||||
$prefix = get_option('woonoow_licensing_license_key_prefix', '');
|
||||
|
||||
|
||||
switch ($format) {
|
||||
case 'uuid':
|
||||
$key = wp_generate_uuid4();
|
||||
@@ -241,80 +251,84 @@ class LicenseManager {
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
return $prefix . $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;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
|
||||
return $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $table WHERE license_key = %s",
|
||||
$license_key
|
||||
), ARRAY_A);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get license by ID
|
||||
*/
|
||||
public static function get_license($license_id) {
|
||||
public static function get_license($license_id)
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
|
||||
return $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $table WHERE id = %d",
|
||||
$license_id
|
||||
), ARRAY_A);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get licenses for user
|
||||
*/
|
||||
public static function get_user_licenses($user_id, $args = []) {
|
||||
public static function get_user_licenses($user_id, $args = [])
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
|
||||
$defaults = [
|
||||
'status' => null,
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
];
|
||||
$args = wp_parse_args($args, $defaults);
|
||||
|
||||
|
||||
$where = "user_id = %d";
|
||||
$params = [$user_id];
|
||||
|
||||
|
||||
if ($args['status']) {
|
||||
$where .= " AND status = %s";
|
||||
$params[] = $args['status'];
|
||||
}
|
||||
|
||||
|
||||
$sql = "SELECT * FROM $table WHERE $where ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $args['limit'];
|
||||
$params[] = $args['offset'];
|
||||
|
||||
|
||||
return $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Activate license
|
||||
*/
|
||||
public static function activate($license_key, $activation_data = []) {
|
||||
public static function activate($license_key, $activation_data = [])
|
||||
{
|
||||
global $wpdb;
|
||||
$license = self::get_license_by_key($license_key);
|
||||
|
||||
|
||||
if (!$license) {
|
||||
return new \WP_Error('invalid_license', __('Invalid license key', 'woonoow'));
|
||||
}
|
||||
|
||||
|
||||
if ($license['status'] !== 'active') {
|
||||
return new \WP_Error('license_inactive', __('License is not active', 'woonoow'));
|
||||
}
|
||||
|
||||
|
||||
// Check expiry
|
||||
if ($license['expires_at'] && strtotime($license['expires_at']) < time()) {
|
||||
$block_expired = get_option('woonoow_licensing_block_expired_activations', true);
|
||||
@@ -322,16 +336,52 @@ class LicenseManager {
|
||||
return new \WP_Error('license_expired', __('License has expired', 'woonoow'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check subscription status if linked
|
||||
$subscription_status = self::get_order_subscription_status($license['order_id']);
|
||||
if ($subscription_status !== null && !in_array($subscription_status, ['active', 'pending-cancel'])) {
|
||||
return new \WP_Error('subscription_inactive', __('Subscription is not active', 'woonoow'));
|
||||
}
|
||||
|
||||
// Check activation limit
|
||||
if ($license['activation_limit'] > 0 && $license['activation_count'] >= $license['activation_limit']) {
|
||||
return new \WP_Error('activation_limit_reached', __('Activation limit reached', 'woonoow'));
|
||||
}
|
||||
|
||||
|
||||
// Check if product requires OAuth activation
|
||||
// Get licensing module settings
|
||||
$licensing_settings = get_option('woonoow_module_licensing_settings', []);
|
||||
|
||||
// 1. Get site-level setting (default)
|
||||
$activation_method = $licensing_settings['activation_method'] ?? 'api';
|
||||
|
||||
// 2. Check for product-level override (only if allow_product_override is enabled)
|
||||
$allow_override = $licensing_settings['allow_product_override'] ?? false;
|
||||
if ($allow_override) {
|
||||
$product_method = get_post_meta($license['product_id'], '_woonoow_license_activation_method', true);
|
||||
if (!empty($product_method)) {
|
||||
$activation_method = $product_method;
|
||||
}
|
||||
}
|
||||
|
||||
if ($activation_method === 'oauth') {
|
||||
// Check if this is an OAuth callback (has valid activation token)
|
||||
if (!empty($activation_data['activation_token'])) {
|
||||
$validated = self::validate_activation_token($activation_data['activation_token'], $license_key);
|
||||
if (is_wp_error($validated)) {
|
||||
return $validated;
|
||||
}
|
||||
// Token is valid, proceed with activation
|
||||
} else {
|
||||
// Not a callback, return redirect URL for OAuth flow
|
||||
return self::build_oauth_redirect_response($license, $activation_data);
|
||||
}
|
||||
}
|
||||
|
||||
// Create activation record
|
||||
$activations_table = $wpdb->prefix . self::$activations_table;
|
||||
$licenses_table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
|
||||
$wpdb->insert($activations_table, [
|
||||
'license_id' => $license['id'],
|
||||
'domain' => $activation_data['domain'] ?? null,
|
||||
@@ -340,48 +390,186 @@ class LicenseManager {
|
||||
'user_agent' => $activation_data['user_agent'] ?? null,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
|
||||
// Increment activation count
|
||||
$wpdb->query($wpdb->prepare(
|
||||
"UPDATE $licenses_table SET activation_count = activation_count + 1 WHERE id = %d",
|
||||
$license['id']
|
||||
));
|
||||
|
||||
|
||||
do_action('woonoow/license/activated', $license['id'], $activation_data);
|
||||
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'activation_id' => $wpdb->insert_id,
|
||||
'activations_remaining' => $license['activation_limit'] > 0
|
||||
'activations_remaining' => $license['activation_limit'] > 0
|
||||
? max(0, $license['activation_limit'] - $license['activation_count'] - 1)
|
||||
: -1,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Deactivate license
|
||||
* Build OAuth redirect response for license activation
|
||||
*/
|
||||
public static function deactivate($license_key, $activation_id = null, $machine_id = null) {
|
||||
private static function build_oauth_redirect_response($license, $activation_data)
|
||||
{
|
||||
// Generate state token for CSRF protection
|
||||
$state = self::generate_oauth_state($license['license_key'], $activation_data['domain'] ?? '');
|
||||
|
||||
// Build redirect URL to vendor site
|
||||
$connect_url = home_url('/my-account/license-connect/');
|
||||
$redirect_url = add_query_arg([
|
||||
'license_key' => $license['license_key'],
|
||||
'site_url' => $activation_data['domain'] ?? '',
|
||||
'return_url' => $activation_data['return_url'] ?? '',
|
||||
'state' => $state,
|
||||
'nonce' => wp_create_nonce('woonoow_oauth_connect'),
|
||||
], $connect_url);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'code' => 'oauth_required',
|
||||
'message' => __('This license requires account verification. You will be redirected to complete activation.', 'woonoow'),
|
||||
'redirect_url' => $redirect_url,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth state token
|
||||
*/
|
||||
public static function generate_oauth_state($license_key, $domain)
|
||||
{
|
||||
$data = [
|
||||
'license_key' => $license_key,
|
||||
'domain' => $domain,
|
||||
'timestamp' => time(),
|
||||
];
|
||||
$payload = base64_encode(wp_json_encode($data));
|
||||
$signature = hash_hmac('sha256', $payload, wp_salt('auth'));
|
||||
|
||||
return $payload . '.' . $signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify OAuth state token
|
||||
*/
|
||||
public static function verify_oauth_state($state)
|
||||
{
|
||||
$parts = explode('.', $state, 2);
|
||||
if (count($parts) !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
list($payload, $signature) = $parts;
|
||||
$expected_signature = hash_hmac('sha256', $payload, wp_salt('auth'));
|
||||
|
||||
if (!hash_equals($expected_signature, $signature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode(base64_decode($payload), true);
|
||||
if (!$data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check timestamp (10 minute expiry)
|
||||
if (empty($data['timestamp']) || (time() - $data['timestamp']) > 600) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate activation token (short-lived, single-use)
|
||||
*/
|
||||
public static function generate_activation_token($license_id, $domain)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$token = wp_generate_password(32, false);
|
||||
$expires_at = gmdate('Y-m-d H:i:s', time() + 300); // 5 minute expiry
|
||||
|
||||
// Store token in activations table temporarily
|
||||
$table = $wpdb->prefix . self::$activations_table;
|
||||
$wpdb->insert($table, [
|
||||
'license_id' => $license_id,
|
||||
'domain' => $domain,
|
||||
'machine_id' => 'oauth_token:' . $token,
|
||||
'status' => 'pending',
|
||||
'user_agent' => 'OAuth activation token expires: ' . $expires_at,
|
||||
]);
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'activation_id' => $wpdb->insert_id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate activation token
|
||||
*/
|
||||
private static function validate_activation_token($token, $license_key)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$license = self::get_license_by_key($license_key);
|
||||
|
||||
if (!$license) {
|
||||
return new \WP_Error('invalid_license', __('Invalid license key', 'woonoow'));
|
||||
}
|
||||
|
||||
|
||||
$table = $wpdb->prefix . self::$activations_table;
|
||||
$activation = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $table WHERE license_id = %d AND machine_id = %s AND status = 'pending' LIMIT 1",
|
||||
$license['id'],
|
||||
'oauth_token:' . $token
|
||||
), ARRAY_A);
|
||||
|
||||
if (!$activation) {
|
||||
return new \WP_Error('invalid_token', __('Invalid or expired activation token', 'woonoow'));
|
||||
}
|
||||
|
||||
// Check expiry from user_agent field
|
||||
if (preg_match('/expires: (.+)$/', $activation['user_agent'], $matches)) {
|
||||
$expires_at = strtotime($matches[1]);
|
||||
if ($expires_at && time() > $expires_at) {
|
||||
// Delete expired token
|
||||
$wpdb->delete($table, ['id' => $activation['id']]);
|
||||
return new \WP_Error('token_expired', __('Activation token has expired', 'woonoow'));
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the pending record (it will be replaced by actual activation)
|
||||
$wpdb->delete($table, ['id' => $activation['id']]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate license
|
||||
*/
|
||||
public static function deactivate($license_key, $activation_id = null, $machine_id = null)
|
||||
{
|
||||
global $wpdb;
|
||||
$license = self::get_license_by_key($license_key);
|
||||
|
||||
if (!$license) {
|
||||
return new \WP_Error('invalid_license', __('Invalid license key', 'woonoow'));
|
||||
}
|
||||
|
||||
// Check if deactivation is allowed
|
||||
$allow_deactivation = get_option('woonoow_licensing_allow_deactivation', true);
|
||||
if (!$allow_deactivation) {
|
||||
return new \WP_Error('deactivation_disabled', __('License deactivation is disabled', 'woonoow'));
|
||||
}
|
||||
|
||||
|
||||
$activations_table = $wpdb->prefix . self::$activations_table;
|
||||
$licenses_table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
|
||||
// Find activation to deactivate
|
||||
$where = "license_id = %d AND status = 'active'";
|
||||
$params = [$license['id']];
|
||||
|
||||
|
||||
if ($activation_id) {
|
||||
$where .= " AND id = %d";
|
||||
$params[] = $activation_id;
|
||||
@@ -389,40 +577,41 @@ class LicenseManager {
|
||||
$where .= " AND machine_id = %s";
|
||||
$params[] = $machine_id;
|
||||
}
|
||||
|
||||
|
||||
$activation = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $activations_table WHERE $where LIMIT 1",
|
||||
$params
|
||||
), ARRAY_A);
|
||||
|
||||
|
||||
if (!$activation) {
|
||||
return new \WP_Error('no_activation', __('No active activation found', 'woonoow'));
|
||||
}
|
||||
|
||||
|
||||
// Deactivate
|
||||
$wpdb->update(
|
||||
$activations_table,
|
||||
['status' => 'deactivated', 'deactivated_at' => current_time('mysql')],
|
||||
['id' => $activation['id']]
|
||||
);
|
||||
|
||||
|
||||
// Decrement activation count
|
||||
$wpdb->query($wpdb->prepare(
|
||||
"UPDATE $licenses_table SET activation_count = GREATEST(0, activation_count - 1) WHERE id = %d",
|
||||
$license['id']
|
||||
));
|
||||
|
||||
|
||||
do_action('woonoow/license/deactivated', $license['id'], $activation['id']);
|
||||
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate license (check if valid without activating)
|
||||
*/
|
||||
public static function validate($license_key) {
|
||||
public static function validate($license_key)
|
||||
{
|
||||
$license = self::get_license_by_key($license_key);
|
||||
|
||||
|
||||
if (!$license) {
|
||||
return [
|
||||
'valid' => false,
|
||||
@@ -430,51 +619,101 @@ class LicenseManager {
|
||||
'message' => __('Invalid license key', 'woonoow'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
$is_expired = $license['expires_at'] && strtotime($license['expires_at']) < time();
|
||||
|
||||
|
||||
// Check subscription status if linked
|
||||
$subscription_status = self::get_order_subscription_status($license['order_id']);
|
||||
$is_subscription_valid = $subscription_status === null || in_array($subscription_status, ['active', 'pending-cancel']);
|
||||
|
||||
return [
|
||||
'valid' => $license['status'] === 'active' && !$is_expired,
|
||||
'valid' => $license['status'] === 'active' && !$is_expired && $is_subscription_valid,
|
||||
'license_key' => $license['license_key'],
|
||||
'status' => $license['status'],
|
||||
'activation_limit' => (int) $license['activation_limit'],
|
||||
'activation_count' => (int) $license['activation_count'],
|
||||
'activations_remaining' => $license['activation_limit'] > 0
|
||||
'activations_remaining' => $license['activation_limit'] > 0
|
||||
? max(0, $license['activation_limit'] - $license['activation_count'])
|
||||
: -1,
|
||||
'expires_at' => $license['expires_at'],
|
||||
'is_expired' => $is_expired,
|
||||
'subscription_status' => $subscription_status,
|
||||
'subscription_active' => $is_subscription_valid,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if an order has a linked subscription and return its status
|
||||
*
|
||||
* @param int $order_id
|
||||
* @return string|null Subscription status or null if no subscription
|
||||
*/
|
||||
public static function get_order_subscription_status($order_id)
|
||||
{
|
||||
// Check if subscription module is enabled
|
||||
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'woonoow_subscription_orders';
|
||||
|
||||
// Check if table exists
|
||||
$table_exists = $wpdb->get_var("SHOW TABLES LIKE '$table'");
|
||||
if (!$table_exists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find subscription linked to this order
|
||||
$subscription_id = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT subscription_id FROM $table WHERE order_id = %d LIMIT 1",
|
||||
$order_id
|
||||
));
|
||||
|
||||
if (!$subscription_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get subscription status
|
||||
$subscriptions_table = $wpdb->prefix . 'woonoow_subscriptions';
|
||||
$status = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT status FROM $subscriptions_table WHERE id = %d",
|
||||
$subscription_id
|
||||
));
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke license
|
||||
*/
|
||||
public static function revoke($license_id) {
|
||||
public static function revoke($license_id)
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
|
||||
$result = $wpdb->update(
|
||||
$table,
|
||||
['status' => 'revoked'],
|
||||
['id' => $license_id]
|
||||
);
|
||||
|
||||
|
||||
if ($result !== false) {
|
||||
do_action('woonoow/license/revoked', $license_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all licenses (admin)
|
||||
*/
|
||||
public static function get_all_licenses($args = []) {
|
||||
public static function get_all_licenses($args = [])
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
|
||||
$defaults = [
|
||||
'search' => '',
|
||||
'status' => null,
|
||||
@@ -486,56 +725,57 @@ class LicenseManager {
|
||||
'order' => 'DESC',
|
||||
];
|
||||
$args = wp_parse_args($args, $defaults);
|
||||
|
||||
|
||||
$where_clauses = ['1=1'];
|
||||
$params = [];
|
||||
|
||||
|
||||
if ($args['search']) {
|
||||
$where_clauses[] = "license_key LIKE %s";
|
||||
$params[] = '%' . $wpdb->esc_like($args['search']) . '%';
|
||||
}
|
||||
|
||||
|
||||
if ($args['status']) {
|
||||
$where_clauses[] = "status = %s";
|
||||
$params[] = $args['status'];
|
||||
}
|
||||
|
||||
|
||||
if ($args['product_id']) {
|
||||
$where_clauses[] = "product_id = %d";
|
||||
$params[] = $args['product_id'];
|
||||
}
|
||||
|
||||
|
||||
if ($args['user_id']) {
|
||||
$where_clauses[] = "user_id = %d";
|
||||
$params[] = $args['user_id'];
|
||||
}
|
||||
|
||||
|
||||
$where = implode(' AND ', $where_clauses);
|
||||
$orderby = sanitize_sql_orderby($args['orderby'] . ' ' . $args['order']) ?: 'created_at DESC';
|
||||
|
||||
|
||||
$sql = "SELECT * FROM $table WHERE $where ORDER BY $orderby LIMIT %d OFFSET %d";
|
||||
$params[] = $args['limit'];
|
||||
$params[] = $args['offset'];
|
||||
|
||||
|
||||
$licenses = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
|
||||
|
||||
// Get total count
|
||||
$count_sql = "SELECT COUNT(*) FROM $table WHERE $where";
|
||||
$total = $wpdb->get_var($wpdb->prepare($count_sql, array_slice($params, 0, -2)));
|
||||
|
||||
|
||||
return [
|
||||
'licenses' => $licenses,
|
||||
'total' => (int) $total,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get activations for a license
|
||||
*/
|
||||
public static function get_activations($license_id) {
|
||||
public static function get_activations($license_id)
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$activations_table;
|
||||
|
||||
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM $table WHERE license_id = %d ORDER BY activated_at DESC",
|
||||
$license_id
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Licensing Module Bootstrap
|
||||
*
|
||||
@@ -12,79 +13,306 @@ if (!defined('ABSPATH')) exit;
|
||||
use WooNooW\Core\ModuleRegistry;
|
||||
use WooNooW\Modules\LicensingSettings;
|
||||
|
||||
class LicensingModule {
|
||||
|
||||
class LicensingModule
|
||||
{
|
||||
|
||||
/**
|
||||
* Initialize the licensing module
|
||||
*/
|
||||
public static function init() {
|
||||
public static function init()
|
||||
{
|
||||
// Register settings schema
|
||||
LicensingSettings::init();
|
||||
|
||||
|
||||
// Initialize license manager immediately since we're already in plugins_loaded
|
||||
// Note: This is called from woonoow.php inside plugins_loaded action,
|
||||
// so we can call maybe_init_manager directly instead of scheduling another hook
|
||||
self::maybe_init_manager();
|
||||
|
||||
|
||||
// Install tables on module enable
|
||||
add_action('woonoow/module/enabled', [__CLASS__, 'on_module_enabled']);
|
||||
|
||||
|
||||
// Add product meta fields
|
||||
add_action('woocommerce_product_options_general_product_data', [__CLASS__, 'add_product_licensing_fields']);
|
||||
add_action('woocommerce_process_product_meta', [__CLASS__, 'save_product_licensing_fields']);
|
||||
|
||||
// License Connect OAuth endpoint
|
||||
add_action('init', [__CLASS__, 'register_license_connect_endpoint']);
|
||||
add_action('template_redirect', [__CLASS__, 'handle_license_connect'], 5);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize manager if module is enabled
|
||||
*/
|
||||
public static function maybe_init_manager() {
|
||||
public static function maybe_init_manager()
|
||||
{
|
||||
if (ModuleRegistry::is_enabled('licensing')) {
|
||||
// Ensure tables exist
|
||||
self::ensure_tables();
|
||||
LicenseManager::init();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Ensure database tables exist
|
||||
*/
|
||||
private static function ensure_tables() {
|
||||
private static function ensure_tables()
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'woonoow_licenses';
|
||||
|
||||
|
||||
// Check if table exists
|
||||
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
|
||||
LicenseManager::create_tables();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle module enable
|
||||
*/
|
||||
public static function on_module_enabled($module_id) {
|
||||
public static function on_module_enabled($module_id)
|
||||
{
|
||||
if ($module_id === 'licensing') {
|
||||
LicenseManager::create_tables();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Register license connect rewrite endpoint
|
||||
*/
|
||||
public static function register_license_connect_endpoint()
|
||||
{
|
||||
add_rewrite_endpoint('license-connect', EP_ROOT | EP_PAGES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle license-connect endpoint (OAuth confirmation page)
|
||||
*/
|
||||
public static function handle_license_connect()
|
||||
{
|
||||
// Parse the request URI to check if this is the license-connect page
|
||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$parsed_path = parse_url($request_uri, PHP_URL_PATH);
|
||||
|
||||
// Check if path contains license-connect
|
||||
if (strpos($parsed_path, '/license-connect') === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get parameters
|
||||
$license_key = sanitize_text_field($_GET['license_key'] ?? '');
|
||||
$site_url = esc_url_raw($_GET['site_url'] ?? '');
|
||||
$return_url = esc_url_raw($_GET['return_url'] ?? '');
|
||||
$state = sanitize_text_field($_GET['state'] ?? '');
|
||||
$action = sanitize_text_field($_GET['action'] ?? '');
|
||||
|
||||
// Handle form submission (confirmation)
|
||||
if ($action === 'confirm' && !empty($_POST['confirm_license'])) {
|
||||
self::process_license_confirmation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Require login
|
||||
if (!is_user_logged_in()) {
|
||||
$login_url = wp_login_url(add_query_arg($_GET, home_url('/my-account/license-connect/')));
|
||||
wp_redirect($login_url);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
if (empty($license_key) || empty($site_url) || empty($state)) {
|
||||
self::render_license_connect_page([
|
||||
'error' => __('Invalid license connection request. Missing required parameters.', 'woonoow'),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify state token
|
||||
$state_data = LicenseManager::verify_oauth_state($state);
|
||||
if (!$state_data) {
|
||||
self::render_license_connect_page([
|
||||
'error' => __('Invalid or expired connection request. Please try again.', 'woonoow'),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get license and verify ownership
|
||||
$license = LicenseManager::get_license_by_key($license_key);
|
||||
if (!$license) {
|
||||
self::render_license_connect_page([
|
||||
'error' => __('License key not found.', 'woonoow'),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify license belongs to current user
|
||||
$current_user_id = get_current_user_id();
|
||||
if ((int)$license['user_id'] !== $current_user_id) {
|
||||
self::render_license_connect_page([
|
||||
'error' => __('This license does not belong to your account.', 'woonoow'),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check license status
|
||||
if ($license['status'] !== 'active') {
|
||||
self::render_license_connect_page([
|
||||
'error' => __('This license is not active.', 'woonoow'),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check activation limit
|
||||
if ($license['activation_limit'] > 0 && $license['activation_count'] >= $license['activation_limit']) {
|
||||
self::render_license_connect_page([
|
||||
'error' => sprintf(
|
||||
__('Activation limit reached (%d/%d sites).', 'woonoow'),
|
||||
$license['activation_count'],
|
||||
$license['activation_limit']
|
||||
),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get product info
|
||||
$product = wc_get_product($license['product_id']);
|
||||
$product_name = $product ? $product->get_name() : __('Unknown Product', 'woonoow');
|
||||
|
||||
// Render confirmation page
|
||||
self::render_license_connect_page([
|
||||
'license' => $license,
|
||||
'product_name' => $product_name,
|
||||
'site_url' => $site_url,
|
||||
'return_url' => $return_url,
|
||||
'state' => $state,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process license confirmation form submission
|
||||
*/
|
||||
private static function process_license_confirmation()
|
||||
{
|
||||
if (!is_user_logged_in()) {
|
||||
wp_die(__('You must be logged in.', 'woonoow'));
|
||||
}
|
||||
|
||||
// Verify nonce
|
||||
if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'woonoow_license_connect')) {
|
||||
wp_die(__('Security check failed.', 'woonoow'));
|
||||
}
|
||||
|
||||
$license_key = sanitize_text_field($_POST['license_key'] ?? '');
|
||||
$site_url = esc_url_raw($_POST['site_url'] ?? '');
|
||||
$return_url = esc_url_raw($_POST['return_url'] ?? '');
|
||||
$state = sanitize_text_field($_POST['state'] ?? '');
|
||||
|
||||
// Verify state
|
||||
$state_data = LicenseManager::verify_oauth_state($state);
|
||||
if (!$state_data) {
|
||||
wp_die(__('Invalid or expired request.', 'woonoow'));
|
||||
}
|
||||
|
||||
// Get and verify license
|
||||
$license = LicenseManager::get_license_by_key($license_key);
|
||||
if (!$license || (int)$license['user_id'] !== get_current_user_id()) {
|
||||
wp_die(__('Invalid license.', 'woonoow'));
|
||||
}
|
||||
|
||||
// Generate activation token
|
||||
$token_data = LicenseManager::generate_activation_token($license['id'], $site_url);
|
||||
|
||||
// Build return URL with token
|
||||
$callback_url = add_query_arg([
|
||||
'activation_token' => $token_data['token'],
|
||||
'license_key' => $license_key,
|
||||
'state' => $state,
|
||||
], $return_url);
|
||||
|
||||
// Redirect back to client site
|
||||
wp_redirect($callback_url);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render license connect confirmation page
|
||||
*/
|
||||
private static function render_license_connect_page($args)
|
||||
{
|
||||
// Set headers
|
||||
status_header(200);
|
||||
nocache_headers();
|
||||
|
||||
// Include WP header
|
||||
get_header('woonoow');
|
||||
|
||||
echo '<div class="woonoow-license-connect" style="max-width: 600px; margin: 40px auto; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', sans-serif;">';
|
||||
|
||||
if (!empty($args['error'])) {
|
||||
echo '<div style="background: #fee; border: 1px solid #c00; padding: 15px; border-radius: 4px; margin-bottom: 20px;">';
|
||||
echo '<strong>' . esc_html__('Error', 'woonoow') . ':</strong> ' . esc_html($args['error']);
|
||||
echo '</div>';
|
||||
echo '<a href="' . esc_url(home_url()) . '" style="color: #0073aa;">← ' . esc_html__('Return Home', 'woonoow') . '</a>';
|
||||
} else {
|
||||
$license = $args['license'];
|
||||
$activations_remaining = $license['activation_limit'] > 0
|
||||
? $license['activation_limit'] - $license['activation_count']
|
||||
: '∞';
|
||||
|
||||
echo '<h1 style="font-size: 24px; margin-bottom: 20px;">' . esc_html__('Connect Site to License', 'woonoow') . '</h1>';
|
||||
|
||||
echo '<div style="background: #f8f9fa; border: 1px solid #e5e7eb; padding: 20px; border-radius: 8px; margin-bottom: 20px;">';
|
||||
echo '<table style="width: 100%; border-collapse: collapse;">';
|
||||
echo '<tr><td style="padding: 8px 0; color: #666;">' . esc_html__('Site', 'woonoow') . ':</td><td style="padding: 8px 0; font-weight: 600;">' . esc_html($args['site_url']) . '</td></tr>';
|
||||
echo '<tr><td style="padding: 8px 0; color: #666;">' . esc_html__('Product', 'woonoow') . ':</td><td style="padding: 8px 0; font-weight: 600;">' . esc_html($args['product_name']) . '</td></tr>';
|
||||
echo '<tr><td style="padding: 8px 0; color: #666;">' . esc_html__('License', 'woonoow') . ':</td><td style="padding: 8px 0; font-family: monospace;">' . esc_html($license['license_key']) . '</td></tr>';
|
||||
echo '<tr><td style="padding: 8px 0; color: #666;">' . esc_html__('Activations', 'woonoow') . ':</td><td style="padding: 8px 0;">' . esc_html($license['activation_count']) . '/' . ($license['activation_limit'] ?: '∞') . ' ' . esc_html__('used', 'woonoow') . '</td></tr>';
|
||||
echo '</table>';
|
||||
echo '</div>';
|
||||
|
||||
echo '<form method="post" action="' . esc_url(add_query_arg('action', 'confirm', home_url('/my-account/license-connect/'))) . '">';
|
||||
echo wp_nonce_field('woonoow_license_connect', '_wpnonce', true, false);
|
||||
echo '<input type="hidden" name="license_key" value="' . esc_attr($license['license_key']) . '">';
|
||||
echo '<input type="hidden" name="site_url" value="' . esc_attr($args['site_url']) . '">';
|
||||
echo '<input type="hidden" name="return_url" value="' . esc_attr($args['return_url']) . '">';
|
||||
echo '<input type="hidden" name="state" value="' . esc_attr($args['state']) . '">';
|
||||
|
||||
echo '<div style="display: flex; gap: 10px;">';
|
||||
echo '<button type="submit" name="confirm_license" value="1" style="background: #2563eb; color: white; border: none; padding: 12px 24px; border-radius: 6px; font-size: 16px; cursor: pointer;">';
|
||||
echo esc_html__('Connect This Site', 'woonoow');
|
||||
echo '</button>';
|
||||
echo '<a href="' . esc_url($args['return_url'] ?: home_url()) . '" style="background: #e5e7eb; color: #374151; border: none; padding: 12px 24px; border-radius: 6px; font-size: 16px; text-decoration: none;">';
|
||||
echo esc_html__('Cancel', 'woonoow');
|
||||
echo '</a>';
|
||||
echo '</div>';
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
|
||||
get_footer('woonoow');
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add licensing fields to product edit page
|
||||
*/
|
||||
public static function add_product_licensing_fields() {
|
||||
public static function add_product_licensing_fields()
|
||||
{
|
||||
global $post;
|
||||
|
||||
|
||||
if (!ModuleRegistry::is_enabled('licensing')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
echo '<div class="options_group show_if_simple show_if_downloadable">';
|
||||
|
||||
|
||||
woocommerce_wp_checkbox([
|
||||
'id' => '_woonoow_licensing_enabled',
|
||||
'label' => __('Enable Licensing', 'woonoow'),
|
||||
'description' => __('Generate license keys for this product on purchase', 'woonoow'),
|
||||
]);
|
||||
|
||||
|
||||
woocommerce_wp_text_input([
|
||||
'id' => '_woonoow_license_activation_limit',
|
||||
'label' => __('Activation Limit', 'woonoow'),
|
||||
@@ -95,7 +323,7 @@ class LicensingModule {
|
||||
'step' => '1',
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
woocommerce_wp_text_input([
|
||||
'id' => '_woonoow_license_expiry_days',
|
||||
'label' => __('License Expiry (Days)', 'woonoow'),
|
||||
@@ -106,23 +334,48 @@ class LicensingModule {
|
||||
'step' => '1',
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
// Only show activation method if per-product override is enabled
|
||||
$licensing_settings = get_option('woonoow_module_licensing_settings', []);
|
||||
$allow_override = $licensing_settings['allow_product_override'] ?? false;
|
||||
if ($allow_override) {
|
||||
woocommerce_wp_select([
|
||||
'id' => '_woonoow_license_activation_method',
|
||||
'label' => __('Activation Method', 'woonoow'),
|
||||
'description' => __('Override site-level setting for this product', 'woonoow'),
|
||||
'options' => [
|
||||
'' => __('Use Site Default', 'woonoow'),
|
||||
'api' => __('Simple API (license key only)', 'woonoow'),
|
||||
'oauth' => __('Secure OAuth (requires account login)', 'woonoow'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Save licensing fields
|
||||
*/
|
||||
public static function save_product_licensing_fields($post_id) {
|
||||
public static function save_product_licensing_fields($post_id)
|
||||
{
|
||||
$licensing_enabled = isset($_POST['_woonoow_licensing_enabled']) ? 'yes' : 'no';
|
||||
update_post_meta($post_id, '_woonoow_licensing_enabled', $licensing_enabled);
|
||||
|
||||
|
||||
if (isset($_POST['_woonoow_license_activation_limit'])) {
|
||||
update_post_meta($post_id, '_woonoow_license_activation_limit', absint($_POST['_woonoow_license_activation_limit']));
|
||||
}
|
||||
|
||||
|
||||
if (isset($_POST['_woonoow_license_expiry_days'])) {
|
||||
update_post_meta($post_id, '_woonoow_license_expiry_days', absint($_POST['_woonoow_license_expiry_days']));
|
||||
}
|
||||
|
||||
if (isset($_POST['_woonoow_license_activation_method'])) {
|
||||
$method = $_POST['_woonoow_license_activation_method'];
|
||||
// Accept empty (site default), api, or oauth
|
||||
if ($method === '' || in_array($method, ['api', 'oauth'])) {
|
||||
update_post_meta($post_id, '_woonoow_license_activation_method', sanitize_key($method));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Licensing Module Settings
|
||||
*
|
||||
@@ -9,19 +10,22 @@ namespace WooNooW\Modules;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
class LicensingSettings {
|
||||
|
||||
class LicensingSettings
|
||||
{
|
||||
|
||||
/**
|
||||
* Initialize the settings
|
||||
*/
|
||||
public static function init() {
|
||||
public static function init()
|
||||
{
|
||||
add_filter('woonoow/module_settings_schema', [__CLASS__, 'register_schema']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Register licensing settings schema
|
||||
*/
|
||||
public static function register_schema($schemas) {
|
||||
public static function register_schema($schemas)
|
||||
{
|
||||
$schemas['licensing'] = [
|
||||
'license_key_format' => [
|
||||
'type' => 'select',
|
||||
@@ -88,8 +92,24 @@ class LicensingSettings {
|
||||
'min' => 1,
|
||||
'max' => 30,
|
||||
],
|
||||
'activation_method' => [
|
||||
'type' => 'select',
|
||||
'label' => __('Activation Method', 'woonoow'),
|
||||
'description' => __('How licenses are activated. OAuth requires user login on your site (anti-piracy).', 'woonoow'),
|
||||
'options' => [
|
||||
'api' => __('Simple API (license key only)', 'woonoow'),
|
||||
'oauth' => __('Secure OAuth (requires account login)', 'woonoow'),
|
||||
],
|
||||
'default' => 'api',
|
||||
],
|
||||
'allow_product_override' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Allow Per-Product Override', 'woonoow'),
|
||||
'description' => __('Show activation method field on each product for individual customization', 'woonoow'),
|
||||
'default' => false,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
return $schemas;
|
||||
}
|
||||
}
|
||||
|
||||
893
includes/Modules/Subscription/SubscriptionManager.php
Normal file
893
includes/Modules/Subscription/SubscriptionManager.php
Normal file
@@ -0,0 +1,893 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Subscription Manager
|
||||
*
|
||||
* Core business logic for subscription management
|
||||
*
|
||||
* @package WooNooW\Modules\Subscription
|
||||
*/
|
||||
|
||||
namespace WooNooW\Modules\Subscription;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
use WooNooW\Core\ModuleRegistry;
|
||||
|
||||
class SubscriptionManager
|
||||
{
|
||||
|
||||
/** @var string Subscriptions table name */
|
||||
private static $table_subscriptions;
|
||||
|
||||
/** @var string Subscription orders table name */
|
||||
private static $table_subscription_orders;
|
||||
|
||||
/**
|
||||
* Initialize the manager
|
||||
*/
|
||||
public static function init()
|
||||
{
|
||||
global $wpdb;
|
||||
self::$table_subscriptions = $wpdb->prefix . 'woonoow_subscriptions';
|
||||
self::$table_subscription_orders = $wpdb->prefix . 'woonoow_subscription_orders';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create database tables
|
||||
*/
|
||||
public static function create_tables()
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
$table_subscriptions = $wpdb->prefix . 'woonoow_subscriptions';
|
||||
$table_subscription_orders = $wpdb->prefix . 'woonoow_subscription_orders';
|
||||
|
||||
$sql_subscriptions = "CREATE TABLE $table_subscriptions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
product_id BIGINT UNSIGNED NOT NULL,
|
||||
variation_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
status ENUM('pending', 'active', 'on-hold', 'cancelled', 'expired', 'pending-cancel') DEFAULT 'pending',
|
||||
billing_period ENUM('day', 'week', 'month', 'year') NOT NULL,
|
||||
billing_interval INT UNSIGNED DEFAULT 1,
|
||||
recurring_amount DECIMAL(12,4) DEFAULT 0,
|
||||
start_date DATETIME NOT NULL,
|
||||
trial_end_date DATETIME DEFAULT NULL,
|
||||
next_payment_date DATETIME DEFAULT NULL,
|
||||
end_date DATETIME DEFAULT NULL,
|
||||
last_payment_date DATETIME DEFAULT NULL,
|
||||
payment_method VARCHAR(100) DEFAULT NULL,
|
||||
payment_meta LONGTEXT,
|
||||
cancel_reason TEXT DEFAULT NULL,
|
||||
pause_count INT UNSIGNED DEFAULT 0,
|
||||
failed_payment_count INT UNSIGNED DEFAULT 0,
|
||||
reminder_sent_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_order_id (order_id),
|
||||
INDEX idx_product_id (product_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_next_payment (next_payment_date)
|
||||
) $charset_collate;";
|
||||
|
||||
$sql_orders = "CREATE TABLE $table_subscription_orders (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
subscription_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
order_type ENUM('parent', 'renewal', 'switch', 'resubscribe') DEFAULT 'renewal',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_subscription (subscription_id),
|
||||
INDEX idx_order (order_id)
|
||||
) $charset_collate;";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta($sql_subscriptions);
|
||||
dbDelta($sql_orders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create subscription from order item
|
||||
*
|
||||
* @param \WC_Order $order
|
||||
* @param \WC_Order_Item_Product $item
|
||||
* @return int|false Subscription ID or false on failure
|
||||
*/
|
||||
public static function create_from_order($order, $item)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$product_id = $item->get_product_id();
|
||||
$variation_id = $item->get_variation_id();
|
||||
$user_id = $order->get_user_id();
|
||||
|
||||
if (!$user_id) {
|
||||
// Guest orders not supported for subscriptions
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get subscription settings from product
|
||||
$billing_period = get_post_meta($product_id, '_woonoow_subscription_period', true) ?: 'month';
|
||||
$billing_interval = absint(get_post_meta($product_id, '_woonoow_subscription_interval', true)) ?: 1;
|
||||
$trial_days = absint(get_post_meta($product_id, '_woonoow_subscription_trial_days', true));
|
||||
$subscription_length = absint(get_post_meta($product_id, '_woonoow_subscription_length', true));
|
||||
|
||||
// Calculate dates
|
||||
$now = current_time('mysql');
|
||||
$start_date = $now;
|
||||
$trial_end_date = null;
|
||||
|
||||
if ($trial_days > 0) {
|
||||
$trial_end_date = date('Y-m-d H:i:s', strtotime($now . " + $trial_days days"));
|
||||
$next_payment_date = $trial_end_date;
|
||||
} else {
|
||||
$next_payment_date = self::calculate_next_payment_date($now, $billing_period, $billing_interval);
|
||||
}
|
||||
|
||||
// Calculate end date if subscription has fixed length
|
||||
$end_date = null;
|
||||
if ($subscription_length > 0) {
|
||||
$end_date = self::calculate_end_date($start_date, $billing_period, $billing_interval, $subscription_length);
|
||||
}
|
||||
|
||||
// Get recurring amount (product price)
|
||||
$product = $item->get_product();
|
||||
$recurring_amount = $product ? $product->get_price() : $item->get_total();
|
||||
|
||||
// Get payment method
|
||||
$payment_method = $order->get_payment_method();
|
||||
$payment_meta = json_encode([
|
||||
'method_title' => $order->get_payment_method_title(),
|
||||
'customer_id' => $order->get_customer_id(),
|
||||
]);
|
||||
|
||||
// Insert subscription
|
||||
$inserted = $wpdb->insert(
|
||||
self::$table_subscriptions,
|
||||
[
|
||||
'user_id' => $user_id,
|
||||
'order_id' => $order->get_id(),
|
||||
'product_id' => $product_id,
|
||||
'variation_id' => $variation_id ?: null,
|
||||
'status' => 'active',
|
||||
'billing_period' => $billing_period,
|
||||
'billing_interval' => $billing_interval,
|
||||
'recurring_amount' => $recurring_amount,
|
||||
'start_date' => $start_date,
|
||||
'trial_end_date' => $trial_end_date,
|
||||
'next_payment_date' => $next_payment_date,
|
||||
'end_date' => $end_date,
|
||||
'last_payment_date' => $now,
|
||||
'payment_method' => $payment_method,
|
||||
'payment_meta' => $payment_meta,
|
||||
],
|
||||
['%d', '%d', '%d', '%d', '%s', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s']
|
||||
);
|
||||
|
||||
if (!$inserted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscription_id = $wpdb->insert_id;
|
||||
|
||||
// Link parent order to subscription
|
||||
$wpdb->insert(
|
||||
self::$table_subscription_orders,
|
||||
[
|
||||
'subscription_id' => $subscription_id,
|
||||
'order_id' => $order->get_id(),
|
||||
'order_type' => 'parent',
|
||||
],
|
||||
['%d', '%d', '%s']
|
||||
);
|
||||
|
||||
// Trigger action
|
||||
do_action('woonoow/subscription/created', $subscription_id, $order, $item);
|
||||
|
||||
return $subscription_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription by ID
|
||||
*
|
||||
* @param int $subscription_id
|
||||
* @return object|null
|
||||
*/
|
||||
public static function get($subscription_id)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
return $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM " . self::$table_subscriptions . " WHERE id = %d",
|
||||
$subscription_id
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription by related order ID (parent or renewal)
|
||||
*
|
||||
* @param int $order_id
|
||||
* @return object|null
|
||||
*/
|
||||
public static function get_by_order_id($order_id)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$table_subscriptions = $wpdb->prefix . 'woonoow_subscriptions';
|
||||
$table_subscription_orders = $wpdb->prefix . 'woonoow_subscription_orders';
|
||||
|
||||
// Join subscriptions table to get full subscription data
|
||||
return $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT s.*
|
||||
FROM $table_subscriptions s
|
||||
JOIN $table_subscription_orders so ON s.id = so.subscription_id
|
||||
WHERE so.order_id = %d",
|
||||
$order_id
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscriptions by user
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param array $args
|
||||
* @return array
|
||||
*/
|
||||
public static function get_by_user($user_id, $args = [])
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$defaults = [
|
||||
'status' => null,
|
||||
'limit' => 20,
|
||||
'offset' => 0,
|
||||
];
|
||||
$args = wp_parse_args($args, $defaults);
|
||||
|
||||
$where = "WHERE user_id = %d";
|
||||
$params = [$user_id];
|
||||
|
||||
if ($args['status']) {
|
||||
$where .= " AND status = %s";
|
||||
$params[] = $args['status'];
|
||||
}
|
||||
|
||||
$sql = $wpdb->prepare(
|
||||
"SELECT * FROM " . self::$table_subscriptions . " $where ORDER BY created_at DESC LIMIT %d OFFSET %d",
|
||||
array_merge($params, [$args['limit'], $args['offset']])
|
||||
);
|
||||
|
||||
return $wpdb->get_results($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all subscriptions (admin)
|
||||
*
|
||||
* @param array $args
|
||||
* @return array
|
||||
*/
|
||||
public static function get_all($args = [])
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$defaults = [
|
||||
'status' => null,
|
||||
'product_id' => null,
|
||||
'user_id' => null,
|
||||
'limit' => 20,
|
||||
'offset' => 0,
|
||||
'search' => null,
|
||||
];
|
||||
$args = wp_parse_args($args, $defaults);
|
||||
|
||||
$where = "WHERE 1=1";
|
||||
$params = [];
|
||||
|
||||
if ($args['status']) {
|
||||
$where .= " AND status = %s";
|
||||
$params[] = $args['status'];
|
||||
}
|
||||
|
||||
if ($args['product_id']) {
|
||||
$where .= " AND product_id = %d";
|
||||
$params[] = $args['product_id'];
|
||||
}
|
||||
|
||||
if ($args['user_id']) {
|
||||
$where .= " AND user_id = %d";
|
||||
$params[] = $args['user_id'];
|
||||
}
|
||||
|
||||
$order = "ORDER BY created_at DESC";
|
||||
$limit = "LIMIT " . intval($args['limit']) . " OFFSET " . intval($args['offset']);
|
||||
|
||||
$sql = "SELECT * FROM " . self::$table_subscriptions . " $where $order $limit";
|
||||
|
||||
if (!empty($params)) {
|
||||
$sql = $wpdb->prepare($sql, $params);
|
||||
}
|
||||
|
||||
return $wpdb->get_results($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count subscriptions
|
||||
*
|
||||
* @param array $args
|
||||
* @return int
|
||||
*/
|
||||
public static function count($args = [])
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$where = "WHERE 1=1";
|
||||
$params = [];
|
||||
|
||||
if (!empty($args['status'])) {
|
||||
$where .= " AND status = %s";
|
||||
$params[] = $args['status'];
|
||||
}
|
||||
|
||||
$sql = "SELECT COUNT(*) FROM " . self::$table_subscriptions . " $where";
|
||||
|
||||
if (!empty($params)) {
|
||||
$sql = $wpdb->prepare($sql, $params);
|
||||
}
|
||||
|
||||
return (int) $wpdb->get_var($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update subscription status
|
||||
*
|
||||
* @param int $subscription_id
|
||||
* @param string $status
|
||||
* @param string|null $reason
|
||||
* @return bool
|
||||
*/
|
||||
public static function update_status($subscription_id, $status, $reason = null)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$data = ['status' => $status];
|
||||
$format = ['%s'];
|
||||
|
||||
if ($reason !== null) {
|
||||
$data['cancel_reason'] = $reason;
|
||||
$format[] = '%s';
|
||||
}
|
||||
|
||||
$updated = $wpdb->update(
|
||||
self::$table_subscriptions,
|
||||
$data,
|
||||
['id' => $subscription_id],
|
||||
$format,
|
||||
['%d']
|
||||
);
|
||||
|
||||
if ($updated !== false) {
|
||||
do_action('woonoow/subscription/status_changed', $subscription_id, $status, $reason);
|
||||
}
|
||||
|
||||
return $updated !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel subscription
|
||||
*
|
||||
* @param int $subscription_id
|
||||
* @param string $reason
|
||||
* @param bool $immediate Force immediate cancellation
|
||||
* @return bool
|
||||
*/
|
||||
public static function cancel($subscription_id, $reason = '', $immediate = false)
|
||||
{
|
||||
$subscription = self::get($subscription_id);
|
||||
if (!$subscription || in_array($subscription->status, ['cancelled', 'expired'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default to pending-cancel if there's time left
|
||||
$new_status = 'cancelled';
|
||||
$now = current_time('mysql');
|
||||
|
||||
if (!$immediate && $subscription->next_payment_date && $subscription->next_payment_date > $now) {
|
||||
$new_status = 'pending-cancel';
|
||||
}
|
||||
|
||||
$success = self::update_status($subscription_id, $new_status, $reason);
|
||||
|
||||
if ($success) {
|
||||
if ($new_status === 'pending-cancel') {
|
||||
do_action('woonoow/subscription/pending_cancel', $subscription_id, $reason);
|
||||
} else {
|
||||
do_action('woonoow/subscription/cancelled', $subscription_id, $reason);
|
||||
}
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause subscription
|
||||
*
|
||||
* @param int $subscription_id
|
||||
* @return bool
|
||||
*/
|
||||
public static function pause($subscription_id)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$subscription = self::get($subscription_id);
|
||||
if (!$subscription || $subscription->status !== 'active') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check max pause count
|
||||
$settings = ModuleRegistry::get_settings('subscription');
|
||||
$max_pause = $settings['max_pause_count'] ?? 3;
|
||||
|
||||
if ($max_pause > 0 && $subscription->pause_count >= $max_pause) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$updated = $wpdb->update(
|
||||
self::$table_subscriptions,
|
||||
[
|
||||
'status' => 'on-hold',
|
||||
'pause_count' => $subscription->pause_count + 1,
|
||||
],
|
||||
['id' => $subscription_id],
|
||||
['%s', '%d'],
|
||||
['%d']
|
||||
);
|
||||
|
||||
if ($updated !== false) {
|
||||
do_action('woonoow/subscription/paused', $subscription_id);
|
||||
}
|
||||
|
||||
return $updated !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume subscription
|
||||
*
|
||||
* @param int $subscription_id
|
||||
* @return bool
|
||||
*/
|
||||
public static function resume($subscription_id)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$subscription = self::get($subscription_id);
|
||||
if (!$subscription || !in_array($subscription->status, ['on-hold', 'pending-cancel'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$update_data = ['status' => 'active'];
|
||||
$format = ['%s'];
|
||||
|
||||
// Only recalculate payment date if resuming from on-hold
|
||||
if ($subscription->status === 'on-hold') {
|
||||
// Recalculate next payment date from now
|
||||
$next_payment = self::calculate_next_payment_date(
|
||||
current_time('mysql'),
|
||||
$subscription->billing_period,
|
||||
$subscription->billing_interval
|
||||
);
|
||||
$update_data['next_payment_date'] = $next_payment;
|
||||
$format[] = '%s';
|
||||
}
|
||||
|
||||
$updated = $wpdb->update(
|
||||
self::$table_subscriptions,
|
||||
$update_data,
|
||||
['id' => $subscription_id],
|
||||
$format,
|
||||
['%d']
|
||||
);
|
||||
|
||||
if ($updated !== false) {
|
||||
do_action('woonoow/subscription/resumed', $subscription_id);
|
||||
}
|
||||
|
||||
return $updated !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process renewal for a subscription
|
||||
*
|
||||
* @param int $subscription_id
|
||||
* @return bool
|
||||
*/
|
||||
/**
|
||||
* Process renewal for a subscription
|
||||
*
|
||||
* @param int $subscription_id
|
||||
* @return bool
|
||||
*/
|
||||
public static function renew($subscription_id)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$subscription = self::get($subscription_id);
|
||||
if (!$subscription || !in_array($subscription->status, ['active', 'on-hold'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for existing pending renewal order to prevent duplicates
|
||||
$existing_pending = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT so.order_id FROM " . self::$table_subscription_orders . " so
|
||||
JOIN {$wpdb->posts} p ON so.order_id = p.ID
|
||||
WHERE so.subscription_id = %d
|
||||
AND so.order_type = 'renewal'
|
||||
AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold')",
|
||||
$subscription_id
|
||||
));
|
||||
|
||||
if ($existing_pending) {
|
||||
return ['success' => true, 'order_id' => (int) $existing_pending->order_id, 'status' => 'existing'];
|
||||
}
|
||||
|
||||
// Create renewal order
|
||||
$renewal_order = self::create_renewal_order($subscription);
|
||||
if (!$renewal_order) {
|
||||
// Failed to create order
|
||||
self::handle_renewal_failure($subscription_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process payment
|
||||
// Result can be: true (paid), false (failed), or 'manual' (waiting for payment)
|
||||
$payment_result = self::process_renewal_payment($subscription, $renewal_order);
|
||||
|
||||
if ($payment_result === true) {
|
||||
self::handle_renewal_success($subscription_id, $renewal_order);
|
||||
return ['success' => true, 'order_id' => $renewal_order->get_id(), 'status' => 'complete'];
|
||||
} elseif ($payment_result === 'manual') {
|
||||
// Manual payment required
|
||||
|
||||
// CHECK: Is this an early renewal? (Next payment date is in future)
|
||||
$now = current_time('mysql');
|
||||
$is_early_renewal = $subscription->next_payment_date && $subscription->next_payment_date > $now;
|
||||
|
||||
if ($is_early_renewal) {
|
||||
// Early renewal: Keep active, just waiting for payment
|
||||
return ['success' => true, 'order_id' => $renewal_order->get_id(), 'status' => 'manual'];
|
||||
}
|
||||
|
||||
// Normal/Overdue renewal: Set to on-hold
|
||||
self::update_status($subscription_id, 'on-hold', 'awaiting_payment');
|
||||
return ['success' => true, 'order_id' => $renewal_order->get_id(), 'status' => 'manual'];
|
||||
} else {
|
||||
// Auto-debit failed
|
||||
self::handle_renewal_failure($subscription_id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a renewal order
|
||||
*
|
||||
* @param object $subscription
|
||||
* @return \WC_Order|false
|
||||
*/
|
||||
private static function create_renewal_order($subscription)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
// Get original order
|
||||
$parent_order = wc_get_order($subscription->order_id);
|
||||
if (!$parent_order) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create new order
|
||||
$renewal_order = wc_create_order([
|
||||
'customer_id' => $subscription->user_id,
|
||||
'status' => 'pending',
|
||||
'parent' => $subscription->order_id,
|
||||
]);
|
||||
|
||||
if (is_wp_error($renewal_order)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add product
|
||||
$product = wc_get_product($subscription->variation_id ?: $subscription->product_id);
|
||||
if ($product) {
|
||||
$renewal_order->add_product($product, 1, [
|
||||
'total' => $subscription->recurring_amount,
|
||||
'subtotal' => $subscription->recurring_amount,
|
||||
]);
|
||||
}
|
||||
|
||||
// Copy billing/shipping from parent
|
||||
$renewal_order->set_address($parent_order->get_address('billing'), 'billing');
|
||||
$renewal_order->set_address($parent_order->get_address('shipping'), 'shipping');
|
||||
$renewal_order->set_payment_method($subscription->payment_method);
|
||||
|
||||
// Calculate totals
|
||||
$renewal_order->calculate_totals();
|
||||
$renewal_order->save();
|
||||
|
||||
// Link to subscription
|
||||
$wpdb->insert(
|
||||
self::$table_subscription_orders,
|
||||
[
|
||||
'subscription_id' => $subscription->id,
|
||||
'order_id' => $renewal_order->get_id(),
|
||||
'order_type' => 'renewal',
|
||||
],
|
||||
['%d', '%d', '%s']
|
||||
);
|
||||
|
||||
return $renewal_order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process payment for renewal order
|
||||
*
|
||||
* @param object $subscription
|
||||
* @param \WC_Order $order
|
||||
* @return bool
|
||||
*/
|
||||
/**
|
||||
* Process payment for renewal order
|
||||
*
|
||||
* @param object $subscription
|
||||
* @param \WC_Order $order
|
||||
* @return bool|string True if paid, false if failed, 'manual' if waiting
|
||||
*/
|
||||
private static function process_renewal_payment($subscription, $order)
|
||||
{
|
||||
// Allow plugins to override payment processing completely
|
||||
// Return true/false/'manual' to bypass default logic
|
||||
$pre = apply_filters('woonoow_pre_process_subscription_payment', null, $subscription, $order);
|
||||
if ($pre !== null) {
|
||||
return $pre;
|
||||
}
|
||||
|
||||
// Get payment gateway
|
||||
$gateways = WC()->payment_gateways()->get_available_payment_gateways();
|
||||
$gateway_id = $subscription->payment_method;
|
||||
|
||||
if (!isset($gateways[$gateway_id])) {
|
||||
// Payment method not available - treat as failure so user can fix
|
||||
$order->update_status('failed', __('Payment method not available for renewal', 'woonoow'));
|
||||
return false;
|
||||
}
|
||||
|
||||
$gateway = $gateways[$gateway_id];
|
||||
|
||||
// 1. Try Auto-Debit if supported
|
||||
if (method_exists($gateway, 'process_subscription_renewal_payment')) {
|
||||
$result = $gateway->process_subscription_renewal_payment($order, $subscription);
|
||||
if (!is_wp_error($result) && $result) {
|
||||
return true;
|
||||
}
|
||||
// If explicit failure from auto-debit, return false (will trigger retry logic)
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Allow other plugins to handle auto-debit via filter (e.g. Stripe/PayPal adapters)
|
||||
$external_result = apply_filters('woonoow_process_subscription_payment', null, $gateway, $order, $subscription);
|
||||
if ($external_result !== null) {
|
||||
return $external_result ? true : false;
|
||||
}
|
||||
|
||||
// 3. Fallback: Manual Payment
|
||||
// Set order to pending-payment
|
||||
$order->update_status('pending', __('Awaiting manual renewal payment', 'woonoow'));
|
||||
|
||||
// Send renewal payment email to customer
|
||||
do_action('woonoow/subscription/renewal_payment_due', $subscription->id, $order);
|
||||
|
||||
return 'manual'; // Return special status
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful renewal
|
||||
*
|
||||
* @param int $subscription_id
|
||||
* @param \WC_Order $order
|
||||
*/
|
||||
public static function handle_renewal_success($subscription_id, $order)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$subscription = self::get($subscription_id);
|
||||
|
||||
// Calculate next payment date
|
||||
// For early renewal, start from the current next_payment_date if it's in the future
|
||||
// Otherwise start from now (for expired/overdue subscriptions)
|
||||
$now = current_time('mysql');
|
||||
$base_date = $now;
|
||||
|
||||
if ($subscription->next_payment_date && $subscription->next_payment_date > $now) {
|
||||
$base_date = $subscription->next_payment_date;
|
||||
}
|
||||
|
||||
$next_payment = self::calculate_next_payment_date(
|
||||
$base_date,
|
||||
$subscription->billing_period,
|
||||
$subscription->billing_interval
|
||||
);
|
||||
|
||||
// Check if subscription should end
|
||||
if ($subscription->end_date && strtotime($next_payment) > strtotime($subscription->end_date)) {
|
||||
$next_payment = null;
|
||||
}
|
||||
|
||||
$wpdb->update(
|
||||
self::$table_subscriptions,
|
||||
[
|
||||
'status' => 'active',
|
||||
'next_payment_date' => $next_payment,
|
||||
'last_payment_date' => current_time('mysql'),
|
||||
'failed_payment_count' => 0,
|
||||
],
|
||||
['id' => $subscription_id],
|
||||
['%s', '%s', '%s', '%d'],
|
||||
['%d']
|
||||
);
|
||||
|
||||
// Complete the order
|
||||
$order->payment_complete();
|
||||
|
||||
do_action('woonoow/subscription/renewed', $subscription_id, $order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle failed renewal
|
||||
*
|
||||
* @param int $subscription_id
|
||||
*/
|
||||
private static function handle_renewal_failure($subscription_id)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$subscription = self::get($subscription_id);
|
||||
$new_failed_count = $subscription->failed_payment_count + 1;
|
||||
|
||||
// Get settings
|
||||
$settings = ModuleRegistry::get_settings('subscription');
|
||||
$max_attempts = $settings['expire_after_failed_attempts'] ?? 3;
|
||||
|
||||
if ($new_failed_count >= $max_attempts) {
|
||||
// Mark as expired
|
||||
$wpdb->update(
|
||||
self::$table_subscriptions,
|
||||
[
|
||||
'status' => 'expired',
|
||||
'failed_payment_count' => $new_failed_count,
|
||||
],
|
||||
['id' => $subscription_id],
|
||||
['%s', '%d'],
|
||||
['%d']
|
||||
);
|
||||
|
||||
do_action('woonoow/subscription/expired', $subscription_id, 'payment_failed');
|
||||
} else {
|
||||
// Just increment failed count
|
||||
$wpdb->update(
|
||||
self::$table_subscriptions,
|
||||
['failed_payment_count' => $new_failed_count],
|
||||
['id' => $subscription_id],
|
||||
['%d'],
|
||||
['%d']
|
||||
);
|
||||
|
||||
do_action('woonoow/subscription/renewal_failed', $subscription_id, $new_failed_count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next payment date
|
||||
*
|
||||
* @param string $from_date
|
||||
* @param string $period
|
||||
* @param int $interval
|
||||
* @return string
|
||||
*/
|
||||
public static function calculate_next_payment_date($from_date, $period, $interval = 1)
|
||||
{
|
||||
$interval = max(1, $interval);
|
||||
|
||||
switch ($period) {
|
||||
case 'day':
|
||||
$modifier = "+ $interval days";
|
||||
break;
|
||||
case 'week':
|
||||
$modifier = "+ $interval weeks";
|
||||
break;
|
||||
case 'month':
|
||||
$modifier = "+ $interval months";
|
||||
break;
|
||||
case 'year':
|
||||
$modifier = "+ $interval years";
|
||||
break;
|
||||
default:
|
||||
$modifier = "+ 1 month";
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', strtotime($from_date . ' ' . $modifier));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate subscription end date
|
||||
*
|
||||
* @param string $start_date
|
||||
* @param string $period
|
||||
* @param int $interval
|
||||
* @param int $length
|
||||
* @return string
|
||||
*/
|
||||
private static function calculate_end_date($start_date, $period, $interval, $length)
|
||||
{
|
||||
$total_periods = $interval * $length;
|
||||
|
||||
switch ($period) {
|
||||
case 'day':
|
||||
$modifier = "+ $total_periods days";
|
||||
break;
|
||||
case 'week':
|
||||
$modifier = "+ $total_periods weeks";
|
||||
break;
|
||||
case 'month':
|
||||
$modifier = "+ $total_periods months";
|
||||
break;
|
||||
case 'year':
|
||||
$modifier = "+ $total_periods years";
|
||||
break;
|
||||
default:
|
||||
$modifier = "+ $total_periods months";
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', strtotime($start_date . ' ' . $modifier));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscriptions due for renewal
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_due_renewals()
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$now = current_time('mysql');
|
||||
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM " . self::$table_subscriptions . "
|
||||
WHERE status = 'active'
|
||||
AND next_payment_date IS NOT NULL
|
||||
AND next_payment_date <= %s
|
||||
ORDER BY next_payment_date ASC",
|
||||
$now
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription orders
|
||||
*
|
||||
* @param int $subscription_id
|
||||
* @return array
|
||||
*/
|
||||
public static function get_orders($subscription_id)
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT so.*, p.post_status as order_status
|
||||
FROM " . self::$table_subscription_orders . " so
|
||||
LEFT JOIN {$wpdb->posts} p ON so.order_id = p.ID
|
||||
WHERE so.subscription_id = %d
|
||||
ORDER BY so.created_at DESC",
|
||||
$subscription_id
|
||||
));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user