Compare commits
81 Commits
v1.0-pre-s
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0b5f8496d | ||
|
|
d80f34c8b9 | ||
|
|
6d2136d3b5 | ||
|
|
0e9ace902d | ||
|
|
f4f7ff10f0 | ||
|
|
8e53a9d65b | ||
|
|
c5b572b2c2 | ||
|
|
75cd338c60 | ||
|
|
e66f5e54a1 | ||
|
|
fe243a42cb | ||
|
|
6c79e7cbac | ||
|
|
f3540a8448 | ||
|
|
bdded61221 | ||
|
|
749cfb3f92 | ||
|
|
9331989102 | ||
|
|
1ff9a36af3 | ||
|
|
3357fbfcf1 | ||
|
|
d3ec580ec8 | ||
|
|
942fb48a0b | ||
|
|
e04f1fd93f | ||
|
|
c6489b6b05 | ||
|
|
7a45b243cb | ||
|
|
0e561d9e8c | ||
|
|
e8c60b3a09 | ||
|
|
26faa008cb | ||
|
|
56b0040f7a | ||
|
|
533cf5e7d2 | ||
|
|
f518d7e589 | ||
|
|
f6b778c7fc | ||
|
|
906ad38a36 | ||
|
|
274c3d35e1 | ||
|
|
6694d9e0c4 | ||
|
|
2939ebfe6b | ||
|
|
786e01c8f6 | ||
|
|
83836298ec | ||
|
|
068fbe3a26 | ||
|
|
ab0eb3ab28 | ||
|
|
740cfcbb94 | ||
|
|
687e51654b | ||
|
|
a0e580878e | ||
|
|
e66f260e75 | ||
|
|
a52f5fc707 | ||
|
|
5170aea882 | ||
|
|
d262bd3ae8 | ||
|
|
9204189448 | ||
|
|
a4a055a98e | ||
|
|
d7b132d9d9 | ||
|
|
3a08e80c1f | ||
|
|
2cc20ff760 | ||
|
|
f334e018fa | ||
|
|
984f4e2db4 | ||
|
|
b44c8b767d | ||
|
|
2b94f26cae | ||
|
|
1cef11a1d2 | ||
|
|
40aee67c46 | ||
|
|
2efc6a7605 | ||
|
|
60d749cd65 | ||
|
|
26ab626966 | ||
|
|
3d2bab90ec | ||
|
|
b367c1fcf8 | ||
|
|
663e6c13e6 | ||
|
|
86dca3e9c2 | ||
|
|
51c759a4f5 | ||
|
|
6c8cbb93e6 | ||
|
|
0f542ad452 | ||
|
|
befacf9d29 | ||
|
|
d9878c8b20 | ||
|
|
d65259db8a | ||
|
|
54a1ec1c88 | ||
|
|
3a8c436839 | ||
|
|
bfb961ccbe | ||
|
|
f49dde9484 | ||
|
|
b64a979a61 | ||
|
|
0e38b0eb5f | ||
|
|
68c3423f50 | ||
|
|
1206117df1 | ||
|
|
7c2f21f7a2 | ||
|
|
7c15850c8f | ||
|
|
670bd7d351 | ||
|
|
75a82cf16c | ||
|
|
45fcbf9d29 |
241
.agent/plans/affiliate-module.md
Normal file
241
.agent/plans/affiliate-module.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# Affiliate Module Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Referral tracking with hybrid customer/affiliate roles, integrated as a core plugin module.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module Architecture
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
- **Affiliate registration**: Customers can become affiliates
|
||||||
|
- **Approval workflow**: Manual or auto-approval of affiliates
|
||||||
|
- **Unique referral links/codes**: Each affiliate gets unique tracking
|
||||||
|
- **Commission tracking**: Track referrals and calculate earnings
|
||||||
|
- **Tiered commission rates**: Different rates per product/category/affiliate level
|
||||||
|
- **Payout management**: Track and process affiliate payouts
|
||||||
|
- **Affiliate dashboard**: Self-service stats and link generator
|
||||||
|
|
||||||
|
### Hybrid Roles
|
||||||
|
- A customer can also be an affiliate
|
||||||
|
- No separate user type; affiliate data linked to existing user
|
||||||
|
- Affiliates can still make purchases (self-referral rules configurable)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Table: `woonoow_affiliates`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE woonoow_affiliates (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL UNIQUE,
|
||||||
|
status ENUM('pending', 'active', 'rejected', 'suspended') DEFAULT 'pending',
|
||||||
|
referral_code VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
commission_rate DECIMAL(5,2) DEFAULT NULL, -- Override global rate
|
||||||
|
tier_id BIGINT UNSIGNED DEFAULT NULL,
|
||||||
|
payment_email VARCHAR(255) DEFAULT NULL,
|
||||||
|
payment_method VARCHAR(50) DEFAULT NULL,
|
||||||
|
total_earnings DECIMAL(15,2) DEFAULT 0,
|
||||||
|
total_unpaid DECIMAL(15,2) DEFAULT 0,
|
||||||
|
total_paid DECIMAL(15,2) DEFAULT 0,
|
||||||
|
referral_count INT UNSIGNED DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_referral_code (referral_code)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table: `woonoow_referrals`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE woonoow_referrals (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
affiliate_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
customer_id BIGINT UNSIGNED DEFAULT NULL,
|
||||||
|
subtotal DECIMAL(15,2) NOT NULL,
|
||||||
|
commission_rate DECIMAL(5,2) NOT NULL,
|
||||||
|
commission_amount DECIMAL(15,2) NOT NULL,
|
||||||
|
status ENUM('pending', 'approved', 'rejected', 'paid') DEFAULT 'pending',
|
||||||
|
referral_type ENUM('link', 'code', 'coupon') DEFAULT 'link',
|
||||||
|
ip_address VARCHAR(45) DEFAULT NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
approved_at DATETIME DEFAULT NULL,
|
||||||
|
paid_at DATETIME DEFAULT NULL,
|
||||||
|
INDEX idx_affiliate (affiliate_id),
|
||||||
|
INDEX idx_order (order_id),
|
||||||
|
INDEX idx_status (status)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table: `woonoow_payouts`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE woonoow_payouts (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
affiliate_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
amount DECIMAL(15,2) NOT NULL,
|
||||||
|
method VARCHAR(50) DEFAULT NULL,
|
||||||
|
reference VARCHAR(255) DEFAULT NULL, -- Bank ref, PayPal transaction, etc.
|
||||||
|
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
|
||||||
|
notes TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at DATETIME DEFAULT NULL,
|
||||||
|
INDEX idx_affiliate (affiliate_id),
|
||||||
|
INDEX idx_status (status)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table: `woonoow_affiliate_tiers` (Optional)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE woonoow_affiliate_tiers (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
commission_rate DECIMAL(5,2) NOT NULL,
|
||||||
|
min_referrals INT UNSIGNED DEFAULT 0, -- Auto-promote at X referrals
|
||||||
|
min_earnings DECIMAL(15,2) DEFAULT 0, -- Auto-promote at X earnings
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
includes/Modules/Affiliate/
|
||||||
|
├── AffiliateModule.php # Bootstrap, hooks, tracking
|
||||||
|
├── AffiliateManager.php # Core logic
|
||||||
|
├── ReferralTracker.php # Track referral cookies/links
|
||||||
|
└── AffiliateSettings.php # Settings schema
|
||||||
|
|
||||||
|
includes/Api/AffiliatesController.php # REST endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
### AffiliateManager Methods
|
||||||
|
```php
|
||||||
|
- register($user_id) # Register user as affiliate
|
||||||
|
- approve($affiliate_id) # Approve pending affiliate
|
||||||
|
- reject($affiliate_id, $reason) # Reject application
|
||||||
|
- suspend($affiliate_id) # Suspend affiliate
|
||||||
|
- track_referral($order, $affiliate) # Create referral record
|
||||||
|
- calculate_commission($order, $affiliate) # Calculate earnings
|
||||||
|
- approve_referral($referral_id) # Approve pending referral
|
||||||
|
- create_payout($affiliate_id, $amount) # Create payout request
|
||||||
|
- process_payout($payout_id) # Mark payout complete
|
||||||
|
- get_affiliate_stats($affiliate_id) # Dashboard stats
|
||||||
|
```
|
||||||
|
|
||||||
|
### Referral Tracking
|
||||||
|
1. Affiliate shares link: `yourstore.com/?ref=CODE`
|
||||||
|
2. ReferralTracker sets cookie: `wnw_ref=CODE` (30 days default)
|
||||||
|
3. On checkout, check cookie and link to affiliate
|
||||||
|
4. On order completion, create referral record
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REST API Endpoints
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
```
|
||||||
|
GET /affiliates # List affiliates
|
||||||
|
GET /affiliates/{id} # Affiliate details
|
||||||
|
PUT /affiliates/{id} # Update affiliate
|
||||||
|
POST /affiliates/{id}/approve # Approve affiliate
|
||||||
|
POST /affiliates/{id}/reject # Reject affiliate
|
||||||
|
GET /affiliates/referrals # All referrals
|
||||||
|
GET /affiliates/payouts # All payouts
|
||||||
|
POST /affiliates/payouts/{id}/complete # Complete payout
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customer/Affiliate Endpoints
|
||||||
|
```
|
||||||
|
GET /my-affiliate # Check if affiliate
|
||||||
|
POST /my-affiliate/register # Register as affiliate
|
||||||
|
GET /my-affiliate/stats # Dashboard stats
|
||||||
|
GET /my-affiliate/referrals # My referrals
|
||||||
|
GET /my-affiliate/payouts # My payouts
|
||||||
|
POST /my-affiliate/payout-request # Request payout
|
||||||
|
GET /my-affiliate/links # Generate referral links
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin SPA
|
||||||
|
|
||||||
|
### Affiliates List (`/marketing/affiliates`)
|
||||||
|
- Table: Name, Email, Status, Referrals, Earnings, Actions
|
||||||
|
- Filters: Status, Date range
|
||||||
|
- Actions: Approve, Reject, View
|
||||||
|
|
||||||
|
### Affiliate Detail (`/marketing/affiliates/:id`)
|
||||||
|
- Affiliate info card
|
||||||
|
- Stats summary
|
||||||
|
- Referrals list
|
||||||
|
- Payouts history
|
||||||
|
- Action buttons
|
||||||
|
|
||||||
|
### Referrals List (`/marketing/affiliates/referrals`)
|
||||||
|
- All referrals across affiliates
|
||||||
|
- Filters: Status, Affiliate, Date
|
||||||
|
|
||||||
|
### Payouts (`/marketing/affiliates/payouts`)
|
||||||
|
- Payout requests
|
||||||
|
- Process payouts
|
||||||
|
- Payment history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customer SPA
|
||||||
|
|
||||||
|
### Become an Affiliate (`/my-account/affiliate`)
|
||||||
|
- Registration form (if not affiliate)
|
||||||
|
- Dashboard (if affiliate)
|
||||||
|
|
||||||
|
### Affiliate Dashboard
|
||||||
|
- Stats: Total Referrals, Pending, Approved, Earnings
|
||||||
|
- Referral link generator
|
||||||
|
- Recent referrals
|
||||||
|
- Payout request button
|
||||||
|
|
||||||
|
### My Referrals
|
||||||
|
- List of referrals with status
|
||||||
|
- Commission amount
|
||||||
|
|
||||||
|
### My Payouts
|
||||||
|
- Payout history
|
||||||
|
- Pending amount
|
||||||
|
- Request payout form
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings Schema
|
||||||
|
|
||||||
|
```php
|
||||||
|
return [
|
||||||
|
'enabled' => true,
|
||||||
|
'registration_type' => 'open', // open, approval, invite
|
||||||
|
'auto_approve' => false,
|
||||||
|
'default_commission_rate' => 10, // 10%
|
||||||
|
'commission_type' => 'percentage', // percentage, flat
|
||||||
|
'cookie_duration' => 30, // days
|
||||||
|
'min_payout_amount' => 50,
|
||||||
|
'payout_methods' => ['bank_transfer', 'paypal'],
|
||||||
|
'allow_self_referral' => false,
|
||||||
|
'referral_approval' => 'auto', // auto, manual
|
||||||
|
'approval_delay_days' => 14, // Wait X days before auto-approve
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
1. Database tables and AffiliateManager
|
||||||
|
2. ReferralTracker (cookie-based tracking)
|
||||||
|
3. Order hook to create referrals
|
||||||
|
4. Admin SPA affiliates management
|
||||||
|
5. Customer SPA affiliate dashboard
|
||||||
|
6. Payout management
|
||||||
|
7. Tier system (optional)
|
||||||
84
.agent/plans/shipping-label.md
Normal file
84
.agent/plans/shipping-label.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Shipping Label Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Standardized waybill data structure for shipping label generation.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
- Different shipping carrier addons (JNE, JNT, SiCepat, etc.) store data differently
|
||||||
|
- No standard structure for label generation
|
||||||
|
- Label button needs waybill data to function
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
### 1. Standardized Meta Key
|
||||||
|
Order meta: `_shipping_waybill`
|
||||||
|
|
||||||
|
### 2. Data Structure
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tracking_number": "JNE123456789",
|
||||||
|
"carrier": "jne",
|
||||||
|
"carrier_name": "JNE Express",
|
||||||
|
"service": "REG",
|
||||||
|
"estimated_days": 3,
|
||||||
|
"sender": {
|
||||||
|
"name": "Store Name",
|
||||||
|
"address": "Full address line 1",
|
||||||
|
"city": "Jakarta",
|
||||||
|
"postcode": "12345",
|
||||||
|
"phone": "08123456789"
|
||||||
|
},
|
||||||
|
"recipient": {
|
||||||
|
"name": "Customer Name",
|
||||||
|
"address": "Full address line 1",
|
||||||
|
"city": "Bandung",
|
||||||
|
"postcode": "40123",
|
||||||
|
"phone": "08987654321"
|
||||||
|
},
|
||||||
|
"package": {
|
||||||
|
"weight": "1.5",
|
||||||
|
"weight_unit": "kg",
|
||||||
|
"dimensions": "20x15x10",
|
||||||
|
"dimensions_unit": "cm"
|
||||||
|
},
|
||||||
|
"label_url": null,
|
||||||
|
"barcode": "JNE123456789",
|
||||||
|
"barcode_type": "128",
|
||||||
|
"created_at": "2026-01-05T12:00:00+07:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Addon Integration Contract
|
||||||
|
|
||||||
|
Shipping addons MUST:
|
||||||
|
1. Call `update_post_meta($order_id, '_shipping_waybill', $waybill_data)`
|
||||||
|
2. Use the standard structure above
|
||||||
|
3. Set `label_url` if carrier provides downloadable PDF
|
||||||
|
4. Set `barcode` for local label generation
|
||||||
|
|
||||||
|
### 4. Label Button Behavior
|
||||||
|
1. Check if `_shipping_waybill` meta exists on order
|
||||||
|
2. If `label_url` → open carrier's PDF
|
||||||
|
3. Otherwise → generate printable label from meta data
|
||||||
|
|
||||||
|
### 5. UI Behavior
|
||||||
|
- Label button hidden if order is virtual-only
|
||||||
|
- Label button shows "Generate Label" if no waybill yet
|
||||||
|
- Label button shows "Print Label" if waybill exists
|
||||||
|
|
||||||
|
## API Endpoint (Future)
|
||||||
|
```
|
||||||
|
POST /woonoow/v1/orders/{id}/generate-waybill
|
||||||
|
- Calls shipping carrier API
|
||||||
|
- Stores waybill in standardized format
|
||||||
|
- Returns waybill data
|
||||||
|
|
||||||
|
GET /woonoow/v1/orders/{id}/waybill
|
||||||
|
- Returns current waybill data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
1. Define standard structure (this document)
|
||||||
|
2. Implement Label UI conditional logic
|
||||||
|
3. Create waybill API endpoint
|
||||||
|
4. Document for addon developers
|
||||||
191
.agent/plans/subscription-module.md
Normal file
191
.agent/plans/subscription-module.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# Subscription Module Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Recurring product subscriptions with flexible billing, integrated as a core plugin module (like Newsletter/Wishlist/Licensing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module Architecture
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
- **Recurring billing**: Weekly, monthly, yearly, custom intervals
|
||||||
|
- **Free trials**: X days free before billing starts
|
||||||
|
- **Sign-up fees**: One-time fee on first subscription
|
||||||
|
- **Automatic renewals**: Process payment on renewal date
|
||||||
|
- **Manual renewal**: Allow customers to renew manually
|
||||||
|
- **Proration**: Calculate prorated amounts on plan changes
|
||||||
|
- **Pause/Resume**: Allow customers to pause subscriptions
|
||||||
|
|
||||||
|
### Product Integration
|
||||||
|
- Checkbox under "Additional Options": **Enable subscription for this product**
|
||||||
|
- When enabled, show subscription settings:
|
||||||
|
- Billing period (weekly/monthly/yearly/custom)
|
||||||
|
- Billing interval (every X periods)
|
||||||
|
- Free trial days
|
||||||
|
- Sign-up fee
|
||||||
|
- Subscription length (0 = unlimited)
|
||||||
|
- Variable products: Variation-level subscription settings (different durations/prices per variation)
|
||||||
|
|
||||||
|
### Integration with Licensing
|
||||||
|
- Licenses can be bound to subscriptions
|
||||||
|
- When subscription is active → license is valid
|
||||||
|
- When subscription expires/cancelled → license is revoked
|
||||||
|
- Auto-renewal keeps license active
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Table: `woonoow_subscriptions`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE woonoow_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,
|
||||||
|
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,
|
||||||
|
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_status (status),
|
||||||
|
INDEX idx_next_payment (next_payment_date)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table: `woonoow_subscription_orders`
|
||||||
|
Links subscription to renewal orders:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE woonoow_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)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
includes/Modules/Subscription/
|
||||||
|
├── SubscriptionModule.php # Bootstrap, hooks, product meta
|
||||||
|
├── SubscriptionManager.php # Core logic
|
||||||
|
├── SubscriptionScheduler.php # Cron jobs for renewals
|
||||||
|
└── SubscriptionSettings.php # Settings schema
|
||||||
|
|
||||||
|
includes/Api/SubscriptionsController.php # REST endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
### SubscriptionManager Methods
|
||||||
|
```php
|
||||||
|
- create($order, $product, $user) # Create subscription from order
|
||||||
|
- renew($subscription_id) # Process renewal
|
||||||
|
- cancel($subscription_id, $reason) # Cancel subscription
|
||||||
|
- pause($subscription_id) # Pause subscription
|
||||||
|
- resume($subscription_id) # Resume paused subscription
|
||||||
|
- switch($subscription_id, $new_product) # Switch plan
|
||||||
|
- get_next_payment_date($subscription) # Calculate next date
|
||||||
|
- process_renewal_payment($subscription) # Charge payment
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Jobs
|
||||||
|
- `woonoow_process_subscription_renewals`: Run daily, process due renewals
|
||||||
|
- `woonoow_check_expired_subscriptions`: Mark expired subscriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REST API Endpoints
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
```
|
||||||
|
GET /subscriptions # List all subscriptions
|
||||||
|
GET /subscriptions/{id} # Get subscription details
|
||||||
|
PUT /subscriptions/{id} # Update subscription
|
||||||
|
POST /subscriptions/{id}/cancel # Cancel subscription
|
||||||
|
POST /subscriptions/{id}/renew # Force renewal
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customer Endpoints
|
||||||
|
```
|
||||||
|
GET /my-subscriptions # Customer's subscriptions
|
||||||
|
GET /my-subscriptions/{id} # Subscription detail
|
||||||
|
POST /my-subscriptions/{id}/cancel # Request cancellation
|
||||||
|
POST /my-subscriptions/{id}/pause # Pause subscription
|
||||||
|
POST /my-subscriptions/{id}/resume # Resume subscription
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin SPA
|
||||||
|
|
||||||
|
### Subscriptions List (`/subscriptions`)
|
||||||
|
- Table: ID, Customer, Product, Status, Next Payment, Actions
|
||||||
|
- Filters: Status, Product, Date range
|
||||||
|
- Actions: View, Cancel, Renew
|
||||||
|
|
||||||
|
### Subscription Detail (`/subscriptions/:id`)
|
||||||
|
- Subscription info card
|
||||||
|
- Related orders list
|
||||||
|
- Payment history
|
||||||
|
- Action buttons: Cancel, Pause, Renew
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customer SPA
|
||||||
|
|
||||||
|
### My Subscriptions (`/my-account/subscriptions`)
|
||||||
|
- List of active/past subscriptions
|
||||||
|
- Status badges
|
||||||
|
- Next payment info
|
||||||
|
- Actions: Cancel, Pause, View
|
||||||
|
|
||||||
|
### Subscription Detail
|
||||||
|
- Product info
|
||||||
|
- Billing schedule
|
||||||
|
- Payment history
|
||||||
|
- Management actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings Schema
|
||||||
|
|
||||||
|
```php
|
||||||
|
return [
|
||||||
|
'default_status' => 'active',
|
||||||
|
'button_text_subscribe' => 'Subscribe Now',
|
||||||
|
'button_text_renew' => 'Renew Subscription',
|
||||||
|
'allow_customer_cancel' => true,
|
||||||
|
'allow_customer_pause' => true,
|
||||||
|
'max_pause_count' => 3,
|
||||||
|
'renewal_retry_days' => [1, 3, 5], // Retry failed payments
|
||||||
|
'expire_after_failed_attempts' => 3,
|
||||||
|
'send_renewal_reminder' => true,
|
||||||
|
'reminder_days_before' => 3,
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
1. Database tables and SubscriptionManager
|
||||||
|
2. Product meta fields for subscription settings
|
||||||
|
3. Order hook to create subscription
|
||||||
|
4. Renewal cron job
|
||||||
|
5. Admin SPA list/detail pages
|
||||||
|
6. Customer SPA pages
|
||||||
|
7. Integration with Licensing module
|
||||||
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
|
GET /analytics/customers # Customer analytics
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Licensing Module (`LicensesController.php`)
|
||||||
|
```
|
||||||
|
# Admin Endpoints (admin auth required)
|
||||||
|
GET /licenses # List licenses (with pagination, search)
|
||||||
|
GET /licenses/{id} # Get single license
|
||||||
|
POST /licenses # Create license
|
||||||
|
PUT /licenses/{id} # Update license
|
||||||
|
DELETE /licenses/{id} # Delete license
|
||||||
|
|
||||||
|
# Public Endpoints (for client software validation)
|
||||||
|
POST /licenses/validate # Validate license key
|
||||||
|
POST /licenses/activate # Activate license on domain
|
||||||
|
POST /licenses/deactivate # Deactivate license from domain
|
||||||
|
|
||||||
|
# OAuth Endpoints (user auth required)
|
||||||
|
GET /licenses/oauth/validate # Validate OAuth state and license ownership
|
||||||
|
POST /licenses/oauth/confirm # Confirm activation and generate token
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation Details:**
|
||||||
|
- **List:** Supports pagination (`page`, `per_page`), search by key/email
|
||||||
|
- **activate:** Supports Simple API and OAuth modes
|
||||||
|
- **OAuth flow:** `oauth/validate` + `oauth/confirm` for secure user verification
|
||||||
|
- See `LICENSING_MODULE.md` for full OAuth flow documentation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Conflict Prevention Rules
|
## Conflict Prevention Rules
|
||||||
|
|||||||
284
LICENSING_MODULE.md
Normal file
284
LICENSING_MODULE.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# Licensing Module Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
WooNooW's Licensing Module provides software license management for digital products. It supports two activation methods:
|
||||||
|
|
||||||
|
1. **Simple API** - Direct license key validation via API
|
||||||
|
2. **Secure OAuth** - User verification via vendor portal before activation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Admin Endpoints (Authenticated Admin)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /licenses # List all licenses (with pagination, search)
|
||||||
|
GET /licenses/{id} # Get single license
|
||||||
|
POST /licenses # Create license
|
||||||
|
PUT /licenses/{id} # Update license
|
||||||
|
DELETE /licenses/{id} # Delete license
|
||||||
|
```
|
||||||
|
|
||||||
|
### Public Endpoints (For Client Software)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /licenses/validate # Validate license key
|
||||||
|
POST /licenses/activate # Activate license on domain
|
||||||
|
POST /licenses/deactivate # Deactivate license from domain
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Endpoints (Authenticated User)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /licenses/oauth/validate # Validate OAuth state and license ownership
|
||||||
|
POST /licenses/oauth/confirm # Confirm activation and get token
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Activation Flows
|
||||||
|
|
||||||
|
### 1. Simple API Flow
|
||||||
|
|
||||||
|
Direct license activation without user verification. Suitable for trusted environments.
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Vendor API
|
||||||
|
| |
|
||||||
|
|-- POST /licenses/activate -|
|
||||||
|
| {license_key, domain} |
|
||||||
|
| |
|
||||||
|
|<-- {success, activation_id}|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://vendor.com/wp-json/woonoow/v1/licenses/activate" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"license_key": "XXXX-YYYY-ZZZZ-WWWW",
|
||||||
|
"domain": "https://customer-site.com",
|
||||||
|
"machine_id": "optional-unique-id"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"activation_id": 123,
|
||||||
|
"license_key": "XXXX-YYYY-ZZZZ-WWWW",
|
||||||
|
"status": "active",
|
||||||
|
"expires_at": "2025-01-31T00:00:00Z",
|
||||||
|
"activation_limit": 3,
|
||||||
|
"activation_count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Secure OAuth Flow (Recommended)
|
||||||
|
|
||||||
|
User must verify ownership on vendor portal before activation. More secure.
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Vendor Portal Vendor API
|
||||||
|
| | |
|
||||||
|
|-- POST /licenses/activate -| |
|
||||||
|
| {license_key, domain} | |
|
||||||
|
| | |
|
||||||
|
|<-- {oauth_redirect, state}-| |
|
||||||
|
| | |
|
||||||
|
|== User redirects browser ==| |
|
||||||
|
| | |
|
||||||
|
|-------- BROWSER ---------->| |
|
||||||
|
| /my-account/license-connect?license_key=...&state=...|
|
||||||
|
| | |
|
||||||
|
| [User logs in if needed] |
|
||||||
|
| [User sees confirmation page] |
|
||||||
|
| [User clicks "Authorize"] |
|
||||||
|
| | |
|
||||||
|
| |-- POST /oauth/confirm -->|
|
||||||
|
| |<-- {token} --------------|
|
||||||
|
| | |
|
||||||
|
|<------- REDIRECT ----------| |
|
||||||
|
| {return_url}?activation_token=xxx |
|
||||||
|
| | |
|
||||||
|
|-- POST /licenses/activate -----------------------> |
|
||||||
|
| {license_key, activation_token} |
|
||||||
|
| | |
|
||||||
|
|<-- {success, activation_id} --------------------------|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OAuth Flow Step by Step
|
||||||
|
|
||||||
|
### Step 1: Client Requests Activation (OAuth Mode)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://vendor.com/wp-json/woonoow/v1/licenses/activate" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"license_key": "XXXX-YYYY-ZZZZ-WWWW",
|
||||||
|
"domain": "https://customer-site.com",
|
||||||
|
"return_url": "https://customer-site.com/activation-callback",
|
||||||
|
"activation_mode": "oauth"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (OAuth Required):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"oauth_required": true,
|
||||||
|
"oauth_redirect": "https://vendor.com/my-account/license-connect/?license_key=XXXX-YYYY-ZZZZ-WWWW&site_url=https://customer-site.com&return_url=https://customer-site.com/activation-callback&state=abc123&nonce=xyz789",
|
||||||
|
"state": "abc123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: User Opens Browser to OAuth URL
|
||||||
|
|
||||||
|
Client opens the `oauth_redirect` URL in user's browser. The user:
|
||||||
|
1. Logs into vendor portal (if not already)
|
||||||
|
2. Sees license activation confirmation page
|
||||||
|
3. Reviews license key and requesting site
|
||||||
|
4. Clicks "Authorize" to confirm
|
||||||
|
|
||||||
|
### Step 3: User Gets Redirected Back
|
||||||
|
|
||||||
|
After authorization, user is redirected to `return_url` with token:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://customer-site.com/activation-callback?activation_token=xyz123&license_key=XXXX-YYYY-ZZZZ-WWWW&nonce=xyz789
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Client Exchanges Token for Activation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://vendor.com/wp-json/woonoow/v1/licenses/activate" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"license_key": "XXXX-YYYY-ZZZZ-WWWW",
|
||||||
|
"domain": "https://customer-site.com",
|
||||||
|
"activation_token": "xyz123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (Success):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"activation_id": 456,
|
||||||
|
"license_key": "XXXX-YYYY-ZZZZ-WWWW",
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Site-Level Settings
|
||||||
|
|
||||||
|
In Admin SPA: **Settings > Licensing**
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| Default Activation Method | `api` or `oauth` - Default for all products |
|
||||||
|
| License Key Format | Format pattern for generated keys |
|
||||||
|
| Default Validity Period | Days until license expires |
|
||||||
|
| Default Activation Limit | Max activations per license |
|
||||||
|
|
||||||
|
### Per-Product Settings
|
||||||
|
|
||||||
|
In Admin SPA: **Products > Edit Product > General Tab**
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| Enable Licensing | Toggle to enable license generation |
|
||||||
|
| Activation Method | `Use Site Default`, `Simple API`, or `Secure OAuth` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Licenses Table (`wp_woonoow_licenses`)
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | BIGINT | Primary key |
|
||||||
|
| license_key | VARCHAR(255) | Unique license key |
|
||||||
|
| product_id | BIGINT | WooCommerce product ID |
|
||||||
|
| order_id | BIGINT | WooCommerce order ID |
|
||||||
|
| user_id | BIGINT | Customer user ID |
|
||||||
|
| status | VARCHAR(50) | active, inactive, expired, revoked |
|
||||||
|
| activation_limit | INT | Max allowed activations |
|
||||||
|
| activation_count | INT | Current activation count |
|
||||||
|
| expires_at | DATETIME | Expiration date |
|
||||||
|
| created_at | DATETIME | Created timestamp |
|
||||||
|
| updated_at | DATETIME | Updated timestamp |
|
||||||
|
|
||||||
|
### Activations Table (`wp_woonoow_license_activations`)
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | BIGINT | Primary key |
|
||||||
|
| license_id | BIGINT | Foreign key to licenses |
|
||||||
|
| domain | VARCHAR(255) | Activated domain |
|
||||||
|
| machine_id | VARCHAR(255) | Optional machine identifier |
|
||||||
|
| status | VARCHAR(50) | active, deactivated, pending |
|
||||||
|
| user_agent | TEXT | Client user agent |
|
||||||
|
| activated_at | DATETIME | Activation timestamp |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customer SPA: License Connect Page
|
||||||
|
|
||||||
|
The OAuth confirmation page is available at:
|
||||||
|
```
|
||||||
|
/my-account/license-connect/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Required | Description |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| license_key | Yes | License key to activate |
|
||||||
|
| site_url | Yes | Requesting site URL |
|
||||||
|
| return_url | Yes | Callback URL after authorization |
|
||||||
|
| state | Yes | CSRF protection token |
|
||||||
|
| nonce | No | Additional security nonce |
|
||||||
|
|
||||||
|
### UI Features
|
||||||
|
|
||||||
|
- **Focused Layout** - No header/sidebar/footer, just the authorization card
|
||||||
|
- **Brand Display** - Shows vendor site name
|
||||||
|
- **License Details** - Displays license key, site URL, product name
|
||||||
|
- **Security Warning** - Warns user to only authorize trusted sites
|
||||||
|
- **Authorize/Deny Buttons** - Clear actions for user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **State Token** - Prevents CSRF attacks, expires after 5 minutes
|
||||||
|
2. **Activation Token** - Single-use, expires after 5 minutes
|
||||||
|
3. **User Verification** - OAuth ensures license owner authorizes activation
|
||||||
|
4. **Domain Validation** - Tracks activated domains for audit
|
||||||
|
5. **Rate Limiting** - Consider implementing on activation endpoints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Reference
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `includes/Modules/Licensing/LicensingModule.php` | Module registration, endpoint handlers |
|
||||||
|
| `includes/Modules/Licensing/LicenseManager.php` | Core license operations |
|
||||||
|
| `includes/Api/LicensesController.php` | REST API endpoints |
|
||||||
|
| `customer-spa/src/pages/Account/LicenseConnect.tsx` | OAuth confirmation UI |
|
||||||
|
| `customer-spa/src/pages/Account/index.tsx` | Routing for license pages |
|
||||||
|
| `customer-spa/src/App.tsx` | Top-level routing (license-connect outside BaseLayout) |
|
||||||
313
RAJAONGKIR_INTEGRATION.md
Normal file
313
RAJAONGKIR_INTEGRATION.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
# Rajaongkir Integration with WooNooW SPA
|
||||||
|
|
||||||
|
This guide explains how to integrate Rajaongkir's destination selector with WooNooW's customer checkout SPA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before using this integration:
|
||||||
|
|
||||||
|
1. **Rajaongkir Plugin Installed & Active**
|
||||||
|
2. **WooCommerce Shipping Zone Configured**
|
||||||
|
- Go to: WC → Settings → Shipping → Zones
|
||||||
|
- Add Rajaongkir method to your Indonesia zone
|
||||||
|
3. **Valid API Key** (Check in Rajaongkir settings)
|
||||||
|
4. **Couriers Selected** (In Rajaongkir settings)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Snippet
|
||||||
|
|
||||||
|
Add this to **Code Snippets** or **WPCodebox**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Rajaongkir Bridge for WooNooW SPA Checkout
|
||||||
|
*
|
||||||
|
* Enables searchable destination field in WooNooW checkout
|
||||||
|
* and bridges data to Rajaongkir plugin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1. REST API Endpoint: Search destinations via Rajaongkir API
|
||||||
|
// ============================================================
|
||||||
|
add_action('rest_api_init', function() {
|
||||||
|
register_rest_route('woonoow/v1', '/rajaongkir/destinations', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => 'woonoow_rajaongkir_search_destinations',
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
'args' => [
|
||||||
|
'search' => [
|
||||||
|
'required' => false,
|
||||||
|
'type' => 'string',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function woonoow_rajaongkir_search_destinations($request) {
|
||||||
|
$search = sanitize_text_field($request->get_param('search') ?? '');
|
||||||
|
|
||||||
|
if (strlen($search) < 3) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Rajaongkir plugin is active
|
||||||
|
if (!class_exists('Cekongkir_API')) {
|
||||||
|
return new WP_Error('rajaongkir_missing', 'Rajaongkir plugin not active', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Rajaongkir's API class for the search
|
||||||
|
// NOTE: Method is search_destination_api() not search_destination()
|
||||||
|
$api = Cekongkir_API::get_instance();
|
||||||
|
$results = $api->search_destination_api($search);
|
||||||
|
|
||||||
|
if (is_wp_error($results)) {
|
||||||
|
error_log('Rajaongkir search error: ' . $results->get_error_message());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($results)) {
|
||||||
|
error_log('Rajaongkir search returned non-array: ' . print_r($results, true));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format for WooNooW's SearchableSelect component
|
||||||
|
$formatted = [];
|
||||||
|
foreach ($results as $r) {
|
||||||
|
$formatted[] = [
|
||||||
|
'value' => (string) ($r['id'] ?? ''),
|
||||||
|
'label' => $r['label'] ?? $r['text'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit results
|
||||||
|
return array_slice($formatted, 0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 2. Add destination field and hide redundant fields for Indonesia
|
||||||
|
// The destination_id from Rajaongkir contains province/city/subdistrict
|
||||||
|
// ============================================================
|
||||||
|
add_filter('woocommerce_checkout_fields', function($fields) {
|
||||||
|
// Check if Rajaongkir is active
|
||||||
|
if (!class_exists('Cekongkir_API')) {
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if store sells to Indonesia (check allowed countries)
|
||||||
|
$allowed = WC()->countries->get_allowed_countries();
|
||||||
|
if (!isset($allowed['ID'])) {
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Indonesia is the ONLY allowed country
|
||||||
|
$indonesia_only = count($allowed) === 1 && isset($allowed['ID']);
|
||||||
|
|
||||||
|
// If Indonesia only, hide country/state/city fields (Rajaongkir destination has all this)
|
||||||
|
if ($indonesia_only) {
|
||||||
|
// Hide billing fields
|
||||||
|
if (isset($fields['billing']['billing_country'])) {
|
||||||
|
$fields['billing']['billing_country']['type'] = 'hidden';
|
||||||
|
$fields['billing']['billing_country']['default'] = 'ID';
|
||||||
|
$fields['billing']['billing_country']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['billing']['billing_state'])) {
|
||||||
|
$fields['billing']['billing_state']['type'] = 'hidden';
|
||||||
|
$fields['billing']['billing_state']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['billing']['billing_city'])) {
|
||||||
|
$fields['billing']['billing_city']['type'] = 'hidden';
|
||||||
|
$fields['billing']['billing_city']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['billing']['billing_postcode'])) {
|
||||||
|
$fields['billing']['billing_postcode']['type'] = 'hidden';
|
||||||
|
$fields['billing']['billing_postcode']['required'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide shipping fields
|
||||||
|
if (isset($fields['shipping']['shipping_country'])) {
|
||||||
|
$fields['shipping']['shipping_country']['type'] = 'hidden';
|
||||||
|
$fields['shipping']['shipping_country']['default'] = 'ID';
|
||||||
|
$fields['shipping']['shipping_country']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['shipping']['shipping_state'])) {
|
||||||
|
$fields['shipping']['shipping_state']['type'] = 'hidden';
|
||||||
|
$fields['shipping']['shipping_state']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['shipping']['shipping_city'])) {
|
||||||
|
$fields['shipping']['shipping_city']['type'] = 'hidden';
|
||||||
|
$fields['shipping']['shipping_city']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['shipping']['shipping_postcode'])) {
|
||||||
|
$fields['shipping']['shipping_postcode']['type'] = 'hidden';
|
||||||
|
$fields['shipping']['shipping_postcode']['required'] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destination field definition (reused for billing and shipping)
|
||||||
|
$destination_field = [
|
||||||
|
'type' => 'searchable_select',
|
||||||
|
'label' => __('Destination (Province, City, Subdistrict)', 'woonoow'),
|
||||||
|
'required' => $indonesia_only, // Required if Indonesia only
|
||||||
|
'priority' => 85,
|
||||||
|
'class' => ['form-row-wide'],
|
||||||
|
'placeholder' => __('Search destination...', 'woonoow'),
|
||||||
|
// WooNooW-specific: API endpoint configuration
|
||||||
|
// NOTE: Path is relative to /wp-json/woonoow/v1
|
||||||
|
'search_endpoint' => '/rajaongkir/destinations',
|
||||||
|
'search_param' => 'search',
|
||||||
|
'min_chars' => 3,
|
||||||
|
// Custom attribute to indicate this is for Indonesia only
|
||||||
|
'custom_attributes' => [
|
||||||
|
'data-show-for-country' => 'ID',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add to billing (used when "Ship to different address" is NOT checked)
|
||||||
|
$fields['billing']['billing_destination_id'] = $destination_field;
|
||||||
|
|
||||||
|
// Add to shipping (used when "Ship to different address" IS checked)
|
||||||
|
$fields['shipping']['shipping_destination_id'] = $destination_field;
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}, 20); // Priority 20 to run after Rajaongkir's own filter
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 3. Bridge WooNooW shipping data to Rajaongkir session
|
||||||
|
// Sets destination_id in WC session for Rajaongkir to use
|
||||||
|
// ============================================================
|
||||||
|
add_action('woonoow/shipping/before_calculate', function($shipping, $items) {
|
||||||
|
// Check if Rajaongkir is active
|
||||||
|
if (!class_exists('Cekongkir_API')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Indonesia-only stores, always set country to ID
|
||||||
|
$allowed = WC()->countries->get_allowed_countries();
|
||||||
|
$indonesia_only = count($allowed) === 1 && isset($allowed['ID']);
|
||||||
|
|
||||||
|
if ($indonesia_only) {
|
||||||
|
WC()->customer->set_shipping_country('ID');
|
||||||
|
WC()->customer->set_billing_country('ID');
|
||||||
|
} elseif (!empty($shipping['country'])) {
|
||||||
|
WC()->customer->set_shipping_country($shipping['country']);
|
||||||
|
WC()->customer->set_billing_country($shipping['country']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process Rajaongkir for Indonesia
|
||||||
|
$country = $shipping['country'] ?? WC()->customer->get_shipping_country();
|
||||||
|
if ($country !== 'ID') {
|
||||||
|
// Clear destination for non-Indonesia
|
||||||
|
WC()->session->__unset('selected_destination_id');
|
||||||
|
WC()->session->__unset('selected_destination_label');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get destination_id from shipping data (various possible keys)
|
||||||
|
$destination_id = $shipping['destination_id']
|
||||||
|
?? $shipping['shipping_destination_id']
|
||||||
|
?? $shipping['billing_destination_id']
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
if (empty($destination_id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set session for Rajaongkir
|
||||||
|
WC()->session->set('selected_destination_id', intval($destination_id));
|
||||||
|
|
||||||
|
// Also set label if provided
|
||||||
|
$label = $shipping['destination_label']
|
||||||
|
?? $shipping['shipping_destination_id_label']
|
||||||
|
?? $shipping['billing_destination_id_label']
|
||||||
|
?? '';
|
||||||
|
if ($label) {
|
||||||
|
WC()->session->set('selected_destination_label', sanitize_text_field($label));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear shipping cache to force recalculation
|
||||||
|
WC()->session->set('shipping_for_package_0', false);
|
||||||
|
|
||||||
|
}, 10, 2);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### 1. Test the API Endpoint
|
||||||
|
|
||||||
|
After adding the snippet:
|
||||||
|
```
|
||||||
|
GET /wp-json/woonoow/v1/rajaongkir/destinations?search=bandung
|
||||||
|
```
|
||||||
|
|
||||||
|
Should return:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"value": "1234", "label": "Jawa Barat, Bandung, Kota"},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test Checkout Flow
|
||||||
|
|
||||||
|
1. Add product to cart
|
||||||
|
2. Go to SPA checkout: `/store/checkout`
|
||||||
|
3. Set country to Indonesia
|
||||||
|
4. "Destination" field should appear
|
||||||
|
5. Type 2+ characters to search
|
||||||
|
6. Select a destination
|
||||||
|
7. Rajaongkir rates should appear
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### API returns empty?
|
||||||
|
|
||||||
|
Check `debug.log` for errors:
|
||||||
|
```php
|
||||||
|
// Added logging in the search function
|
||||||
|
error_log('Rajaongkir search error: ...');
|
||||||
|
```
|
||||||
|
|
||||||
|
Common issues:
|
||||||
|
- Invalid Rajaongkir API key
|
||||||
|
- Rajaongkir plugin not active
|
||||||
|
- API quota exceeded
|
||||||
|
|
||||||
|
### Field not appearing?
|
||||||
|
|
||||||
|
1. Ensure snippet is active
|
||||||
|
2. Check if store sells to Indonesia
|
||||||
|
3. Check browser console for JS errors
|
||||||
|
|
||||||
|
### Rajaongkir rates not showing?
|
||||||
|
|
||||||
|
1. Check session is set:
|
||||||
|
```php
|
||||||
|
add_action('woonoow/shipping/before_calculate', function($shipping) {
|
||||||
|
error_log('Shipping data: ' . print_r($shipping, true));
|
||||||
|
}, 5);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check Rajaongkir is enabled in shipping zone
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
1. **Field visibility**: Currently field always shows for checkout. Future improvement: hide in React when country ≠ ID.
|
||||||
|
|
||||||
|
2. **Session timing**: Must select destination before calculating shipping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [SHIPPING_INTEGRATION.md](SHIPPING_INTEGRATION.md) - General shipping patterns
|
||||||
|
- [HOOKS_REGISTRY.md](HOOKS_REGISTRY.md) - WooNooW hooks reference
|
||||||
219
SHIPPING_BRIDGE_PATTERN.md
Normal file
219
SHIPPING_BRIDGE_PATTERN.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# WooNooW Shipping Bridge Pattern
|
||||||
|
|
||||||
|
This document describes a generic pattern for integrating any external shipping API with WooNooW's SPA checkout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
WooNooW provides hooks and endpoints that allow any shipping plugin to:
|
||||||
|
1. **Register custom checkout fields** (searchable selects, dropdowns, etc.)
|
||||||
|
2. **Bridge data to the plugin's session/API** before shipping calculation
|
||||||
|
3. **Display live rates** from external APIs
|
||||||
|
|
||||||
|
This pattern is NOT specific to Rajaongkir - it can be used for any shipping provider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ WooNooW Customer SPA │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 1. Checkout loads → calls /checkout/fields │
|
||||||
|
│ 2. Renders custom fields (e.g., searchable destination) │
|
||||||
|
│ 3. User fills form → calls /checkout/shipping-rates │
|
||||||
|
│ 4. Hook triggers → shipping plugin calculates rates │
|
||||||
|
│ 5. Rates displayed → user selects → order submitted │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Your Bridge Snippet │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ • woocommerce_checkout_fields → Add custom fields │
|
||||||
|
│ • register_rest_route → API endpoint for field data │
|
||||||
|
│ • woonoow/shipping/before_calculate → Set session/data │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Shipping Plugin (Any) │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ • Reads from WC session or customer data │
|
||||||
|
│ • Calls external API (Rajaongkir, Sicepat, JNE, etc.) │
|
||||||
|
│ • Returns rates via get_rates_for_package() │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generic Bridge Template
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* [PROVIDER_NAME] Bridge for WooNooW SPA Checkout
|
||||||
|
*
|
||||||
|
* Replace [PROVIDER_NAME], [PROVIDER_CLASS], and [PROVIDER_SESSION_KEY]
|
||||||
|
* with your shipping plugin's specifics.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1. REST API Endpoint: Search/fetch data from provider
|
||||||
|
// ============================================================
|
||||||
|
add_action('rest_api_init', function() {
|
||||||
|
register_rest_route('woonoow/v1', '/[provider]/search', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => 'woonoow_[provider]_search',
|
||||||
|
'permission_callback' => '__return_true', // Public for customer use
|
||||||
|
'args' => [
|
||||||
|
'search' => ['type' => 'string', 'required' => false],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function woonoow_[provider]_search($request) {
|
||||||
|
$search = sanitize_text_field($request->get_param('search') ?? '');
|
||||||
|
|
||||||
|
if (strlen($search) < 3) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if plugin is active
|
||||||
|
if (!class_exists('[PROVIDER_CLASS]')) {
|
||||||
|
return new WP_Error('[provider]_missing', 'Plugin not active', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call provider's API
|
||||||
|
// $results = [PROVIDER_CLASS]::search($search);
|
||||||
|
|
||||||
|
// Format for WooNooW's SearchableSelect
|
||||||
|
$formatted = [];
|
||||||
|
foreach ($results as $r) {
|
||||||
|
$formatted[] = [
|
||||||
|
'value' => (string) $r['id'],
|
||||||
|
'label' => $r['name'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice($formatted, 0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 2. Add custom field(s) to checkout
|
||||||
|
// ============================================================
|
||||||
|
add_filter('woocommerce_checkout_fields', function($fields) {
|
||||||
|
if (!class_exists('[PROVIDER_CLASS]')) {
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
$custom_field = [
|
||||||
|
'type' => 'searchable_select', // or 'select', 'text'
|
||||||
|
'label' => __('[Field Label]', 'woonoow'),
|
||||||
|
'required' => true,
|
||||||
|
'priority' => 85,
|
||||||
|
'class' => ['form-row-wide'],
|
||||||
|
'placeholder' => __('Search...', 'woonoow'),
|
||||||
|
'search_endpoint' => '/[provider]/search', // Relative to /wp-json/woonoow/v1
|
||||||
|
'search_param' => 'search',
|
||||||
|
'min_chars' => 3,
|
||||||
|
];
|
||||||
|
|
||||||
|
$fields['billing']['billing_[provider]_field'] = $custom_field;
|
||||||
|
$fields['shipping']['shipping_[provider]_field'] = $custom_field;
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}, 20);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 3. Bridge data to provider before shipping calculation
|
||||||
|
// ============================================================
|
||||||
|
add_action('woonoow/shipping/before_calculate', function($shipping, $items) {
|
||||||
|
if (!class_exists('[PROVIDER_CLASS]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get custom field value from shipping data
|
||||||
|
$field_value = $shipping['[provider]_field']
|
||||||
|
?? $shipping['shipping_[provider]_field']
|
||||||
|
?? $shipping['billing_[provider]_field']
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
if (empty($field_value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set in WC session for the shipping plugin to use
|
||||||
|
WC()->session->set('[PROVIDER_SESSION_KEY]', $field_value);
|
||||||
|
|
||||||
|
// Clear shipping cache to force recalculation
|
||||||
|
WC()->session->set('shipping_for_package_0', false);
|
||||||
|
|
||||||
|
}, 10, 2);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Field Types
|
||||||
|
|
||||||
|
| Type | Description | Use Case |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| `searchable_select` | Dropdown with API search | Destinations, locations, service points |
|
||||||
|
| `select` | Static dropdown | Service types, delivery options |
|
||||||
|
| `text` | Free text input | Reference numbers, notes |
|
||||||
|
| `hidden` | Hidden field | Default values, auto-set data |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WooNooW Hooks Reference
|
||||||
|
|
||||||
|
| Hook | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `woonoow/shipping/before_calculate` | Action | Called before shipping rates are calculated |
|
||||||
|
| `woocommerce_checkout_fields` | Filter | Standard WC filter for checkout fields |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: Sicepat Integration
|
||||||
|
```php
|
||||||
|
// Endpoint
|
||||||
|
register_rest_route('woonoow/v1', '/sicepat/destinations', ...);
|
||||||
|
|
||||||
|
// Session key
|
||||||
|
WC()->session->set('sicepat_destination_code', $code);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: JNE Direct API
|
||||||
|
```php
|
||||||
|
// Endpoint for origin selection
|
||||||
|
register_rest_route('woonoow/v1', '/jne/origins', ...);
|
||||||
|
register_rest_route('woonoow/v1', '/jne/destinations', ...);
|
||||||
|
|
||||||
|
// Multiple session keys
|
||||||
|
WC()->session->set('jne_origin', $origin);
|
||||||
|
WC()->session->set('jne_destination', $destination);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist for New Integration
|
||||||
|
|
||||||
|
- [ ] Identify the shipping plugin's class name
|
||||||
|
- [ ] Find what session/data it reads for API calls
|
||||||
|
- [ ] Create REST endpoint for searchable data
|
||||||
|
- [ ] Add checkout field(s) via filter
|
||||||
|
- [ ] Bridge data via `woonoow/shipping/before_calculate`
|
||||||
|
- [ ] Test shipping rate calculation
|
||||||
|
- [ ] Document in dedicated `[PROVIDER]_INTEGRATION.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [RAJAONGKIR_INTEGRATION.md](RAJAONGKIR_INTEGRATION.md) - Rajaongkir-specific implementation
|
||||||
|
- [SHIPPING_INTEGRATION.md](SHIPPING_INTEGRATION.md) - General shipping patterns
|
||||||
|
- [HOOKS_REGISTRY.md](HOOKS_REGISTRY.md) - All WooNooW hooks
|
||||||
1500
admin-spa/package-lock.json
generated
1500
admin-spa/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -49,14 +49,17 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"recharts": "^3.3.0",
|
"recharts": "^3.3.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||||
|
|||||||
@@ -13,12 +13,18 @@ import OrdersIndex from '@/routes/Orders';
|
|||||||
import OrderNew from '@/routes/Orders/New';
|
import OrderNew from '@/routes/Orders/New';
|
||||||
import OrderEdit from '@/routes/Orders/Edit';
|
import OrderEdit from '@/routes/Orders/Edit';
|
||||||
import OrderDetail from '@/routes/Orders/Detail';
|
import OrderDetail from '@/routes/Orders/Detail';
|
||||||
|
import OrderInvoice from '@/routes/Orders/Invoice';
|
||||||
|
import OrderLabel from '@/routes/Orders/Label';
|
||||||
import ProductsIndex from '@/routes/Products';
|
import ProductsIndex from '@/routes/Products';
|
||||||
import ProductNew from '@/routes/Products/New';
|
import ProductNew from '@/routes/Products/New';
|
||||||
import ProductEdit from '@/routes/Products/Edit';
|
import ProductEdit from '@/routes/Products/Edit';
|
||||||
import ProductCategories from '@/routes/Products/Categories';
|
import ProductCategories from '@/routes/Products/Categories';
|
||||||
import ProductTags from '@/routes/Products/Tags';
|
import ProductTags from '@/routes/Products/Tags';
|
||||||
import ProductAttributes from '@/routes/Products/Attributes';
|
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 CouponsIndex from '@/routes/Marketing/Coupons';
|
||||||
import CouponNew from '@/routes/Marketing/Coupons/New';
|
import CouponNew from '@/routes/Marketing/Coupons/New';
|
||||||
import CouponEdit from '@/routes/Marketing/Coupons/Edit';
|
import CouponEdit from '@/routes/Marketing/Coupons/Edit';
|
||||||
@@ -27,7 +33,7 @@ import CustomerNew from '@/routes/Customers/New';
|
|||||||
import CustomerEdit from '@/routes/Customers/Edit';
|
import CustomerEdit from '@/routes/Customers/Edit';
|
||||||
import CustomerDetail from '@/routes/Customers/Detail';
|
import CustomerDetail from '@/routes/Customers/Detail';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
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 { Toaster } from 'sonner';
|
||||||
import { useShortcuts } from "@/hooks/useShortcuts";
|
import { useShortcuts } from "@/hooks/useShortcuts";
|
||||||
import { CommandPalette } from "@/components/CommandPalette";
|
import { CommandPalette } from "@/components/CommandPalette";
|
||||||
@@ -130,8 +136,14 @@ function ActiveNavLink({ to, startsWith, end, className, children, childPaths }:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sidebar() {
|
interface 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";
|
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 active = "bg-secondary";
|
||||||
const { main } = useActiveSection();
|
const { main } = useActiveSection();
|
||||||
|
|
||||||
@@ -145,14 +157,27 @@ function Sidebar() {
|
|||||||
'mail': Mail,
|
'mail': Mail,
|
||||||
'palette': Palette,
|
'palette': Palette,
|
||||||
'settings': SettingsIcon,
|
'settings': SettingsIcon,
|
||||||
|
'help-circle': HelpCircle,
|
||||||
|
'repeat': Repeat,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get navigation tree from backend
|
// Get navigation tree from backend
|
||||||
const navTree = (window as any).WNW_NAV_TREE || [];
|
const navTree = (window as any).WNW_NAV_TREE || [];
|
||||||
|
|
||||||
return (
|
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">
|
<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'}`}>
|
||||||
<nav className="flex flex-col gap-1">
|
{/* 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) => {
|
{navTree.map((item: any) => {
|
||||||
const IconComponent = iconMap[item.icon] || Package;
|
const IconComponent = iconMap[item.icon] || Package;
|
||||||
const isActive = main.key === item.key;
|
const isActive = main.key === item.key;
|
||||||
@@ -160,10 +185,11 @@ function Sidebar() {
|
|||||||
<Link
|
<Link
|
||||||
key={item.key}
|
key={item.key}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={`${link} ${isActive ? active : ''}`}
|
className={`${collapsed ? linkCollapsed : link} ${isActive ? active : ''}`}
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
>
|
>
|
||||||
<IconComponent className="w-4 h-4" />
|
<IconComponent className="w-4 h-4 flex-shrink-0" />
|
||||||
<span>{item.label}</span>
|
{!collapsed && <span>{item.label}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -188,13 +214,14 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
|
|||||||
'mail': Mail,
|
'mail': Mail,
|
||||||
'palette': Palette,
|
'palette': Palette,
|
||||||
'settings': SettingsIcon,
|
'settings': SettingsIcon,
|
||||||
|
'repeat': Repeat,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get navigation tree from backend
|
// Get navigation tree from backend
|
||||||
const navTree = (window as any).WNW_NAV_TREE || [];
|
const navTree = (window as any).WNW_NAV_TREE || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`border-b border-border sticky ${topClass} z-30 bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60`}>
|
<div data-mainmenu className={`border-b border-border sticky ${topClass} z-30 bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60`}>
|
||||||
<div className="px-4 h-12 flex flex-nowrap overflow-auto items-center gap-2">
|
<div className="px-4 h-12 flex flex-nowrap overflow-auto items-center gap-2">
|
||||||
{navTree.map((item: any) => {
|
{navTree.map((item: any) => {
|
||||||
const IconComponent = iconMap[item.icon] || Package;
|
const IconComponent = iconMap[item.icon] || Package;
|
||||||
@@ -243,6 +270,7 @@ import EmailConfiguration from '@/routes/Settings/Notifications/EmailConfigurati
|
|||||||
import PushConfiguration from '@/routes/Settings/Notifications/PushConfiguration';
|
import PushConfiguration from '@/routes/Settings/Notifications/PushConfiguration';
|
||||||
import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomization';
|
import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomization';
|
||||||
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
|
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
|
||||||
|
import ActivityLog from '@/routes/Settings/Notifications/ActivityLog';
|
||||||
import SettingsDeveloper from '@/routes/Settings/Developer';
|
import SettingsDeveloper from '@/routes/Settings/Developer';
|
||||||
import SettingsModules from '@/routes/Settings/Modules';
|
import SettingsModules from '@/routes/Settings/Modules';
|
||||||
import ModuleSettings from '@/routes/Settings/ModuleSettings';
|
import ModuleSettings from '@/routes/Settings/ModuleSettings';
|
||||||
@@ -256,11 +284,13 @@ import AppearanceCart from '@/routes/Appearance/Cart';
|
|||||||
import AppearanceCheckout from '@/routes/Appearance/Checkout';
|
import AppearanceCheckout from '@/routes/Appearance/Checkout';
|
||||||
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
||||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
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 MarketingIndex from '@/routes/Marketing';
|
||||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
|
import Newsletter from '@/routes/Marketing/Newsletter';
|
||||||
import CampaignsList from '@/routes/Marketing/Campaigns';
|
|
||||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||||
import MorePage from '@/routes/More';
|
import MorePage from '@/routes/More';
|
||||||
|
import Help from '@/routes/Help';
|
||||||
|
|
||||||
// Addon Route Component - Dynamically loads addon components
|
// Addon Route Component - Dynamically loads addon components
|
||||||
function AddonRoute({ config }: { config: any }) {
|
function AddonRoute({ config }: { config: any }) {
|
||||||
@@ -450,6 +480,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
) : (
|
) : (
|
||||||
<div className="font-semibold">{siteTitle}</div>
|
<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>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-sm opacity-70 hidden sm:block">{window.WNW_API?.isDev ? 'Dev Server' : 'Production'}</div>
|
<div className="text-sm opacity-70 hidden sm:block">{window.WNW_API?.isDev ? 'Dev Server' : 'Production'}</div>
|
||||||
@@ -462,6 +503,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
>
|
>
|
||||||
<span>{__('WordPress')}</span>
|
<span>{__('WordPress')}</span>
|
||||||
</a>
|
</a>
|
||||||
|
{window.WNW_CONFIG?.customerSpaEnabled && (
|
||||||
|
<a
|
||||||
|
href={window.WNW_CONFIG?.storeUrl || '/store/'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
title="Open Store"
|
||||||
|
>
|
||||||
|
<span>{__('Store')}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
|
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
@@ -471,6 +523,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!isStandalone && window.WNW_CONFIG?.customerSpaEnabled && (
|
||||||
|
<a
|
||||||
|
href={window.WNW_CONFIG?.storeUrl || '/store/'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
title="Open Store"
|
||||||
|
>
|
||||||
|
<span>{__('Store')}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{showToggle && (
|
{showToggle && (
|
||||||
<button
|
<button
|
||||||
@@ -518,12 +581,20 @@ function AppRoutes() {
|
|||||||
<Route path="/products/categories" element={<ProductCategories />} />
|
<Route path="/products/categories" element={<ProductCategories />} />
|
||||||
<Route path="/products/tags" element={<ProductTags />} />
|
<Route path="/products/tags" element={<ProductTags />} />
|
||||||
<Route path="/products/attributes" element={<ProductAttributes />} />
|
<Route path="/products/attributes" element={<ProductAttributes />} />
|
||||||
|
<Route path="/products/licenses" element={<Licenses />} />
|
||||||
|
<Route path="/products/licenses/:id" element={<LicenseDetail />} />
|
||||||
|
|
||||||
{/* Orders */}
|
{/* Orders */}
|
||||||
<Route path="/orders" element={<OrdersIndex />} />
|
<Route path="/orders" element={<OrdersIndex />} />
|
||||||
<Route path="/orders/new" element={<OrderNew />} />
|
<Route path="/orders/new" element={<OrderNew />} />
|
||||||
<Route path="/orders/:id" element={<OrderDetail />} />
|
<Route path="/orders/:id" element={<OrderDetail />} />
|
||||||
<Route path="/orders/:id/edit" element={<OrderEdit />} />
|
<Route path="/orders/:id/edit" element={<OrderEdit />} />
|
||||||
|
<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) */}
|
{/* Coupons (under Marketing) */}
|
||||||
<Route path="/coupons" element={<CouponsIndex />} />
|
<Route path="/coupons" element={<CouponsIndex />} />
|
||||||
@@ -560,6 +631,7 @@ function AppRoutes() {
|
|||||||
<Route path="/settings/notifications/channels/push" element={<PushConfiguration />} />
|
<Route path="/settings/notifications/channels/push" element={<PushConfiguration />} />
|
||||||
<Route path="/settings/notifications/email-customization" element={<EmailCustomization />} />
|
<Route path="/settings/notifications/email-customization" element={<EmailCustomization />} />
|
||||||
<Route path="/settings/notifications/edit-template" element={<EditTemplate />} />
|
<Route path="/settings/notifications/edit-template" element={<EditTemplate />} />
|
||||||
|
<Route path="/settings/notifications/activity-log" element={<ActivityLog />} />
|
||||||
<Route path="/settings/brand" element={<SettingsIndex />} />
|
<Route path="/settings/brand" element={<SettingsIndex />} />
|
||||||
<Route path="/settings/developer" element={<SettingsDeveloper />} />
|
<Route path="/settings/developer" element={<SettingsDeveloper />} />
|
||||||
<Route path="/settings/modules" element={<SettingsModules />} />
|
<Route path="/settings/modules" element={<SettingsModules />} />
|
||||||
@@ -576,12 +648,16 @@ function AppRoutes() {
|
|||||||
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
|
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
|
||||||
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
|
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
|
||||||
<Route path="/appearance/account" element={<AppearanceAccount />} />
|
<Route path="/appearance/account" element={<AppearanceAccount />} />
|
||||||
|
<Route path="/appearance/menus" element={<AppearanceMenus />} />
|
||||||
|
<Route path="/appearance/pages" element={<AppearancePages />} />
|
||||||
|
|
||||||
{/* Marketing */}
|
{/* Marketing */}
|
||||||
<Route path="/marketing" element={<MarketingIndex />} />
|
<Route path="/marketing" element={<MarketingIndex />} />
|
||||||
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
|
<Route path="/marketing/newsletter" element={<Newsletter />} />
|
||||||
<Route path="/marketing/campaigns" element={<CampaignsList />} />
|
<Route path="/marketing/newsletter/campaigns/:id" element={<CampaignEdit />} />
|
||||||
<Route path="/marketing/campaigns/:id" element={<CampaignEdit />} />
|
|
||||||
|
{/* Help - Main menu route with no submenu */}
|
||||||
|
<Route path="/help" element={<Help />} />
|
||||||
|
|
||||||
{/* Dynamic Addon Routes */}
|
{/* Dynamic Addon Routes */}
|
||||||
{addonRoutes.map((route: any) => (
|
{addonRoutes.map((route: any) => (
|
||||||
@@ -604,6 +680,42 @@ function Shell() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
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
|
// Check if standalone mode - force fullscreen and hide toggle
|
||||||
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
|
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
|
||||||
const fullscreen = isStandalone ? true : on;
|
const fullscreen = isStandalone ? true : on;
|
||||||
@@ -626,7 +738,7 @@ function Shell() {
|
|||||||
{fullscreen ? (
|
{fullscreen ? (
|
||||||
isDesktop ? (
|
isDesktop ? (
|
||||||
<div className="flex flex-1 min-h-0">
|
<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">
|
<main className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||||
{/* Flex wrapper: desktop = col-reverse (SubmenuBar first, PageHeader second) */}
|
{/* Flex wrapper: desktop = col-reverse (SubmenuBar first, PageHeader second) */}
|
||||||
<div className="flex flex-col-reverse">
|
<div className="flex flex-col-reverse">
|
||||||
|
|||||||
270
admin-spa/src/components/DynamicCheckoutField.tsx
Normal file
270
admin-spa/src/components/DynamicCheckoutField.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
export interface CheckoutField {
|
||||||
|
key: string;
|
||||||
|
fieldset: 'billing' | 'shipping' | 'account' | 'order';
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required: boolean;
|
||||||
|
hidden: boolean;
|
||||||
|
class?: string[];
|
||||||
|
priority: number;
|
||||||
|
options?: Record<string, string> | null;
|
||||||
|
custom: boolean;
|
||||||
|
autocomplete?: string;
|
||||||
|
validate?: string[];
|
||||||
|
input_class?: string[];
|
||||||
|
custom_attributes?: Record<string, string>;
|
||||||
|
default?: string;
|
||||||
|
// For searchable_select type
|
||||||
|
search_endpoint?: string | null;
|
||||||
|
search_param?: string;
|
||||||
|
min_chars?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DynamicCheckoutFieldProps {
|
||||||
|
field: CheckoutField;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
countryOptions?: { value: string; label: string }[];
|
||||||
|
stateOptions?: { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicCheckoutField({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
countryOptions = [],
|
||||||
|
stateOptions = [],
|
||||||
|
}: DynamicCheckoutFieldProps) {
|
||||||
|
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
// Handle API search for searchable_select
|
||||||
|
const handleApiSearch = async (searchTerm: string) => {
|
||||||
|
if (!field.search_endpoint) return;
|
||||||
|
|
||||||
|
const minChars = field.min_chars || 2;
|
||||||
|
if (searchTerm.length < minChars) {
|
||||||
|
setSearchOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const param = field.search_param || 'search';
|
||||||
|
const results = await api.get<SearchOption[]>(field.search_endpoint, { [param]: searchTerm });
|
||||||
|
setSearchOptions(Array.isArray(results) ? results : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setSearchOptions([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't render hidden fields
|
||||||
|
if (field.hidden || field.type === 'hidden') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get field key without prefix (billing_, shipping_)
|
||||||
|
const fieldName = field.key.replace(/^(billing_|shipping_)/, '');
|
||||||
|
|
||||||
|
// Determine CSS classes
|
||||||
|
const isWide = ['address_1', 'address_2', 'email'].includes(fieldName) ||
|
||||||
|
field.class?.includes('form-row-wide');
|
||||||
|
const wrapperClass = isWide ? 'md:col-span-2' : '';
|
||||||
|
|
||||||
|
// Render based on type
|
||||||
|
const renderInput = () => {
|
||||||
|
switch (field.type) {
|
||||||
|
case 'country':
|
||||||
|
return (
|
||||||
|
<SearchableSelect
|
||||||
|
options={countryOptions}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={field.placeholder || 'Select country'}
|
||||||
|
disabled={countryOptions.length <= 1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'state':
|
||||||
|
return stateOptions.length > 0 ? (
|
||||||
|
<SearchableSelect
|
||||||
|
options={stateOptions}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={field.placeholder || 'Select state'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
autoComplete={field.autocomplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'select':
|
||||||
|
if (field.options && Object.keys(field.options).length > 0) {
|
||||||
|
const options = Object.entries(field.options).map(([val, label]) => ({
|
||||||
|
value: val,
|
||||||
|
label: String(label),
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<SearchableSelect
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={field.placeholder || `Select ${field.label}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'searchable_select':
|
||||||
|
return (
|
||||||
|
<SearchableSelect
|
||||||
|
options={searchOptions}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => {
|
||||||
|
onChange(v);
|
||||||
|
// Store label for display
|
||||||
|
const selected = searchOptions.find(o => o.value === v);
|
||||||
|
if (selected) {
|
||||||
|
const event = new CustomEvent('woonoow:field_label', {
|
||||||
|
detail: { key: field.key + '_label', value: selected.label }
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSearch={handleApiSearch}
|
||||||
|
isSearching={isSearching}
|
||||||
|
placeholder={field.placeholder || `Search ${field.label}...`}
|
||||||
|
emptyLabel={
|
||||||
|
isSearching
|
||||||
|
? 'Searching...'
|
||||||
|
: `Type at least ${field.min_chars || 2} characters to search`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'textarea':
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'checkbox':
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value === '1' || value === 'true'}
|
||||||
|
onChange={(e) => onChange(e.target.checked ? '1' : '0')}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>{field.label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'radio':
|
||||||
|
if (field.options) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(field.options).map(([val, label]) => (
|
||||||
|
<label key={val} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={field.key}
|
||||||
|
value={val}
|
||||||
|
checked={value === val}
|
||||||
|
onChange={() => onChange(val)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>{String(label)}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'email':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
autoComplete={field.autocomplete || 'email'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'tel':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
autoComplete={field.autocomplete || 'tel'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Default: text input
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
autoComplete={field.autocomplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't render label for checkbox (it's inline)
|
||||||
|
if (field.type === 'checkbox') {
|
||||||
|
return (
|
||||||
|
<div className={wrapperClass}>
|
||||||
|
{renderInput()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={wrapperClass}>
|
||||||
|
<Label>
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
{renderInput()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,24 +14,24 @@ interface BlockRendererProps {
|
|||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BlockRenderer({
|
export function BlockRenderer({
|
||||||
block,
|
block,
|
||||||
isEditing,
|
isEditing,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onMoveUp,
|
onMoveUp,
|
||||||
onMoveDown,
|
onMoveDown,
|
||||||
isFirst,
|
isFirst,
|
||||||
isLast
|
isLast
|
||||||
}: BlockRendererProps) {
|
}: BlockRendererProps) {
|
||||||
|
|
||||||
// Prevent navigation in builder
|
// Prevent navigation in builder
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (
|
if (
|
||||||
target.tagName === 'A' ||
|
target.tagName === 'A' ||
|
||||||
target.tagName === 'BUTTON' ||
|
target.tagName === 'BUTTON' ||
|
||||||
target.closest('a') ||
|
target.closest('a') ||
|
||||||
target.closest('button') ||
|
target.closest('button') ||
|
||||||
target.classList.contains('button') ||
|
target.classList.contains('button') ||
|
||||||
target.classList.contains('button-outline') ||
|
target.classList.contains('button-outline') ||
|
||||||
@@ -42,7 +42,7 @@ export function BlockRenderer({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBlockContent = () => {
|
const renderBlockContent = () => {
|
||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case 'card':
|
case 'card':
|
||||||
@@ -75,48 +75,48 @@ export function BlockRenderer({
|
|||||||
marginBottom: '24px'
|
marginBottom: '24px'
|
||||||
},
|
},
|
||||||
hero: {
|
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',
|
color: '#fff',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
padding: '32px 40px',
|
padding: '32px 40px',
|
||||||
marginBottom: '24px'
|
marginBottom: '24px'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert markdown to HTML for visual rendering
|
// Convert markdown to HTML for visual rendering
|
||||||
const htmlContent = parseMarkdownBasics(block.content);
|
const htmlContent = parseMarkdownBasics(block.content);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardStyles[block.cardType]}>
|
<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"
|
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' } : {}}
|
style={block.cardType === 'hero' ? { color: '#fff' } : {}}
|
||||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'button': {
|
case 'button': {
|
||||||
const buttonStyle: React.CSSProperties = block.style === 'solid'
|
const buttonStyle: React.CSSProperties = block.style === 'solid'
|
||||||
? {
|
? {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
background: '#7f54b3',
|
background: 'var(--wn-primary, #7f54b3)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
padding: '14px 28px',
|
padding: '14px 28px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
color: '#7f54b3',
|
color: 'var(--wn-secondary, #7f54b3)',
|
||||||
padding: '12px 26px',
|
padding: '12px 26px',
|
||||||
border: '2px solid #7f54b3',
|
border: '2px solid var(--wn-secondary, #7f54b3)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerStyle: React.CSSProperties = {
|
const containerStyle: React.CSSProperties = {
|
||||||
textAlign: block.align || 'center',
|
textAlign: block.align || 'center',
|
||||||
@@ -130,7 +130,7 @@ export function BlockRenderer({
|
|||||||
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
|
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
|
||||||
buttonStyle.width = '100%';
|
buttonStyle.width = '100%';
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div style={containerStyle}>
|
||||||
<a href={block.link} style={buttonStyle}>
|
<a href={block.link} style={buttonStyle}>
|
||||||
@@ -166,13 +166,13 @@ export function BlockRenderer({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'divider':
|
case 'divider':
|
||||||
return <hr className="border-t border-gray-300 my-4" />;
|
return <hr className="border-t border-gray-300 my-4" />;
|
||||||
|
|
||||||
case 'spacer':
|
case 'spacer':
|
||||||
return <div style={{ height: `${block.height}px` }} />;
|
return <div style={{ height: `${block.height}px` }} />;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -184,7 +184,7 @@ export function BlockRenderer({
|
|||||||
<div className={`transition-all ${isEditing ? 'ring-2 ring-purple-500 ring-offset-2' : ''}`}>
|
<div className={`transition-all ${isEditing ? 'ring-2 ring-purple-500 ring-offset-2' : ''}`}>
|
||||||
{renderBlockContent()}
|
{renderBlockContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover Controls */}
|
{/* 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">
|
<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 && (
|
{!isFirst && (
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
if (block.type === 'card') {
|
if (block.type === 'card') {
|
||||||
// Convert markdown to HTML for rich text editor
|
// Convert markdown to HTML for rich text editor
|
||||||
const htmlContent = parseMarkdownBasics(block.content);
|
const htmlContent = parseMarkdownBasics(block.content);
|
||||||
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
|
|
||||||
setEditingContent(htmlContent);
|
setEditingContent(htmlContent);
|
||||||
setEditingCardType(block.cardType);
|
setEditingCardType(block.cardType);
|
||||||
} else if (block.type === 'button') {
|
} 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) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -28,19 +28,45 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
|||||||
const AlertDialogContent = React.forwardRef<
|
const AlertDialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => {
|
||||||
<AlertDialogPortal>
|
// Get or create portal container inside the app for proper CSS scoping
|
||||||
<AlertDialogOverlay />
|
const getPortalContainer = () => {
|
||||||
<AlertDialogPrimitive.Content
|
const appContainer = document.getElementById('woonoow-admin-app');
|
||||||
ref={ref}
|
if (!appContainer) return document.body;
|
||||||
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",
|
let portalRoot = document.getElementById('woonoow-dialog-portal');
|
||||||
className
|
if (!portalRoot) {
|
||||||
)}
|
portalRoot = document.createElement('div');
|
||||||
{...props}
|
portalRoot.id = 'woonoow-dialog-portal';
|
||||||
/>
|
// Copy theme class from documentElement for proper CSS variable inheritance
|
||||||
</AlertDialogPortal>
|
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
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const AlertDialogHeader = ({
|
const AlertDialogHeader = ({
|
||||||
|
|||||||
@@ -40,7 +40,17 @@ const DialogContent = React.forwardRef<
|
|||||||
if (!portalRoot) {
|
if (!portalRoot) {
|
||||||
portalRoot = document.createElement('div');
|
portalRoot = document.createElement('div');
|
||||||
portalRoot.id = 'woonoow-dialog-portal';
|
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);
|
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 portalRoot;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,20 +57,46 @@ DropdownMenuSubContent.displayName =
|
|||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
>(({ className, sideOffset = 4, ...props }, ref) => {
|
||||||
<DropdownMenuPrimitive.Portal>
|
// Get or create portal container inside the app for proper CSS scoping
|
||||||
<DropdownMenuPrimitive.Content
|
const getPortalContainer = () => {
|
||||||
ref={ref}
|
const appContainer = document.getElementById('woonoow-admin-app');
|
||||||
sideOffset={sideOffset}
|
if (!appContainer) return document.body;
|
||||||
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",
|
let portalRoot = document.getElementById('woonoow-dropdown-portal');
|
||||||
"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]",
|
if (!portalRoot) {
|
||||||
className
|
portalRoot = document.createElement('div');
|
||||||
)}
|
portalRoot.id = 'woonoow-dropdown-portal';
|
||||||
{...props}
|
// Copy theme class from documentElement for proper CSS variable inheritance
|
||||||
/>
|
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||||
</DropdownMenuPrimitive.Portal>
|
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
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const PopoverContent = React.forwardRef<
|
|||||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
<PopoverPrimitive.Portal>
|
<PopoverPrimitive.Portal container={document.getElementById("woonoow-admin-app")}>
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
align={align}
|
align={align}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export function RichTextEditor({
|
|||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
}),
|
}),
|
||||||
|
// ButtonExtension MUST come before Link to ensure buttons are parsed first
|
||||||
|
ButtonExtension,
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
@@ -65,7 +67,6 @@ export function RichTextEditor({
|
|||||||
class: 'max-w-full h-auto rounded',
|
class: 'max-w-full h-auto rounded',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
ButtonExtension,
|
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface Option {
|
|||||||
/** What to render in the button/list. Can be a string or React node. */
|
/** What to render in the button/list. Can be a string or React node. */
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
/** Optional text used for filtering. Falls back to string label or value. */
|
/** Optional text used for filtering. Falls back to string label or value. */
|
||||||
searchText?: string;
|
triggerLabel?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -55,7 +55,7 @@ export function SearchableSelect({
|
|||||||
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
|
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={disabled ? false : open} onOpenChange={(o)=> !disabled && setOpen(o)}>
|
<Popover open={disabled ? false : open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -65,7 +65,7 @@ export function SearchableSelect({
|
|||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
tabIndex={disabled ? -1 : 0}
|
tabIndex={disabled ? -1 : 0}
|
||||||
>
|
>
|
||||||
{selected ? selected.label : placeholder}
|
{selected ? (selected.triggerLabel ?? selected.label) : placeholder}
|
||||||
<ChevronsUpDown className="opacity-50 h-4 w-4 shrink-0" />
|
<ChevronsUpDown className="opacity-50 h-4 w-4 shrink-0" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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" &&
|
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",
|
"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
|
className
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: 'a[data-button]',
|
tag: 'a[data-button]',
|
||||||
|
priority: 100, // Higher priority than Link extension (default 50)
|
||||||
getAttrs: (node: HTMLElement) => ({
|
getAttrs: (node: HTMLElement) => ({
|
||||||
text: node.getAttribute('data-text') || node.textContent || 'Click Here',
|
text: node.getAttribute('data-text') || node.textContent || 'Click Here',
|
||||||
href: node.getAttribute('data-href') || node.getAttribute('href') || '#',
|
href: node.getAttribute('data-href') || node.getAttribute('href') || '#',
|
||||||
@@ -47,6 +48,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'a.button',
|
tag: 'a.button',
|
||||||
|
priority: 100,
|
||||||
getAttrs: (node: HTMLElement) => ({
|
getAttrs: (node: HTMLElement) => ({
|
||||||
text: node.textContent || 'Click Here',
|
text: node.textContent || 'Click Here',
|
||||||
href: node.getAttribute('href') || '#',
|
href: node.getAttribute('href') || '#',
|
||||||
@@ -55,6 +57,7 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'a.button-outline',
|
tag: 'a.button-outline',
|
||||||
|
priority: 100,
|
||||||
getAttrs: (node: HTMLElement) => ({
|
getAttrs: (node: HTMLElement) => ({
|
||||||
text: node.textContent || 'Click Here',
|
text: node.textContent || 'Click Here',
|
||||||
href: node.getAttribute('href') || '#',
|
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 {
|
interface AppContextType {
|
||||||
isStandalone: boolean;
|
isStandalone: boolean;
|
||||||
@@ -7,15 +7,44 @@ interface AppContextType {
|
|||||||
|
|
||||||
const AppContext = createContext<AppContextType | undefined>(undefined);
|
const AppContext = createContext<AppContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function AppProvider({
|
export function AppProvider({
|
||||||
children,
|
children,
|
||||||
isStandalone,
|
isStandalone,
|
||||||
exitFullscreen
|
exitFullscreen
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
isStandalone: boolean;
|
isStandalone: boolean;
|
||||||
exitFullscreen?: () => void;
|
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 (
|
return (
|
||||||
<AppContext.Provider value={{ isStandalone, exitFullscreen }}>
|
<AppContext.Provider value={{ isStandalone, exitFullscreen }}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
--chart-5: 27 87% 67%;
|
--chart-5: 27 87% 67%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 222.2 84% 4.9%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 210 40% 98%;
|
||||||
@@ -63,10 +64,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* { @apply border-border; }
|
* {
|
||||||
body { @apply bg-background text-foreground; }
|
@apply border-border;
|
||||||
h1, h2, h3, h4, h5, h6 { @apply text-foreground; }
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
/* Override WordPress common.css focus/active styles */
|
/* Override WordPress common.css focus/active styles */
|
||||||
a:focus,
|
a:focus,
|
||||||
a:active {
|
a:active {
|
||||||
@@ -126,11 +140,14 @@
|
|||||||
|
|
||||||
/* Page defaults for print */
|
/* Page defaults for print */
|
||||||
@page {
|
@page {
|
||||||
size: auto; /* let the browser choose */
|
size: auto;
|
||||||
margin: 12mm; /* comfortable default */
|
/* let the browser choose */
|
||||||
|
margin: 12mm;
|
||||||
|
/* comfortable default */
|
||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
|
|
||||||
/* Hide WordPress admin chrome */
|
/* Hide WordPress admin chrome */
|
||||||
#adminmenuback,
|
#adminmenuback,
|
||||||
#adminmenuwrap,
|
#adminmenuwrap,
|
||||||
@@ -139,44 +156,173 @@
|
|||||||
#wpfooter,
|
#wpfooter,
|
||||||
#screen-meta,
|
#screen-meta,
|
||||||
.notice,
|
.notice,
|
||||||
.update-nag { display: none !important; }
|
.update-nag {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide WooNooW app shell - all nav, header, submenu elements */
|
||||||
|
#woonoow-admin-app header,
|
||||||
|
#woonoow-admin-app nav,
|
||||||
|
#woonoow-admin-app [data-submenubar],
|
||||||
|
#woonoow-admin-app [data-bottomnav],
|
||||||
|
.woonoow-app-header,
|
||||||
|
.woonoow-topnav,
|
||||||
|
.woonoow-bottom-nav,
|
||||||
|
.woonoow-submenu,
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reset layout to full-bleed for our app */
|
/* Reset layout to full-bleed for our app */
|
||||||
html, body, #wpwrap, #wpcontent { background: #fff !important; margin: 0 !important; padding: 0 !important; }
|
html,
|
||||||
#woonoow-admin-app, #woonoow-admin-app > div { margin: 0 !important; padding: 0 !important; max-width: 100% !important; }
|
body,
|
||||||
|
#wpwrap,
|
||||||
|
#wpcontent,
|
||||||
|
#woonoow-admin-app,
|
||||||
|
#woonoow-admin-app>div,
|
||||||
|
.woonoow-app {
|
||||||
|
background: #fff !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
max-width: none !important;
|
||||||
|
width: 100% !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide elements flagged as no-print, reveal print-only */
|
/* Ensure print content is visible and takes full page */
|
||||||
.no-print { display: none !important; }
|
.print-a4 {
|
||||||
.print-only { display: block !important; }
|
position: absolute !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
padding: 15mm !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: white !important;
|
||||||
|
-webkit-print-color-adjust: exact !important;
|
||||||
|
print-color-adjust: exact !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-a4 * {
|
||||||
|
-webkit-print-color-adjust: exact !important;
|
||||||
|
print-color-adjust: exact !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure backgrounds print */
|
||||||
|
.print-a4 .bg-gray-50 {
|
||||||
|
background-color: #f9fafb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-a4 .bg-gray-900 {
|
||||||
|
background-color: #111827 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-a4 .text-white {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide outer container styling */
|
||||||
|
.min-h-screen {
|
||||||
|
min-height: auto !important;
|
||||||
|
background: white !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-only {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Improve table row density on paper */
|
/* Improve table row density on paper */
|
||||||
.print-tight tr > * { padding-top: 6px !important; padding-bottom: 6px !important; }
|
.print-tight tr>* {
|
||||||
|
padding-top: 6px !important;
|
||||||
|
padding-bottom: 6px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* By default, label-only content stays hidden unless in print or label mode */
|
/* By default, label-only content stays hidden unless in print or label mode */
|
||||||
.print-only { display: none; }
|
.print-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Label mode toggled by router (?mode=label) */
|
/* Label mode toggled by router (?mode=label) */
|
||||||
.woonoow-label-mode .print-only { display: block; }
|
.woonoow-label-mode .print-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.woonoow-label-mode .no-print-label,
|
.woonoow-label-mode .no-print-label,
|
||||||
.woonoow-label-mode .wp-header-end,
|
.woonoow-label-mode .wp-header-end,
|
||||||
.woonoow-label-mode .wrap { display: none !important; }
|
.woonoow-label-mode .wrap {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Optional page presets (opt-in by adding the class to a wrapper before printing) */
|
/* Optional page presets (opt-in by adding the class to a wrapper before printing) */
|
||||||
.print-a4 { }
|
.print-a4 {}
|
||||||
.print-letter { }
|
|
||||||
.print-4x6 { }
|
.print-letter {}
|
||||||
|
|
||||||
|
.print-4x6 {}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.print-a4 { }
|
|
||||||
.print-letter { }
|
/* A4 Invoice layout */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-a4 {
|
||||||
|
width: 210mm !important;
|
||||||
|
min-height: 297mm !important;
|
||||||
|
padding: 20mm !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
background: white !important;
|
||||||
|
-webkit-print-color-adjust: exact !important;
|
||||||
|
print-color-adjust: exact !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-a4 * {
|
||||||
|
-webkit-print-color-adjust: exact !important;
|
||||||
|
print-color-adjust: exact !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure backgrounds print */
|
||||||
|
.print-a4 .bg-gray-50 {
|
||||||
|
background-color: #f9fafb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-a4 .bg-gray-900 {
|
||||||
|
background-color: #111827 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-a4 .text-white {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-letter {}
|
||||||
|
|
||||||
/* Thermal label (4x6in) with minimal margins */
|
/* Thermal label (4x6in) with minimal margins */
|
||||||
.print-4x6 { width: 6in; }
|
.print-4x6 {
|
||||||
.print-4x6 * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
width: 6in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-4x6 * {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- WooNooW: Popper menus & fullscreen fixes --- */
|
/* --- WooNooW: Popper menus & fullscreen fixes --- */
|
||||||
[data-radix-popper-content-wrapper] { z-index: 2147483647 !important; }
|
[data-radix-popper-content-wrapper] {
|
||||||
body.woonoow-fullscreen .woonoow-app { overflow: visible; }
|
z-index: 2147483647 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.woonoow-fullscreen .woonoow-app {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- WooCommerce Admin Notices --- */
|
/* --- WooCommerce Admin Notices --- */
|
||||||
.woocommerce-message,
|
.woocommerce-message,
|
||||||
|
|||||||
@@ -68,8 +68,23 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
}).join('\n') + '\n\n';
|
}).join('\n') + '\n\n';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Paragraphs - convert to double newlines
|
// Paragraphs - preserve text-align by using placeholders
|
||||||
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gis, '$1\n\n');
|
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
|
// Line breaks
|
||||||
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
||||||
@@ -80,6 +95,11 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
// Remove remaining HTML tags
|
// Remove remaining HTML tags
|
||||||
markdown = markdown.replace(/<[^>]+>/g, '');
|
markdown = markdown.replace(/<[^>]+>/g, '');
|
||||||
|
|
||||||
|
// Restore aligned paragraphs
|
||||||
|
Object.entries(alignedParagraphs).forEach(([placeholder, html]) => {
|
||||||
|
markdown = markdown.replace(placeholder, html);
|
||||||
|
});
|
||||||
|
|
||||||
// Clean up excessive newlines
|
// Clean up excessive newlines
|
||||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||||
|
|
||||||
|
|||||||
@@ -36,13 +36,15 @@ export default function AppearanceGeneral() {
|
|||||||
friendly: { name: 'Friendly', fonts: 'Poppins + Open Sans' },
|
friendly: { name: 'Friendly', fonts: 'Poppins + Open Sans' },
|
||||||
elegant: { name: 'Elegant', fonts: 'Cormorant + Lato' },
|
elegant: { name: 'Elegant', fonts: 'Cormorant + Lato' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const [colors, setColors] = useState({
|
const [colors, setColors] = useState({
|
||||||
primary: '#1a1a1a',
|
primary: '#1a1a1a',
|
||||||
secondary: '#6b7280',
|
secondary: '#6b7280',
|
||||||
accent: '#3b82f6',
|
accent: '#3b82f6',
|
||||||
text: '#111827',
|
text: '#111827',
|
||||||
background: '#ffffff',
|
background: '#ffffff',
|
||||||
|
gradientStart: '#9333ea', // purple-600 defaults
|
||||||
|
gradientEnd: '#3b82f6', // blue-500 defaults
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,7 +53,7 @@ export default function AppearanceGeneral() {
|
|||||||
// Load appearance settings
|
// Load appearance settings
|
||||||
const response = await api.get('/appearance/settings');
|
const response = await api.get('/appearance/settings');
|
||||||
const general = response.data?.general;
|
const general = response.data?.general;
|
||||||
|
|
||||||
if (general) {
|
if (general) {
|
||||||
if (general.spa_mode) setSpaMode(general.spa_mode);
|
if (general.spa_mode) setSpaMode(general.spa_mode);
|
||||||
if (general.spa_page) setSpaPage(general.spa_page || 0);
|
if (general.spa_page) setSpaPage(general.spa_page || 0);
|
||||||
@@ -70,10 +72,12 @@ export default function AppearanceGeneral() {
|
|||||||
accent: general.colors.accent || '#3b82f6',
|
accent: general.colors.accent || '#3b82f6',
|
||||||
text: general.colors.text || '#111827',
|
text: general.colors.text || '#111827',
|
||||||
background: general.colors.background || '#ffffff',
|
background: general.colors.background || '#ffffff',
|
||||||
|
gradientStart: general.colors.gradientStart || '#9333ea',
|
||||||
|
gradientEnd: general.colors.gradientEnd || '#3b82f6',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load available pages
|
// Load available pages
|
||||||
const pagesResponse = await api.get('/pages/list');
|
const pagesResponse = await api.get('/pages/list');
|
||||||
console.log('Pages API response:', pagesResponse);
|
console.log('Pages API response:', pagesResponse);
|
||||||
@@ -90,7 +94,7 @@ export default function AppearanceGeneral() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -108,7 +112,7 @@ export default function AppearanceGeneral() {
|
|||||||
},
|
},
|
||||||
colors,
|
colors,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success('General settings saved successfully');
|
toast.success('General settings saved successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save error:', error);
|
console.error('Save error:', error);
|
||||||
@@ -139,7 +143,7 @@ export default function AppearanceGeneral() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<RadioGroupItem value="checkout_only" id="spa-checkout" />
|
<RadioGroupItem value="checkout_only" id="spa-checkout" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -151,7 +155,7 @@ export default function AppearanceGeneral() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<RadioGroupItem value="full" id="spa-full" />
|
<RadioGroupItem value="full" id="spa-full" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -175,14 +179,14 @@ export default function AppearanceGeneral() {
|
|||||||
<Alert>
|
<Alert>
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<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.
|
The SPA Mode above determines the initial route (shop or cart). React Router handles navigation via /#/ routing.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<SettingsSection label="SPA Entry Page" htmlFor="spa-page">
|
<SettingsSection label="SPA Entry Page" htmlFor="spa-page">
|
||||||
<Select
|
<Select
|
||||||
value={spaPage.toString()}
|
value={spaPage.toString()}
|
||||||
onValueChange={(value) => setSpaPage(parseInt(value))}
|
onValueChange={(value) => setSpaPage(parseInt(value))}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="spa-page">
|
<SelectTrigger id="spa-page">
|
||||||
@@ -246,7 +250,7 @@ export default function AppearanceGeneral() {
|
|||||||
<p className="text-sm text-muted-foreground mb-3">
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
Self-hosted fonts, no external requests
|
Self-hosted fonts, no external requests
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{typographyMode === 'predefined' && (
|
{typographyMode === 'predefined' && (
|
||||||
<Select value={predefinedPair} onValueChange={setPredefinedPair}>
|
<Select value={predefinedPair} onValueChange={setPredefinedPair}>
|
||||||
<SelectTrigger className="w-full min-w-[300px] [&>span]:line-clamp-none [&>span]:whitespace-normal">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<RadioGroupItem value="custom_google" id="typo-custom" />
|
<RadioGroupItem value="custom_google" id="typo-custom" />
|
||||||
<div className="space-y-1 flex-1">
|
<div className="space-y-1 flex-1">
|
||||||
@@ -297,7 +301,7 @@ export default function AppearanceGeneral() {
|
|||||||
Using Google Fonts may not be GDPR compliant
|
Using Google Fonts may not be GDPR compliant
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{typographyMode === 'custom_google' && (
|
{typographyMode === 'custom_google' && (
|
||||||
<div className="space-y-3 mt-3">
|
<div className="space-y-3 mt-3">
|
||||||
<SettingsSection label="Heading Font" htmlFor="heading-font">
|
<SettingsSection label="Heading Font" htmlFor="heading-font">
|
||||||
@@ -321,7 +325,7 @@ export default function AppearanceGeneral() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
|
||||||
<div className="space-y-3 pt-4 border-t">
|
<div className="space-y-3 pt-4 border-t">
|
||||||
<Label>Font Scale: {fontScale[0].toFixed(1)}x</Label>
|
<Label>Font Scale: {fontScale[0].toFixed(1)}x</Label>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -345,18 +349,18 @@ export default function AppearanceGeneral() {
|
|||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{Object.entries(colors).map(([key, value]) => (
|
{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">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id={`color-${key}`}
|
id={`color-${key}`}
|
||||||
type="color"
|
type="color"
|
||||||
value={value}
|
value={value as string}
|
||||||
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
||||||
className="w-20 h-10 cursor-pointer"
|
className="w-20 h-10 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value as string}
|
||||||
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
||||||
className="flex-1 font-mono"
|
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,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
167
admin-spa/src/routes/Help/DocContent.tsx
Normal file
167
admin-spa/src/routes/Help/DocContent.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import type { DocContent as DocContentType } from './types';
|
||||||
|
|
||||||
|
interface DocContentProps {
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocContent({ slug }: DocContentProps) {
|
||||||
|
const [doc, setDoc] = useState<DocContentType | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDoc = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nonce = (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '';
|
||||||
|
const response = await fetch(`/wp-json/woonoow/v1/docs/${slug}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setDoc(data.doc);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || 'Failed to load document');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load document');
|
||||||
|
setDoc(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDoc();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-10 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<Skeleton className="h-32 w-full mt-6" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-5/6" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{error}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="prose prose-slate dark:prose-invert max-w-none">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
// Custom heading with anchor links
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 className="text-3xl font-bold mb-6 pb-4 border-b">{children}</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 className="text-2xl font-semibold mt-10 mb-4">{children}</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="text-xl font-medium mt-8 mb-3">{children}</h3>
|
||||||
|
),
|
||||||
|
// Styled tables
|
||||||
|
table: ({ children }) => (
|
||||||
|
<div className="overflow-x-auto my-6">
|
||||||
|
<table className="min-w-full border-collapse border border-border rounded-lg">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
th: ({ children }) => (
|
||||||
|
<th className="border border-border bg-muted px-4 py-2 text-left font-semibold">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
td: ({ children }) => (
|
||||||
|
<td className="border border-border px-4 py-2">{children}</td>
|
||||||
|
),
|
||||||
|
// Styled code blocks
|
||||||
|
code: ({ className, children }) => {
|
||||||
|
const isInline = !className;
|
||||||
|
if (isInline) {
|
||||||
|
return (
|
||||||
|
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code className={className}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pre: ({ children }) => (
|
||||||
|
<pre className="bg-muted p-4 rounded-lg overflow-x-auto my-4">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
// Styled blockquotes for notes
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="border-l-4 border-primary bg-primary/5 pl-4 py-2 my-4 italic">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
// Links
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
// Lists
|
||||||
|
ul: ({ children }) => (
|
||||||
|
<ul className="list-disc pl-6 my-4 space-y-2">{children}</ul>
|
||||||
|
),
|
||||||
|
ol: ({ children }) => (
|
||||||
|
<ol className="list-decimal pl-6 my-4 space-y-2">{children}</ol>
|
||||||
|
),
|
||||||
|
// Horizontal rule
|
||||||
|
hr: () => <hr className="my-8 border-border" />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{doc.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
admin-spa/src/routes/Help/index.tsx
Normal file
212
admin-spa/src/routes/Help/index.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Book, ChevronRight, FileText, Settings, Layers, Puzzle, Menu, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import DocContent from './DocContent';
|
||||||
|
import type { DocSection } from './types';
|
||||||
|
|
||||||
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
|
'book-open': <Book className="w-4 h-4" />,
|
||||||
|
'file-text': <FileText className="w-4 h-4" />,
|
||||||
|
'settings': <Settings className="w-4 h-4" />,
|
||||||
|
'layers': <Layers className="w-4 h-4" />,
|
||||||
|
'puzzle': <Puzzle className="w-4 h-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Help() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [sections, setSections] = useState<DocSection[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const currentSlug = searchParams.get('doc') || 'getting-started';
|
||||||
|
|
||||||
|
// Fetch documentation registry
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDocs = async () => {
|
||||||
|
try {
|
||||||
|
const nonce = (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '';
|
||||||
|
const response = await fetch('/wp-json/woonoow/v1/docs', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setSections(data.sections);
|
||||||
|
const expanded: Record<string, boolean> = {};
|
||||||
|
data.sections.forEach((section: DocSection) => {
|
||||||
|
expanded[section.key] = true;
|
||||||
|
});
|
||||||
|
setExpandedSections(expanded);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch docs:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDocs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSection = (key: string) => {
|
||||||
|
setExpandedSections(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: !prev[key],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectDoc = (slug: string) => {
|
||||||
|
setSearchParams({ doc: slug });
|
||||||
|
setSidebarOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActive = (slug: string) => slug === currentSlug;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="lg:hidden fixed bottom-20 right-4 z-50 bg-primary text-primary-foreground shadow-lg rounded-full w-12 h-12"
|
||||||
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
>
|
||||||
|
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Backdrop for mobile sidebar */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile sidebar - fixed overlay */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"lg:hidden fixed left-0 top-0 bottom-0 z-40 w-72 bg-background border-r overflow-y-auto",
|
||||||
|
sidebarOpen ? "block" : "hidden"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SidebarContent
|
||||||
|
loading={loading}
|
||||||
|
sections={sections}
|
||||||
|
expandedSections={expandedSections}
|
||||||
|
toggleSection={toggleSection}
|
||||||
|
selectDoc={selectDoc}
|
||||||
|
isActive={isActive}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Desktop layout - simple flexbox, no sticky */}
|
||||||
|
<div className="hidden lg:flex gap-0">
|
||||||
|
{/* Desktop sidebar - flex-shrink-0 keeps it visible */}
|
||||||
|
<aside className="w-72 flex-shrink-0 border-r bg-muted/30 min-h-[600px]">
|
||||||
|
<SidebarContent
|
||||||
|
loading={loading}
|
||||||
|
sections={sections}
|
||||||
|
expandedSections={expandedSections}
|
||||||
|
toggleSection={toggleSection}
|
||||||
|
selectDoc={selectDoc}
|
||||||
|
isActive={isActive}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Desktop content */}
|
||||||
|
<main className="flex-1 min-w-0">
|
||||||
|
<div className="max-w-4xl mx-auto py-6 px-10">
|
||||||
|
<DocContent slug={currentSlug} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile content - shown when sidebar is hidden */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<div className="max-w-4xl mx-auto py-6 px-6">
|
||||||
|
<DocContent slug={currentSlug} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracted sidebar content to avoid duplication
|
||||||
|
function SidebarContent({
|
||||||
|
loading,
|
||||||
|
sections,
|
||||||
|
expandedSections,
|
||||||
|
toggleSection,
|
||||||
|
selectDoc,
|
||||||
|
isActive,
|
||||||
|
}: {
|
||||||
|
loading: boolean;
|
||||||
|
sections: DocSection[];
|
||||||
|
expandedSections: Record<string, boolean>;
|
||||||
|
toggleSection: (key: string) => void;
|
||||||
|
selectDoc: (slug: string) => void;
|
||||||
|
isActive: (slug: string) => boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="p-4 border-b bg-muted/30">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Book className="w-5 h-5" />
|
||||||
|
Documentation
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Help & Guides</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="p-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
|
||||||
|
) : sections.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">No documentation available</div>
|
||||||
|
) : (
|
||||||
|
sections.map((section) => (
|
||||||
|
<div key={section.key} className="mb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection(section.key)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-foreground hover:bg-muted rounded-md"
|
||||||
|
>
|
||||||
|
{iconMap[section.icon] || <FileText className="w-4 h-4" />}
|
||||||
|
<span className="flex-1 text-left">{section.label}</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4 transition-transform",
|
||||||
|
expandedSections[section.key] && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections[section.key] && (
|
||||||
|
<div className="ml-4 mt-1 space-y-1">
|
||||||
|
{section.items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.slug}
|
||||||
|
onClick={() => selectDoc(item.slug)}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-1.5 text-sm rounded-md transition-colors",
|
||||||
|
isActive(item.slug)
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
admin-spa/src/routes/Help/types.ts
Normal file
31
admin-spa/src/routes/Help/types.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Documentation Types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DocItem {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocSection {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
items: DocItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocContent {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocsRegistryResponse {
|
||||||
|
success: boolean;
|
||||||
|
sections: DocSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocContentResponse {
|
||||||
|
success: boolean;
|
||||||
|
doc: DocContent;
|
||||||
|
}
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
|
||||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Download, Trash2, Mail, Search } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useModules } from '@/hooks/useModules';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
|
|
||||||
export default function NewsletterSubscribers() {
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { isEnabled } = useModules();
|
|
||||||
|
|
||||||
// Always call ALL hooks before any conditional returns
|
|
||||||
const { data: subscribersData, isLoading } = useQuery({
|
|
||||||
queryKey: ['newsletter-subscribers'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await api.get('/newsletter/subscribers');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
enabled: isEnabled('newsletter'), // Only fetch when module is enabled
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteSubscriber = useMutation({
|
|
||||||
mutationFn: async (email: string) => {
|
|
||||||
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
|
|
||||||
toast.success('Subscriber removed successfully');
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error('Failed to remove subscriber');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const exportSubscribers = () => {
|
|
||||||
if (!subscribersData?.subscribers) return;
|
|
||||||
|
|
||||||
const csv = ['Email,Subscribed Date'].concat(
|
|
||||||
subscribersData.subscribers.map((sub: any) =>
|
|
||||||
`${sub.email},${sub.subscribed_at || 'N/A'}`
|
|
||||||
)
|
|
||||||
).join('\n');
|
|
||||||
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `newsletter-subscribers-${new Date().toISOString().split('T')[0]}.csv`;
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const subscribers = subscribersData?.subscribers || [];
|
|
||||||
const filteredSubscribers = subscribers.filter((sub: any) =>
|
|
||||||
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isEnabled('newsletter')) {
|
|
||||||
return (
|
|
||||||
<SettingsLayout
|
|
||||||
title="Newsletter Subscribers"
|
|
||||||
description="Newsletter module is disabled"
|
|
||||||
>
|
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
|
|
||||||
<Mail className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
|
|
||||||
<h3 className="font-semibold text-lg mb-2">Newsletter Module Disabled</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
The newsletter module is currently disabled. Enable it in Settings > Modules to use this feature.
|
|
||||||
</p>
|
|
||||||
<Button onClick={() => navigate('/settings/modules')}>
|
|
||||||
Go to Module Settings
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsLayout
|
|
||||||
title="Newsletter Subscribers"
|
|
||||||
description="Manage your newsletter subscribers and send campaigns"
|
|
||||||
>
|
|
||||||
<SettingsCard
|
|
||||||
title="Subscribers List"
|
|
||||||
description={`Total subscribers: ${subscribersData?.count || 0}`}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Actions Bar */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
||||||
<div className="relative flex-1 max-w-sm">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
|
||||||
<Input
|
|
||||||
placeholder="Filter subscribers..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="!pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={exportSubscribers} variant="outline" size="sm">
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Export CSV
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Mail className="mr-2 h-4 w-4" />
|
|
||||||
Send Campaign
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Subscribers Table */}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
Loading subscribers...
|
|
||||||
</div>
|
|
||||||
) : filteredSubscribers.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
{searchQuery ? 'No subscribers found matching your search' : 'No subscribers yet'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="border rounded-lg">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Email</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Subscribed Date</TableHead>
|
|
||||||
<TableHead>WP User</TableHead>
|
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredSubscribers.map((subscriber: any) => (
|
|
||||||
<TableRow key={subscriber.email}>
|
|
||||||
<TableCell className="font-medium">{subscriber.email}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
||||||
{subscriber.status || 'Active'}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{subscriber.subscribed_at
|
|
||||||
? new Date(subscriber.subscribed_at).toLocaleDateString()
|
|
||||||
: 'N/A'
|
|
||||||
}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{subscriber.user_id ? (
|
|
||||||
<span className="text-xs text-blue-600">Yes (ID: {subscriber.user_id})</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">No</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => deleteSubscriber.mutate(subscriber.email)}
|
|
||||||
disabled={deleteSubscriber.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
{/* Email Template Settings */}
|
|
||||||
<SettingsCard
|
|
||||||
title="Email Templates"
|
|
||||||
description="Customize newsletter email templates using the email builder"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="p-4 border rounded-lg bg-muted/50">
|
|
||||||
<h4 className="font-medium mb-2">Newsletter Welcome Email</h4>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Welcome email sent when someone subscribes to your newsletter
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_welcome&channel=email&recipient=customer')}
|
|
||||||
>
|
|
||||||
Edit Template
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 border rounded-lg bg-muted/50">
|
|
||||||
<h4 className="font-medium mb-2">New Subscriber Notification (Admin)</h4>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Admin notification when someone subscribes to newsletter
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_subscribed_admin&channel=email&recipient=staff')}
|
|
||||||
>
|
|
||||||
Edit Template
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
289
admin-spa/src/routes/Marketing/Newsletter/Campaigns.tsx
Normal file
289
admin-spa/src/routes/Marketing/Newsletter/Campaigns.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Send,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Trash2,
|
||||||
|
Edit,
|
||||||
|
MoreHorizontal,
|
||||||
|
Copy
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface Campaign {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
subject: string;
|
||||||
|
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed';
|
||||||
|
recipient_count: number;
|
||||||
|
sent_count: number;
|
||||||
|
failed_count: number;
|
||||||
|
scheduled_at: string | null;
|
||||||
|
sent_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
draft: { label: 'Draft', icon: Edit, className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300' },
|
||||||
|
scheduled: { label: 'Scheduled', icon: Clock, className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
|
||||||
|
sending: { label: 'Sending', icon: Send, className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
|
||||||
|
sent: { label: 'Sent', icon: CheckCircle2, className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||||
|
failed: { label: 'Failed', icon: AlertCircle, className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Campaigns() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['campaigns'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get('/campaigns');
|
||||||
|
return response.data as Campaign[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
await api.del(`/campaigns/${id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||||
|
toast.success(__('Campaign deleted'));
|
||||||
|
setDeleteId(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to delete campaign'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicateMutation = useMutation({
|
||||||
|
mutationFn: async (campaign: Campaign) => {
|
||||||
|
const response = await api.post('/campaigns', {
|
||||||
|
title: `${campaign.title} (Copy)`,
|
||||||
|
subject: campaign.subject,
|
||||||
|
content: '',
|
||||||
|
status: 'draft',
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||||
|
toast.success(__('Campaign duplicated'));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to duplicate campaign'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const campaigns = data || [];
|
||||||
|
const filteredCampaigns = campaigns.filter((c) =>
|
||||||
|
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.subject?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SettingsCard
|
||||||
|
title={__('All Campaigns')}
|
||||||
|
description={`${campaigns.length} ${__('campaigns total')}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Actions Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
placeholder={__('Search campaigns...')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="!pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{__('New Campaign')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campaigns Table */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{__('Loading campaigns...')}
|
||||||
|
</div>
|
||||||
|
) : filteredCampaigns.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
{searchQuery ? __('No campaigns found matching your search') : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Send className="h-12 w-12 mx-auto opacity-50" />
|
||||||
|
<p>{__('No campaigns yet')}</p>
|
||||||
|
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{__('Create your first campaign')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{__('Title')}</TableHead>
|
||||||
|
<TableHead>{__('Status')}</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">{__('Recipients')}</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">{__('Date')}</TableHead>
|
||||||
|
<TableHead className="text-right">{__('Actions')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredCampaigns.map((campaign) => {
|
||||||
|
const status = statusConfig[campaign.status] || statusConfig.draft;
|
||||||
|
const StatusIcon = status.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={campaign.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{campaign.title}</div>
|
||||||
|
{campaign.subject && (
|
||||||
|
<div className="text-sm text-muted-foreground truncate max-w-[200px]">
|
||||||
|
{campaign.subject}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${status.className}`}>
|
||||||
|
<StatusIcon className="h-3 w-3" />
|
||||||
|
{__(status.label)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
{campaign.status === 'sent' ? (
|
||||||
|
<span>
|
||||||
|
{campaign.sent_count}/{campaign.recipient_count}
|
||||||
|
{campaign.failed_count > 0 && (
|
||||||
|
<span className="text-red-500 ml-1">
|
||||||
|
({campaign.failed_count} {__('failed')})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||||
|
{campaign.sent_at
|
||||||
|
? formatDate(campaign.sent_at)
|
||||||
|
: campaign.scheduled_at
|
||||||
|
? `${__('Scheduled')}: ${formatDate(campaign.scheduled_at)}`
|
||||||
|
: formatDate(campaign.created_at)
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => navigate(`/marketing/newsletter/campaigns/${campaign.id}`)}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
{__('Edit')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => duplicateMutation.mutate(campaign)}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
{__('Duplicate')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteId(campaign.id)}
|
||||||
|
className="text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{__('Delete')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Delete Campaign')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('Are you sure you want to delete this campaign? This action cannot be undone.')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{__('Delete')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx
Normal file
192
admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Download, Trash2, Search } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
|
||||||
|
export default function Subscribers() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: subscribersData, isLoading } = useQuery({
|
||||||
|
queryKey: ['newsletter-subscribers'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get('/newsletter/subscribers');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSubscriber = useMutation({
|
||||||
|
mutationFn: async (email: string) => {
|
||||||
|
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
|
||||||
|
toast.success(__('Subscriber removed successfully'));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to remove subscriber'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportSubscribers = () => {
|
||||||
|
if (!subscribersData?.subscribers) return;
|
||||||
|
|
||||||
|
const csv = ['Email,Subscribed Date'].concat(
|
||||||
|
subscribersData.subscribers.map((sub: any) =>
|
||||||
|
`${sub.email},${sub.subscribed_at || 'N/A'}`
|
||||||
|
)
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `newsletter-subscribers-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribers = subscribersData?.subscribers || [];
|
||||||
|
const filteredSubscribers = subscribers.filter((sub: any) =>
|
||||||
|
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Subscribers List')}
|
||||||
|
description={`${__('Total subscribers')}: ${subscribersData?.count || 0}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Actions Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
placeholder={__('Filter subscribers...')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="!pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={exportSubscribers} variant="outline" size="sm">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
{__('Export CSV')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscribers Table */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{__('Loading subscribers...')}
|
||||||
|
</div>
|
||||||
|
) : filteredSubscribers.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{__('Email')}</TableHead>
|
||||||
|
<TableHead>{__('Status')}</TableHead>
|
||||||
|
<TableHead>{__('Subscribed Date')}</TableHead>
|
||||||
|
<TableHead>{__('WP User')}</TableHead>
|
||||||
|
<TableHead className="text-right">{__('Actions')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredSubscribers.map((subscriber: any) => (
|
||||||
|
<TableRow key={subscriber.email}>
|
||||||
|
<TableCell className="font-medium">{subscriber.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
|
||||||
|
{subscriber.status || __('Active')}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{subscriber.subscribed_at
|
||||||
|
? new Date(subscriber.subscribed_at).toLocaleDateString()
|
||||||
|
: 'N/A'
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{subscriber.user_id ? (
|
||||||
|
<span className="text-xs text-blue-600">{__('Yes')} (ID: {subscriber.user_id})</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">{__('No')}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteSubscriber.mutate(subscriber.email)}
|
||||||
|
disabled={deleteSubscriber.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
{/* Email Template Settings */}
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Email Templates')}
|
||||||
|
description={__('Customize newsletter email templates using the email builder')}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border rounded-lg bg-muted/50">
|
||||||
|
<h4 className="font-medium mb-2">{__('Newsletter Welcome Email')}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{__('Welcome email sent when someone subscribes to your newsletter')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_welcome&channel=email&recipient=customer')}
|
||||||
|
>
|
||||||
|
{__('Edit Template')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border rounded-lg bg-muted/50">
|
||||||
|
<h4 className="font-medium mb-2">{__('New Subscriber Notification (Admin)')}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{__('Admin notification when someone subscribes to newsletter')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_subscribed_admin&channel=email&recipient=staff')}
|
||||||
|
>
|
||||||
|
{__('Edit Template')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
admin-spa/src/routes/Marketing/Newsletter/index.tsx
Normal file
74
admin-spa/src/routes/Marketing/Newsletter/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Mail } from 'lucide-react';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { useModules } from '@/hooks/useModules';
|
||||||
|
import Subscribers from './Subscribers';
|
||||||
|
import Campaigns from './Campaigns';
|
||||||
|
|
||||||
|
export default function Newsletter() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [activeTab, setActiveTab] = useState('subscribers');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isEnabled } = useModules();
|
||||||
|
|
||||||
|
// Check for tab query param
|
||||||
|
useEffect(() => {
|
||||||
|
const tabParam = searchParams.get('tab');
|
||||||
|
if (tabParam && ['subscribers', 'campaigns'].includes(tabParam)) {
|
||||||
|
setActiveTab(tabParam);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Update URL when tab changes
|
||||||
|
const handleTabChange = (value: string) => {
|
||||||
|
setActiveTab(value);
|
||||||
|
setSearchParams({ tab: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show disabled state if newsletter module is off
|
||||||
|
if (!isEnabled('newsletter')) {
|
||||||
|
return (
|
||||||
|
<SettingsLayout
|
||||||
|
title={__('Newsletter')}
|
||||||
|
description={__('Newsletter module is disabled')}
|
||||||
|
>
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
|
||||||
|
<Mail className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{__('Newsletter Module Disabled')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{__('The newsletter module is currently disabled. Enable it in Settings > Modules to use this feature.')}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/settings/modules')}>
|
||||||
|
{__('Go to Module Settings')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsLayout
|
||||||
|
title={__('Newsletter')}
|
||||||
|
description={__('Manage subscribers and send email campaigns')}
|
||||||
|
>
|
||||||
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||||
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
|
<TabsTrigger value="subscribers">{__('Subscribers')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="campaigns">{__('Campaigns')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="subscribers" className="space-y-4 mt-6">
|
||||||
|
<Subscribers />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="campaigns" className="space-y-4 mt-6">
|
||||||
|
<Campaigns />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||||
import { Mail, Send, Tag } from 'lucide-react';
|
import { Mail, Tag } from 'lucide-react';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
interface MarketingCard {
|
interface MarketingCard {
|
||||||
@@ -13,16 +13,10 @@ interface MarketingCard {
|
|||||||
const cards: MarketingCard[] = [
|
const cards: MarketingCard[] = [
|
||||||
{
|
{
|
||||||
title: __('Newsletter'),
|
title: __('Newsletter'),
|
||||||
description: __('Manage subscribers and email templates'),
|
description: __('Manage subscribers and send email campaigns'),
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
to: '/marketing/newsletter',
|
to: '/marketing/newsletter',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: __('Campaigns'),
|
|
||||||
description: __('Create and send email campaigns'),
|
|
||||||
icon: Send,
|
|
||||||
to: '/marketing/campaigns',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: __('Coupons'),
|
title: __('Coupons'),
|
||||||
description: __('Discounts, promotions, and coupon codes'),
|
description: __('Discounts, promotions, and coupon codes'),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, Mail, Megaphone } from 'lucide-react';
|
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, Mail, Megaphone, HelpCircle } from 'lucide-react';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
import { useApp } from '@/contexts/AppContext';
|
import { useApp } from '@/contexts/AppContext';
|
||||||
@@ -32,6 +32,12 @@ const menuItems: MenuItem[] = [
|
|||||||
label: __('Settings'),
|
label: __('Settings'),
|
||||||
description: __('Configure your store settings'),
|
description: __('Configure your store settings'),
|
||||||
to: '/settings'
|
to: '/settings'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <HelpCircle className="w-5 h-5" />,
|
||||||
|
label: __('Help & Docs'),
|
||||||
|
description: __('Documentation and guides'),
|
||||||
|
to: '/help'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -116,6 +122,15 @@ export default function MorePage() {
|
|||||||
|
|
||||||
{/* Exit Fullscreen / Logout */}
|
{/* Exit Fullscreen / Logout */}
|
||||||
<div className=" py-6 space-y-3">
|
<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 && (
|
{isStandalone && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => window.location.href = window.WNW_CONFIG?.wpAdminUrl || '/wp-admin'}
|
onClick={() => window.location.href = window.WNW_CONFIG?.wpAdminUrl || '/wp-admin'}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api, OrdersApi } from '@/lib/api';
|
import { api, OrdersApi } from '@/lib/api';
|
||||||
import { formatRelativeOrDate } from '@/lib/dates';
|
import { formatRelativeOrDate } from '@/lib/dates';
|
||||||
import { formatMoney } from '@/lib/currency';
|
import { formatMoney } from '@/lib/currency';
|
||||||
import { ArrowLeft, Printer, ExternalLink, Loader2, Ticket, FileText, Pencil, RefreshCw } from 'lucide-react';
|
import { ExternalLink, Loader2, Ticket, FileText, RefreshCw } from 'lucide-react';
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle
|
AlertDialogTitle
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
|
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
@@ -48,28 +48,7 @@ export default function OrderShow() {
|
|||||||
const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW';
|
const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW';
|
||||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||||
|
|
||||||
const [params, setParams] = useSearchParams();
|
|
||||||
const mode = params.get('mode'); // undefined | 'label' | 'invoice'
|
|
||||||
const isPrintMode = mode === 'label' || mode === 'invoice';
|
|
||||||
|
|
||||||
function triggerPrint(nextMode: 'label' | 'invoice') {
|
|
||||||
params.set('mode', nextMode);
|
|
||||||
setParams(params, { replace: true });
|
|
||||||
setTimeout(() => {
|
|
||||||
window.print();
|
|
||||||
params.delete('mode');
|
|
||||||
setParams(params, { replace: true });
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
function printLabel() {
|
|
||||||
triggerPrint('label');
|
|
||||||
}
|
|
||||||
function printInvoice() {
|
|
||||||
triggerPrint('invoice');
|
|
||||||
}
|
|
||||||
|
|
||||||
const [showRetryDialog, setShowRetryDialog] = useState(false);
|
const [showRetryDialog, setShowRetryDialog] = useState(false);
|
||||||
const qrRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: ['order', id],
|
queryKey: ['order', id],
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
@@ -90,16 +69,16 @@ export default function OrderShow() {
|
|||||||
onMutate: async (nextStatus) => {
|
onMutate: async (nextStatus) => {
|
||||||
// Cancel outgoing refetches
|
// Cancel outgoing refetches
|
||||||
await qc.cancelQueries({ queryKey: ['order', id] });
|
await qc.cancelQueries({ queryKey: ['order', id] });
|
||||||
|
|
||||||
// Snapshot previous value
|
// Snapshot previous value
|
||||||
const previous = qc.getQueryData(['order', id]);
|
const previous = qc.getQueryData(['order', id]);
|
||||||
|
|
||||||
// Optimistically update
|
// Optimistically update
|
||||||
qc.setQueryData(['order', id], (old: any) => ({
|
qc.setQueryData(['order', id], (old: any) => ({
|
||||||
...old,
|
...old,
|
||||||
status: nextStatus,
|
status: nextStatus,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { previous };
|
return { previous };
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -154,7 +133,7 @@ export default function OrderShow() {
|
|||||||
|
|
||||||
// Set contextual header with Back button and Edit action
|
// Set contextual header with Back button and Edit action
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!order || isPrintMode) {
|
if (!order) {
|
||||||
clearPageHeader();
|
clearPageHeader();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -178,39 +157,21 @@ export default function OrderShow() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return () => clearPageHeader();
|
return () => clearPageHeader();
|
||||||
}, [order, isPrintMode, id, setPageHeader, clearPageHeader, nav]);
|
}, [order, id, setPageHeader, clearPageHeader, nav]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPrintMode || !qrRef.current || !order) return;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const mod = await import( 'qrcode' );
|
|
||||||
const QR = (mod as any).default || (mod as any);
|
|
||||||
const text = `ORDER:${order.number || id}`;
|
|
||||||
await QR.toCanvas(qrRef.current, text, { width: 128, margin: 1 });
|
|
||||||
} catch (_) {
|
|
||||||
// optional dependency not installed; silently ignore
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [mode, order, id, isPrintMode]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-4 ${mode === 'label' ? 'woonoow-label-mode' : ''}`}>
|
<div className="space-y-4">
|
||||||
{/* Desktop extra actions - hidden on mobile, shown on desktop */}
|
{/* Desktop extra actions - hidden on mobile, shown on desktop */}
|
||||||
<div className="hidden md:flex flex-wrap items-center gap-2">
|
<div className="hidden md:flex flex-wrap items-center gap-2">
|
||||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||||
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printInvoice} title={__('Print order')}>
|
<Link to={`/orders/${id}/invoice`} className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 hover:bg-gray-50">
|
||||||
<Printer className="w-4 h-4" /> {__('Print')}
|
|
||||||
</button>
|
|
||||||
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printInvoice} title={__('Print invoice')}>
|
|
||||||
<FileText className="w-4 h-4" /> {__('Invoice')}
|
<FileText className="w-4 h-4" /> {__('Invoice')}
|
||||||
</button>
|
|
||||||
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printLabel} title={__('Print shipping label')}>
|
|
||||||
<Ticket className="w-4 h-4" /> {__('Label')}
|
|
||||||
</button>
|
|
||||||
<Link className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" to={`/orders`} title={__('Back to orders list')}>
|
|
||||||
<ExternalLink className="w-4 h-4" /> {__('Orders')}
|
|
||||||
</Link>
|
</Link>
|
||||||
|
{!isVirtualOnly && (
|
||||||
|
<Link to={`/orders/${id}/label`} className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 hover:bg-gray-50">
|
||||||
|
<Ticket className="w-4 h-4" /> {__('Label')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -232,8 +193,8 @@ export default function OrderShow() {
|
|||||||
<div className="px-4 py-3 border-b flex items-center justify-between">
|
<div className="px-4 py-3 border-b flex items-center justify-between">
|
||||||
<div className="font-medium">{__('Summary')}</div>
|
<div className="font-medium">{__('Summary')}</div>
|
||||||
<div className="w-[180px] flex items-center gap-2">
|
<div className="w-[180px] flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={order.status || ''}
|
value={order.status || ''}
|
||||||
onValueChange={(v) => handleStatusChange(v)}
|
onValueChange={(v) => handleStatusChange(v)}
|
||||||
disabled={statusMutation.isPending}
|
disabled={statusMutation.isPending}
|
||||||
>
|
>
|
||||||
@@ -333,9 +294,9 @@ export default function OrderShow() {
|
|||||||
<div className="opacity-60">{meta.label}</div>
|
<div className="opacity-60">{meta.label}</div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{meta.key.includes('url') || meta.key.includes('redirect') ? (
|
{meta.key.includes('url') || meta.key.includes('redirect') ? (
|
||||||
<a
|
<a
|
||||||
href={meta.value}
|
href={meta.value}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline flex items-center gap-1"
|
className="text-blue-600 hover:underline flex items-center gap-1"
|
||||||
>
|
>
|
||||||
@@ -473,84 +434,6 @@ export default function OrderShow() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Print-only layouts */}
|
|
||||||
{order && (
|
|
||||||
<div className="print-only">
|
|
||||||
{mode === 'invoice' && (
|
|
||||||
<div className="max-w-[800px] mx-auto p-6 text-sm">
|
|
||||||
<div className="flex items-start justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<div className="text-xl font-semibold">Invoice</div>
|
|
||||||
<div className="opacity-60">Order #{order.number} · {new Date((order.date_ts||0)*1000).toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="font-medium">{siteTitle}</div>
|
|
||||||
<div className="opacity-60 text-xs">{window.location.origin}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs opacity-60 mb-1">{__('Bill To')}</div>
|
|
||||||
<div className="text-sm" dangerouslySetInnerHTML={{ __html: order.billing?.address || order.billing?.name || '' }} />
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<canvas ref={qrRef} className="inline-block w-24 h-24 border" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<table className="w-full border-collapse mb-6">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="text-left border-b py-2 pr-2">Product</th>
|
|
||||||
<th className="text-right border-b py-2 px-2">Qty</th>
|
|
||||||
<th className="text-right border-b py-2 px-2">Subtotal</th>
|
|
||||||
<th className="text-right border-b py-2 pl-2">Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{(order.items || []).map((it:any) => (
|
|
||||||
<tr key={it.id}>
|
|
||||||
<td className="py-1 pr-2">{it.name}</td>
|
|
||||||
<td className="py-1 px-2 text-right">×{it.qty}</td>
|
|
||||||
<td className="py-1 px-2 text-right"><Money value={it.subtotal} currency={order.currency} symbol={order.currency_symbol} /></td>
|
|
||||||
<td className="py-1 pl-2 text-right"><Money value={it.total} currency={order.currency} symbol={order.currency_symbol} /></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<div className="min-w-[260px]">
|
|
||||||
<div className="flex justify-between"><span>Subtotal</span><span><Money value={order.totals?.subtotal} currency={order.currency} symbol={order.currency_symbol} /></span></div>
|
|
||||||
<div className="flex justify-between"><span>Discount</span><span><Money value={order.totals?.discount} currency={order.currency} symbol={order.currency_symbol} /></span></div>
|
|
||||||
<div className="flex justify-between"><span>Shipping</span><span><Money value={order.totals?.shipping} currency={order.currency} symbol={order.currency_symbol} /></span></div>
|
|
||||||
<div className="flex justify-between"><span>Tax</span><span><Money value={order.totals?.tax} currency={order.currency} symbol={order.currency_symbol} /></span></div>
|
|
||||||
<div className="flex justify-between font-semibold border-t mt-2 pt-2"><span>Total</span><span><Money value={order.totals?.total} currency={order.currency} symbol={order.currency_symbol} /></span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mode === 'label' && (
|
|
||||||
<div className="p-4 print-4x6">
|
|
||||||
<div className="border rounded p-4 h-full">
|
|
||||||
<div className="flex justify-between items-start mb-3">
|
|
||||||
<div className="text-base font-semibold">#{order.number}</div>
|
|
||||||
<canvas ref={qrRef} className="w-24 h-24 border" />
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="text-xs opacity-60 mb-1">{__('Ship To')}</div>
|
|
||||||
<div className="text-sm" dangerouslySetInnerHTML={{ __html: order.shipping?.address || order.billing?.address || '' }} />
|
|
||||||
</div>
|
|
||||||
<div className="text-xs opacity-60 mb-1">{__('Items')}</div>
|
|
||||||
<ul className="text-sm list-disc pl-4">
|
|
||||||
{(order.items||[]).map((it:any)=> (
|
|
||||||
<li key={it.id}>{it.name} ×{it.qty}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
211
admin-spa/src/routes/Orders/Invoice.tsx
Normal file
211
admin-spa/src/routes/Orders/Invoice.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { formatMoney } from '@/lib/currency';
|
||||||
|
import { ArrowLeft, Printer } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { InlineLoadingState } from '@/components/LoadingState';
|
||||||
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
|
function Money({ value, currency, symbol }: { value?: number; currency?: string; symbol?: string }) {
|
||||||
|
return <>{formatMoney(value, { currency, symbol })}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Invoice() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW';
|
||||||
|
const qrRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ['order', id],
|
||||||
|
enabled: !!id,
|
||||||
|
queryFn: () => api.get(`/orders/${id}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = q.data;
|
||||||
|
|
||||||
|
// Check if all items are virtual
|
||||||
|
const isVirtualOnly = React.useMemo(() => {
|
||||||
|
if (!order?.items || order.items.length === 0) return false;
|
||||||
|
return order.items.every((item: any) => item.virtual || item.downloadable);
|
||||||
|
}, [order?.items]);
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
useEffect(() => {
|
||||||
|
if (!qrRef.current || !order) return;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const mod = await import('qrcode');
|
||||||
|
const QR = (mod as any).default || (mod as any);
|
||||||
|
const text = `ORDER:${order.number || id}`;
|
||||||
|
await QR.toCanvas(qrRef.current, text, { width: 96, margin: 1 });
|
||||||
|
} catch (_) {
|
||||||
|
// QR library not available
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [order, id]);
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (q.isLoading) {
|
||||||
|
return <InlineLoadingState message={__('Loading invoice...')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorCard
|
||||||
|
title={__('Failed to load order')}
|
||||||
|
message={getPageLoadErrorMessage(q.error)}
|
||||||
|
onRetry={() => q.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
{/* Actions bar - hidden on print */}
|
||||||
|
<div className="no-print bg-white border-b sticky top-0 z-10">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||||
|
<Link to={`/orders/${id}`}>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
{__('Back to Order')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button onClick={handlePrint} size="sm">
|
||||||
|
<Printer className="w-4 h-4 mr-2" />
|
||||||
|
{__('Print Invoice')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invoice content */}
|
||||||
|
<div className="py-8 print:py-0 print:bg-white">
|
||||||
|
<div className="print-a4 bg-white shadow-lg print:shadow-none mx-auto max-w-3xl p-8 print:max-w-none print:p-0">
|
||||||
|
{/* Invoice Header */}
|
||||||
|
<div className="flex items-start justify-between mb-8 pb-6 border-b-2 border-gray-200">
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-bold text-gray-900">{__('INVOICE')}</div>
|
||||||
|
<div className="text-sm text-gray-600 mt-1">#{order.number}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xl font-semibold text-gray-900">{siteTitle}</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">{window.location.origin}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invoice Meta */}
|
||||||
|
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">{__('Invoice Date')}</div>
|
||||||
|
<div className="text-sm text-gray-900">{new Date((order.date_ts || 0) * 1000).toLocaleDateString()}</div>
|
||||||
|
{order.payment_method && (
|
||||||
|
<>
|
||||||
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2 mt-4">{__('Payment Method')}</div>
|
||||||
|
<div className="text-sm text-gray-900">{order.payment_method}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<canvas ref={qrRef} className="w-24 h-24" style={{ border: '1px solid #e5e7eb' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Billing & Shipping */}
|
||||||
|
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">{__('Bill To')}</div>
|
||||||
|
<div className="text-sm text-gray-900 font-medium">{order.billing?.name || '—'}</div>
|
||||||
|
{order.billing?.email && <div className="text-sm text-gray-600">{order.billing.email}</div>}
|
||||||
|
{order.billing?.phone && <div className="text-sm text-gray-600">{order.billing.phone}</div>}
|
||||||
|
<div className="text-sm text-gray-600 mt-2" dangerouslySetInnerHTML={{ __html: order.billing?.address || '' }} />
|
||||||
|
</div>
|
||||||
|
{!isVirtualOnly && order.shipping?.name && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">{__('Ship To')}</div>
|
||||||
|
<div className="text-sm text-gray-900 font-medium">{order.shipping?.name || '—'}</div>
|
||||||
|
<div className="text-sm text-gray-600 mt-2" dangerouslySetInnerHTML={{ __html: order.shipping?.address || '' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items Table */}
|
||||||
|
<table className="w-full mb-8" style={{ borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-900 text-white">
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-semibold">{__('Product')}</th>
|
||||||
|
<th className="text-center py-3 px-4 text-sm font-semibold w-20">{__('Qty')}</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-semibold w-32">{__('Price')}</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-semibold w-32">{__('Total')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(order.items || []).map((it: any, idx: number) => (
|
||||||
|
<tr key={it.id} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||||
|
<td className="py-3 px-4 text-sm">
|
||||||
|
<div className="font-medium text-gray-900">{it.name}</div>
|
||||||
|
{it.sku && <div className="text-xs text-gray-500">SKU: {it.sku}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-center text-gray-600">{it.qty}</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-right text-gray-600">
|
||||||
|
<Money value={it.subtotal / it.qty} currency={order.currency} symbol={order.currency_symbol} />
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-right font-medium text-gray-900">
|
||||||
|
<Money value={it.total} currency={order.currency} symbol={order.currency_symbol} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="flex justify-end mb-8">
|
||||||
|
<div className="w-72">
|
||||||
|
<div className="flex justify-between py-2 text-sm">
|
||||||
|
<span className="text-gray-600">{__('Subtotal')}</span>
|
||||||
|
<span className="text-gray-900"><Money value={order.totals?.subtotal} currency={order.currency} symbol={order.currency_symbol} /></span>
|
||||||
|
</div>
|
||||||
|
{(order.totals?.discount || 0) > 0 && (
|
||||||
|
<div className="flex justify-between py-2 text-sm">
|
||||||
|
<span className="text-gray-600">{__('Discount')}</span>
|
||||||
|
<span className="text-green-600">-<Money value={order.totals?.discount} currency={order.currency} symbol={order.currency_symbol} /></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(order.totals?.shipping || 0) > 0 && (
|
||||||
|
<div className="flex justify-between py-2 text-sm">
|
||||||
|
<span className="text-gray-600">{__('Shipping')}</span>
|
||||||
|
<span className="text-gray-900"><Money value={order.totals?.shipping} currency={order.currency} symbol={order.currency_symbol} /></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(order.totals?.tax || 0) > 0 && (
|
||||||
|
<div className="flex justify-between py-2 text-sm">
|
||||||
|
<span className="text-gray-600">{__('Tax')}</span>
|
||||||
|
<span className="text-gray-900"><Money value={order.totals?.tax} currency={order.currency} symbol={order.currency_symbol} /></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between py-3 mt-2 border-t-2 border-gray-900">
|
||||||
|
<span className="text-lg font-bold text-gray-900">{__('Total')}</span>
|
||||||
|
<span className="text-lg font-bold text-gray-900">
|
||||||
|
<Money value={order.totals?.total} currency={order.currency} symbol={order.currency_symbol} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-auto pt-8 border-t border-gray-200 text-center text-xs text-gray-500">
|
||||||
|
<p>{__('Thank you for your business!')}</p>
|
||||||
|
<p className="mt-1">{siteTitle} • {window.location.origin}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
}
|
||||||
136
admin-spa/src/routes/Orders/Label.tsx
Normal file
136
admin-spa/src/routes/Orders/Label.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { ArrowLeft, Printer } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { InlineLoadingState } from '@/components/LoadingState';
|
||||||
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export default function Label() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const qrRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ['order', id],
|
||||||
|
enabled: !!id,
|
||||||
|
queryFn: () => api.get(`/orders/${id}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = q.data;
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
useEffect(() => {
|
||||||
|
if (!qrRef.current || !order) return;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const mod = await import('qrcode');
|
||||||
|
const QR = (mod as any).default || (mod as any);
|
||||||
|
const text = `ORDER:${order.number || id}`;
|
||||||
|
await QR.toCanvas(qrRef.current, text, { width: 128, margin: 1 });
|
||||||
|
} catch (_) {
|
||||||
|
// QR library not available
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [order, id]);
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (q.isLoading) {
|
||||||
|
return <InlineLoadingState message={__('Loading label...')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorCard
|
||||||
|
title={__('Failed to load order')}
|
||||||
|
message={getPageLoadErrorMessage(q.error)}
|
||||||
|
onRetry={() => q.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
{/* Actions bar - hidden on print */}
|
||||||
|
<div className="no-print bg-white border-b sticky top-0 z-10">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||||
|
<Link to={`/orders/${id}`}>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
{__('Back to Order')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button onClick={handlePrint} size="sm">
|
||||||
|
<Printer className="w-4 h-4 mr-2" />
|
||||||
|
{__('Print Label')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label content - 4x6 inch thermal label size */}
|
||||||
|
<div className="py-8 print:py-0 flex justify-center">
|
||||||
|
<div
|
||||||
|
className="print-4x6 bg-white shadow-lg print:shadow-none"
|
||||||
|
style={{
|
||||||
|
width: '4in',
|
||||||
|
height: '6in',
|
||||||
|
padding: '0.5in',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Order Number & QR */}
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">#{order.number}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
{new Date((order.date_ts || 0) * 1000).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas ref={qrRef} className="w-24 h-24" style={{ border: '1px solid #e5e7eb' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ship To */}
|
||||||
|
<div className="mb-4 p-3 border-2 border-gray-900 rounded">
|
||||||
|
<div className="text-xs font-bold text-gray-500 uppercase tracking-wide mb-2">{__('SHIP TO')}</div>
|
||||||
|
<div className="text-lg font-bold text-gray-900">{order.shipping?.name || order.billing?.name || '—'}</div>
|
||||||
|
{(order.shipping?.phone || order.billing?.phone) && (
|
||||||
|
<div className="text-sm text-gray-700 mt-1">{order.shipping?.phone || order.billing?.phone}</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="text-sm text-gray-700 mt-2 leading-relaxed"
|
||||||
|
dangerouslySetInnerHTML={{ __html: order.shipping?.address || order.billing?.address || '' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-xs font-bold text-gray-500 uppercase tracking-wide mb-2">{__('ITEMS')}</div>
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{(order.items || []).filter((it: any) => !it.virtual && !it.downloadable).map((it: any) => (
|
||||||
|
<li key={it.id} className="flex justify-between">
|
||||||
|
<span className="truncate">{it.name}</span>
|
||||||
|
<span className="font-medium ml-2">×{it.qty}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shipping Method */}
|
||||||
|
{order.shipping_method && (
|
||||||
|
<div className="pt-3 border-t border-gray-200">
|
||||||
|
<div className="text-xs font-bold text-gray-500 uppercase tracking-wide mb-1">{__('SHIPPING METHOD')}</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">{order.shipping_method}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,14 +39,15 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||||
|
import { DynamicCheckoutField, type CheckoutField } from '@/components/DynamicCheckoutField';
|
||||||
|
|
||||||
// --- Types ------------------------------------------------------------
|
// --- Types ------------------------------------------------------------
|
||||||
export type CountryOption = { code: string; name: string };
|
export type CountryOption = { code: string; name: string };
|
||||||
export type StatesMap = Record<string, Record<string, string>>; // { US: { CA: 'California' } }
|
export type StatesMap = Record<string, Record<string, string>>; // { US: { CA: 'California' } }
|
||||||
export type PaymentChannel = { id: string; title: string; meta?: any };
|
export type PaymentChannel = { id: string; title: string; meta?: any };
|
||||||
export type PaymentMethod = {
|
export type PaymentMethod = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
channels?: PaymentChannel[]; // If present, show channels instead of gateway
|
channels?: PaymentChannel[]; // If present, show channels instead of gateway
|
||||||
};
|
};
|
||||||
@@ -79,6 +80,14 @@ export type ExistingOrderDTO = {
|
|||||||
customer_note?: string;
|
customer_note?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
currency_symbol?: string;
|
currency_symbol?: string;
|
||||||
|
totals?: {
|
||||||
|
total_items?: number;
|
||||||
|
total_shipping?: number;
|
||||||
|
total_tax?: number;
|
||||||
|
total_discount?: number;
|
||||||
|
total?: number;
|
||||||
|
shipping?: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OrderPayload = {
|
export type OrderPayload = {
|
||||||
@@ -91,6 +100,7 @@ export type OrderPayload = {
|
|||||||
customer_note?: string;
|
customer_note?: string;
|
||||||
register_as_member?: boolean;
|
register_as_member?: boolean;
|
||||||
coupons?: string[];
|
coupons?: string[];
|
||||||
|
custom_fields?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -113,7 +123,7 @@ type Props = {
|
|||||||
hideSubmitButton?: boolean;
|
hideSubmitButton?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LIST = ['pending','processing','on-hold','completed','cancelled','refunded','failed'];
|
const STATUS_LIST = ['pending', 'processing', 'on-hold', 'completed', 'cancelled', 'refunded', 'failed'];
|
||||||
|
|
||||||
// --- Component --------------------------------------------------------
|
// --- Component --------------------------------------------------------
|
||||||
export default function OrderForm({
|
export default function OrderForm({
|
||||||
@@ -167,11 +177,11 @@ export default function OrderForm({
|
|||||||
const only = countries[0]?.code || '';
|
const only = countries[0]?.code || '';
|
||||||
if (shipDiff) {
|
if (shipDiff) {
|
||||||
if (only && shippingData.country !== only) {
|
if (only && shippingData.country !== only) {
|
||||||
setShippingData({...shippingData, country: only});
|
setShippingData({ ...shippingData, country: only });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// keep shipping synced to billing when not different
|
// keep shipping synced to billing when not different
|
||||||
setShippingData({...shippingData, country: bCountry});
|
setShippingData({ ...shippingData, country: bCountry });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [oneCountryOnly, countries, shipDiff, bCountry, shippingData.country]);
|
}, [oneCountryOnly, countries, shipDiff, bCountry, shippingData.country]);
|
||||||
@@ -189,6 +199,9 @@ export default function OrderForm({
|
|||||||
const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]);
|
const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]);
|
||||||
const [couponValidating, setCouponValidating] = React.useState(false);
|
const [couponValidating, setCouponValidating] = React.useState(false);
|
||||||
|
|
||||||
|
// Custom field values (for plugin fields like destination_id)
|
||||||
|
const [customFieldData, setCustomFieldData] = React.useState<Record<string, string>>({});
|
||||||
|
|
||||||
// Fetch dynamic checkout fields based on cart items
|
// Fetch dynamic checkout fields based on cart items
|
||||||
const { data: checkoutFields } = useQuery({
|
const { data: checkoutFields } = useQuery({
|
||||||
queryKey: ['checkout-fields', items.map(i => ({ product_id: i.product_id, qty: i.qty }))],
|
queryKey: ['checkout-fields', items.map(i => ({ product_id: i.product_id, qty: i.qty }))],
|
||||||
@@ -201,10 +214,46 @@ export default function OrderForm({
|
|||||||
enabled: items.length > 0,
|
enabled: items.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Apply default values from API for hidden fields (like customer checkout does)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!checkoutFields?.fields) return;
|
||||||
|
|
||||||
|
// Initialize custom field defaults
|
||||||
|
const customDefaults: Record<string, string> = {};
|
||||||
|
checkoutFields.fields.forEach((field: any) => {
|
||||||
|
if (field.default && field.custom) {
|
||||||
|
customDefaults[field.key] = field.default;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (Object.keys(customDefaults).length > 0) {
|
||||||
|
setCustomFieldData(prev => ({ ...customDefaults, ...prev }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set billing country default for hidden fields (e.g., Indonesia-only stores)
|
||||||
|
const billingCountryField = checkoutFields.fields.find((f: any) => f.key === 'billing_country');
|
||||||
|
if ((billingCountryField?.type === 'hidden' || billingCountryField?.hidden) && billingCountryField.default && !bCountry) {
|
||||||
|
setBCountry(billingCountryField.default);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set shipping country default for hidden fields
|
||||||
|
const shippingCountryField = checkoutFields.fields.find((f: any) => f.key === 'shipping_country');
|
||||||
|
if ((shippingCountryField?.type === 'hidden' || shippingCountryField?.hidden) && shippingCountryField.default && !shippingData.country) {
|
||||||
|
setShippingData(prev => ({ ...prev, country: shippingCountryField.default }));
|
||||||
|
}
|
||||||
|
}, [checkoutFields?.fields]);
|
||||||
|
|
||||||
// Get effective shipping address (use billing if not shipping to different address)
|
// Get effective shipping address (use billing if not shipping to different address)
|
||||||
const effectiveShippingAddress = React.useMemo(() => {
|
const effectiveShippingAddress = React.useMemo(() => {
|
||||||
|
// Get destination_id from custom fields (Rajaongkir)
|
||||||
|
const destinationId = shipDiff
|
||||||
|
? customFieldData['shipping_destination_id']
|
||||||
|
: customFieldData['billing_destination_id'];
|
||||||
|
|
||||||
if (shipDiff) {
|
if (shipDiff) {
|
||||||
return shippingData;
|
return {
|
||||||
|
...shippingData,
|
||||||
|
destination_id: destinationId || undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
// Use billing address
|
// Use billing address
|
||||||
return {
|
return {
|
||||||
@@ -214,37 +263,34 @@ export default function OrderForm({
|
|||||||
postcode: bPost,
|
postcode: bPost,
|
||||||
address_1: bAddr1,
|
address_1: bAddr1,
|
||||||
address_2: '',
|
address_2: '',
|
||||||
|
destination_id: destinationId || undefined,
|
||||||
};
|
};
|
||||||
}, [shipDiff, shippingData, bCountry, bState, bCity, bPost, bAddr1]);
|
}, [shipDiff, shippingData, bCountry, bState, bCity, bPost, bAddr1, customFieldData]);
|
||||||
|
|
||||||
// Check if shipping address is complete enough to calculate rates
|
// Check if shipping address is complete enough to calculate rates
|
||||||
|
// Should match customer checkout: just needs country to fetch (destination_id can provide more specific rates)
|
||||||
const isShippingAddressComplete = React.useMemo(() => {
|
const isShippingAddressComplete = React.useMemo(() => {
|
||||||
const addr = effectiveShippingAddress;
|
const addr = effectiveShippingAddress;
|
||||||
// Need at minimum: country, state (if applicable), city
|
// Need at minimum: country OR destination_id
|
||||||
if (!addr.country) return false;
|
// destination_id from Rajaongkir is sufficient to calculate shipping
|
||||||
if (!addr.city) return false;
|
if (addr.destination_id) return true;
|
||||||
// If country has states, require state
|
return !!addr.country;
|
||||||
const countryStates = states[addr.country];
|
}, [effectiveShippingAddress]);
|
||||||
if (countryStates && Object.keys(countryStates).length > 0 && !addr.state) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}, [effectiveShippingAddress, states]);
|
|
||||||
|
|
||||||
// Debounce city input to avoid hitting backend on every keypress
|
// Debounce city input to avoid hitting backend on every keypress
|
||||||
const [debouncedCity, setDebouncedCity] = React.useState(effectiveShippingAddress.city);
|
const [debouncedCity, setDebouncedCity] = React.useState(effectiveShippingAddress.city);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedCity(effectiveShippingAddress.city);
|
setDebouncedCity(effectiveShippingAddress.city);
|
||||||
}, 500); // Wait 500ms after user stops typing
|
}, 500); // Wait 500ms after user stops typing
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [effectiveShippingAddress.city]);
|
}, [effectiveShippingAddress.city]);
|
||||||
|
|
||||||
// Calculate shipping rates dynamically
|
// Calculate shipping rates dynamically
|
||||||
const { data: shippingRates, isLoading: shippingLoading } = useQuery({
|
const { data: shippingRates, isLoading: shippingLoading } = useQuery({
|
||||||
queryKey: ['shipping-rates', items.map(i => ({ product_id: i.product_id, qty: i.qty })), effectiveShippingAddress.country, effectiveShippingAddress.state, debouncedCity, effectiveShippingAddress.postcode],
|
queryKey: ['shipping-rates', items.map(i => ({ product_id: i.product_id, qty: i.qty })), effectiveShippingAddress.country, effectiveShippingAddress.state, debouncedCity, effectiveShippingAddress.postcode, effectiveShippingAddress.destination_id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return api.post('/shipping/calculate', {
|
return api.post('/shipping/calculate', {
|
||||||
items: items.map(i => ({ product_id: i.product_id, qty: i.qty })),
|
items: items.map(i => ({ product_id: i.product_id, qty: i.qty })),
|
||||||
@@ -295,10 +341,10 @@ export default function OrderForm({
|
|||||||
const products: ProductSearchItem[] = Array.isArray(raw)
|
const products: ProductSearchItem[] = Array.isArray(raw)
|
||||||
? raw
|
? raw
|
||||||
: Array.isArray(raw?.data)
|
: Array.isArray(raw?.data)
|
||||||
? raw.data
|
? raw.data
|
||||||
: Array.isArray(raw?.rows)
|
: Array.isArray(raw?.rows)
|
||||||
? raw.rows
|
? raw.rows
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const customersRaw = customersQ.data as any;
|
const customersRaw = customersQ.data as any;
|
||||||
const customers: any[] = Array.isArray(customersRaw) ? customersRaw : [];
|
const customers: any[] = Array.isArray(customersRaw) ? customersRaw : [];
|
||||||
@@ -311,7 +357,7 @@ export default function OrderForm({
|
|||||||
() => items.reduce((sum, it) => sum + (Number(it.qty) || 0) * (Number(it.price) || 0), 0),
|
() => items.reduce((sum, it) => sum + (Number(it.qty) || 0) * (Number(it.price) || 0), 0),
|
||||||
[items]
|
[items]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate shipping cost
|
// Calculate shipping cost
|
||||||
// In edit mode: use existing order shipping total (fixed unless address changes)
|
// In edit mode: use existing order shipping total (fixed unless address changes)
|
||||||
// In create mode: calculate from selected shipping method
|
// In create mode: calculate from selected shipping method
|
||||||
@@ -325,34 +371,34 @@ export default function OrderForm({
|
|||||||
const method = shippings.find(s => s.id === shippingMethod);
|
const method = shippings.find(s => s.id === shippingMethod);
|
||||||
return method ? Number(method.cost) || 0 : 0;
|
return method ? Number(method.cost) || 0 : 0;
|
||||||
}, [mode, initial?.totals?.shipping, shippingMethod, shippings]);
|
}, [mode, initial?.totals?.shipping, shippingMethod, shippings]);
|
||||||
|
|
||||||
// Calculate discount from validated coupons
|
// Calculate discount from validated coupons
|
||||||
const couponDiscount = React.useMemo(() => {
|
const couponDiscount = React.useMemo(() => {
|
||||||
return validatedCoupons.reduce((sum, c) => sum + (c.discount_amount || 0), 0);
|
return validatedCoupons.reduce((sum, c) => sum + (c.discount_amount || 0), 0);
|
||||||
}, [validatedCoupons]);
|
}, [validatedCoupons]);
|
||||||
|
|
||||||
// Calculate order total (items + shipping - coupons)
|
// Calculate order total (items + shipping - coupons)
|
||||||
const orderTotal = React.useMemo(() => {
|
const orderTotal = React.useMemo(() => {
|
||||||
return Math.max(0, itemsTotal + shippingCost - couponDiscount);
|
return Math.max(0, itemsTotal + shippingCost - couponDiscount);
|
||||||
}, [itemsTotal, shippingCost, couponDiscount]);
|
}, [itemsTotal, shippingCost, couponDiscount]);
|
||||||
|
|
||||||
// Validate coupon
|
// Validate coupon
|
||||||
const validateCoupon = async (code: string) => {
|
const validateCoupon = async (code: string) => {
|
||||||
if (!code.trim()) return;
|
if (!code.trim()) return;
|
||||||
|
|
||||||
// Check if already added
|
// Check if already added
|
||||||
if (validatedCoupons.some(c => c.code.toLowerCase() === code.toLowerCase())) {
|
if (validatedCoupons.some(c => c.code.toLowerCase() === code.toLowerCase())) {
|
||||||
toast.error(__('Coupon already added'));
|
toast.error(__('Coupon already added'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCouponValidating(true);
|
setCouponValidating(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/coupons/validate', {
|
const response = await api.post('/coupons/validate', {
|
||||||
code: code.trim(),
|
code: code.trim(),
|
||||||
subtotal: itemsTotal,
|
subtotal: itemsTotal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.valid) {
|
if (response.valid) {
|
||||||
setValidatedCoupons([...validatedCoupons, response]);
|
setValidatedCoupons([...validatedCoupons, response]);
|
||||||
setCouponInput('');
|
setCouponInput('');
|
||||||
@@ -366,7 +412,7 @@ export default function OrderForm({
|
|||||||
setCouponValidating(false);
|
setCouponValidating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeCoupon = (code: string) => {
|
const removeCoupon = (code: string) => {
|
||||||
setValidatedCoupons(validatedCoupons.filter(c => c.code !== code));
|
setValidatedCoupons(validatedCoupons.filter(c => c.code !== code));
|
||||||
};
|
};
|
||||||
@@ -408,7 +454,7 @@ export default function OrderForm({
|
|||||||
|
|
||||||
// Keep shipping country synced to billing when unchecked
|
// Keep shipping country synced to billing when unchecked
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!shipDiff) setShippingData({...shippingData, country: bCountry});
|
if (!shipDiff) setShippingData({ ...shippingData, country: bCountry });
|
||||||
}, [shipDiff, bCountry]);
|
}, [shipDiff, bCountry]);
|
||||||
|
|
||||||
// Clamp states when country changes
|
// Clamp states when country changes
|
||||||
@@ -417,16 +463,53 @@ export default function OrderForm({
|
|||||||
}, [bCountry]);
|
}, [bCountry]);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (shippingData.state && !states[shippingData.country]?.[shippingData.state]) {
|
if (shippingData.state && !states[shippingData.country]?.[shippingData.state]) {
|
||||||
setShippingData({...shippingData, state: ''});
|
setShippingData({ ...shippingData, state: '' });
|
||||||
}
|
}
|
||||||
}, [shippingData.country]);
|
}, [shippingData.country]);
|
||||||
|
|
||||||
const countryOptions = countries.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }));
|
const countryOptions = countries.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }));
|
||||||
const bStateOptions = Object.entries(states[bCountry] || {}).map(([code, name]) => ({ value: code, label: name }));
|
const bStateOptions = Object.entries(states[bCountry] || {}).map(([code, name]) => ({ value: code, label: name }));
|
||||||
|
|
||||||
|
// Helper to get billing field config from API - returns null if hidden
|
||||||
|
const getBillingField = (key: string) => {
|
||||||
|
if (!checkoutFields?.fields) return { label: '', required: false }; // fallback when API not loaded
|
||||||
|
const field = checkoutFields.fields.find((f: any) => f.key === key);
|
||||||
|
// Check both hidden flag and type === 'hidden'
|
||||||
|
if (!field || field.hidden || field.type === 'hidden') return null;
|
||||||
|
return field;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to check if billing field should have full width
|
||||||
|
const isBillingFieldWide = (key: string) => {
|
||||||
|
const field = getBillingField(key);
|
||||||
|
if (!field) return false;
|
||||||
|
const hasFormRowWide = Array.isArray(field.class) && field.class.includes('form-row-wide');
|
||||||
|
return hasFormRowWide || ['billing_address_1', 'billing_address_2'].includes(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derive custom fields from API (for plugin fields like destination_id)
|
||||||
|
const billingCustomFields = React.useMemo(() => {
|
||||||
|
if (!checkoutFields?.fields) return [];
|
||||||
|
return checkoutFields.fields
|
||||||
|
.filter((f: any) => f.fieldset === 'billing' && f.custom && !f.hidden && f.type !== 'hidden')
|
||||||
|
.sort((a: any, b: any) => (a.priority || 10) - (b.priority || 10));
|
||||||
|
}, [checkoutFields?.fields]);
|
||||||
|
|
||||||
|
const shippingCustomFields = React.useMemo(() => {
|
||||||
|
if (!checkoutFields?.fields) return [];
|
||||||
|
return checkoutFields.fields
|
||||||
|
.filter((f: any) => f.fieldset === 'shipping' && f.custom && !f.hidden && f.type !== 'hidden')
|
||||||
|
.sort((a: any, b: any) => (a.priority || 10) - (b.priority || 10));
|
||||||
|
}, [checkoutFields?.fields]);
|
||||||
|
|
||||||
|
// Helper to handle custom field changes
|
||||||
|
const handleCustomFieldChange = (key: string, value: string) => {
|
||||||
|
setCustomFieldData(prev => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// For virtual-only products, don't send address fields
|
// For virtual-only products, don't send address fields
|
||||||
const billingData: any = {
|
const billingData: any = {
|
||||||
first_name: bFirst,
|
first_name: bFirst,
|
||||||
@@ -434,7 +517,7 @@ export default function OrderForm({
|
|||||||
email: bEmail,
|
email: bEmail,
|
||||||
phone: bPhone,
|
phone: bPhone,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only add address fields for physical products
|
// Only add address fields for physical products
|
||||||
if (hasPhysicalProduct) {
|
if (hasPhysicalProduct) {
|
||||||
billingData.address_1 = bAddr1;
|
billingData.address_1 = bAddr1;
|
||||||
@@ -443,7 +526,7 @@ export default function OrderForm({
|
|||||||
billingData.postcode = bPost;
|
billingData.postcode = bPost;
|
||||||
billingData.country = bCountry;
|
billingData.country = bCountry;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: OrderPayload = {
|
const payload: OrderPayload = {
|
||||||
status,
|
status,
|
||||||
billing: billingData,
|
billing: billingData,
|
||||||
@@ -453,6 +536,7 @@ export default function OrderForm({
|
|||||||
customer_note: note || undefined,
|
customer_note: note || undefined,
|
||||||
items: itemsEditable ? items : undefined,
|
items: itemsEditable ? items : undefined,
|
||||||
coupons: showCoupons ? validatedCoupons.map(c => c.code) : undefined,
|
coupons: showCoupons ? validatedCoupons.map(c => c.code) : undefined,
|
||||||
|
custom_fields: Object.keys(customFieldData).length > 0 ? customFieldData : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -502,7 +586,7 @@ export default function OrderForm({
|
|||||||
onChange={(val: string) => {
|
onChange={(val: string) => {
|
||||||
const p = products.find((prod: ProductSearchItem) => String(prod.id) === val);
|
const p = products.find((prod: ProductSearchItem) => String(prod.id) === val);
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
|
|
||||||
// If variable product, show variation selector
|
// If variable product, show variation selector
|
||||||
if (p.type === 'variable' && p.variations && p.variations.length > 0) {
|
if (p.type === 'variable' && p.variations && p.variations.length > 0) {
|
||||||
setSelectedProduct(p);
|
setSelectedProduct(p);
|
||||||
@@ -510,7 +594,7 @@ export default function OrderForm({
|
|||||||
setShowVariationDrawer(true);
|
setShowVariationDrawer(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple product - add directly (but allow duplicates for different quantities)
|
// Simple product - add directly (but allow duplicates for different quantities)
|
||||||
setItems(prev => [
|
setItems(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
@@ -729,11 +813,11 @@ export default function OrderForm({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3 p-4">
|
<div className="space-y-3 p-4">
|
||||||
{selectedProduct.variations?.map((variation) => {
|
{selectedProduct.variations?.map((variation) => {
|
||||||
const variationLabel = Object.entries(variation.attributes)
|
// Build formatted label with styled key:value pairs
|
||||||
.map(([key, value]) => `${key}: ${value || ''}`)
|
const variationParts = Object.entries(variation.attributes)
|
||||||
.filter(([_, value]) => value) // Remove empty values
|
.filter(([_, value]) => value) // Remove empty values
|
||||||
.join(', ');
|
.map(([key, value]) => ({ key, value: value || '' }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={variation.id}
|
key={variation.id}
|
||||||
@@ -743,11 +827,11 @@ export default function OrderForm({
|
|||||||
const existingIndex = items.findIndex(
|
const existingIndex = items.findIndex(
|
||||||
item => item.product_id === selectedProduct.id && item.variation_id === variation.id
|
item => item.product_id === selectedProduct.id && item.variation_id === variation.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
// Increment quantity of existing item
|
// Increment quantity of existing item
|
||||||
setItems(prev => prev.map((item, idx) =>
|
setItems(prev => prev.map((item, idx) =>
|
||||||
idx === existingIndex
|
idx === existingIndex
|
||||||
? { ...item, qty: item.qty + 1 }
|
? { ...item, qty: item.qty + 1 }
|
||||||
: item
|
: item
|
||||||
));
|
));
|
||||||
@@ -759,7 +843,7 @@ export default function OrderForm({
|
|||||||
product_id: selectedProduct.id,
|
product_id: selectedProduct.id,
|
||||||
variation_id: variation.id,
|
variation_id: variation.id,
|
||||||
name: selectedProduct.name,
|
name: selectedProduct.name,
|
||||||
variation_name: variationLabel,
|
variation_name: variationParts.map(p => `${p.key}: ${p.value}`).join(', '),
|
||||||
price: variation.price,
|
price: variation.price,
|
||||||
regular_price: variation.regular_price,
|
regular_price: variation.regular_price,
|
||||||
sale_price: variation.sale_price,
|
sale_price: variation.sale_price,
|
||||||
@@ -777,7 +861,15 @@ export default function OrderForm({
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium">{variationLabel}</div>
|
<div className="text-sm">
|
||||||
|
{variationParts.map((part, idx) => (
|
||||||
|
<span key={part.key}>
|
||||||
|
<span className="font-semibold">{part.key}:</span>{' '}
|
||||||
|
<span>{part.value}</span>
|
||||||
|
{idx < variationParts.length - 1 && ', '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
{variation.sku && (
|
{variation.sku && (
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
SKU: {variation.sku}
|
SKU: {variation.sku}
|
||||||
@@ -827,11 +919,11 @@ export default function OrderForm({
|
|||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
<div className="p-4 space-y-3 max-h-[60vh] overflow-y-auto">
|
<div className="p-4 space-y-3 max-h-[60vh] overflow-y-auto">
|
||||||
{selectedProduct.variations?.map((variation) => {
|
{selectedProduct.variations?.map((variation) => {
|
||||||
const variationLabel = Object.entries(variation.attributes)
|
// Build formatted label with styled key:value pairs
|
||||||
.map(([key, value]) => `${key}: ${value || ''}`)
|
const variationParts = Object.entries(variation.attributes)
|
||||||
.filter(([_, value]) => value) // Remove empty values
|
.filter(([_, value]) => value) // Remove empty values
|
||||||
.join(', ');
|
.map(([key, value]) => ({ key, value: value || '' }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={variation.id}
|
key={variation.id}
|
||||||
@@ -841,11 +933,11 @@ export default function OrderForm({
|
|||||||
const existingIndex = items.findIndex(
|
const existingIndex = items.findIndex(
|
||||||
item => item.product_id === selectedProduct.id && item.variation_id === variation.id
|
item => item.product_id === selectedProduct.id && item.variation_id === variation.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
// Increment quantity of existing item
|
// Increment quantity of existing item
|
||||||
setItems(prev => prev.map((item, idx) =>
|
setItems(prev => prev.map((item, idx) =>
|
||||||
idx === existingIndex
|
idx === existingIndex
|
||||||
? { ...item, qty: item.qty + 1 }
|
? { ...item, qty: item.qty + 1 }
|
||||||
: item
|
: item
|
||||||
));
|
));
|
||||||
@@ -857,7 +949,7 @@ export default function OrderForm({
|
|||||||
product_id: selectedProduct.id,
|
product_id: selectedProduct.id,
|
||||||
variation_id: variation.id,
|
variation_id: variation.id,
|
||||||
name: selectedProduct.name,
|
name: selectedProduct.name,
|
||||||
variation_name: variationLabel,
|
variation_name: variationParts.map(p => `${p.key}: ${p.value}`).join(', '),
|
||||||
price: variation.price,
|
price: variation.price,
|
||||||
regular_price: variation.regular_price,
|
regular_price: variation.regular_price,
|
||||||
sale_price: variation.sale_price,
|
sale_price: variation.sale_price,
|
||||||
@@ -875,7 +967,15 @@ export default function OrderForm({
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium">{variationLabel}</div>
|
<div className="text-sm">
|
||||||
|
{variationParts.map((part, idx) => (
|
||||||
|
<span key={part.key}>
|
||||||
|
<span className="font-semibold">{part.key}:</span>{' '}
|
||||||
|
<span>{part.value}</span>
|
||||||
|
{idx < variationParts.length - 1 && ', '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
{variation.sku && (
|
{variation.sku && (
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
SKU: {variation.sku}
|
SKU: {variation.sku}
|
||||||
@@ -924,7 +1024,7 @@ export default function OrderForm({
|
|||||||
<span className="text-xs opacity-70">({__('locked')})</span>
|
<span className="text-xs opacity-70">({__('locked')})</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Coupon Input */}
|
{/* Coupon Input */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -949,7 +1049,7 @@ export default function OrderForm({
|
|||||||
{couponValidating ? __('Validating...') : __('Apply')}
|
{couponValidating ? __('Validating...') : __('Apply')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Applied Coupons */}
|
{/* Applied Coupons */}
|
||||||
{validatedCoupons.length > 0 && (
|
{validatedCoupons.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -982,7 +1082,7 @@ export default function OrderForm({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-[11px] opacity-70">
|
<div className="text-[11px] opacity-70">
|
||||||
{__('Enter coupon code and click Apply to validate and calculate discount')}
|
{__('Enter coupon code and click Apply to validate and calculate discount')}
|
||||||
</div>
|
</div>
|
||||||
@@ -990,141 +1090,229 @@ export default function OrderForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Billing address - only show full address for physical products */}
|
{/* Billing address - only show when items are added (so checkout fields API is loaded) */}
|
||||||
<div className="rounded border p-4 space-y-3">
|
{items.length > 0 && (
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="rounded border p-4 space-y-3">
|
||||||
<h3 className="text-sm font-medium">{__('Billing address')}</h3>
|
<div className="flex items-center justify-between mb-3">
|
||||||
{mode === 'create' && (
|
<h3 className="text-sm font-medium">{__('Billing address')}</h3>
|
||||||
<SearchableSelect
|
{mode === 'create' && (
|
||||||
options={customers.map((c: any) => ({
|
<SearchableSelect
|
||||||
value: String(c.id),
|
options={customers.map((c: any) => ({
|
||||||
label: (
|
value: String(c.id),
|
||||||
<div className="leading-tight">
|
label: (
|
||||||
<div className="font-medium">{c.name || c.email}</div>
|
<div className="leading-tight">
|
||||||
<div className="text-xs text-muted-foreground">{c.email}</div>
|
<div className="font-medium">{c.name || c.email}</div>
|
||||||
</div>
|
<div className="text-xs text-muted-foreground">{c.email}</div>
|
||||||
),
|
</div>
|
||||||
searchText: `${c.name} ${c.email}`,
|
),
|
||||||
customer: c,
|
searchText: `${c.name} ${c.email}`,
|
||||||
}))}
|
customer: c,
|
||||||
value={undefined}
|
}))}
|
||||||
onChange={async (val: string) => {
|
value={undefined}
|
||||||
const customer = customers.find((c: any) => String(c.id) === val);
|
onChange={async (val: string) => {
|
||||||
if (!customer) return;
|
const customer = customers.find((c: any) => String(c.id) === val);
|
||||||
|
if (!customer) return;
|
||||||
// Fetch full customer data
|
|
||||||
try {
|
// Fetch full customer data
|
||||||
const data = await CustomersApi.searchByEmail(customer.email);
|
try {
|
||||||
if (data.found && data.billing) {
|
const data = await CustomersApi.searchByEmail(customer.email);
|
||||||
// Always fill name, email, phone
|
if (data.found && data.billing) {
|
||||||
setBFirst(data.billing.first_name || data.first_name || '');
|
// Always fill name, email, phone
|
||||||
setBLast(data.billing.last_name || data.last_name || '');
|
setBFirst(data.billing.first_name || data.first_name || '');
|
||||||
setBEmail(data.email || '');
|
setBLast(data.billing.last_name || data.last_name || '');
|
||||||
setBPhone(data.billing.phone || '');
|
setBEmail(data.email || '');
|
||||||
|
setBPhone(data.billing.phone || '');
|
||||||
// Only fill address fields if cart has physical products
|
|
||||||
if (hasPhysicalProduct) {
|
// Only fill address fields if cart has physical products
|
||||||
setBAddr1(data.billing.address_1 || '');
|
if (hasPhysicalProduct) {
|
||||||
setBCity(data.billing.city || '');
|
setBAddr1(data.billing.address_1 || '');
|
||||||
setBPost(data.billing.postcode || '');
|
setBCity(data.billing.city || '');
|
||||||
setBCountry(data.billing.country || bCountry);
|
setBPost(data.billing.postcode || '');
|
||||||
setBState(data.billing.state || '');
|
setBCountry(data.billing.country || bCountry);
|
||||||
|
setBState(data.billing.state || '');
|
||||||
// Autofill shipping if available
|
|
||||||
if (data.shipping && data.shipping.address_1) {
|
// Autofill shipping if available
|
||||||
setShipDiff(true);
|
if (data.shipping && data.shipping.address_1) {
|
||||||
setShippingData({
|
setShipDiff(true);
|
||||||
first_name: data.shipping.first_name || '',
|
setShippingData({
|
||||||
last_name: data.shipping.last_name || '',
|
first_name: data.shipping.first_name || '',
|
||||||
address_1: data.shipping.address_1 || '',
|
last_name: data.shipping.last_name || '',
|
||||||
city: data.shipping.city || '',
|
address_1: data.shipping.address_1 || '',
|
||||||
postcode: data.shipping.postcode || '',
|
city: data.shipping.city || '',
|
||||||
country: data.shipping.country || bCountry,
|
postcode: data.shipping.postcode || '',
|
||||||
state: data.shipping.state || '',
|
country: data.shipping.country || bCountry,
|
||||||
});
|
state: data.shipping.state || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark customer as selected
|
||||||
|
setSelectedCustomerId(data.user_id);
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
// Mark customer as selected
|
console.error('Customer autofill error:', e);
|
||||||
setSelectedCustomerId(data.user_id);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error('Customer autofill error:', e);
|
setCustomerSearchQ('');
|
||||||
}
|
}}
|
||||||
|
onSearch={setCustomerSearchQ}
|
||||||
setCustomerSearchQ('');
|
placeholder={__('Search customer...')}
|
||||||
}}
|
className="w-64"
|
||||||
onSearch={setCustomerSearchQ}
|
/>
|
||||||
placeholder={__('Search customer...')}
|
)}
|
||||||
className="w-64"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label>{__('First name')}</Label>
|
|
||||||
<Input className="rounded-md border px-3 py-2" value={bFirst} onChange={e=>setBFirst(e.target.value)} />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<Label>{__('Last name')}</Label>
|
{/* Dynamic billing fields - respects API visibility, labels, required status */}
|
||||||
<Input className="rounded-md border px-3 py-2" value={bLast} onChange={e=>setBLast(e.target.value)} />
|
{getBillingField('billing_first_name') && (
|
||||||
</div>
|
<div className={isBillingFieldWide('billing_first_name') ? 'md:col-span-2' : ''}>
|
||||||
<div>
|
<Label>
|
||||||
<Label>{__('Email')}</Label>
|
{getBillingField('billing_first_name')?.label || __('First name')}
|
||||||
<Input
|
{getBillingField('billing_first_name')?.required && <span className="text-destructive ml-1">*</span>}
|
||||||
inputMode="email"
|
</Label>
|
||||||
autoComplete="email"
|
<Input
|
||||||
className="rounded-md border px-3 py-2 appearance-none"
|
className="rounded-md border px-3 py-2"
|
||||||
value={bEmail}
|
value={bFirst}
|
||||||
onChange={e=>setBEmail(e.target.value)}
|
onChange={e => setBFirst(e.target.value)}
|
||||||
/>
|
required={getBillingField('billing_first_name')?.required}
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>{__('Phone')}</Label>
|
|
||||||
<Input className="rounded-md border px-3 py-2" value={bPhone} onChange={e=>setBPhone(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
{/* Only show full address fields for physical products */}
|
|
||||||
{hasPhysicalProduct && (
|
|
||||||
<>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<Label>{__('Address')}</Label>
|
|
||||||
<Input className="rounded-md border px-3 py-2" value={bAddr1} onChange={e=>setBAddr1(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>{__('City')}</Label>
|
|
||||||
<Input className="rounded-md border px-3 py-2" value={bCity} onChange={e=>setBCity(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>{__('Postcode')}</Label>
|
|
||||||
<Input className="rounded-md border px-3 py-2" value={bPost} onChange={e=>setBPost(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>{__('Country')}</Label>
|
|
||||||
<SearchableSelect
|
|
||||||
options={countryOptions}
|
|
||||||
value={bCountry}
|
|
||||||
onChange={setBCountry}
|
|
||||||
placeholder={countries.length ? __('Select country') : __('No countries')}
|
|
||||||
disabled={oneCountryOnly}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<Label>{__('State/Province')}</Label>
|
{getBillingField('billing_last_name') && (
|
||||||
<Select value={bState} onValueChange={setBState}>
|
<div className={isBillingFieldWide('billing_last_name') ? 'md:col-span-2' : ''}>
|
||||||
<SelectTrigger className="w-full"><SelectValue placeholder={__('Select state')} /></SelectTrigger>
|
<Label>
|
||||||
<SelectContent className="max-h-64">
|
{getBillingField('billing_last_name')?.label || __('Last name')}
|
||||||
{bStateOptions.length ? bStateOptions.map(o => (
|
{getBillingField('billing_last_name')?.required && <span className="text-destructive ml-1">*</span>}
|
||||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
</Label>
|
||||||
)) : (
|
<Input
|
||||||
<SelectItem value="__none__" disabled>{__('N/A')}</SelectItem>
|
className="rounded-md border px-3 py-2"
|
||||||
)}
|
value={bLast}
|
||||||
</SelectContent>
|
onChange={e => setBLast(e.target.value)}
|
||||||
</Select>
|
required={getBillingField('billing_last_name')?.required}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
{getBillingField('billing_email') && (
|
||||||
|
<div className={isBillingFieldWide('billing_email') ? 'md:col-span-2' : ''}>
|
||||||
|
<Label>
|
||||||
|
{getBillingField('billing_email')?.label || __('Email')}
|
||||||
|
{getBillingField('billing_email')?.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
inputMode="email"
|
||||||
|
autoComplete="email"
|
||||||
|
className="rounded-md border px-3 py-2 appearance-none"
|
||||||
|
value={bEmail}
|
||||||
|
onChange={e => setBEmail(e.target.value)}
|
||||||
|
required={getBillingField('billing_email')?.required}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{getBillingField('billing_phone') && (
|
||||||
|
<div className={isBillingFieldWide('billing_phone') ? 'md:col-span-2' : ''}>
|
||||||
|
<Label>
|
||||||
|
{getBillingField('billing_phone')?.label || __('Phone')}
|
||||||
|
{getBillingField('billing_phone')?.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="rounded-md border px-3 py-2"
|
||||||
|
value={bPhone}
|
||||||
|
onChange={e => setBPhone(e.target.value)}
|
||||||
|
required={getBillingField('billing_phone')?.required}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Address fields - only shown for physical products AND when not hidden by API */}
|
||||||
|
{hasPhysicalProduct && (
|
||||||
|
<>
|
||||||
|
{getBillingField('billing_address_1') && (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label>
|
||||||
|
{getBillingField('billing_address_1')?.label || __('Address')}
|
||||||
|
{getBillingField('billing_address_1')?.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="rounded-md border px-3 py-2"
|
||||||
|
value={bAddr1}
|
||||||
|
onChange={e => setBAddr1(e.target.value)}
|
||||||
|
required={getBillingField('billing_address_1')?.required}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{getBillingField('billing_city') && (
|
||||||
|
<div className={isBillingFieldWide('billing_city') ? 'md:col-span-2' : ''}>
|
||||||
|
<Label>
|
||||||
|
{getBillingField('billing_city')?.label || __('City')}
|
||||||
|
{getBillingField('billing_city')?.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="rounded-md border px-3 py-2"
|
||||||
|
value={bCity}
|
||||||
|
onChange={e => setBCity(e.target.value)}
|
||||||
|
required={getBillingField('billing_city')?.required}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{getBillingField('billing_postcode') && (
|
||||||
|
<div className={isBillingFieldWide('billing_postcode') ? 'md:col-span-2' : ''}>
|
||||||
|
<Label>
|
||||||
|
{getBillingField('billing_postcode')?.label || __('Postcode')}
|
||||||
|
{getBillingField('billing_postcode')?.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="rounded-md border px-3 py-2"
|
||||||
|
value={bPost}
|
||||||
|
onChange={e => setBPost(e.target.value)}
|
||||||
|
required={getBillingField('billing_postcode')?.required}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{getBillingField('billing_country') && (
|
||||||
|
<div className={isBillingFieldWide('billing_country') ? 'md:col-span-2' : ''}>
|
||||||
|
<Label>
|
||||||
|
{getBillingField('billing_country')?.label || __('Country')}
|
||||||
|
{getBillingField('billing_country')?.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<SearchableSelect
|
||||||
|
options={countryOptions}
|
||||||
|
value={bCountry}
|
||||||
|
onChange={setBCountry}
|
||||||
|
placeholder={countries.length ? __('Select country') : __('No countries')}
|
||||||
|
disabled={oneCountryOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{getBillingField('billing_state') && (
|
||||||
|
<div className={isBillingFieldWide('billing_state') ? 'md:col-span-2' : ''}>
|
||||||
|
<Label>
|
||||||
|
{getBillingField('billing_state')?.label || __('State/Province')}
|
||||||
|
{getBillingField('billing_state')?.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<SearchableSelect
|
||||||
|
options={bStateOptions}
|
||||||
|
value={bState}
|
||||||
|
onChange={setBState}
|
||||||
|
placeholder={bStateOptions.length ? __('Select state') : __('N/A')}
|
||||||
|
disabled={!bStateOptions.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Billing custom fields from plugins (e.g., destination_id from Rajaongkir) */}
|
||||||
|
{hasPhysicalProduct && billingCustomFields.map((field: CheckoutField) => (
|
||||||
|
<DynamicCheckoutField
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
value={customFieldData[field.key] || ''}
|
||||||
|
onChange={(v) => handleCustomFieldChange(field.key, v)}
|
||||||
|
countryOptions={countryOptions}
|
||||||
|
stateOptions={bStateOptions}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Conditional: Only show address fields and shipping for physical products */}
|
{/* Conditional: Only show address fields and shipping for physical products */}
|
||||||
{!hasPhysicalProduct && (
|
{!hasPhysicalProduct && (
|
||||||
@@ -1137,7 +1325,7 @@ export default function OrderForm({
|
|||||||
{hasPhysicalProduct && (
|
{hasPhysicalProduct && (
|
||||||
<div className="pt-2 mt-4">
|
<div className="pt-2 mt-4">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Checkbox id="shipDiff" checked={shipDiff} onCheckedChange={(v)=> setShipDiff(Boolean(v))} />
|
<Checkbox id="shipDiff" checked={shipDiff} onCheckedChange={(v) => setShipDiff(Boolean(v))} />
|
||||||
<Label htmlFor="shipDiff" className="leading-none">{__('Ship to a different address')}</Label>
|
<Label htmlFor="shipDiff" className="leading-none">{__('Ship to a different address')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1149,22 +1337,38 @@ export default function OrderForm({
|
|||||||
<h3 className="text-sm font-medium">{__('Shipping address')}</h3>
|
<h3 className="text-sm font-medium">{__('Shipping address')}</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{checkoutFields.fields
|
{checkoutFields.fields
|
||||||
.filter((f: any) => f.fieldset === 'shipping' && !f.hidden)
|
.filter((f: any) => f.fieldset === 'shipping' && !f.hidden && f.type !== 'hidden')
|
||||||
.sort((a: any, b: any) => (a.priority || 0) - (b.priority || 0))
|
.sort((a: any, b: any) => (a.priority || 0) - (b.priority || 0))
|
||||||
.map((field: any) => {
|
.map((field: any) => {
|
||||||
const isWide = ['address_1', 'address_2'].includes(field.key.replace('shipping_', ''));
|
// Check for full width: address fields or form-row-wide class from PHP
|
||||||
|
const hasFormRowWide = Array.isArray(field.class) && field.class.includes('form-row-wide');
|
||||||
|
const isWide = hasFormRowWide || ['address_1', 'address_2'].includes(field.key.replace('shipping_', ''));
|
||||||
const fieldKey = field.key.replace('shipping_', '');
|
const fieldKey = field.key.replace('shipping_', '');
|
||||||
|
|
||||||
|
// For searchable_select, DynamicCheckoutField renders its own label wrapper
|
||||||
|
if (field.type === 'searchable_select') {
|
||||||
|
return (
|
||||||
|
<DynamicCheckoutField
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
value={customFieldData[field.key] || ''}
|
||||||
|
onChange={(v) => handleCustomFieldChange(field.key, v)}
|
||||||
|
countryOptions={countryOptions}
|
||||||
|
stateOptions={Object.entries(states[shippingData.country] || {}).map(([code, name]) => ({ value: code, label: name }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={field.key} className={isWide ? 'md:col-span-2' : ''}>
|
<div key={field.key} className={isWide ? 'md:col-span-2' : ''}>
|
||||||
<Label>
|
<Label>
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
{field.type === 'select' && field.options ? (
|
{field.type === 'select' && field.options && field.key !== 'shipping_state' ? (
|
||||||
<Select
|
<Select
|
||||||
value={shippingData[fieldKey] || ''}
|
value={shippingData[fieldKey] || ''}
|
||||||
onValueChange={(v) => setShippingData({...shippingData, [fieldKey]: v})}
|
onValueChange={(v) => setShippingData({ ...shippingData, [fieldKey]: v })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder={field.placeholder || field.label} />
|
<SelectValue placeholder={field.placeholder || field.label} />
|
||||||
@@ -1179,14 +1383,34 @@ export default function OrderForm({
|
|||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={countryOptions}
|
options={countryOptions}
|
||||||
value={shippingData.country || ''}
|
value={shippingData.country || ''}
|
||||||
onChange={(v) => setShippingData({...shippingData, country: v})}
|
onChange={(v) => setShippingData({ ...shippingData, country: v })}
|
||||||
placeholder={field.placeholder || __('Select country')}
|
placeholder={field.placeholder || __('Select country')}
|
||||||
disabled={oneCountryOnly}
|
disabled={oneCountryOnly}
|
||||||
/>
|
/>
|
||||||
|
) : field.key === 'shipping_state' && field.options ? (
|
||||||
|
<SearchableSelect
|
||||||
|
options={Object.entries(field.options).map(([value, label]: [string, any]) => ({ value, label }))}
|
||||||
|
value={shippingData.state || ''}
|
||||||
|
onChange={(v) => setShippingData({ ...shippingData, state: v })}
|
||||||
|
placeholder={field.placeholder || __('Select state')}
|
||||||
|
disabled={!Object.keys(field.options).length}
|
||||||
|
/>
|
||||||
) : field.type === 'textarea' ? (
|
) : field.type === 'textarea' ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
value={shippingData[fieldKey] || ''}
|
value={field.custom ? customFieldData[field.key] || '' : shippingData[fieldKey] || ''}
|
||||||
onChange={(e) => setShippingData({...shippingData, [fieldKey]: e.target.value})}
|
onChange={(e) => field.custom
|
||||||
|
? handleCustomFieldChange(field.key, e.target.value)
|
||||||
|
: setShippingData({ ...shippingData, [fieldKey]: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
) : field.custom ? (
|
||||||
|
// For other custom field types, store in customFieldData
|
||||||
|
<Input
|
||||||
|
type={field.type === 'email' ? 'email' : field.type === 'tel' ? 'tel' : 'text'}
|
||||||
|
value={customFieldData[field.key] || ''}
|
||||||
|
onChange={(e) => handleCustomFieldChange(field.key, e.target.value)}
|
||||||
placeholder={field.placeholder}
|
placeholder={field.placeholder}
|
||||||
required={field.required}
|
required={field.required}
|
||||||
/>
|
/>
|
||||||
@@ -1194,7 +1418,7 @@ export default function OrderForm({
|
|||||||
<Input
|
<Input
|
||||||
type={field.type === 'email' ? 'email' : field.type === 'tel' ? 'tel' : 'text'}
|
type={field.type === 'email' ? 'email' : field.type === 'tel' ? 'tel' : 'text'}
|
||||||
value={shippingData[fieldKey] || ''}
|
value={shippingData[fieldKey] || ''}
|
||||||
onChange={(e) => setShippingData({...shippingData, [fieldKey]: e.target.value})}
|
onChange={(e) => setShippingData({ ...shippingData, [fieldKey]: e.target.value })}
|
||||||
placeholder={field.placeholder}
|
placeholder={field.placeholder}
|
||||||
required={field.required}
|
required={field.required}
|
||||||
/>
|
/>
|
||||||
@@ -1250,7 +1474,12 @@ export default function OrderForm({
|
|||||||
{hasPhysicalProduct && (
|
{hasPhysicalProduct && (
|
||||||
<div>
|
<div>
|
||||||
<Label>{__('Shipping method')}</Label>
|
<Label>{__('Shipping method')}</Label>
|
||||||
{shippingLoading ? (
|
{!isShippingAddressComplete ? (
|
||||||
|
/* Prompt user to enter address first */
|
||||||
|
<div className="text-sm text-muted-foreground py-2 italic">
|
||||||
|
{__('Enter shipping address to see available rates')}
|
||||||
|
</div>
|
||||||
|
) : shippingLoading ? (
|
||||||
<div className="text-sm text-muted-foreground py-2">{__('Calculating rates...')}</div>
|
<div className="text-sm text-muted-foreground py-2">{__('Calculating rates...')}</div>
|
||||||
) : shippingRates?.methods && shippingRates.methods.length > 0 ? (
|
) : shippingRates?.methods && shippingRates.methods.length > 0 ? (
|
||||||
<Select value={shippingMethod} onValueChange={setShippingMethod}>
|
<Select value={shippingMethod} onValueChange={setShippingMethod}>
|
||||||
@@ -1263,17 +1492,9 @@ export default function OrderForm({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : shippingData.country ? (
|
|
||||||
<div className="text-sm text-muted-foreground py-2">{__('No shipping methods available')}</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Select value={shippingMethod} onValueChange={setShippingMethod}>
|
/* Address is complete but no methods returned */
|
||||||
<SelectTrigger className="w-full"><SelectValue placeholder={shippings.length ? __('Select shipping') : __('No methods')} /></SelectTrigger>
|
<div className="text-sm text-muted-foreground py-2">{__('No shipping methods available for this address')}</div>
|
||||||
<SelectContent>
|
|
||||||
{shippings.map(s => (
|
|
||||||
<SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1281,7 +1502,7 @@ export default function OrderForm({
|
|||||||
|
|
||||||
<div className="rounded border p-4 space-y-2">
|
<div className="rounded border p-4 space-y-2">
|
||||||
<Label>{__('Customer note (optional)')}</Label>
|
<Label>{__('Customer note (optional)')}</Label>
|
||||||
<Textarea value={note} onChange={e=>setNote(e.target.value)} placeholder={__('Write a note for this order…')} />
|
<Textarea value={note} onChange={e => setNote(e.target.value)} placeholder={__('Write a note for this order…')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hideSubmitButton && (
|
{!hideSubmitButton && (
|
||||||
@@ -1297,6 +1518,6 @@ export default function OrderForm({
|
|||||||
|
|
||||||
function isEmptyAddress(a: any) {
|
function isEmptyAddress(a: any) {
|
||||||
if (!a) return true;
|
if (!a) return true;
|
||||||
const keys = ['first_name','last_name','address_1','city','state','postcode','country'];
|
const keys = ['first_name', 'last_name', 'address_1', 'city', 'state', 'postcode', 'country'];
|
||||||
return keys.every(k => !a[k]);
|
return keys.every(k => !a[k]);
|
||||||
}
|
}
|
||||||
233
admin-spa/src/routes/Products/Licenses/Detail.tsx
Normal file
233
admin-spa/src/routes/Products/Licenses/Detail.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
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 { ArrowLeft, Key, Monitor, Globe, Clock } from 'lucide-react';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface Activation {
|
||||||
|
id: number;
|
||||||
|
license_id: number;
|
||||||
|
domain: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
machine_id: string | null;
|
||||||
|
user_agent: string | null;
|
||||||
|
status: 'active' | 'deactivated';
|
||||||
|
activated_at: string;
|
||||||
|
deactivated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LicenseDetail {
|
||||||
|
id: number;
|
||||||
|
license_key: string;
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
order_id: number;
|
||||||
|
user_id: number;
|
||||||
|
user_email: string;
|
||||||
|
user_name: string;
|
||||||
|
status: 'active' | 'revoked' | 'expired';
|
||||||
|
activation_limit: number;
|
||||||
|
activation_count: number;
|
||||||
|
activations_remaining: number;
|
||||||
|
expires_at: string | null;
|
||||||
|
is_expired: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
activations: Activation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LicenseDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: license, isLoading } = useQuery<LicenseDetail>({
|
||||||
|
queryKey: ['license', id],
|
||||||
|
queryFn: () => api.get(`/licenses/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
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-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!license) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground">{__('License not found')}</p>
|
||||||
|
<Button variant="link" onClick={() => navigate('/products/licenses')}>
|
||||||
|
{__('Back to Licenses')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = () => {
|
||||||
|
if (license.status === 'revoked') {
|
||||||
|
return <Badge variant="destructive">{__('Revoked')}</Badge>;
|
||||||
|
}
|
||||||
|
if (license.is_expired) {
|
||||||
|
return <Badge variant="secondary">{__('Expired')}</Badge>;
|
||||||
|
}
|
||||||
|
return <Badge variant="default">{__('Active')}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate('/products/licenses')}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Key className="h-6 w-6" />
|
||||||
|
{__('License Details')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground font-mono text-sm">{license.license_key}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto">{getStatusBadge()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{__('Product')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="font-semibold">{license.product_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Order #{license.order_id}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{__('Customer')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="font-semibold">{license.user_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{license.user_email}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{__('Activations')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{license.activation_count} / {license.activation_limit === 0 ? '∞' : license.activation_limit}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{license.activations_remaining === -1 ? __('Unlimited') : `${license.activations_remaining} ${__('remaining')}`}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium">{__('Dates')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{__('Created')}</p>
|
||||||
|
<p>{new Date(license.created_at).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{__('Expires')}</p>
|
||||||
|
<p className={license.is_expired ? 'text-red-500' : ''}>
|
||||||
|
{license.expires_at ? new Date(license.expires_at).toLocaleDateString() : __('Never')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Activations */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{__('Activation History')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{__('All activations and deactivations for this license')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{license.activations.length === 0 ? (
|
||||||
|
<p className="text-center py-8 text-muted-foreground">
|
||||||
|
{__('No activations yet')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{__('Domain/Machine')}</TableHead>
|
||||||
|
<TableHead>{__('IP Address')}</TableHead>
|
||||||
|
<TableHead>{__('Activated')}</TableHead>
|
||||||
|
<TableHead>{__('Status')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{license.activations.map((activation) => (
|
||||||
|
<TableRow key={activation.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{activation.domain ? (
|
||||||
|
<>
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{activation.domain}</span>
|
||||||
|
</>
|
||||||
|
) : activation.machine_id ? (
|
||||||
|
<>
|
||||||
|
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-mono text-xs">{activation.machine_id}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{__('Unknown')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-mono text-xs">{activation.ip_address || '-'}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{new Date(activation.activated_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{activation.status === 'active' ? (
|
||||||
|
<Badge variant="default">{__('Active')}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">{__('Deactivated')}</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
292
admin-spa/src/routes/Products/Licenses/index.tsx
Normal file
292
admin-spa/src/routes/Products/Licenses/index.tsx
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Search, Key, Ban, Eye, Copy, Check } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface License {
|
||||||
|
id: number;
|
||||||
|
license_key: string;
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
order_id: number;
|
||||||
|
user_id: number;
|
||||||
|
user_email: string;
|
||||||
|
user_name: string;
|
||||||
|
status: 'active' | 'revoked' | 'expired';
|
||||||
|
activation_limit: number;
|
||||||
|
activation_count: number;
|
||||||
|
activations_remaining: number;
|
||||||
|
expires_at: string | null;
|
||||||
|
is_expired: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LicensesResponse {
|
||||||
|
licenses: License[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Licenses() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [status, setStatus] = useState<string>('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<LicensesResponse>({
|
||||||
|
queryKey: ['licenses', { search, status, page }],
|
||||||
|
queryFn: () => api.get('/licenses', {
|
||||||
|
params: { search, status: status || undefined, page, per_page: 20 }
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => api.del(`/licenses/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['licenses'] });
|
||||||
|
toast.success(__('License revoked'));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to revoke license'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyToClipboard = (key: string) => {
|
||||||
|
navigator.clipboard.writeText(key);
|
||||||
|
setCopiedKey(key);
|
||||||
|
setTimeout(() => setCopiedKey(null), 2000);
|
||||||
|
toast.success(__('License key copied'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (license: License) => {
|
||||||
|
if (license.status === 'revoked') {
|
||||||
|
return <Badge variant="destructive">{__('Revoked')}</Badge>;
|
||||||
|
}
|
||||||
|
if (license.is_expired) {
|
||||||
|
return <Badge variant="secondary">{__('Expired')}</Badge>;
|
||||||
|
}
|
||||||
|
return <Badge variant="default">{__('Active')}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / data.per_page) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Key className="h-6 w-6" />
|
||||||
|
{__('Licenses')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{__('Manage software licenses for your digital products')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={__('Search license keys...')}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="!pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={status} onValueChange={(v) => { setStatus(v); setPage(1); }}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder={__('All Statuses')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">{__('All Statuses')}</SelectItem>
|
||||||
|
<SelectItem value="active">{__('Active')}</SelectItem>
|
||||||
|
<SelectItem value="revoked">{__('Revoked')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{__('License Key')}</TableHead>
|
||||||
|
<TableHead>{__('Product')}</TableHead>
|
||||||
|
<TableHead>{__('Customer')}</TableHead>
|
||||||
|
<TableHead>{__('Activations')}</TableHead>
|
||||||
|
<TableHead>{__('Status')}</TableHead>
|
||||||
|
<TableHead>{__('Expires')}</TableHead>
|
||||||
|
<TableHead className="w-[100px]">{__('Actions')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-8">
|
||||||
|
{__('Loading...')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : data?.licenses.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||||
|
{__('No licenses found')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data?.licenses.map((license) => (
|
||||||
|
<TableRow key={license.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
|
||||||
|
{license.license_key}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => copyToClipboard(license.license_key)}
|
||||||
|
>
|
||||||
|
{copiedKey === license.license_key ? (
|
||||||
|
<Check className="h-3 w-3 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-medium">{license.product_name}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{license.user_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{license.user_email}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={license.activations_remaining === 0 ? 'text-red-500' : ''}>
|
||||||
|
{license.activation_count} / {license.activation_limit === 0 ? '∞' : license.activation_limit}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(license)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{license.expires_at ? (
|
||||||
|
<span className={license.is_expired ? 'text-red-500' : ''}>
|
||||||
|
{new Date(license.expires_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{__('Never')}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(`/products/licenses/${license.id}`)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{license.status === 'active' && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Ban className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Revoke License')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('This will permanently revoke the license. The customer will no longer be able to use it.')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => revokeMutation.mutate(license.id)}
|
||||||
|
className="bg-destructive text-destructive-foreground"
|
||||||
|
>
|
||||||
|
{__('Revoke')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{__('Showing')} {((page - 1) * 20) + 1} - {Math.min(page * 20, data?.total || 0)} {__('of')} {data?.total || 0}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
{__('Previous')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
{__('Next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
|||||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||||
|
|
||||||
const siteUrl = window.location.origin;
|
const siteUrl = window.location.origin;
|
||||||
const spaPagePath = '/store'; // This should ideally come from settings
|
const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
|
||||||
|
|
||||||
const generateLink = (variationId?: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
const generateLink = (variationId?: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -33,7 +33,7 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
|||||||
params.set('quantity', quantity.toString());
|
params.set('quantity', quantity.toString());
|
||||||
}
|
}
|
||||||
params.set('redirect', redirect);
|
params.set('redirect', redirect);
|
||||||
|
|
||||||
return `${siteUrl}${spaPagePath}?${params.toString()}`;
|
return `${siteUrl}${spaPagePath}?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,17 +48,17 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const LinkRow = ({
|
const LinkRow = ({
|
||||||
label,
|
label,
|
||||||
link,
|
link,
|
||||||
description
|
description
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
link: string;
|
link: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const isCopied = copiedLink === link;
|
const isCopied = copiedLink === link;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -137,13 +137,13 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
|||||||
<div className="border-b pb-2">
|
<div className="border-b pb-2">
|
||||||
<h4 className="font-medium">Simple Product Links</h4>
|
<h4 className="font-medium">Simple Product Links</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LinkRow
|
<LinkRow
|
||||||
label="Add to Cart"
|
label="Add to Cart"
|
||||||
link={generateLink(undefined, 'cart')}
|
link={generateLink(undefined, 'cart')}
|
||||||
description="Adds product to cart and shows cart page"
|
description="Adds product to cart and shows cart page"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LinkRow
|
<LinkRow
|
||||||
label="Direct to Checkout"
|
label="Direct to Checkout"
|
||||||
link={generateLink(undefined, 'checkout')}
|
link={generateLink(undefined, 'checkout')}
|
||||||
@@ -172,22 +172,22 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
|||||||
(ID: {variation.id})
|
(ID: {variation.id})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 transition-transform group-open:rotate-180"
|
className="w-4 h-4 transition-transform group-open:rotate-180"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div className="p-4 pt-0 space-y-3 border-t">
|
<div className="p-4 pt-0 space-y-3 border-t">
|
||||||
<LinkRow
|
<LinkRow
|
||||||
label="Add to Cart"
|
label="Add to Cart"
|
||||||
link={generateLink(variation.id, 'cart')}
|
link={generateLink(variation.id, 'cart')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LinkRow
|
<LinkRow
|
||||||
label="Direct to Checkout"
|
label="Direct to Checkout"
|
||||||
link={generateLink(variation.id, 'checkout')}
|
link={generateLink(variation.id, 'checkout')}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { api } from '@/lib/api';
|
|||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm';
|
import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm';
|
||||||
import { Package, DollarSign, Layers, Tag } from 'lucide-react';
|
import { Package, DollarSign, Layers, Tag, Download } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { GeneralTab } from './tabs/GeneralTab';
|
import { GeneralTab } from './tabs/GeneralTab';
|
||||||
import { InventoryTab } from './tabs/InventoryTab';
|
import { InventoryTab } from './tabs/InventoryTab';
|
||||||
import { VariationsTab, ProductVariant } from './tabs/VariationsTab';
|
import { VariationsTab, ProductVariant } from './tabs/VariationsTab';
|
||||||
import { OrganizationTab } from './tabs/OrganizationTab';
|
import { OrganizationTab } from './tabs/OrganizationTab';
|
||||||
|
import { DownloadsTab, DownloadableFile } from './tabs/DownloadsTab';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type ProductFormData = {
|
export type ProductFormData = {
|
||||||
@@ -32,6 +33,20 @@ export type ProductFormData = {
|
|||||||
virtual?: boolean;
|
virtual?: boolean;
|
||||||
downloadable?: boolean;
|
downloadable?: boolean;
|
||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
|
downloads?: DownloadableFile[];
|
||||||
|
download_limit?: string;
|
||||||
|
download_expiry?: string;
|
||||||
|
// Licensing
|
||||||
|
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 = {
|
type Props = {
|
||||||
@@ -75,6 +90,19 @@ export function ProductFormTabbed({
|
|||||||
const [virtual, setVirtual] = useState(initial?.virtual || false);
|
const [virtual, setVirtual] = useState(initial?.virtual || false);
|
||||||
const [downloadable, setDownloadable] = useState(initial?.downloadable || false);
|
const [downloadable, setDownloadable] = useState(initial?.downloadable || false);
|
||||||
const [featured, setFeatured] = useState(initial?.featured || false);
|
const [featured, setFeatured] = useState(initial?.featured || false);
|
||||||
|
const [downloads, setDownloads] = useState<DownloadableFile[]>(initial?.downloads || []);
|
||||||
|
const [downloadLimit, setDownloadLimit] = useState(initial?.download_limit || '');
|
||||||
|
const [downloadExpiry, setDownloadExpiry] = useState(initial?.download_expiry || '');
|
||||||
|
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);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Update form state when initial data changes (for edit mode)
|
// Update form state when initial data changes (for edit mode)
|
||||||
@@ -99,6 +127,19 @@ export function ProductFormTabbed({
|
|||||||
setVirtual(initial.virtual || false);
|
setVirtual(initial.virtual || false);
|
||||||
setDownloadable(initial.downloadable || false);
|
setDownloadable(initial.downloadable || false);
|
||||||
setFeatured(initial.featured || false);
|
setFeatured(initial.featured || false);
|
||||||
|
setDownloads(initial.downloads || []);
|
||||||
|
setDownloadLimit(initial.download_limit || '');
|
||||||
|
setDownloadExpiry(initial.download_expiry || '');
|
||||||
|
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]);
|
}, [initial, mode]);
|
||||||
|
|
||||||
@@ -155,6 +196,19 @@ export function ProductFormTabbed({
|
|||||||
virtual,
|
virtual,
|
||||||
downloadable,
|
downloadable,
|
||||||
featured,
|
featured,
|
||||||
|
downloads: downloadable ? downloads : undefined,
|
||||||
|
download_limit: downloadable ? downloadLimit : undefined,
|
||||||
|
download_expiry: downloadable ? downloadExpiry : undefined,
|
||||||
|
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);
|
await onSubmit(payload);
|
||||||
@@ -169,6 +223,7 @@ export function ProductFormTabbed({
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'general', label: __('General'), icon: <Package className="w-4 h-4" /> },
|
{ id: 'general', label: __('General'), icon: <Package className="w-4 h-4" /> },
|
||||||
{ id: 'inventory', label: __('Inventory'), icon: <Layers className="w-4 h-4" /> },
|
{ id: 'inventory', label: __('Inventory'), icon: <Layers className="w-4 h-4" /> },
|
||||||
|
...(downloadable ? [{ id: 'downloads', label: __('Downloads'), icon: <Download className="w-4 h-4" /> }] : []),
|
||||||
...(type === 'variable' ? [{ id: 'variations', label: __('Variations'), icon: <Layers className="w-4 h-4" /> }] : []),
|
...(type === 'variable' ? [{ id: 'variations', label: __('Variations'), icon: <Layers className="w-4 h-4" /> }] : []),
|
||||||
{ id: 'organization', label: __('Organization'), icon: <Tag className="w-4 h-4" /> },
|
{ id: 'organization', label: __('Organization'), icon: <Tag className="w-4 h-4" /> },
|
||||||
];
|
];
|
||||||
@@ -203,6 +258,25 @@ export function ProductFormTabbed({
|
|||||||
setRegularPrice={setRegularPrice}
|
setRegularPrice={setRegularPrice}
|
||||||
salePrice={salePrice}
|
salePrice={salePrice}
|
||||||
setSalePrice={setSalePrice}
|
setSalePrice={setSalePrice}
|
||||||
|
productId={productId}
|
||||||
|
licensingEnabled={licensingEnabled}
|
||||||
|
setLicensingEnabled={setLicensingEnabled}
|
||||||
|
licenseActivationLimit={licenseActivationLimit}
|
||||||
|
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>
|
</FormSection>
|
||||||
|
|
||||||
@@ -218,6 +292,20 @@ export function ProductFormTabbed({
|
|||||||
/>
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Downloads Tab (only for downloadable products) */}
|
||||||
|
{downloadable && (
|
||||||
|
<FormSection id="downloads">
|
||||||
|
<DownloadsTab
|
||||||
|
downloads={downloads}
|
||||||
|
setDownloads={setDownloads}
|
||||||
|
downloadLimit={downloadLimit}
|
||||||
|
setDownloadLimit={setDownloadLimit}
|
||||||
|
downloadExpiry={downloadExpiry}
|
||||||
|
setDownloadExpiry={setDownloadExpiry}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Variations Tab (only for variable products) */}
|
{/* Variations Tab (only for variable products) */}
|
||||||
{type === 'variable' && (
|
{type === 'variable' && (
|
||||||
<FormSection id="variations">
|
<FormSection id="variations">
|
||||||
@@ -251,8 +339,8 @@ export function ProductFormTabbed({
|
|||||||
{submitting
|
{submitting
|
||||||
? __('Saving...')
|
? __('Saving...')
|
||||||
: mode === 'create'
|
: mode === 'create'
|
||||||
? __('Create Product')
|
? __('Create Product')
|
||||||
: __('Update Product')}
|
: __('Update Product')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
195
admin-spa/src/routes/Products/partials/tabs/DownloadsTab.tsx
Normal file
195
admin-spa/src/routes/Products/partials/tabs/DownloadsTab.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Upload, Trash2, FileIcon, Plus, GripVertical } from 'lucide-react';
|
||||||
|
import { openWPMediaGallery } from '@/lib/wp-media';
|
||||||
|
|
||||||
|
export interface DownloadableFile {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
file: string; // URL
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadsTabProps = {
|
||||||
|
downloads: DownloadableFile[];
|
||||||
|
setDownloads: (files: DownloadableFile[]) => void;
|
||||||
|
downloadLimit: string;
|
||||||
|
setDownloadLimit: (value: string) => void;
|
||||||
|
downloadExpiry: string;
|
||||||
|
setDownloadExpiry: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DownloadsTab({
|
||||||
|
downloads,
|
||||||
|
setDownloads,
|
||||||
|
downloadLimit,
|
||||||
|
setDownloadLimit,
|
||||||
|
downloadExpiry,
|
||||||
|
setDownloadExpiry,
|
||||||
|
}: DownloadsTabProps) {
|
||||||
|
|
||||||
|
const addFile = () => {
|
||||||
|
openWPMediaGallery((files) => {
|
||||||
|
const newDownloads = files.map(file => ({
|
||||||
|
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
name: file.name || file.title || 'Untitled',
|
||||||
|
file: file.url,
|
||||||
|
}));
|
||||||
|
setDownloads([...downloads, ...newDownloads]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
const newDownloads = downloads.filter((_, i) => i !== index);
|
||||||
|
setDownloads(newDownloads);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFileName = (index: number, name: string) => {
|
||||||
|
const newDownloads = [...downloads];
|
||||||
|
newDownloads[index] = { ...newDownloads[index], name };
|
||||||
|
setDownloads(newDownloads);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFileUrl = (index: number, file: string) => {
|
||||||
|
const newDownloads = [...downloads];
|
||||||
|
newDownloads[index] = { ...newDownloads[index], file };
|
||||||
|
setDownloads(newDownloads);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{__('Downloadable Files')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{__('Add files that customers can download after purchase')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Downloadable Files List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>{__('Files')}</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addFile}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
{__('Add File')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{downloads.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{downloads.map((download, index) => (
|
||||||
|
<div
|
||||||
|
key={download.id || index}
|
||||||
|
className="flex items-center gap-3 p-3 border rounded-lg bg-muted/30"
|
||||||
|
>
|
||||||
|
<GripVertical className="w-4 h-4 text-muted-foreground cursor-move" />
|
||||||
|
<FileIcon className="w-5 h-5 text-primary flex-shrink-0" />
|
||||||
|
|
||||||
|
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">{__('File Name')}</Label>
|
||||||
|
<Input
|
||||||
|
value={download.name}
|
||||||
|
onChange={(e) => updateFileName(index, e.target.value)}
|
||||||
|
placeholder={__('My Downloadable File')}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">{__('File URL')}</Label>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<Input
|
||||||
|
value={download.file}
|
||||||
|
onChange={(e) => updateFileUrl(index, e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
openWPMediaGallery((files) => {
|
||||||
|
if (files.length > 0) {
|
||||||
|
updateFileUrl(index, files[0].url);
|
||||||
|
if (!download.name || download.name === 'Untitled') {
|
||||||
|
updateFileName(index, files[0].name || files[0].title || 'Untitled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => removeFile(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground">
|
||||||
|
<FileIcon className="mx-auto h-12 w-12 mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">{__('No downloadable files added yet')}</p>
|
||||||
|
<Button type="button" variant="outline" size="sm" className="mt-3" onClick={addFile}>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
{__('Choose files from Media Library')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Settings */}
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<h3 className="font-medium mb-4">{__('Download Settings')}</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="download_limit">{__('Download Limit')}</Label>
|
||||||
|
<Input
|
||||||
|
id="download_limit"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={downloadLimit}
|
||||||
|
onChange={(e) => setDownloadLimit(e.target.value)}
|
||||||
|
placeholder={__('Unlimited')}
|
||||||
|
className="mt-1.5"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{__('Leave blank for unlimited downloads.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="download_expiry">{__('Download Expiry (days)')}</Label>
|
||||||
|
<Input
|
||||||
|
id="download_expiry"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={downloadExpiry}
|
||||||
|
onChange={(e) => setDownloadExpiry(e.target.value)}
|
||||||
|
placeholder={__('Never expires')}
|
||||||
|
className="mt-1.5"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{__('Leave blank for downloads that never expire.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -8,7 +8,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { DollarSign, Upload, X, Image as ImageIcon } 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 { getStoreCurrency } from '@/lib/currency';
|
||||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||||
import { openWPMediaGallery } from '@/lib/wp-media';
|
import { openWPMediaGallery } from '@/lib/wp-media';
|
||||||
@@ -40,6 +41,28 @@ type GeneralTabProps = {
|
|||||||
setRegularPrice: (value: string) => void;
|
setRegularPrice: (value: string) => void;
|
||||||
salePrice: string;
|
salePrice: string;
|
||||||
setSalePrice: (value: string) => void;
|
setSalePrice: (value: string) => void;
|
||||||
|
// For copy links
|
||||||
|
productId?: number;
|
||||||
|
// Licensing
|
||||||
|
licensingEnabled?: boolean;
|
||||||
|
setLicensingEnabled?: (value: boolean) => void;
|
||||||
|
licenseActivationLimit?: string;
|
||||||
|
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({
|
export function GeneralTab({
|
||||||
@@ -67,14 +90,57 @@ export function GeneralTab({
|
|||||||
setRegularPrice,
|
setRegularPrice,
|
||||||
salePrice,
|
salePrice,
|
||||||
setSalePrice,
|
setSalePrice,
|
||||||
|
productId,
|
||||||
|
licensingEnabled,
|
||||||
|
setLicensingEnabled,
|
||||||
|
licenseActivationLimit,
|
||||||
|
setLicenseActivationLimit,
|
||||||
|
licenseDurationDays,
|
||||||
|
setLicenseDurationDays,
|
||||||
|
licenseActivationMethod,
|
||||||
|
setLicenseActivationMethod,
|
||||||
|
subscriptionEnabled,
|
||||||
|
setSubscriptionEnabled,
|
||||||
|
subscriptionPeriod,
|
||||||
|
setSubscriptionPeriod,
|
||||||
|
subscriptionInterval,
|
||||||
|
setSubscriptionInterval,
|
||||||
|
subscriptionTrialDays,
|
||||||
|
setSubscriptionTrialDays,
|
||||||
|
subscriptionSignupFee,
|
||||||
|
setSubscriptionSignupFee,
|
||||||
}: GeneralTabProps) {
|
}: GeneralTabProps) {
|
||||||
const savingsPercent =
|
const savingsPercent =
|
||||||
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
|
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
|
||||||
? Math.round((1 - parseFloat(salePrice) / parseFloat(regularPrice)) * 100)
|
? Math.round((1 - parseFloat(salePrice) / parseFloat(regularPrice)) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const store = getStoreCurrency();
|
const store = getStoreCurrency();
|
||||||
|
|
||||||
|
// Copy link state and helpers
|
||||||
|
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||||
|
const siteUrl = window.location.origin;
|
||||||
|
const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
|
||||||
|
|
||||||
|
const generateSimpleLink = (redirect: 'cart' | 'checkout' = 'cart') => {
|
||||||
|
if (!productId) return '';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('add-to-cart', productId.toString());
|
||||||
|
params.set('redirect', redirect);
|
||||||
|
return `${siteUrl}${spaPagePath}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (link: string, label: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
setCopiedLink(link);
|
||||||
|
toast.success(`${label} link copied!`);
|
||||||
|
setTimeout(() => setCopiedLink(null), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to copy link');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -181,7 +247,7 @@ export function GeneralTab({
|
|||||||
<p className="text-xs text-muted-foreground mt-1 mb-3">
|
<p className="text-xs text-muted-foreground mt-1 mb-3">
|
||||||
{__('First image will be the featured image. Drag to reorder.')}
|
{__('First image will be the featured image. Drag to reorder.')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Image Upload Button */}
|
{/* Image Upload Button */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -227,7 +293,7 @@ export function GeneralTab({
|
|||||||
e.currentTarget.classList.remove('ring-2', 'ring-primary', 'ring-offset-2');
|
e.currentTarget.classList.remove('ring-2', 'ring-primary', 'ring-offset-2');
|
||||||
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'));
|
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'));
|
||||||
const toIndex = index;
|
const toIndex = index;
|
||||||
|
|
||||||
if (fromIndex !== toIndex) {
|
if (fromIndex !== toIndex) {
|
||||||
const newImages = [...images];
|
const newImages = [...images];
|
||||||
const [movedImage] = newImages.splice(fromIndex, 1);
|
const [movedImage] = newImages.splice(fromIndex, 1);
|
||||||
@@ -313,7 +379,7 @@ export function GeneralTab({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{type === 'variable'
|
{type === 'variable'
|
||||||
? __('Base price (can override per variation)')
|
? __('Base price (can override per variation)')
|
||||||
: __('Base price before discounts')}
|
: __('Base price before discounts')}
|
||||||
</p>
|
</p>
|
||||||
@@ -387,9 +453,212 @@ export function GeneralTab({
|
|||||||
{__('Featured product (show in featured sections)')}
|
{__('Featured product (show in featured sections)')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Licensing option */}
|
||||||
|
{setLicensingEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="licensing-enabled"
|
||||||
|
checked={licensingEnabled || false}
|
||||||
|
onCheckedChange={(checked) => setLicensingEnabled(checked as boolean)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="licensing-enabled" className="cursor-pointer font-normal flex items-center gap-1">
|
||||||
|
<Key className="h-3 w-3" />
|
||||||
|
{__('Enable licensing for this product')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Licensing settings panel */}
|
||||||
|
{licensingEnabled && (
|
||||||
|
<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">{__('Activation Limit')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder={__('0 = unlimited')}
|
||||||
|
value={licenseActivationLimit || ''}
|
||||||
|
onChange={(e) => setLicenseActivationLimit?.(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{__('0 or empty = use global default')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">{__('License Duration (Days)')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder={__('365')}
|
||||||
|
value={licenseDurationDays || ''}
|
||||||
|
onChange={(e) => setLicenseDurationDays?.(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{__('0 = never expires')}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Direct Cart Links - Simple products only */}
|
||||||
|
{productId && type === 'simple' && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>{__('Direct-to-Cart Links')}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('Share these links to add this product directly to cart or checkout')}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copyToClipboard(generateSimpleLink('cart'), 'Cart')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{copiedLink === generateSimpleLink('cart') ? (
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
{__('Copy Cart Link')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copyToClipboard(generateSimpleLink('checkout'), 'Checkout')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{copiedLink === generateSimpleLink('checkout') ? (
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
{__('Copy Checkout Link')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -26,6 +27,7 @@ export function OrganizationTab({
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
setSelectedTags,
|
setSelectedTags,
|
||||||
}: OrganizationTabProps) {
|
}: OrganizationTabProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [newCategoryName, setNewCategoryName] = useState('');
|
const [newCategoryName, setNewCategoryName] = useState('');
|
||||||
const [newTagName, setNewTagName] = useState('');
|
const [newTagName, setNewTagName] = useState('');
|
||||||
const [creatingCategory, setCreatingCategory] = useState(false);
|
const [creatingCategory, setCreatingCategory] = useState(false);
|
||||||
@@ -46,7 +48,8 @@ export function OrganizationTab({
|
|||||||
if (response.id) {
|
if (response.id) {
|
||||||
setSelectedCategories([...selectedCategories, response.id]);
|
setSelectedCategories([...selectedCategories, response.id]);
|
||||||
}
|
}
|
||||||
// Note: Parent component should refetch categories
|
// Invalidate categories query to refresh the list
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['product-categories'] });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || __('Failed to create category'));
|
toast.error(error.message || __('Failed to create category'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -183,11 +186,10 @@ export function OrganizationTab({
|
|||||||
setSelectedTags([...selectedTags, tag.id]);
|
setSelectedTags([...selectedTags, tag.id]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`px-3 py-1 rounded-full text-sm border transition-colors ${
|
className={`px-3 py-1 rounded-full text-sm border transition-colors ${selectedTags.includes(tag.id)
|
||||||
selectedTags.includes(tag.id)
|
|
||||||
? 'bg-primary text-primary-foreground border-primary'
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
: 'bg-background border-border hover:bg-accent'
|
: 'bg-background border-border hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type ProductVariant = {
|
|||||||
manage_stock?: boolean;
|
manage_stock?: boolean;
|
||||||
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
|
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
|
||||||
image?: string;
|
image?: string;
|
||||||
|
license_duration_days?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VariationsTabProps = {
|
type VariationsTabProps = {
|
||||||
@@ -43,10 +44,10 @@ export function VariationsTab({
|
|||||||
}: VariationsTabProps) {
|
}: VariationsTabProps) {
|
||||||
const store = getStoreCurrency();
|
const store = getStoreCurrency();
|
||||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||||
|
|
||||||
const siteUrl = window.location.origin;
|
const siteUrl = window.location.origin;
|
||||||
const spaPagePath = '/store';
|
const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
|
||||||
|
|
||||||
const generateLink = (variationId: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
const generateLink = (variationId: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||||
if (!productId) return '';
|
if (!productId) return '';
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -55,7 +56,7 @@ export function VariationsTab({
|
|||||||
params.set('redirect', redirect);
|
params.set('redirect', redirect);
|
||||||
return `${siteUrl}${spaPagePath}?${params.toString()}`;
|
return `${siteUrl}${spaPagePath}?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToClipboard = async (link: string, label: string) => {
|
const copyToClipboard = async (link: string, label: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(link);
|
await navigator.clipboard.writeText(link);
|
||||||
@@ -66,7 +67,7 @@ export function VariationsTab({
|
|||||||
toast.error('Failed to copy link');
|
toast.error('Failed to copy link');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addAttribute = () => {
|
const addAttribute = () => {
|
||||||
setAttributes([...attributes, { name: '', options: [], variation: false }]);
|
setAttributes([...attributes, { name: '', options: [], variation: false }]);
|
||||||
};
|
};
|
||||||
@@ -83,7 +84,7 @@ export function VariationsTab({
|
|||||||
|
|
||||||
const generateVariations = () => {
|
const generateVariations = () => {
|
||||||
const variationAttrs = attributes.filter(attr => attr.variation && attr.options.length > 0);
|
const variationAttrs = attributes.filter(attr => attr.variation && attr.options.length > 0);
|
||||||
|
|
||||||
if (variationAttrs.length === 0) {
|
if (variationAttrs.length === 0) {
|
||||||
toast.warning(__('Please add at least one variation attribute with options'));
|
toast.warning(__('Please add at least one variation attribute with options'));
|
||||||
return;
|
return;
|
||||||
@@ -276,6 +277,26 @@ export function VariationsTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* License Duration - only show if licensing is enabled on product */}
|
||||||
|
<div className="col-span-2 md:col-span-4">
|
||||||
|
<Label className="text-xs">{__('License Duration (Days)')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder={__('Leave empty to use product default')}
|
||||||
|
value={variation.license_duration_days || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...variations];
|
||||||
|
updated[index].license_duration_days = e.target.value;
|
||||||
|
setVariations(updated);
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{__('Override license duration for this variation. 0 = never expires.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<Input
|
<Input
|
||||||
placeholder={__('SKU')}
|
placeholder={__('SKU')}
|
||||||
@@ -331,7 +352,7 @@ export function VariationsTab({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Direct Cart Links */}
|
{/* Direct Cart Links */}
|
||||||
{productId && variation.id && (
|
{productId && variation.id && (
|
||||||
<div className="mt-4 pt-4 border-t space-y-2">
|
<div className="mt-4 pt-4 border-t space-y-2">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
|||||||
interface CustomerSettings {
|
interface CustomerSettings {
|
||||||
auto_register_members: boolean;
|
auto_register_members: boolean;
|
||||||
multiple_addresses_enabled: boolean;
|
multiple_addresses_enabled: boolean;
|
||||||
|
allow_custom_avatar: boolean;
|
||||||
vip_min_spent: number;
|
vip_min_spent: number;
|
||||||
vip_min_orders: number;
|
vip_min_orders: number;
|
||||||
vip_timeframe: 'all' | '30' | '90' | '365';
|
vip_timeframe: 'all' | '30' | '90' | '365';
|
||||||
@@ -24,6 +25,7 @@ export default function CustomersSettings() {
|
|||||||
const [settings, setSettings] = useState<CustomerSettings>({
|
const [settings, setSettings] = useState<CustomerSettings>({
|
||||||
auto_register_members: false,
|
auto_register_members: false,
|
||||||
multiple_addresses_enabled: true,
|
multiple_addresses_enabled: true,
|
||||||
|
allow_custom_avatar: false,
|
||||||
vip_min_spent: 1000,
|
vip_min_spent: 1000,
|
||||||
vip_min_orders: 10,
|
vip_min_orders: 10,
|
||||||
vip_timeframe: 'all',
|
vip_timeframe: 'all',
|
||||||
@@ -138,6 +140,14 @@ export default function CustomersSettings() {
|
|||||||
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
|
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ToggleField
|
||||||
|
id="allow_custom_avatar"
|
||||||
|
label={__('Allow custom profile photo')}
|
||||||
|
description={__('Allow customers to upload their own profile photo. When disabled, customer avatars will use Gravatar or default initials.')}
|
||||||
|
checked={settings.allow_custom_avatar}
|
||||||
|
onCheckedChange={(checked) => setSettings({ ...settings, allow_custom_avatar: checked })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|||||||
@@ -154,12 +154,14 @@ export default function NotificationsSettings() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{__('Coming soon')}
|
{__('Sent, Failed, Pending')}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" disabled>
|
<Link to="/settings/notifications/activity-log">
|
||||||
{__('View Log')}
|
<Button variant="outline" size="sm">
|
||||||
<ChevronRight className="ml-2 h-4 w-4" />
|
{__('View Log')}
|
||||||
</Button>
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
245
admin-spa/src/routes/Settings/Notifications/ActivityLog.tsx
Normal file
245
admin-spa/src/routes/Settings/Notifications/ActivityLog.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { SettingsLayout } from '../components/SettingsLayout';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Mail,
|
||||||
|
Bell,
|
||||||
|
MessageCircle,
|
||||||
|
Send,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
RefreshCw,
|
||||||
|
Filter,
|
||||||
|
Search
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
|
interface NotificationLogEntry {
|
||||||
|
id: number;
|
||||||
|
channel: 'email' | 'push' | 'whatsapp' | 'telegram';
|
||||||
|
event: string;
|
||||||
|
recipient: string;
|
||||||
|
subject?: string;
|
||||||
|
status: 'sent' | 'failed' | 'pending' | 'queued';
|
||||||
|
created_at: string;
|
||||||
|
sent_at?: string;
|
||||||
|
error_message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationLogsResponse {
|
||||||
|
logs: NotificationLogEntry[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelIcons: Record<string, React.ReactNode> = {
|
||||||
|
email: <Mail className="h-4 w-4" />,
|
||||||
|
push: <Bell className="h-4 w-4" />,
|
||||||
|
whatsapp: <MessageCircle className="h-4 w-4" />,
|
||||||
|
telegram: <Send className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
|
||||||
|
sent: { icon: <CheckCircle2 className="h-4 w-4" />, color: 'text-green-600 bg-green-50', label: 'Sent' },
|
||||||
|
failed: { icon: <XCircle className="h-4 w-4" />, color: 'text-red-600 bg-red-50', label: 'Failed' },
|
||||||
|
pending: { icon: <Clock className="h-4 w-4" />, color: 'text-yellow-600 bg-yellow-50', label: 'Pending' },
|
||||||
|
queued: { icon: <RefreshCw className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50', label: 'Queued' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ActivityLog() {
|
||||||
|
const [search, setSearch] = React.useState('');
|
||||||
|
const [channelFilter, setChannelFilter] = React.useState('all');
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
|
||||||
|
const { data, isLoading, error, refetch } = useQuery<NotificationLogsResponse>({
|
||||||
|
queryKey: ['notification-logs', page, channelFilter, statusFilter, search],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', page.toString());
|
||||||
|
params.set('per_page', '20');
|
||||||
|
if (channelFilter !== 'all') params.set('channel', channelFilter);
|
||||||
|
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
return api.get(`/notifications/logs?${params.toString()}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsLayout
|
||||||
|
title={__('Activity Log')}
|
||||||
|
description={__('View notification history and delivery status')}
|
||||||
|
action={
|
||||||
|
<Link to="/settings/notifications">
|
||||||
|
<Button variant="outline">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
{__('Back')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={__('Search by recipient or subject...')}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="!pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select value={channelFilter} onValueChange={setChannelFilter}>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue placeholder={__('Channel')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{__('All Channels')}</SelectItem>
|
||||||
|
<SelectItem value="email">{__('Email')}</SelectItem>
|
||||||
|
<SelectItem value="push">{__('Push')}</SelectItem>
|
||||||
|
<SelectItem value="whatsapp">{__('WhatsApp')}</SelectItem>
|
||||||
|
<SelectItem value="telegram">{__('Telegram')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue placeholder={__('Status')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{__('All Status')}</SelectItem>
|
||||||
|
<SelectItem value="sent">{__('Sent')}</SelectItem>
|
||||||
|
<SelectItem value="failed">{__('Failed')}</SelectItem>
|
||||||
|
<SelectItem value="pending">{__('Pending')}</SelectItem>
|
||||||
|
<SelectItem value="queued">{__('Queued')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="icon" onClick={() => refetch()}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Activity Log Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{__('Recent Activity')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{isLoading
|
||||||
|
? __('Loading...')
|
||||||
|
: data?.total
|
||||||
|
? `${data.total} ${__('notifications found')}`
|
||||||
|
: __('No notifications recorded yet')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<RefreshCw className="h-8 w-8 mx-auto mb-2 animate-spin" />
|
||||||
|
<p>{__('Loading activity log...')}</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<XCircle className="h-8 w-8 mx-auto mb-2 text-red-500" />
|
||||||
|
<p>{__('Failed to load activity log')}</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={() => refetch()}>
|
||||||
|
{__('Try Again')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : !data?.logs?.length ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<Bell className="h-12 w-12 mx-auto mb-2 opacity-30" />
|
||||||
|
<p className="text-lg font-medium">{__('No notifications yet')}</p>
|
||||||
|
<p className="text-sm">{__('Notification activities will appear here once sent.')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.logs.map((log) => (
|
||||||
|
<div
|
||||||
|
key={log.id}
|
||||||
|
className="flex items-start gap-4 p-4 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
{/* Channel Icon */}
|
||||||
|
<div className="p-2 bg-muted rounded-lg">
|
||||||
|
{channelIcons[log.channel] || <Bell className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium truncate">{log.event}</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ${statusConfig[log.status]?.color || 'text-gray-600 bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
{statusConfig[log.status]?.icon}
|
||||||
|
{statusConfig[log.status]?.label || log.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{__('To')}: {log.recipient}
|
||||||
|
{log.subject && ` — ${log.subject}`}
|
||||||
|
</p>
|
||||||
|
{log.error_message && (
|
||||||
|
<p className="text-sm text-red-600 mt-1">
|
||||||
|
{__('Error')}: {log.error_message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timestamp */}
|
||||||
|
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDate(log.sent_at || log.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data.total > 20 && (
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
{__('Previous')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{__('Page')} {page} {__('of')} {Math.ceil(data.total / 20)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= Math.ceil(data.total / 20)}
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
>
|
||||||
|
{__('Next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ export default function CustomerNotifications() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<TabsTrigger value="channels">{__('Channels')}</TabsTrigger>
|
<TabsTrigger value="channels">{__('Channels')}</TabsTrigger>
|
||||||
<TabsTrigger value="events">{__('Events')}</TabsTrigger>
|
<TabsTrigger value="events">{__('Events')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { EmailBuilder, EmailBlock, blocksToMarkdown, markdownToBlocks } from '@/
|
|||||||
import { CodeEditor } from '@/components/ui/code-editor';
|
import { CodeEditor } from '@/components/ui/code-editor';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
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 { toast } from 'sonner';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { markdownToHtml } from '@/lib/markdown-utils';
|
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 [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
|
||||||
const [activeTab, setActiveTab] = useState('preview');
|
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({
|
const { data: emailSettings } = useQuery({
|
||||||
queryKey: ['email-settings'],
|
queryKey: ['email-settings'],
|
||||||
queryFn: () => api.get('/notifications/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
|
// Fetch template
|
||||||
const { data: template, isLoading, error } = useQuery({
|
const { data: template, isLoading, error } = useQuery({
|
||||||
queryKey: ['notification-template', eventId, channelId, recipientType],
|
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)
|
// Visual mode: Update blocks → Markdown (source of truth)
|
||||||
const handleBlocksChange = (newBlocks: EmailBlock[]) => {
|
const handleBlocksChange = (newBlocks: EmailBlock[]) => {
|
||||||
setBlocks(newBlocks);
|
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 settings = emailSettings || {};
|
||||||
const primaryColor = settings.primary_color || '#7f54b3';
|
const appearColors = appearanceSettings?.data?.general?.colors || appearanceSettings?.general?.colors || {};
|
||||||
const secondaryColor = settings.secondary_color || '#7f54b3';
|
const primaryColor = appearColors.primary || '#7f54b3';
|
||||||
const heroGradientStart = settings.hero_gradient_start || '#667eea';
|
const secondaryColor = appearColors.secondary || '#7f54b3';
|
||||||
const heroGradientEnd = settings.hero_gradient_end || '#764ba2';
|
const heroGradientStart = appearColors.gradientStart || '#667eea';
|
||||||
const heroTextColor = settings.hero_text_color || '#ffffff';
|
const heroGradientEnd = appearColors.gradientEnd || '#764ba2';
|
||||||
const buttonTextColor = settings.button_text_color || '#ffffff';
|
const heroTextColor = '#ffffff'; // Always white on gradient
|
||||||
|
const buttonTextColor = '#ffffff'; // Always white on primary
|
||||||
const bodyBgColor = settings.body_bg_color || '#f8f8f8';
|
const bodyBgColor = settings.body_bg_color || '#f8f8f8';
|
||||||
const socialIconColor = settings.social_icon_color || 'white';
|
const socialIconColor = settings.social_icon_color || 'white';
|
||||||
const logoUrl = settings.logo_url || '';
|
const logoUrl = settings.logo_url || '';
|
||||||
@@ -307,10 +345,11 @@ export default function EditTemplate() {
|
|||||||
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
|
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
|
||||||
|
|
||||||
// Generate social icons HTML with PNG images
|
// Generate social icons HTML with PNG images
|
||||||
|
// Get plugin URL from config, with fallback
|
||||||
const pluginUrl =
|
const pluginUrl =
|
||||||
(window as any).woonoowData?.pluginUrl ||
|
(window as any).woonoowData?.pluginUrl ||
|
||||||
(window as any).WNW_CONFIG?.pluginUrl ||
|
(window as any).WNW_CONFIG?.pluginUrl ||
|
||||||
'';
|
'/wp-content/plugins/woonoow/';
|
||||||
const socialIconsHtml = socialLinks.length > 0 ? `
|
const socialIconsHtml = socialLinks.length > 0 ? `
|
||||||
<div style="margin-top: 16px;">
|
<div style="margin-top: 16px;">
|
||||||
${socialLinks.map((link: any) => `
|
${socialLinks.map((link: any) => `
|
||||||
@@ -414,128 +453,175 @@ export default function EditTemplate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsLayout
|
<>
|
||||||
title={template.event_label || __('Edit Template')}
|
<SettingsLayout
|
||||||
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
|
title={template.event_label || __('Edit Template')}
|
||||||
onSave={handleSave}
|
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
|
||||||
saveLabel={__('Save Template')}
|
onSave={handleSave}
|
||||||
isLoading={false}
|
saveLabel={__('Save Template')}
|
||||||
action={
|
isLoading={false}
|
||||||
<div className="flex items-center gap-2">
|
action={
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => {
|
size="sm"
|
||||||
// Determine if staff or customer based on event category
|
onClick={() => {
|
||||||
const isStaffEvent = template.event_category === 'staff' || eventId?.includes('admin') || eventId?.includes('staff');
|
// Determine if staff or customer based on event category
|
||||||
const page = isStaffEvent ? 'staff' : 'customer';
|
const isStaffEvent = template.event_category === 'staff' || eventId?.includes('admin') || eventId?.includes('staff');
|
||||||
navigate(`/settings/notifications/${page}?tab=events`);
|
const page = isStaffEvent ? 'staff' : 'customer';
|
||||||
}}
|
navigate(`/settings/notifications/${page}?tab=events`);
|
||||||
className="gap-2"
|
}}
|
||||||
title={__('Back')}
|
className="gap-2"
|
||||||
>
|
title={__('Back')}
|
||||||
<ArrowLeft className="h-4 w-4" />
|
>
|
||||||
<span className="hidden sm:inline">{__('Back')}</span>
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
<span className="hidden sm:inline">{__('Back')}</span>
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={handleReset}
|
size="sm"
|
||||||
className="gap-2"
|
onClick={handleReset}
|
||||||
title={__('Reset to Default')}
|
className="gap-2"
|
||||||
>
|
title={__('Reset to Default')}
|
||||||
<RotateCcw className="h-4 w-4" />
|
>
|
||||||
<span className="hidden sm:inline">{__('Reset to Default')}</span>
|
<RotateCcw className="h-4 w-4" />
|
||||||
</Button>
|
<span className="hidden sm:inline">{__('Reset to Default')}</span>
|
||||||
</div>
|
</Button>
|
||||||
}
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
<Card>
|
size="sm"
|
||||||
<CardContent className="pt-6 space-y-6">
|
onClick={() => setTestEmailDialogOpen(true)}
|
||||||
{/* Subject */}
|
className="gap-2"
|
||||||
<div className="space-y-2">
|
title={__('Send Test')}
|
||||||
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
>
|
||||||
<Input
|
<Send className="h-4 w-4" />
|
||||||
id="subject"
|
<span className="hidden sm:inline">{__('Send Test')}</span>
|
||||||
value={subject}
|
</Button>
|
||||||
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>
|
</div>
|
||||||
|
}
|
||||||
{/* Body */}
|
>
|
||||||
<div className="space-y-4">
|
<Card>
|
||||||
{/* Three-tab system: Preview | Visual | Markdown */}
|
<CardContent className="pt-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* Subject */}
|
||||||
<Label>{__('Message Body')}</Label>
|
<div className="space-y-2">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
||||||
<TabsList className="grid grid-cols-3">
|
<Input
|
||||||
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
id="subject"
|
||||||
<Eye className="h-3 w-3" />
|
value={subject}
|
||||||
{__('Preview')}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
</TabsTrigger>
|
placeholder={__('Enter notification subject')}
|
||||||
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
|
/>
|
||||||
<Edit className="h-3 w-3" />
|
<p className="text-xs text-muted-foreground">
|
||||||
{__('Visual')}
|
{channelId === 'email'
|
||||||
</TabsTrigger>
|
? __('Email subject line')
|
||||||
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
|
: __('Push notification title')}
|
||||||
<FileText className="h-3 w-3" />
|
</p>
|
||||||
{__('Markdown')}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Tab */}
|
{/* Body */}
|
||||||
{activeTab === 'preview' && (
|
<div className="space-y-4">
|
||||||
<div className="border rounded-md overflow-hidden">
|
{/* Three-tab system: Preview | Visual | Markdown */}
|
||||||
<iframe
|
<div className="flex items-center justify-between">
|
||||||
srcDoc={generatePreviewHTML()}
|
<Label>{__('Message Body')}</Label>
|
||||||
className="w-full min-h-[600px] overflow-hidden bg-white"
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||||
title={__('Email Preview')}
|
<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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Visual Tab */}
|
{/* Preview Tab */}
|
||||||
{activeTab === 'visual' && (
|
{activeTab === 'preview' && (
|
||||||
<div>
|
<div className="border rounded-md overflow-hidden">
|
||||||
<EmailBuilder
|
<iframe
|
||||||
blocks={blocks}
|
srcDoc={generatePreviewHTML()}
|
||||||
onChange={handleBlocksChange}
|
className="w-full min-h-[600px] overflow-hidden bg-white"
|
||||||
variables={variableKeys}
|
title={__('Email Preview')}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
</div>
|
||||||
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
)}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Markdown Tab */}
|
{/* Visual Tab */}
|
||||||
{activeTab === 'markdown' && (
|
{activeTab === 'visual' && (
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<CodeEditor
|
<EmailBuilder
|
||||||
value={markdownContent}
|
blocks={blocks}
|
||||||
onChange={handleMarkdownChange}
|
onChange={handleBlocksChange}
|
||||||
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
variables={variableKeys}
|
||||||
supportMarkdown={true}
|
/>
|
||||||
/>
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
<p className="text-xs text-muted-foreground">
|
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
||||||
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
)}
|
||||||
{__('All changes are automatically synced between Visual and Markdown modes.')}
|
|
||||||
</p>
|
{/* Markdown Tab */}
|
||||||
</div>
|
{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>
|
</div>
|
||||||
</CardContent>
|
<DialogFooter>
|
||||||
</Card>
|
<Button variant="outline" onClick={() => setTestEmailDialogOpen(false)}>
|
||||||
</SettingsLayout>
|
{__('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSendTest} disabled={sendTestMutation.isPending}>
|
||||||
|
{sendTestMutation.isPending ? __('Sending...') : __('Send Test')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function EmailConfiguration() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs defaultValue="template" className="space-y-6">
|
<Tabs defaultValue="template" className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<TabsTrigger value="template">{__('Template Settings')}</TabsTrigger>
|
<TabsTrigger value="template">{__('Template Settings')}</TabsTrigger>
|
||||||
<TabsTrigger value="connection">{__('Connection Settings')}</TabsTrigger>
|
<TabsTrigger value="connection">{__('Connection Settings')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|||||||
@@ -219,190 +219,22 @@ export default function EmailCustomization() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Brand Colors */}
|
{/* Unified Colors Notice */}
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={__('Brand Colors')}
|
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="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
<div className="space-y-2">
|
<p className="text-sm text-blue-900 dark:text-blue-100">
|
||||||
<Label htmlFor="primary_color">{__('Primary Color')}</Label>
|
<strong>{__('Colors are now unified!')}</strong>{' '}
|
||||||
<div className="flex gap-2">
|
{__('Email colors (buttons, gradients) now use the same colors as your storefront for consistent branding.')}
|
||||||
<Input
|
</p>
|
||||||
id="primary_color"
|
<p className="text-sm text-blue-900 dark:text-blue-100 mt-2">
|
||||||
type="color"
|
{__('To change colors, go to')}{' '}
|
||||||
value={formData.primary_color}
|
<a href="#/appearance/general" className="font-medium underline hover:no-underline">
|
||||||
onChange={(e) => handleChange('primary_color', e.target.value)}
|
{__('Appearance → General → Colors')}
|
||||||
className="w-20 h-10 p-1 cursor-pointer"
|
</a>
|
||||||
/>
|
|
||||||
<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)')}
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
@@ -540,7 +372,7 @@ export default function EmailCustomization() {
|
|||||||
{__('Add Social Link')}
|
{__('Add Social Link')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formData.social_links.length === 0 ? (
|
{formData.social_links.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{__('No social links added. Click "Add Social Link" to get started.')}
|
{__('No social links added. Click "Add Social Link" to get started.')}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function PushConfiguration() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs defaultValue="template" className="space-y-6">
|
<Tabs defaultValue="template" className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<TabsTrigger value="template">{__('Template Settings')}</TabsTrigger>
|
<TabsTrigger value="template">{__('Template Settings')}</TabsTrigger>
|
||||||
<TabsTrigger value="connection">{__('Connection Settings')}</TabsTrigger>
|
<TabsTrigger value="connection">{__('Connection Settings')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function StaffNotifications() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<TabsTrigger value="channels">{__('Channels')}</TabsTrigger>
|
<TabsTrigger value="channels">{__('Channels')}</TabsTrigger>
|
||||||
<TabsTrigger value="events">{__('Events')}</TabsTrigger>
|
<TabsTrigger value="events">{__('Events')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export default function TemplateEditor({
|
|||||||
{/* Body - Scrollable */}
|
{/* Body - Scrollable */}
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<TabsTrigger value="editor" className="flex items-center gap-2">
|
<TabsTrigger value="editor" className="flex items-center gap-2">
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
{__('Editor')}
|
{__('Editor')}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ interface ShippingZone {
|
|||||||
|
|
||||||
export default function ShippingPage() {
|
export default function ShippingPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const wcAdminUrl = (window as any).WNW_CONFIG?.wpAdminUrl || '/wp-admin';
|
// Use siteUrl + /wp-admin since wpAdminUrl already includes admin.php?page=woonoow
|
||||||
|
const wcAdminUrl = ((window as any).WNW_CONFIG?.siteUrl || '') + '/wp-admin';
|
||||||
const [togglingMethod, setTogglingMethod] = useState<string | null>(null);
|
const [togglingMethod, setTogglingMethod] = useState<string | null>(null);
|
||||||
const [selectedZone, setSelectedZone] = useState<any | null>(null);
|
const [selectedZone, setSelectedZone] = useState<any | null>(null);
|
||||||
const [showAddMethod, setShowAddMethod] = useState(false);
|
const [showAddMethod, setShowAddMethod] = useState(false);
|
||||||
@@ -44,7 +45,7 @@ export default function ShippingPage() {
|
|||||||
const [deletingZone, setDeletingZone] = useState<any | null>(null);
|
const [deletingZone, setDeletingZone] = useState<any | null>(null);
|
||||||
const [regionSearch, setRegionSearch] = useState('');
|
const [regionSearch, setRegionSearch] = useState('');
|
||||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
// Fetch shipping zones from WooCommerce
|
// Fetch shipping zones from WooCommerce
|
||||||
const { data: zones = [], isLoading, refetch } = useQuery({
|
const { data: zones = [], isLoading, refetch } = useQuery({
|
||||||
queryKey: ['shipping-zones'],
|
queryKey: ['shipping-zones'],
|
||||||
@@ -125,7 +126,7 @@ export default function ShippingPage() {
|
|||||||
// Fetch method settings when accordion expands
|
// Fetch method settings when accordion expands
|
||||||
const fetchMethodSettings = async (instanceId: number) => {
|
const fetchMethodSettings = async (instanceId: number) => {
|
||||||
if (!selectedZone || methodSettings[instanceId]) return;
|
if (!selectedZone || methodSettings[instanceId]) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = await api.get(`/settings/shipping/zones/${selectedZone.id}/methods/${instanceId}/settings`);
|
const settings = await api.get(`/settings/shipping/zones/${selectedZone.id}/methods/${instanceId}/settings`);
|
||||||
setMethodSettings(prev => ({ ...prev, [instanceId]: settings }));
|
setMethodSettings(prev => ({ ...prev, [instanceId]: settings }));
|
||||||
@@ -174,9 +175,9 @@ export default function ShippingPage() {
|
|||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
if (deletingMethod) {
|
if (deletingMethod) {
|
||||||
deleteMethodMutation.mutate({
|
deleteMethodMutation.mutate({
|
||||||
zoneId: deletingMethod.zoneId,
|
zoneId: deletingMethod.zoneId,
|
||||||
instanceId: deletingMethod.instanceId
|
instanceId: deletingMethod.instanceId
|
||||||
});
|
});
|
||||||
setDeletingMethod(null);
|
setDeletingMethod(null);
|
||||||
}
|
}
|
||||||
@@ -230,8 +231,8 @@ export default function ShippingPage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout
|
<SettingsLayout
|
||||||
title={__('Shipping & Delivery')}
|
title={__('Shipping & Delivery')}
|
||||||
description={__('Manage how you ship products to customers')}
|
description={__('Manage how you ship products to customers')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
@@ -287,102 +288,102 @@ export default function ShippingPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{zones.map((zone: any) => (
|
{zones.map((zone: any) => (
|
||||||
<div
|
<div
|
||||||
key={zone.id}
|
key={zone.id}
|
||||||
className="border rounded-lg p-3 md:p-4 hover:border-primary/50 transition-colors"
|
className="border rounded-lg p-3 md:p-4 hover:border-primary/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 md:gap-3 mb-3 md:mb-4">
|
<div className="flex items-start justify-between gap-2 md:gap-3 mb-3 md:mb-4">
|
||||||
<div className="flex items-start gap-2 md:gap-3 flex-1 min-w-0">
|
<div className="flex items-start gap-2 md:gap-3 flex-1 min-w-0">
|
||||||
<div className="p-1.5 md:p-2 bg-primary/10 rounded-lg text-primary flex-shrink-0">
|
<div className="p-1.5 md:p-2 bg-primary/10 rounded-lg text-primary flex-shrink-0">
|
||||||
<Globe className="h-4 w-4 md:h-5 md:w-5" />
|
<Globe className="h-4 w-4 md:h-5 md:w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold text-sm md:text-lg">{zone.name}</h3>
|
||||||
|
<p className="text-xs md:text-sm text-muted-foreground truncate">
|
||||||
|
<span className="font-medium">{__('Available to:')}</span> {zone.regions}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs md:text-sm text-muted-foreground">
|
||||||
|
{zone.rates.length} {zone.rates.length === 1 ? __('delivery option') : __('delivery options')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex gap-1 md:gap-2 flex-shrink-0">
|
||||||
<h3 className="font-semibold text-sm md:text-lg">{zone.name}</h3>
|
<Button
|
||||||
<p className="text-xs md:text-sm text-muted-foreground truncate">
|
variant="ghost"
|
||||||
<span className="font-medium">{__('Available to:')}</span> {zone.regions}
|
size="sm"
|
||||||
</p>
|
onClick={() => setEditingZone(zone)}
|
||||||
<p className="text-xs md:text-sm text-muted-foreground">
|
title={__('Edit zone name and regions')}
|
||||||
{zone.rates.length} {zone.rates.length === 1 ? __('delivery option') : __('delivery options')}
|
>
|
||||||
</p>
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeletingZone(zone)}
|
||||||
|
title={__('Delete zone')}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedZone(zone)}
|
||||||
|
title={__('Manage delivery options')}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 md:gap-2 flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setEditingZone(zone)}
|
|
||||||
title={__('Edit zone name and regions')}
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setDeletingZone(zone)}
|
|
||||||
title={__('Delete zone')}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setSelectedZone(zone)}
|
|
||||||
title={__('Manage delivery options')}
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Shipping Rates */}
|
{/* Shipping Rates */}
|
||||||
<div className="pl-0 md:pl-11 space-y-2">
|
<div className="pl-0 md:pl-11 space-y-2">
|
||||||
{zone.rates?.map((rate: any) => (
|
{zone.rates?.map((rate: any) => (
|
||||||
<div
|
<div
|
||||||
key={rate.id}
|
key={rate.id}
|
||||||
className="flex items-center justify-between gap-2 py-2 px-2 md:px-3 bg-muted/50 rounded-md"
|
className="flex items-center justify-between gap-2 py-2 px-2 md:px-3 bg-muted/50 rounded-md"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
|
||||||
<div className={`p-1 rounded flex-shrink-0 ${rate.enabled ? 'bg-green-500/20 text-green-500' : 'bg-primary/10 text-primary'}`}>
|
<div className={`p-1 rounded flex-shrink-0 ${rate.enabled ? 'bg-green-500/20 text-green-500' : 'bg-primary/10 text-primary'}`}>
|
||||||
<Truck className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
<Truck className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className="text-xs md:text-sm font-medium line-clamp-1"
|
||||||
|
dangerouslySetInnerHTML={{ __html: rate.name }}
|
||||||
|
/>
|
||||||
|
{rate.transitTime && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
• {rate.transitTime}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{rate.condition && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
• {rate.condition}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex items-center gap-2 md:gap-3 flex-shrink-0">
|
||||||
<span
|
<span
|
||||||
className="text-xs md:text-sm font-medium line-clamp-1"
|
className="text-xs md:text-sm font-semibold whitespace-nowrap"
|
||||||
dangerouslySetInnerHTML={{ __html: rate.name }}
|
dangerouslySetInnerHTML={{ __html: rate.price }}
|
||||||
|
/>
|
||||||
|
<ToggleField
|
||||||
|
id={`${zone.id}-${rate.instance_id}`}
|
||||||
|
label=""
|
||||||
|
checked={rate.enabled}
|
||||||
|
onCheckedChange={(checked) => handleToggle(zone.id, rate.instance_id, checked)}
|
||||||
|
disabled={togglingMethod === `${zone.id}-${rate.instance_id}`}
|
||||||
/>
|
/>
|
||||||
{rate.transitTime && (
|
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
|
||||||
• {rate.transitTime}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{rate.condition && (
|
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
|
||||||
• {rate.condition}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 md:gap-3 flex-shrink-0">
|
))}
|
||||||
<span
|
</div>
|
||||||
className="text-xs md:text-sm font-semibold whitespace-nowrap"
|
|
||||||
dangerouslySetInnerHTML={{ __html: rate.price }}
|
|
||||||
/>
|
|
||||||
<ToggleField
|
|
||||||
id={`${zone.id}-${rate.instance_id}`}
|
|
||||||
label=""
|
|
||||||
checked={rate.enabled}
|
|
||||||
onCheckedChange={(checked) => handleToggle(zone.id, rate.instance_id, checked)}
|
|
||||||
disabled={togglingMethod === `${zone.id}-${rate.instance_id}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
@@ -481,11 +482,10 @@ export default function ShippingPage() {
|
|||||||
<div className="font-medium" dangerouslySetInnerHTML={{ __html: rate.name }} />
|
<div className="font-medium" dangerouslySetInnerHTML={{ __html: rate.name }} />
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||||
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
|
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
<span className={`text-xs px-2 py-0.5 rounded-full ${rate.enabled
|
||||||
rate.enabled
|
? 'bg-green-100 text-green-700'
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-gray-100 text-gray-600'
|
: 'bg-gray-100 text-gray-600'
|
||||||
}`}>
|
}`}>
|
||||||
{rate.enabled ? __('On') : __('Off')}
|
{rate.enabled ? __('On') : __('Off')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -521,7 +521,7 @@ export default function ShippingPage() {
|
|||||||
placeholder={methodSettings[rate.instance_id].settings.cost.placeholder || '0'}
|
placeholder={methodSettings[rate.instance_id].settings.cost.placeholder || '0'}
|
||||||
/>
|
/>
|
||||||
{methodSettings[rate.instance_id].settings.cost.description && (
|
{methodSettings[rate.instance_id].settings.cost.description && (
|
||||||
<p
|
<p
|
||||||
className="text-xs text-muted-foreground mt-1"
|
className="text-xs text-muted-foreground mt-1"
|
||||||
dangerouslySetInnerHTML={{ __html: methodSettings[rate.instance_id].settings.cost.description }}
|
dangerouslySetInnerHTML={{ __html: methodSettings[rate.instance_id].settings.cost.description }}
|
||||||
/>
|
/>
|
||||||
@@ -541,7 +541,7 @@ export default function ShippingPage() {
|
|||||||
placeholder={methodSettings[rate.instance_id].settings.min_amount.placeholder || '0'}
|
placeholder={methodSettings[rate.instance_id].settings.min_amount.placeholder || '0'}
|
||||||
/>
|
/>
|
||||||
{methodSettings[rate.instance_id].settings.min_amount.description && (
|
{methodSettings[rate.instance_id].settings.min_amount.description && (
|
||||||
<p
|
<p
|
||||||
className="text-xs text-muted-foreground mt-1"
|
className="text-xs text-muted-foreground mt-1"
|
||||||
dangerouslySetInnerHTML={{ __html: methodSettings[rate.instance_id].settings.min_amount.description }}
|
dangerouslySetInnerHTML={{ __html: methodSettings[rate.instance_id].settings.min_amount.description }}
|
||||||
/>
|
/>
|
||||||
@@ -577,11 +577,11 @@ export default function ShippingPage() {
|
|||||||
const titleInput = document.getElementById(`method-title-${rate.instance_id}`) as HTMLInputElement;
|
const titleInput = document.getElementById(`method-title-${rate.instance_id}`) as HTMLInputElement;
|
||||||
const costInput = document.getElementById(`method-cost-${rate.instance_id}`) as HTMLInputElement;
|
const costInput = document.getElementById(`method-cost-${rate.instance_id}`) as HTMLInputElement;
|
||||||
const minAmountInput = document.getElementById(`method-min-amount-${rate.instance_id}`) as HTMLInputElement;
|
const minAmountInput = document.getElementById(`method-min-amount-${rate.instance_id}`) as HTMLInputElement;
|
||||||
|
|
||||||
if (titleInput) settings.title = titleInput.value;
|
if (titleInput) settings.title = titleInput.value;
|
||||||
if (costInput) settings.cost = costInput.value;
|
if (costInput) settings.cost = costInput.value;
|
||||||
if (minAmountInput) settings.min_amount = minAmountInput.value;
|
if (minAmountInput) settings.min_amount = minAmountInput.value;
|
||||||
|
|
||||||
updateSettingsMutation.mutate({
|
updateSettingsMutation.mutate({
|
||||||
zoneId: selectedZone.id,
|
zoneId: selectedZone.id,
|
||||||
instanceId: rate.instance_id,
|
instanceId: rate.instance_id,
|
||||||
@@ -695,11 +695,10 @@ export default function ShippingPage() {
|
|||||||
<div className="font-medium text-sm line-clamp-1" dangerouslySetInnerHTML={{ __html: rate.name }} />
|
<div className="font-medium text-sm line-clamp-1" dangerouslySetInnerHTML={{ __html: rate.name }} />
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||||
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
|
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
|
||||||
<span className={`px-1.5 py-0.5 rounded-full whitespace-nowrap ${
|
<span className={`px-1.5 py-0.5 rounded-full whitespace-nowrap ${rate.enabled
|
||||||
rate.enabled
|
? 'bg-green-100 text-green-700'
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-gray-100 text-gray-600'
|
: 'bg-gray-100 text-gray-600'
|
||||||
}`}>
|
}`}>
|
||||||
{rate.enabled ? __('On') : __('Off')}
|
{rate.enabled ? __('On') : __('Off')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -732,7 +731,7 @@ export default function ShippingPage() {
|
|||||||
placeholder={methodSettings[rate.instance_id].settings.cost.placeholder || '0'}
|
placeholder={methodSettings[rate.instance_id].settings.cost.placeholder || '0'}
|
||||||
/>
|
/>
|
||||||
{methodSettings[rate.instance_id].settings.cost.description && (
|
{methodSettings[rate.instance_id].settings.cost.description && (
|
||||||
<p
|
<p
|
||||||
className="text-xs text-muted-foreground mt-1"
|
className="text-xs text-muted-foreground mt-1"
|
||||||
dangerouslySetInnerHTML={{ __html: methodSettings[rate.instance_id].settings.cost.description }}
|
dangerouslySetInnerHTML={{ __html: methodSettings[rate.instance_id].settings.cost.description }}
|
||||||
/>
|
/>
|
||||||
@@ -782,11 +781,11 @@ export default function ShippingPage() {
|
|||||||
const titleInput = document.getElementById(`method-title-mobile-${rate.instance_id}`) as HTMLInputElement;
|
const titleInput = document.getElementById(`method-title-mobile-${rate.instance_id}`) as HTMLInputElement;
|
||||||
const costInput = document.getElementById(`method-cost-mobile-${rate.instance_id}`) as HTMLInputElement;
|
const costInput = document.getElementById(`method-cost-mobile-${rate.instance_id}`) as HTMLInputElement;
|
||||||
const minAmountInput = document.getElementById(`method-min-amount-mobile-${rate.instance_id}`) as HTMLInputElement;
|
const minAmountInput = document.getElementById(`method-min-amount-mobile-${rate.instance_id}`) as HTMLInputElement;
|
||||||
|
|
||||||
if (titleInput) settings.title = titleInput.value;
|
if (titleInput) settings.title = titleInput.value;
|
||||||
if (costInput) settings.cost = costInput.value;
|
if (costInput) settings.cost = costInput.value;
|
||||||
if (minAmountInput) settings.min_amount = minAmountInput.value;
|
if (minAmountInput) settings.min_amount = minAmountInput.value;
|
||||||
|
|
||||||
updateSettingsMutation.mutate({
|
updateSettingsMutation.mutate({
|
||||||
zoneId: selectedZone.id,
|
zoneId: selectedZone.id,
|
||||||
instanceId: rate.instance_id,
|
instanceId: rate.instance_id,
|
||||||
@@ -934,7 +933,7 @@ export default function ShippingPage() {
|
|||||||
<p className="text-xs text-muted-foreground mb-3">
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
{__('Select countries, states, or continents for this zone')}
|
{__('Select countries, states, or continents for this zone')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Search Filter */}
|
{/* Search Filter */}
|
||||||
<input
|
<input
|
||||||
placeholder={__('Search regions...')}
|
placeholder={__('Search regions...')}
|
||||||
@@ -942,7 +941,7 @@ export default function ShippingPage() {
|
|||||||
onChange={(e) => setRegionSearch(e.target.value)}
|
onChange={(e) => setRegionSearch(e.target.value)}
|
||||||
className="w-full px-3 py-2 border rounded-md mb-2"
|
className="w-full px-3 py-2 border rounded-md mb-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="border rounded-md max-h-[300px] overflow-y-auto">
|
<div className="border rounded-md max-h-[300px] overflow-y-auto">
|
||||||
{availableLocations.length === 0 ? (
|
{availableLocations.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
@@ -951,7 +950,7 @@ export default function ShippingPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{availableLocations
|
{availableLocations
|
||||||
.filter((location: any) =>
|
.filter((location: any) =>
|
||||||
location.label.toLowerCase().includes(regionSearch.toLowerCase())
|
location.label.toLowerCase().includes(regionSearch.toLowerCase())
|
||||||
)
|
)
|
||||||
.map((location: any) => (
|
.map((location: any) => (
|
||||||
@@ -969,13 +968,13 @@ export default function ShippingPage() {
|
|||||||
<span className="text-sm">{location.label}</span>
|
<span className="text-sm">{location.label}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
{availableLocations.filter((location: any) =>
|
{availableLocations.filter((location: any) =>
|
||||||
location.label.toLowerCase().includes(regionSearch.toLowerCase())
|
location.label.toLowerCase().includes(regionSearch.toLowerCase())
|
||||||
).length === 0 && (
|
).length === 0 && (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
{__('No regions found')}
|
{__('No regions found')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
admin-spa/src/types/window.d.ts
vendored
6
admin-spa/src/types/window.d.ts
vendored
@@ -41,6 +41,10 @@ interface WNW_CONFIG {
|
|||||||
decimalSeparator: string;
|
decimalSeparator: string;
|
||||||
decimals: number;
|
decimals: number;
|
||||||
};
|
};
|
||||||
|
storeUrl?: string;
|
||||||
|
customerSpaEnabled?: boolean;
|
||||||
|
nonce?: string;
|
||||||
|
pluginUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -52,4 +56,4 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export { };
|
||||||
|
|||||||
@@ -22,5 +22,5 @@ module.exports = {
|
|||||||
borderRadius: { lg: "12px", md: "10px", sm: "8px" }
|
borderRadius: { lg: "12px", md: "10px", sm: "8px" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")]
|
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")]
|
||||||
};
|
};
|
||||||
@@ -40,7 +40,7 @@ rsync -av --progress \
|
|||||||
--exclude='admin-spa' \
|
--exclude='admin-spa' \
|
||||||
--exclude='examples' \
|
--exclude='examples' \
|
||||||
--exclude='*.sh' \
|
--exclude='*.sh' \
|
||||||
--exclude='*.md' \
|
--exclude='/*.md' \
|
||||||
--exclude='archive' \
|
--exclude='archive' \
|
||||||
--exclude='test-*.php' \
|
--exclude='test-*.php' \
|
||||||
--exclude='check-*.php' \
|
--exclude='check-*.php' \
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
53
customer-spa/package-lock.json
generated
53
customer-spa/package-lock.json
generated
@@ -25,9 +25,11 @@
|
|||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^0.547.0",
|
"lucide-react": "^0.547.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -3596,6 +3598,22 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cmdk": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
|
"@radix-ui/react-id": "^1.1.0",
|
||||||
|
"@radix-ui/react-primitive": "^2.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -4927,6 +4945,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/invariant": {
|
||||||
|
"version": "2.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
|
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -6214,6 +6241,26 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-fast-compare": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/react-helmet-async": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"invariant": "^2.2.4",
|
||||||
|
"react-fast-compare": "^3.2.2",
|
||||||
|
"shallowequal": "^1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.6.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.66.1",
|
"version": "7.66.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
|
||||||
@@ -6658,6 +6705,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/shallowequal": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -27,9 +27,11 @@
|
|||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^0.547.0",
|
"lucide-react": "^0.547.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { HashRouter, BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { HelmetProvider } from 'react-helmet-async';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
@@ -18,6 +19,8 @@ import Wishlist from './pages/Wishlist';
|
|||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import ForgotPassword from './pages/ForgotPassword';
|
import ForgotPassword from './pages/ForgotPassword';
|
||||||
import ResetPassword from './pages/ResetPassword';
|
import ResetPassword from './pages/ResetPassword';
|
||||||
|
import OrderPay from './pages/OrderPay';
|
||||||
|
import { DynamicPageRenderer } from './pages/DynamicPage';
|
||||||
|
|
||||||
// Create QueryClient instance
|
// Create QueryClient instance
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -54,70 +57,142 @@ const getAppearanceSettings = () => {
|
|||||||
return (window as any).woonoowCustomer?.appearanceSettings || {};
|
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 getInitialRoute = () => {
|
||||||
const appEl = document.getElementById('woonoow-customer-app');
|
const appEl = document.getElementById('woonoow-customer-app');
|
||||||
const initialRoute = appEl?.getAttribute('data-initial-route');
|
const initialRoute = appEl?.getAttribute('data-initial-route');
|
||||||
console.log('[WooNooW Customer] Initial route from data attribute:', initialRoute);
|
if (initialRoute) return initialRoute;
|
||||||
console.log('[WooNooW Customer] App element:', appEl);
|
|
||||||
console.log('[WooNooW Customer] All data attributes:', appEl?.dataset);
|
// Derive from SPA mode if no explicit route
|
||||||
return initialRoute || '/shop'; // Default to shop if not specified
|
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
|
// Router wrapper component that uses hooks requiring Router context
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const initialRoute = getInitialRoute();
|
const initialRoute = getInitialRoute();
|
||||||
console.log('[WooNooW Customer] Using initial route:', initialRoute);
|
const frontPageSlug = getFrontPageSlug();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseLayout>
|
<Routes>
|
||||||
<Routes>
|
{/* License Connect - Standalone focused page without layout */}
|
||||||
{/* Root route redirects to initial route based on SPA mode */}
|
<Route path="/my-account/license-connect" element={<Account />} />
|
||||||
<Route path="/" element={<Navigate to={initialRoute} replace />} />
|
|
||||||
|
|
||||||
{/* Shop Routes */}
|
{/* All other routes wrapped in BaseLayout */}
|
||||||
<Route path="/shop" element={<Shop />} />
|
<Route
|
||||||
<Route path="/product/:slug" element={<Product />} />
|
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 */}
|
{/* Shop Routes */}
|
||||||
<Route path="/cart" element={<Cart />} />
|
<Route path="/shop" element={<Shop />} />
|
||||||
<Route path="/checkout" element={<Checkout />} />
|
<Route path="/product/:slug" element={<Product />} />
|
||||||
<Route path="/order-received/:orderId" element={<ThankYou />} />
|
|
||||||
|
|
||||||
{/* Wishlist - Public route accessible to guests */}
|
{/* Cart & Checkout */}
|
||||||
<Route path="/wishlist" element={<Wishlist />} />
|
<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 */}
|
{/* Wishlist - Public route accessible to guests */}
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/wishlist" element={<Wishlist />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
|
||||||
<Route path="/reset-password" element={<ResetPassword />} />
|
|
||||||
|
|
||||||
{/* My Account */}
|
{/* Login & Auth */}
|
||||||
<Route path="/my-account/*" element={<Account />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
|
|
||||||
{/* Fallback to initial route */}
|
{/* My Account */}
|
||||||
<Route path="*" element={<Navigate to={initialRoute} replace />} />
|
<Route path="/my-account/*" element={<Account />} />
|
||||||
</Routes>
|
|
||||||
</BaseLayout>
|
{/* Dynamic Pages - CPT content with path base (e.g., /blog/:slug) */}
|
||||||
|
<Route path="/:pathBase/:slug" element={<DynamicPageRenderer />} />
|
||||||
|
|
||||||
|
{/* Dynamic Pages - Structural pages (e.g., /about, /contact) */}
|
||||||
|
<Route path="/:slug" element={<DynamicPageRenderer />} />
|
||||||
|
</Routes>
|
||||||
|
</BaseLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get router config from WordPress
|
||||||
|
const getRouterConfig = () => {
|
||||||
|
const config = (window as any).woonoowCustomer;
|
||||||
|
return {
|
||||||
|
useBrowserRouter: config?.useBrowserRouter ?? true,
|
||||||
|
basePath: config?.basePath ?? '/store',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Router wrapper that conditionally uses BrowserRouter or HashRouter
|
||||||
|
function RouterProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { useBrowserRouter, basePath } = getRouterConfig();
|
||||||
|
|
||||||
|
if (useBrowserRouter) {
|
||||||
|
return <BrowserRouter basename={basePath}>{children}</BrowserRouter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <HashRouter>{children}</HashRouter>;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const themeConfig = getThemeConfig();
|
const themeConfig = getThemeConfig();
|
||||||
const appearanceSettings = getAppearanceSettings();
|
const appearanceSettings = getAppearanceSettings();
|
||||||
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
||||||
|
|
||||||
return (
|
// Inject gradient CSS variables
|
||||||
<QueryClientProvider client={queryClient}>
|
React.useEffect(() => {
|
||||||
<ThemeProvider config={themeConfig}>
|
// appearanceSettings is already the 'data' object from Assets.php injection
|
||||||
<HashRouter>
|
// Structure: { general: { colors: { primary, secondary, accent, text, background, gradientStart, gradientEnd } } }
|
||||||
<AppRoutes />
|
const colors = appearanceSettings?.general?.colors;
|
||||||
</HashRouter>
|
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]);
|
||||||
|
|
||||||
{/* Toast notifications - position from settings */}
|
return (
|
||||||
<Toaster position={toastPosition} richColors />
|
<HelmetProvider>
|
||||||
</ThemeProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
</QueryClientProvider>
|
<ThemeProvider config={themeConfig}>
|
||||||
|
<RouterProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</RouterProvider>
|
||||||
|
|
||||||
|
{/* Toast notifications - position from settings */}
|
||||||
|
<Toaster position={toastPosition} richColors />
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</HelmetProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
296
customer-spa/src/components/DynamicCheckoutField.tsx
Normal file
296
customer-spa/src/components/DynamicCheckoutField.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||||
|
import { api } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface CheckoutField {
|
||||||
|
key: string;
|
||||||
|
fieldset: 'billing' | 'shipping' | 'account' | 'order';
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required: boolean;
|
||||||
|
hidden: boolean;
|
||||||
|
class?: string[];
|
||||||
|
priority: number;
|
||||||
|
options?: Record<string, string> | null;
|
||||||
|
custom: boolean;
|
||||||
|
autocomplete?: string;
|
||||||
|
validate?: string[];
|
||||||
|
input_class?: string[];
|
||||||
|
custom_attributes?: Record<string, string>;
|
||||||
|
default?: string;
|
||||||
|
// For searchable_select type
|
||||||
|
search_endpoint?: string | null;
|
||||||
|
search_param?: string;
|
||||||
|
min_chars?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DynamicCheckoutFieldProps {
|
||||||
|
field: CheckoutField;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
countryOptions?: { value: string; label: string }[];
|
||||||
|
stateOptions?: { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicCheckoutField({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
countryOptions = [],
|
||||||
|
stateOptions = [],
|
||||||
|
}: DynamicCheckoutFieldProps) {
|
||||||
|
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
// For searchable_select with API endpoint
|
||||||
|
useEffect(() => {
|
||||||
|
if (field.type !== 'searchable_select' || !field.search_endpoint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a value but no options yet, we might need to load it
|
||||||
|
// This handles pre-selected values
|
||||||
|
}, [field.type, field.search_endpoint, value]);
|
||||||
|
|
||||||
|
// Handle API search for searchable_select
|
||||||
|
const handleApiSearch = async (searchTerm: string) => {
|
||||||
|
if (!field.search_endpoint) return;
|
||||||
|
|
||||||
|
const minChars = field.min_chars || 2;
|
||||||
|
if (searchTerm.length < minChars) {
|
||||||
|
setSearchOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const param = field.search_param || 'search';
|
||||||
|
const results = await api.get<SearchOption[]>(field.search_endpoint, { [param]: searchTerm });
|
||||||
|
setSearchOptions(Array.isArray(results) ? results : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setSearchOptions([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't render hidden fields
|
||||||
|
if (field.hidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get field key without prefix (billing_, shipping_)
|
||||||
|
const fieldName = field.key.replace(/^(billing_|shipping_)/, '');
|
||||||
|
|
||||||
|
// Determine CSS classes
|
||||||
|
const isWide = ['address_1', 'address_2', 'email'].includes(fieldName) ||
|
||||||
|
field.class?.includes('form-row-wide');
|
||||||
|
const wrapperClass = isWide ? 'md:col-span-2' : '';
|
||||||
|
|
||||||
|
// Render based on type
|
||||||
|
const renderInput = () => {
|
||||||
|
switch (field.type) {
|
||||||
|
case 'country':
|
||||||
|
return (
|
||||||
|
<SearchableSelect
|
||||||
|
options={countryOptions}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={field.placeholder || 'Select country'}
|
||||||
|
disabled={countryOptions.length <= 1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'state':
|
||||||
|
return stateOptions.length > 0 ? (
|
||||||
|
<SearchableSelect
|
||||||
|
options={stateOptions}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={field.placeholder || 'Select state'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
autoComplete={field.autocomplete}
|
||||||
|
className="w-full border !rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'select':
|
||||||
|
if (field.options && Object.keys(field.options).length > 0) {
|
||||||
|
const options = Object.entries(field.options).map(([val, label]) => ({
|
||||||
|
value: val,
|
||||||
|
label: String(label),
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<SearchableSelect
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={field.placeholder || `Select ${field.label}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'searchable_select':
|
||||||
|
return (
|
||||||
|
<SearchableSelect
|
||||||
|
options={searchOptions}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => {
|
||||||
|
onChange(v);
|
||||||
|
// Also store label for display
|
||||||
|
const selected = searchOptions.find(o => o.value === v);
|
||||||
|
if (selected) {
|
||||||
|
// Store label in a hidden field with _label suffix
|
||||||
|
const event = new CustomEvent('woonoow:field_label', {
|
||||||
|
detail: { key: field.key + '_label', value: selected.label }
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSearch={handleApiSearch}
|
||||||
|
isSearching={isSearching}
|
||||||
|
placeholder={field.placeholder || `Search ${field.label}...`}
|
||||||
|
emptyLabel={
|
||||||
|
isSearching
|
||||||
|
? 'Searching...'
|
||||||
|
: `Type at least ${field.min_chars || 2} characters to search`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'textarea':
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
className="w-full border !rounded-lg px-4 py-2 min-h-[100px]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'checkbox':
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value === '1' || value === 'true'}
|
||||||
|
onChange={(e) => onChange(e.target.checked ? '1' : '0')}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>{field.label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'radio':
|
||||||
|
if (field.options) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(field.options).map(([val, label]) => (
|
||||||
|
<label key={val} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={field.key}
|
||||||
|
value={val}
|
||||||
|
checked={value === val}
|
||||||
|
onChange={() => onChange(val)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>{String(label)}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'email':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
autoComplete={field.autocomplete || 'email'}
|
||||||
|
className="w-full border !rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'tel':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
autoComplete={field.autocomplete || 'tel'}
|
||||||
|
className="w-full border !rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'password':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
className="w-full border !rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Default: text input
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
autoComplete={field.autocomplete}
|
||||||
|
className="w-full border !rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't render label for checkbox (it's inline)
|
||||||
|
if (field.type === 'checkbox') {
|
||||||
|
return (
|
||||||
|
<div className={wrapperClass}>
|
||||||
|
{renderInput()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={wrapperClass}>
|
||||||
|
<label className="block text-sm font-medium mb-2">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
{renderInput()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CheckoutField };
|
||||||
68
customer-spa/src/components/SEOHead.tsx
Normal file
68
customer-spa/src/components/SEOHead.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
|
||||||
|
interface SEOHeadProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
image?: string;
|
||||||
|
url?: string;
|
||||||
|
type?: 'website' | 'product' | 'article';
|
||||||
|
product?: {
|
||||||
|
price?: string;
|
||||||
|
currency?: string;
|
||||||
|
availability?: 'in stock' | 'out of stock';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEOHead Component
|
||||||
|
* Adds dynamic meta tags for social media sharing (Open Graph, Twitter Cards)
|
||||||
|
* Used for link previews on Facebook, Twitter, Slack, etc.
|
||||||
|
*/
|
||||||
|
export function SEOHead({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
url,
|
||||||
|
type = 'website',
|
||||||
|
product,
|
||||||
|
}: SEOHeadProps) {
|
||||||
|
const config = (window as any).woonoowCustomer;
|
||||||
|
const siteName = config?.siteName || 'Store';
|
||||||
|
const siteUrl = config?.siteUrl || '';
|
||||||
|
|
||||||
|
const fullTitle = title ? `${title} | ${siteName}` : siteName;
|
||||||
|
const fullUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Helmet>
|
||||||
|
{/* Basic Meta Tags */}
|
||||||
|
<title>{fullTitle}</title>
|
||||||
|
{description && <meta name="description" content={description} />}
|
||||||
|
|
||||||
|
{/* Open Graph (Facebook, LinkedIn, etc.) */}
|
||||||
|
<meta property="og:site_name" content={siteName} />
|
||||||
|
<meta property="og:title" content={title || siteName} />
|
||||||
|
{description && <meta property="og:description" content={description} />}
|
||||||
|
<meta property="og:type" content={type} />
|
||||||
|
<meta property="og:url" content={fullUrl} />
|
||||||
|
{image && <meta property="og:image" content={image} />}
|
||||||
|
|
||||||
|
{/* Twitter Card */}
|
||||||
|
<meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'} />
|
||||||
|
<meta name="twitter:title" content={title || siteName} />
|
||||||
|
{description && <meta name="twitter:description" content={description} />}
|
||||||
|
{image && <meta name="twitter:image" content={image} />}
|
||||||
|
|
||||||
|
{/* Product-specific meta tags */}
|
||||||
|
{type === 'product' && product && (
|
||||||
|
<>
|
||||||
|
<meta property="product:price:amount" content={product.price} />
|
||||||
|
<meta property="product:price:currency" content={product.currency} />
|
||||||
|
<meta property="product:availability" content={product.availability} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Helmet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SEOHead;
|
||||||
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;
|
||||||
107
customer-spa/src/components/ui/command.tsx
Normal file
107
customer-spa/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { Search } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-white text-gray-900",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Command.displayName = CommandPrimitive.displayName
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-gray-400 disabled:cursor-not-allowed disabled:opacity-50 border-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
ref={ref}
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-gray-900",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-gray-100 data-[selected=true]:text-gray-900 data-[disabled=true]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
}
|
||||||
30
customer-spa/src/components/ui/popover.tsx
Normal file
30
customer-spa/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-white p-4 shadow-md outline-none 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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
123
customer-spa/src/components/ui/searchable-select.tsx
Normal file
123
customer-spa/src/components/ui/searchable-select.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandItem,
|
||||||
|
CommandEmpty,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface Option {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
searchText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (v: string) => void;
|
||||||
|
options: Option[];
|
||||||
|
placeholder?: string;
|
||||||
|
emptyLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
// For API-based search
|
||||||
|
onSearch?: (searchTerm: string) => void;
|
||||||
|
isSearching?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchableSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder = "Select...",
|
||||||
|
emptyLabel = "No results found.",
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
onSearch,
|
||||||
|
isSearching = false,
|
||||||
|
}: Props) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [searchValue, setSearchValue] = React.useState("");
|
||||||
|
const selected = options.find((o) => o.value === value);
|
||||||
|
|
||||||
|
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
|
||||||
|
|
||||||
|
// Handle search input changes
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
setSearchValue(value);
|
||||||
|
if (onSearch) {
|
||||||
|
onSearch(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine if we should use local filtering or API-based search
|
||||||
|
const shouldFilter = !onSearch;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={disabled ? false : open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
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:ring-2 focus:ring-primary/20",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
>
|
||||||
|
<span className={selected ? "text-gray-900" : "text-gray-400"}>
|
||||||
|
{selected ? selected.label : placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="opacity-50 h-4 w-4 shrink-0" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0 w-[--radix-popover-trigger-width]"
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<Command shouldFilter={shouldFilter}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchValue}
|
||||||
|
onValueChange={handleSearchChange}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
{isSearching ? "Searching..." : emptyLabel}
|
||||||
|
</CommandEmpty>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<CommandItem
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.searchText || opt.label || opt.value}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange?.(opt.value);
|
||||||
|
setOpen(false);
|
||||||
|
setSearchValue("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4 flex-shrink-0",
|
||||||
|
opt.value === value ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export function useAddToCartFromUrl() {
|
|||||||
const hash = window.location.hash;
|
const hash = window.location.hash;
|
||||||
const hashParams = new URLSearchParams(hash.split('?')[1] || '');
|
const hashParams = new URLSearchParams(hash.split('?')[1] || '');
|
||||||
const productId = hashParams.get('add-to-cart');
|
const productId = hashParams.get('add-to-cart');
|
||||||
|
|
||||||
if (!productId) return;
|
if (!productId) return;
|
||||||
|
|
||||||
const variationId = hashParams.get('variation_id');
|
const variationId = hashParams.get('variation_id');
|
||||||
@@ -34,41 +34,29 @@ export function useAddToCartFromUrl() {
|
|||||||
|
|
||||||
// Create unique key for this add-to-cart request
|
// Create unique key for this add-to-cart request
|
||||||
const requestKey = `${productId}-${variationId || 'none'}-${quantity}`;
|
const requestKey = `${productId}-${variationId || 'none'}-${quantity}`;
|
||||||
|
|
||||||
// Skip if already processed
|
// Skip if already processed
|
||||||
if (processedRef.current.has(requestKey)) {
|
if (processedRef.current.has(requestKey)) {
|
||||||
console.log('[WooNooW] Skipping duplicate add-to-cart:', requestKey);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[WooNooW] Add to cart from URL:', {
|
|
||||||
productId,
|
|
||||||
variationId,
|
|
||||||
quantity,
|
|
||||||
redirect,
|
|
||||||
fullUrl: window.location.href,
|
|
||||||
requestKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark as processed
|
// Mark as processed
|
||||||
processedRef.current.add(requestKey);
|
processedRef.current.add(requestKey);
|
||||||
|
|
||||||
addToCart(productId, variationId, quantity)
|
addToCart(productId, variationId, quantity)
|
||||||
.then((cartData) => {
|
.then((cartData) => {
|
||||||
// Update cart store with fresh data from API
|
// Update cart store with fresh data from API
|
||||||
if (cartData) {
|
if (cartData) {
|
||||||
setCart(cartData);
|
setCart(cartData);
|
||||||
console.log('[WooNooW] Cart updated with fresh data:', cartData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove URL parameters after adding to cart
|
// Remove URL parameters after adding to cart
|
||||||
const currentPath = window.location.hash.split('?')[0];
|
const currentPath = window.location.hash.split('?')[0];
|
||||||
window.location.hash = currentPath;
|
window.location.hash = currentPath;
|
||||||
|
|
||||||
// Navigate based on redirect parameter
|
// Navigate based on redirect parameter
|
||||||
const targetPage = redirect === 'checkout' ? '/checkout' : '/cart';
|
const targetPage = redirect === 'checkout' ? '/checkout' : '/cart';
|
||||||
if (!location.pathname.includes(targetPage)) {
|
if (!location.pathname.includes(targetPage)) {
|
||||||
console.log(`[WooNooW] Navigating to ${targetPage}`);
|
|
||||||
navigate(targetPage);
|
navigate(targetPage);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -98,8 +86,6 @@ async function addToCart(
|
|||||||
body.variation_id = parseInt(variationId, 10);
|
body.variation_id = parseInt(variationId, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[WooNooW] Adding to cart:', body);
|
|
||||||
|
|
||||||
const response = await fetch(`${apiRoot}/cart/add`, {
|
const response = await fetch(`${apiRoot}/cart/add`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -116,8 +102,7 @@ async function addToCart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('[WooNooW] Product added to cart:', data);
|
|
||||||
|
|
||||||
// API returns {message, cart_item_key, cart} on success
|
// API returns {message, cart_item_key, cart} on success
|
||||||
if (data.cart_item_key && data.cart) {
|
if (data.cart_item_key && data.cart) {
|
||||||
toast.success(data.message || 'Product added to cart');
|
toast.success(data.message || 'Product added to cart');
|
||||||
|
|||||||
@@ -37,25 +37,35 @@ interface AppearanceSettings {
|
|||||||
thankyou: any;
|
thankyou: any;
|
||||||
account: any;
|
account: any;
|
||||||
};
|
};
|
||||||
|
menus: {
|
||||||
|
primary: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: 'page' | 'custom';
|
||||||
|
value: string;
|
||||||
|
target: '_self' | '_blank';
|
||||||
|
}>;
|
||||||
|
mobile: Array<any>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAppearanceSettings() {
|
export function useAppearanceSettings() {
|
||||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||||
|
|
||||||
// Get preloaded settings from window object
|
// Get preloaded settings from window object
|
||||||
const preloadedSettings = (window as any).woonoowCustomer?.appearanceSettings;
|
const preloadedSettings = (window as any).woonoowCustomer?.appearanceSettings;
|
||||||
|
|
||||||
return useQuery<AppearanceSettings>({
|
return useQuery<AppearanceSettings>({
|
||||||
queryKey: ['appearance-settings'],
|
queryKey: ['appearance-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`${apiRoot}/appearance/settings`, {
|
const response = await fetch(`${apiRoot}/appearance/settings`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch appearance settings');
|
throw new Error('Failed to fetch appearance settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
@@ -68,7 +78,7 @@ export function useAppearanceSettings() {
|
|||||||
|
|
||||||
export function useShopSettings() {
|
export function useShopSettings() {
|
||||||
const { data, isLoading } = useAppearanceSettings();
|
const { data, isLoading } = useAppearanceSettings();
|
||||||
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
layout: {
|
layout: {
|
||||||
grid_columns: '3' as string,
|
grid_columns: '3' as string,
|
||||||
@@ -93,7 +103,7 @@ export function useShopSettings() {
|
|||||||
show_icon: true,
|
show_icon: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout: { ...defaultSettings.layout, ...(data?.pages?.shop?.layout || {}) },
|
layout: { ...defaultSettings.layout, ...(data?.pages?.shop?.layout || {}) },
|
||||||
elements: { ...defaultSettings.elements, ...(data?.pages?.shop?.elements || {}) },
|
elements: { ...defaultSettings.elements, ...(data?.pages?.shop?.elements || {}) },
|
||||||
@@ -105,7 +115,7 @@ export function useShopSettings() {
|
|||||||
|
|
||||||
export function useProductSettings() {
|
export function useProductSettings() {
|
||||||
const { data, isLoading } = useAppearanceSettings();
|
const { data, isLoading } = useAppearanceSettings();
|
||||||
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
layout: {
|
layout: {
|
||||||
image_position: 'left' as string,
|
image_position: 'left' as string,
|
||||||
@@ -127,7 +137,7 @@ export function useProductSettings() {
|
|||||||
hide_if_empty: true,
|
hide_if_empty: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout: { ...defaultSettings.layout, ...(data?.pages?.product?.layout || {}) },
|
layout: { ...defaultSettings.layout, ...(data?.pages?.product?.layout || {}) },
|
||||||
elements: { ...defaultSettings.elements, ...(data?.pages?.product?.elements || {}) },
|
elements: { ...defaultSettings.elements, ...(data?.pages?.product?.elements || {}) },
|
||||||
@@ -139,7 +149,7 @@ export function useProductSettings() {
|
|||||||
|
|
||||||
export function useCartSettings() {
|
export function useCartSettings() {
|
||||||
const { data, isLoading } = useAppearanceSettings();
|
const { data, isLoading } = useAppearanceSettings();
|
||||||
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
layout: {
|
layout: {
|
||||||
style: 'fullwidth' as string,
|
style: 'fullwidth' as string,
|
||||||
@@ -152,7 +162,7 @@ export function useCartSettings() {
|
|||||||
shipping_calculator: false,
|
shipping_calculator: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout: { ...defaultSettings.layout, ...(data?.pages?.cart?.layout || {}) },
|
layout: { ...defaultSettings.layout, ...(data?.pages?.cart?.layout || {}) },
|
||||||
elements: { ...defaultSettings.elements, ...(data?.pages?.cart?.elements || {}) },
|
elements: { ...defaultSettings.elements, ...(data?.pages?.cart?.elements || {}) },
|
||||||
@@ -162,7 +172,7 @@ export function useCartSettings() {
|
|||||||
|
|
||||||
export function useCheckoutSettings() {
|
export function useCheckoutSettings() {
|
||||||
const { data, isLoading } = useAppearanceSettings();
|
const { data, isLoading } = useAppearanceSettings();
|
||||||
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
layout: {
|
layout: {
|
||||||
style: 'two-column' as string,
|
style: 'two-column' as string,
|
||||||
@@ -178,7 +188,7 @@ export function useCheckoutSettings() {
|
|||||||
payment_icons: true,
|
payment_icons: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout: { ...defaultSettings.layout, ...(data?.pages?.checkout?.layout || {}) },
|
layout: { ...defaultSettings.layout, ...(data?.pages?.checkout?.layout || {}) },
|
||||||
elements: { ...defaultSettings.elements, ...(data?.pages?.checkout?.elements || {}) },
|
elements: { ...defaultSettings.elements, ...(data?.pages?.checkout?.elements || {}) },
|
||||||
@@ -188,7 +198,7 @@ export function useCheckoutSettings() {
|
|||||||
|
|
||||||
export function useThankYouSettings() {
|
export function useThankYouSettings() {
|
||||||
const { data, isLoading } = useAppearanceSettings();
|
const { data, isLoading } = useAppearanceSettings();
|
||||||
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
template: 'basic',
|
template: 'basic',
|
||||||
header_visibility: 'show',
|
header_visibility: 'show',
|
||||||
@@ -201,7 +211,7 @@ export function useThankYouSettings() {
|
|||||||
related_products: false,
|
related_products: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
template: data?.pages?.thankyou?.template || defaultSettings.template,
|
template: data?.pages?.thankyou?.template || defaultSettings.template,
|
||||||
headerVisibility: data?.pages?.thankyou?.header_visibility || defaultSettings.header_visibility,
|
headerVisibility: data?.pages?.thankyou?.header_visibility || defaultSettings.header_visibility,
|
||||||
@@ -215,7 +225,7 @@ export function useThankYouSettings() {
|
|||||||
|
|
||||||
export function useAccountSettings() {
|
export function useAccountSettings() {
|
||||||
const { data, isLoading } = useAppearanceSettings();
|
const { data, isLoading } = useAppearanceSettings();
|
||||||
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
layout: {
|
layout: {
|
||||||
navigation_style: 'sidebar' as string,
|
navigation_style: 'sidebar' as string,
|
||||||
@@ -228,7 +238,7 @@ export function useAccountSettings() {
|
|||||||
account_details: true,
|
account_details: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout: { ...defaultSettings.layout, ...(data?.pages?.account?.layout || {}) },
|
layout: { ...defaultSettings.layout, ...(data?.pages?.account?.layout || {}) },
|
||||||
elements: { ...defaultSettings.elements, ...(data?.pages?.account?.elements || {}) },
|
elements: { ...defaultSettings.elements, ...(data?.pages?.account?.elements || {}) },
|
||||||
@@ -238,7 +248,7 @@ export function useAccountSettings() {
|
|||||||
|
|
||||||
export function useHeaderSettings() {
|
export function useHeaderSettings() {
|
||||||
const { data, isLoading } = useAppearanceSettings();
|
const { data, isLoading } = useAppearanceSettings();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
style: data?.header?.style ?? 'classic',
|
style: data?.header?.style ?? 'classic',
|
||||||
sticky: data?.header?.sticky ?? true,
|
sticky: data?.header?.sticky ?? true,
|
||||||
@@ -261,7 +271,7 @@ export function useHeaderSettings() {
|
|||||||
|
|
||||||
export function useFooterSettings() {
|
export function useFooterSettings() {
|
||||||
const { data, isLoading } = useAppearanceSettings();
|
const { data, isLoading } = useAppearanceSettings();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
columns: data?.footer?.columns ?? '4',
|
columns: data?.footer?.columns ?? '4',
|
||||||
style: data?.footer?.style ?? 'detailed',
|
style: data?.footer?.style ?? 'detailed',
|
||||||
@@ -293,4 +303,15 @@ export function useFooterSettings() {
|
|||||||
},
|
},
|
||||||
isLoading,
|
isLoading,
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export function useMenuSettings() {
|
||||||
|
const { data, isLoading } = useAppearanceSettings();
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary: data?.menus?.primary ?? [],
|
||||||
|
mobile: data?.menus?.mobile ?? [],
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user