Compare commits
142 Commits
b6a0a66000
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7da4f0a167 | ||
|
|
5f08c18ec7 | ||
|
|
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 | ||
|
|
0421e5010f | ||
|
|
da6255dd0c | ||
|
|
91ae4956e0 | ||
|
|
b010a88619 | ||
|
|
a98217897c | ||
|
|
316fcbf2f0 | ||
|
|
3f8d15de61 | ||
|
|
930e525421 | ||
|
|
802b64db9f | ||
|
|
8959af8270 | ||
|
|
1ce99e2bb6 | ||
|
|
0a33ba0401 | ||
|
|
2ce7c0b263 | ||
|
|
47f6370ce0 | ||
|
|
47a1e78eb7 | ||
|
|
1af1add5d4 | ||
|
|
6bd50c1659 | ||
|
|
5a831ddf9d | ||
|
|
70006beeb9 | ||
|
|
e84fa969bb | ||
|
|
ccdd88a629 | ||
|
|
b8f179a984 | ||
|
|
78d7bc1161 | ||
|
|
62f25b624b | ||
|
|
10b3c0e47f | ||
|
|
508ec682a7 | ||
|
|
c83ea78911 | ||
|
|
58681e272e | ||
|
|
38a7a4ee23 | ||
|
|
875ab7af34 | ||
|
|
861c45638b | ||
|
|
8bd2713385 | ||
|
|
9671c7255a | ||
|
|
52cea87078 | ||
|
|
e9e54f52a7 | ||
|
|
4fcc69bfcd | ||
|
|
56042d4b8e | ||
|
|
3d7eb5bf48 | ||
|
|
f97cca8061 | ||
|
|
f79938c5be | ||
|
|
0dd7c7af70 | ||
|
|
285589937a | ||
|
|
a87357d890 | ||
|
|
d7505252ac | ||
|
|
3d5191aab3 | ||
|
|
65dd847a66 | ||
|
|
2dbc43a4eb | ||
|
|
771c48e4bb | ||
|
|
4104c6d6ba | ||
|
|
82399d4ddf | ||
|
|
93523a74ac | ||
|
|
2c4050451c | ||
|
|
fe98e6233d | ||
|
|
f054a78c5d | ||
|
|
012effd11d | ||
|
|
48a5a5593b | ||
|
|
e0777c708b | ||
|
|
b2ac2996f9 | ||
|
|
c8ce892d15 |
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)
|
||||||
39
.agent/plans/onboarding_strategy.md
Normal file
39
.agent/plans/onboarding_strategy.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# User Onboarding & Simplification Strategy
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
The current "General Settings" screen presents too many technical decisions (SPA Mode, Entry Page, Container Width, Typography, Colors) at once. This creates analysis paralysis for new users who just want to "get the store running."
|
||||||
|
|
||||||
|
## Recommended Solution: The "Quick Setup" Wizard
|
||||||
|
Instead of dumping users into the Settings screen, we implement a **Linear Onboarding Flow** that launches automatically on the first visit (or manually via "Setup Wizard" button).
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
* **No new libraries** needed. We can build this using your existing `@radix-ui` components (Dialog, Cards, Button).
|
||||||
|
* **State**: Managed via simple React state or Zustand store.
|
||||||
|
|
||||||
|
### The Flow (4 Steps)
|
||||||
|
|
||||||
|
#### 1. Welcome & Mode (The "What"?)
|
||||||
|
* **Question**: "How do you want to run your store?"
|
||||||
|
* **Options**:
|
||||||
|
* **Immersive (Full SPA)**: "Modern, app-like experience. Best for dedicated stores." (Selects 'full')
|
||||||
|
* **Classic (Checkout Only)**: "Keep your current theme, but use our super-fast checkout." (Selects 'checkout_only')
|
||||||
|
* **Standard**: "Use standard WordPress pages." (Selects 'disabled')
|
||||||
|
|
||||||
|
#### 2. The Homepage (The "Where"?)
|
||||||
|
* **Question**: "Where should customers land?"
|
||||||
|
* **Action**: Dropdown to select a page.
|
||||||
|
* **Magic Button**: "Auto-create 'Shop' Page" (Creates a page, sets it as SPA Entry, and sets WP Frontpage setting automatically). **<-- This solves the redirect bug confusion.**
|
||||||
|
|
||||||
|
#### 3. Styling (The "Look")
|
||||||
|
* **Question**: "Choose your vibe."
|
||||||
|
* **Design**:
|
||||||
|
* **Layout**: Simple visual toggle between "Boxed" (Focus) vs "Full Width" (Immersive).
|
||||||
|
* **Theme**: Clickable color swatches (Modern Black, Trusty Blue, Vibrant Purple).
|
||||||
|
|
||||||
|
#### 4. The Finish Line
|
||||||
|
* **Action**: "Save & Launch Builder".
|
||||||
|
* **Result**: Redirects the user directly to the Visual Builder for their home page.
|
||||||
|
|
||||||
|
## Ancillary Improvements
|
||||||
|
1. **Contextual Hints**: Use the already installed `HoverCard` or `Popover` to add "?" icons next to complex settings (like "SPA Entry Page") explaining them in plain English.
|
||||||
|
2. **Smart Defaults**: Pre-select "Boxed", "Full SPA", and "Modern" font pair so users can just click "Next -> Next -> Next" if they don't care.
|
||||||
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/newsletter-module-audit-2026-02-01.md
Normal file
212
.agent/reports/newsletter-module-audit-2026-02-01.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# Newsletter Module Audit Report
|
||||||
|
|
||||||
|
**Date**: 2026-02-01
|
||||||
|
**Auditor**: Antigravity AI
|
||||||
|
**Scope**: Full trace of Newsletter module including broadcast, subscribers, templates, events, and multi-channel support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Module Architecture Overview
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Frontend
|
||||||
|
NF[NewsletterForm.tsx]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph API
|
||||||
|
NC[NewsletterController.php]
|
||||||
|
CC[CampaignsController - via CampaignManager]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Core
|
||||||
|
CM[CampaignManager.php]
|
||||||
|
NS[NewsletterSettings.php]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Notifications
|
||||||
|
ER[EventRegistry.php]
|
||||||
|
NM[NotificationManager.php]
|
||||||
|
ER --> NM
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Admin SPA
|
||||||
|
SUB[Subscribers.tsx]
|
||||||
|
CAMP[Campaigns.tsx]
|
||||||
|
end
|
||||||
|
|
||||||
|
NF -->|POST /subscribe| NC
|
||||||
|
NC -->|triggers| ER
|
||||||
|
CM -->|uses| NM
|
||||||
|
SUB -->|GET /subscribers| NC
|
||||||
|
CAMP -->|CRUD| CM
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Components Traced
|
||||||
|
|
||||||
|
| Component | File | Status |
|
||||||
|
|-----------|------|--------|
|
||||||
|
| Subscriber API | `NewsletterController.php` | ✅ Working |
|
||||||
|
| Subscriber UI | `Subscribers.tsx` | ✅ Working |
|
||||||
|
| Campaign Manager | `CampaignManager.php` | ✅ Built (CPT-based) |
|
||||||
|
| Campaign UI | `Campaigns.tsx` | ✅ Working |
|
||||||
|
| Settings Schema | `NewsletterSettings.php` | ✅ Complete |
|
||||||
|
| Frontend Form | `NewsletterForm.tsx` | ⚠️ Missing GDPR |
|
||||||
|
| Unsubscribe | Token-based URL | ✅ Secure |
|
||||||
|
| Email Events | `EventRegistry.php` | ✅ 3 events registered |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Defects Found
|
||||||
|
|
||||||
|
### 🔴 Critical
|
||||||
|
|
||||||
|
#### 3.1 Double Opt-in NOT Implemented
|
||||||
|
**Location**: `NewsletterController.php` (Line 130-189)
|
||||||
|
**Issue**: `NewsletterSettings.php` defines a `double_opt_in` toggle (Line 46-51), but the subscribe function **ignores it completely**.
|
||||||
|
**Impact**: GDPR non-compliance in EU regions
|
||||||
|
**Expected**: When enabled, subscribers should receive confirmation email before being marked active
|
||||||
|
|
||||||
|
#### 3.2 Dead Code: `send_welcome_email()`
|
||||||
|
**Location**: `NewsletterController.php` (Lines 192-203)
|
||||||
|
**Issue**: This method is **never called**. Welcome emails are now sent via the notification system (`woonoow/notification/event`).
|
||||||
|
**Impact**: Code bloat, potential confusion
|
||||||
|
**Recommendation**: Delete this dead method
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 High Priority
|
||||||
|
|
||||||
|
#### 3.3 No Multi-Channel Support (WhatsApp/Telegram/SMS)
|
||||||
|
**Issue**: Only `email` and `push` channels exist in `NotificationManager.php`
|
||||||
|
**Impact**: Users cannot broadcast newsletters via WhatsApp, Telegram, or SMS
|
||||||
|
**Current State**:
|
||||||
|
- `allowed_platforms` in `NotificationsController.php` (Line 832) lists `telegram`, `whatsapp` for **social links** (not messaging)
|
||||||
|
- No actual message delivery integration exists
|
||||||
|
|
||||||
|
**Recommendation**: Implement channel bridge pattern for:
|
||||||
|
1. **WhatsApp Business API** (or Twilio WhatsApp)
|
||||||
|
2. **Telegram Bot API**
|
||||||
|
3. **SMS Gateway** (Twilio, Vonage, etc.)
|
||||||
|
|
||||||
|
#### 3.4 Subscriber Storage Not Scalable
|
||||||
|
**Location**: `NewsletterController.php` (Line 141)
|
||||||
|
**Issue**: Subscribers stored in `wp_options` as serialized array
|
||||||
|
**Impact**: Performance degrades with 1000+ subscribers (Options table not designed for large arrays)
|
||||||
|
**Note**: `NEWSLETTER_CAMPAIGN_PLAN.md` mentions custom table but `wp_woonoow_subscribers` table is **not created**
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
```php
|
||||||
|
// Create migration for custom table
|
||||||
|
CREATE TABLE wp_woonoow_subscribers (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
user_id BIGINT UNSIGNED NULL,
|
||||||
|
status ENUM('pending', 'active', 'unsubscribed') DEFAULT 'pending',
|
||||||
|
consent TINYINT(1) DEFAULT 0,
|
||||||
|
subscribed_at DATETIME,
|
||||||
|
unsubscribed_at DATETIME NULL,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_email (email)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 Medium Priority
|
||||||
|
|
||||||
|
#### 3.5 GDPR Consent Checkbox Missing in Frontend
|
||||||
|
**Location**: `NewsletterForm.tsx`
|
||||||
|
**Issue**: Settings schema has `gdpr_consent` and `consent_text` fields, but the frontend form doesn't render this checkbox
|
||||||
|
**Impact**: GDPR non-compliance
|
||||||
|
|
||||||
|
**Recommendation**: Add consent checkbox:
|
||||||
|
```tsx
|
||||||
|
{settings.gdpr_consent && (
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<input type="checkbox" required />
|
||||||
|
<span className="text-xs">{settings.consent_text}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.6 No Audience Segmentation
|
||||||
|
**Issue**: All campaigns go to ALL active subscribers
|
||||||
|
**File**: `CampaignManager.php` (Line 393-410)
|
||||||
|
**Impact**: Cannot target specific user groups (e.g., "Subscribed in last 30 days", "WP Users only")
|
||||||
|
|
||||||
|
**Recommendation**: Add filter options to `get_subscribers()`:
|
||||||
|
- By date range
|
||||||
|
- By user_id (registered vs guest)
|
||||||
|
- By custom tags (future feature)
|
||||||
|
|
||||||
|
#### 3.7 No Open/Click Tracking
|
||||||
|
**Issue**: No analytics for campaign performance
|
||||||
|
**Impact**: Cannot measure engagement or ROI
|
||||||
|
|
||||||
|
**Recommendation** (Phase 3):
|
||||||
|
- Add tracking pixel for opens
|
||||||
|
- Wrap links for click tracking
|
||||||
|
- Store in `wp_woonoow_campaign_events` table
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Gaps Between Plan and Implementation
|
||||||
|
|
||||||
|
| Feature | Plan Status | Implementation Status |
|
||||||
|
|---------|-------------|----------------------|
|
||||||
|
| Subscribers Table | "Create migration" | ❌ Not created |
|
||||||
|
| Double Opt-in | Schema defined | ❌ Not enforced |
|
||||||
|
| Campaign Scheduling | Cron registered | ✅ Working |
|
||||||
|
| GDPR Consent | Settings exist | ❌ UI not integrated |
|
||||||
|
| Multi-channel | Not planned | ❌ Not implemented |
|
||||||
|
| A/B Testing | Phase 3 | ❌ Not started |
|
||||||
|
| Analytics | Phase 3 | ❌ Not started |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Recommendations Summary
|
||||||
|
|
||||||
|
### Immediate Actions (Bug Fixes)
|
||||||
|
1. ~~Delete~~ or implement `send_welcome_email()` dead code
|
||||||
|
2. Connect `double_opt_in` setting to subscribe flow
|
||||||
|
3. Add GDPR checkbox to `NewsletterForm.tsx`
|
||||||
|
|
||||||
|
### Short-term (1-2 weeks)
|
||||||
|
4. Create `wp_woonoow_subscribers` table for scalability
|
||||||
|
5. Add audience segmentation to campaign targeting
|
||||||
|
|
||||||
|
### Medium-term (Future Phases)
|
||||||
|
6. Implement WhatsApp/Telegram channel bridges
|
||||||
|
7. Add open/click tracking for analytics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Security Audit
|
||||||
|
|
||||||
|
| Area | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| Unsubscribe Token | ✅ Secure | HMAC-SHA256 with auth salt |
|
||||||
|
| Email Validation | ✅ Validated | `is_email()` + custom validation |
|
||||||
|
| CSRF Protection | ✅ Via REST nonce | API uses WP nonces |
|
||||||
|
| IP Logging | ✅ Stored | For GDPR data export if needed |
|
||||||
|
| Rate Limiting | ⚠️ None | Could be abused for spam subscriptions |
|
||||||
|
|
||||||
|
**Recommendation**: Add rate limiting to `/newsletter/subscribe` endpoint (e.g., 5 requests per IP per hour)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Conclusion
|
||||||
|
|
||||||
|
The Newsletter module is **functionally complete** for basic use cases. The campaign system is well-architected using WordPress Custom Post Types, and the integration with the notification system is clean.
|
||||||
|
|
||||||
|
**Critical gaps** exist around GDPR compliance (double opt-in, consent checkbox) and scalability (options-based storage). Multi-channel support (WhatsApp/Telegram) is **not implemented** and would require significant new development.
|
||||||
|
|
||||||
|
**Priority Order**:
|
||||||
|
1. GDPR fixes (double opt-in + consent checkbox)
|
||||||
|
2. Custom subscribers table
|
||||||
|
3. Audience segmentation
|
||||||
|
4. Multi-channel bridges (optional, significant scope)
|
||||||
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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# WooNooW Feature Roadmap - 2025
|
# WooNooW Feature Roadmap - 2025
|
||||||
|
|
||||||
**Last Updated**: December 26, 2025
|
**Last Updated**: December 31, 2025
|
||||||
**Status**: Planning Phase
|
**Status**: Active Development
|
||||||
|
|
||||||
This document outlines the comprehensive feature roadmap for WooNooW, building upon existing infrastructure.
|
This document outlines the comprehensive feature roadmap for WooNooW, building upon existing infrastructure.
|
||||||
|
|
||||||
@@ -22,11 +22,12 @@ This document outlines the comprehensive feature roadmap for WooNooW, building u
|
|||||||
- ✅ Newsletter Subscribers Management
|
- ✅ Newsletter Subscribers Management
|
||||||
- ✅ Coupon System
|
- ✅ Coupon System
|
||||||
- ✅ Customer Wishlist (basic)
|
- ✅ Customer Wishlist (basic)
|
||||||
- ✅ Product Reviews & Ratings
|
- ✅ Module Management System (enable/disable features)
|
||||||
- ✅ Admin SPA with modern UI
|
- ✅ Admin SPA with modern UI
|
||||||
- ✅ Customer SPA with theme system
|
- ✅ Customer SPA with theme system
|
||||||
- ✅ REST API infrastructure
|
- ✅ REST API infrastructure
|
||||||
- ✅ Addon bridge pattern
|
- ✅ Addon bridge pattern
|
||||||
|
- 🔲 Product Reviews & Ratings (not yet implemented)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ This document outlines the comprehensive feature roadmap for WooNooW, building u
|
|||||||
### Overview
|
### Overview
|
||||||
Central control panel for enabling/disabling features to improve performance and reduce clutter.
|
Central control panel for enabling/disabling features to improve performance and reduce clutter.
|
||||||
|
|
||||||
### Status: **Planning** 🔵
|
### Status: **Built** ✅
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
@@ -94,8 +95,8 @@ class ModuleRegistry {
|
|||||||
#### Navigation Integration
|
#### Navigation Integration
|
||||||
Only show module routes if enabled in navigation tree.
|
Only show module routes if enabled in navigation tree.
|
||||||
|
|
||||||
### Priority: **High** 🔴
|
### Priority: ~~High~~ **Complete** ✅
|
||||||
### Effort: 1 week
|
### Effort: ~~1 week~~ Done
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { HashRouter, Routes, Route, NavLink, useLocation, useParams, Navigate, Link } from 'react-router-dom';
|
import { HashRouter, Routes, Route, NavLink, useLocation, useParams, Navigate, Link } from 'react-router-dom';
|
||||||
import { Login } from './routes/Login';
|
import { Login } from './routes/Login';
|
||||||
|
import ResetPassword from './routes/ResetPassword';
|
||||||
import Dashboard from '@/routes/Dashboard';
|
import Dashboard from '@/routes/Dashboard';
|
||||||
import DashboardRevenue from '@/routes/Dashboard/Revenue';
|
import DashboardRevenue from '@/routes/Dashboard/Revenue';
|
||||||
import DashboardOrders from '@/routes/Dashboard/Orders';
|
import DashboardOrders from '@/routes/Dashboard/Orders';
|
||||||
@@ -12,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';
|
||||||
@@ -26,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";
|
||||||
@@ -46,6 +53,8 @@ import { __ } from '@/lib/i18n';
|
|||||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||||
import { initializeWindowAPI } from '@/lib/windowAPI';
|
import { initializeWindowAPI } from '@/lib/windowAPI';
|
||||||
|
|
||||||
|
import { LegacyCampaignRedirect } from '@/components/LegacyCampaignRedirect';
|
||||||
|
|
||||||
function useFullscreen() {
|
function useFullscreen() {
|
||||||
const [on, setOn] = useState<boolean>(() => {
|
const [on, setOn] = useState<boolean>(() => {
|
||||||
try { return localStorage.getItem('wnwFullscreen') === '1'; } catch { return false; }
|
try { return localStorage.getItem('wnwFullscreen') === '1'; } catch { return false; }
|
||||||
@@ -101,12 +110,12 @@ function ActiveNavLink({ to, startsWith, end, className, children, childPaths }:
|
|||||||
className={(nav) => {
|
className={(nav) => {
|
||||||
// Special case: Dashboard should ONLY match root path "/" or paths starting with "/dashboard"
|
// Special case: Dashboard should ONLY match root path "/" or paths starting with "/dashboard"
|
||||||
const isDashboard = starts === '/dashboard' && (location.pathname === '/' || location.pathname.startsWith('/dashboard'));
|
const isDashboard = starts === '/dashboard' && (location.pathname === '/' || location.pathname.startsWith('/dashboard'));
|
||||||
|
|
||||||
// Check if current path matches any child paths (e.g., /coupons under Marketing)
|
// Check if current path matches any child paths (e.g., /coupons under Marketing)
|
||||||
const matchesChild = childPaths && Array.isArray(childPaths)
|
const matchesChild = childPaths && Array.isArray(childPaths)
|
||||||
? childPaths.some((childPath: string) => location.pathname.startsWith(childPath))
|
? childPaths.some((childPath: string) => location.pathname.startsWith(childPath))
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
// For dashboard: only active if isDashboard is true
|
// For dashboard: only active if isDashboard is true
|
||||||
// For others: active if path starts with their path OR matches a child path
|
// For others: active if path starts with their path OR matches a child path
|
||||||
let activeByPath = false;
|
let activeByPath = false;
|
||||||
@@ -115,7 +124,7 @@ function ActiveNavLink({ to, startsWith, end, className, children, childPaths }:
|
|||||||
} else if (starts) {
|
} else if (starts) {
|
||||||
activeByPath = location.pathname.startsWith(starts) || matchesChild;
|
activeByPath = location.pathname.startsWith(starts) || matchesChild;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedActive = nav.isActive || activeByPath;
|
const mergedActive = nav.isActive || activeByPath;
|
||||||
if (typeof className === 'function') {
|
if (typeof className === 'function') {
|
||||||
// Preserve caller pattern: className receives { isActive }
|
// Preserve caller pattern: className receives { isActive }
|
||||||
@@ -129,11 +138,17 @@ 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();
|
||||||
|
|
||||||
// Icon mapping
|
// Icon mapping
|
||||||
const iconMap: Record<string, any> = {
|
const iconMap: Record<string, any> = {
|
||||||
'layout-dashboard': LayoutDashboard,
|
'layout-dashboard': LayoutDashboard,
|
||||||
@@ -144,14 +159,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;
|
||||||
@@ -159,10 +187,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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -176,7 +205,7 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
|
|||||||
const active = "bg-secondary";
|
const active = "bg-secondary";
|
||||||
const topClass = fullscreen ? 'top-16' : 'top-[calc(4rem+32px)]';
|
const topClass = fullscreen ? 'top-16' : 'top-[calc(4rem+32px)]';
|
||||||
const { main } = useActiveSection();
|
const { main } = useActiveSection();
|
||||||
|
|
||||||
// Icon mapping (same as Sidebar)
|
// Icon mapping (same as Sidebar)
|
||||||
const iconMap: Record<string, any> = {
|
const iconMap: Record<string, any> = {
|
||||||
'layout-dashboard': LayoutDashboard,
|
'layout-dashboard': LayoutDashboard,
|
||||||
@@ -187,13 +216,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;
|
||||||
@@ -233,6 +263,7 @@ import SettingsPayments from '@/routes/Settings/Payments';
|
|||||||
import SettingsShipping from '@/routes/Settings/Shipping';
|
import SettingsShipping from '@/routes/Settings/Shipping';
|
||||||
import SettingsTax from '@/routes/Settings/Tax';
|
import SettingsTax from '@/routes/Settings/Tax';
|
||||||
import SettingsCustomers from '@/routes/Settings/Customers';
|
import SettingsCustomers from '@/routes/Settings/Customers';
|
||||||
|
import SettingsSecurity from '@/routes/Settings/Security';
|
||||||
import SettingsLocalPickup from '@/routes/Settings/LocalPickup';
|
import SettingsLocalPickup from '@/routes/Settings/LocalPickup';
|
||||||
import SettingsNotifications from '@/routes/Settings/Notifications';
|
import SettingsNotifications from '@/routes/Settings/Notifications';
|
||||||
import StaffNotifications from '@/routes/Settings/Notifications/Staff';
|
import StaffNotifications from '@/routes/Settings/Notifications/Staff';
|
||||||
@@ -242,6 +273,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';
|
||||||
@@ -255,9 +287,15 @@ 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 NewsletterLayout from '@/routes/Marketing/Newsletter';
|
||||||
|
import NewsletterSubscribers from '@/routes/Marketing/Newsletter/Subscribers';
|
||||||
|
import NewsletterCampaignsList from '@/routes/Marketing/Campaigns';
|
||||||
|
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 }) {
|
||||||
@@ -332,31 +370,31 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
const lastScrollYRef = React.useRef(0);
|
const lastScrollYRef = React.useRef(0);
|
||||||
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
|
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
|
||||||
const [isDark, setIsDark] = React.useState(false);
|
const [isDark, setIsDark] = React.useState(false);
|
||||||
|
|
||||||
// Detect dark mode
|
// Detect dark mode
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const checkDarkMode = () => {
|
const checkDarkMode = () => {
|
||||||
const htmlEl = document.documentElement;
|
const htmlEl = document.documentElement;
|
||||||
setIsDark(htmlEl.classList.contains('dark'));
|
setIsDark(htmlEl.classList.contains('dark'));
|
||||||
};
|
};
|
||||||
|
|
||||||
checkDarkMode();
|
checkDarkMode();
|
||||||
|
|
||||||
// Watch for theme changes
|
// Watch for theme changes
|
||||||
const observer = new MutationObserver(checkDarkMode);
|
const observer = new MutationObserver(checkDarkMode);
|
||||||
observer.observe(document.documentElement, {
|
observer.observe(document.documentElement, {
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ['class']
|
attributeFilter: ['class']
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Notify parent of visibility changes
|
// Notify parent of visibility changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
onVisibilityChange?.(isVisible);
|
onVisibilityChange?.(isVisible);
|
||||||
}, [isVisible, onVisibilityChange]);
|
}, [isVisible, onVisibilityChange]);
|
||||||
|
|
||||||
// Fetch store branding on mount
|
// Fetch store branding on mount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const fetchBranding = async () => {
|
const fetchBranding = async () => {
|
||||||
@@ -374,7 +412,7 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
};
|
};
|
||||||
fetchBranding();
|
fetchBranding();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Listen for store settings updates
|
// Listen for store settings updates
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleStoreUpdate = (event: CustomEvent) => {
|
const handleStoreUpdate = (event: CustomEvent) => {
|
||||||
@@ -382,25 +420,25 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
if (event.detail?.store_logo_dark) setStoreLogoDark(event.detail.store_logo_dark);
|
if (event.detail?.store_logo_dark) setStoreLogoDark(event.detail.store_logo_dark);
|
||||||
if (event.detail?.store_name) setSiteTitle(event.detail.store_name);
|
if (event.detail?.store_name) setSiteTitle(event.detail.store_name);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('woonoow:store:updated' as any, handleStoreUpdate);
|
window.addEventListener('woonoow:store:updated' as any, handleStoreUpdate);
|
||||||
return () => window.removeEventListener('woonoow:store:updated' as any, handleStoreUpdate);
|
return () => window.removeEventListener('woonoow:store:updated' as any, handleStoreUpdate);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Hide/show header on scroll (mobile only)
|
// Hide/show header on scroll (mobile only)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const scrollContainer = scrollContainerRef?.current;
|
const scrollContainer = scrollContainerRef?.current;
|
||||||
if (!scrollContainer) return;
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const currentScrollY = scrollContainer.scrollTop;
|
const currentScrollY = scrollContainer.scrollTop;
|
||||||
|
|
||||||
// Only apply on mobile (check window width)
|
// Only apply on mobile (check window width)
|
||||||
if (window.innerWidth >= 768) {
|
if (window.innerWidth >= 768) {
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentScrollY > lastScrollYRef.current && currentScrollY > 50) {
|
if (currentScrollY > lastScrollYRef.current && currentScrollY > 50) {
|
||||||
// Scrolling down & past threshold
|
// Scrolling down & past threshold
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
@@ -408,17 +446,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
// Scrolling up
|
// Scrolling up
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastScrollYRef.current = currentScrollY;
|
lastScrollYRef.current = currentScrollY;
|
||||||
};
|
};
|
||||||
|
|
||||||
scrollContainer.addEventListener('scroll', handleScroll, { passive: true });
|
scrollContainer.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
scrollContainer.removeEventListener('scroll', handleScroll);
|
||||||
};
|
};
|
||||||
}, [scrollContainerRef]);
|
}, [scrollContainerRef]);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch((window.WNW_CONFIG?.restUrl || '') + '/auth/logout', {
|
await fetch((window.WNW_CONFIG?.restUrl || '') + '/auth/logout', {
|
||||||
@@ -430,15 +468,15 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
console.error('Logout failed:', err);
|
console.error('Logout failed:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hide header completely on mobile in fullscreen mode (both standalone and wp-admin fullscreen)
|
// Hide header completely on mobile in fullscreen mode (both standalone and wp-admin fullscreen)
|
||||||
if (fullscreen && typeof window !== 'undefined' && window.innerWidth < 768) {
|
if (fullscreen && typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choose logo based on theme
|
// Choose logo based on theme
|
||||||
const currentLogo = isDark && storeLogoDark ? storeLogoDark : storeLogo;
|
const currentLogo = isDark && storeLogoDark ? storeLogoDark : storeLogo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`h-16 border-b border-border flex items-center px-4 justify-between sticky ${fullscreen ? `top-0` : `top-[32px]`} z-40 bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60 transition-transform duration-300 ${fullscreen && !isVisible ? '-translate-y-full md:translate-y-0' : 'translate-y-0'}`}>
|
<header className={`h-16 border-b border-border flex items-center px-4 justify-between sticky ${fullscreen ? `top-0` : `top-[32px]`} z-40 bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60 transition-transform duration-300 ${fullscreen && !isVisible ? '-translate-y-full md:translate-y-0' : 'translate-y-0'}`}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -447,6 +485,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>
|
||||||
@@ -459,6 +508,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"
|
||||||
@@ -468,6 +528,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
|
||||||
@@ -494,11 +565,12 @@ function ShortcutsBinder({ onToggle }: { onToggle: () => void }) {
|
|||||||
// Centralized route controller so we don't duplicate <Routes> in each layout
|
// Centralized route controller so we don't duplicate <Routes> in each layout
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const addonRoutes = (window as any).WNW_ADDON_ROUTES || [];
|
const addonRoutes = (window as any).WNW_ADDON_ROUTES || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Dashboard */}
|
{/* Dashboard */}
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/dashboard/revenue" element={<DashboardRevenue />} />
|
<Route path="/dashboard/revenue" element={<DashboardRevenue />} />
|
||||||
<Route path="/dashboard/orders" element={<DashboardOrders />} />
|
<Route path="/dashboard/orders" element={<DashboardOrders />} />
|
||||||
@@ -514,12 +586,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 />} />
|
||||||
@@ -545,6 +625,7 @@ function AppRoutes() {
|
|||||||
<Route path="/settings/shipping" element={<SettingsShipping />} />
|
<Route path="/settings/shipping" element={<SettingsShipping />} />
|
||||||
<Route path="/settings/tax" element={<SettingsTax />} />
|
<Route path="/settings/tax" element={<SettingsTax />} />
|
||||||
<Route path="/settings/customers" element={<SettingsCustomers />} />
|
<Route path="/settings/customers" element={<SettingsCustomers />} />
|
||||||
|
<Route path="/settings/security" element={<SettingsSecurity />} />
|
||||||
<Route path="/settings/taxes" element={<Navigate to="/settings/tax" replace />} />
|
<Route path="/settings/taxes" element={<Navigate to="/settings/tax" replace />} />
|
||||||
<Route path="/settings/local-pickup" element={<SettingsLocalPickup />} />
|
<Route path="/settings/local-pickup" element={<SettingsLocalPickup />} />
|
||||||
<Route path="/settings/checkout" element={<SettingsIndex />} />
|
<Route path="/settings/checkout" element={<SettingsIndex />} />
|
||||||
@@ -556,11 +637,12 @@ 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 />} />
|
||||||
<Route path="/settings/modules/:moduleId" element={<ModuleSettings />} />
|
<Route path="/settings/modules/:moduleId" element={<ModuleSettings />} />
|
||||||
|
|
||||||
{/* Appearance */}
|
{/* Appearance */}
|
||||||
<Route path="/appearance" element={<AppearanceIndex />} />
|
<Route path="/appearance" element={<AppearanceIndex />} />
|
||||||
<Route path="/appearance/general" element={<AppearanceGeneral />} />
|
<Route path="/appearance/general" element={<AppearanceGeneral />} />
|
||||||
@@ -572,10 +654,25 @@ 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={<NewsletterLayout />}>
|
||||||
|
<Route index element={<Navigate to="subscribers" replace />} />
|
||||||
|
<Route path="subscribers" element={<NewsletterSubscribers />} />
|
||||||
|
<Route path="campaigns" element={<NewsletterCampaignsList />} />
|
||||||
|
<Route path="campaigns/:id" element={<CampaignEdit />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Legacy Redirects for Newsletter (using component to preserve params) */}
|
||||||
|
<Route path="/marketing/campaigns" element={<Navigate to="/marketing/newsletter/campaigns" replace />} />
|
||||||
|
<Route path="/marketing/campaigns/new" element={<Navigate to="/marketing/newsletter/campaigns/new" replace />} />
|
||||||
|
<Route path="/marketing/campaigns/:id" element={<LegacyCampaignRedirect />} />
|
||||||
|
|
||||||
|
{/* 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) => (
|
||||||
@@ -597,14 +694,50 @@ function Shell() {
|
|||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
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;
|
||||||
|
|
||||||
// Check if current route is dashboard
|
// Check if current route is dashboard
|
||||||
const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard');
|
const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard');
|
||||||
|
|
||||||
// Check if current route is More page (no submenu needed)
|
// Check if current route is More page (no submenu needed)
|
||||||
const isMorePage = location.pathname === '/more';
|
const isMorePage = location.pathname === '/more';
|
||||||
|
|
||||||
@@ -620,7 +753,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">
|
||||||
@@ -740,7 +873,7 @@ export default function App() {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
initializeWindowAPI();
|
initializeWindowAPI();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
|
|||||||
26
admin-spa/src/components/DocLink.tsx
Normal file
26
admin-spa/src/components/DocLink.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { BookOpen } from 'lucide-react';
|
||||||
|
import { getDocUrl } from '@/config/docRoutes';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function DocLink() {
|
||||||
|
const location = useLocation();
|
||||||
|
const docUrl = getDocUrl(location.pathname);
|
||||||
|
|
||||||
|
if (!docUrl) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 ml-2 text-muted-foreground hover:text-primary"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a href={docUrl} target="_blank" rel="noopener noreferrer" title="View Documentation">
|
||||||
|
<BookOpen className="h-4 w-4" />
|
||||||
|
<span className="sr-only">View Documentation</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,62 +75,77 @@ 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 [&_.text-link]:text-purple-600 [&_.text-link]:underline"
|
||||||
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'
|
// Different styles based on button type
|
||||||
? {
|
let buttonStyle: React.CSSProperties;
|
||||||
display: 'inline-block',
|
|
||||||
background: '#7f54b3',
|
if (block.style === 'link') {
|
||||||
color: '#fff',
|
// Plain link style - just underlined text
|
||||||
padding: '14px 28px',
|
buttonStyle = {
|
||||||
borderRadius: '6px',
|
color: 'var(--wn-primary, #7f54b3)',
|
||||||
textDecoration: 'none',
|
textDecoration: 'underline',
|
||||||
fontWeight: 600,
|
};
|
||||||
}
|
} else if (block.style === 'outline') {
|
||||||
: {
|
buttonStyle = {
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
// Solid style (default)
|
||||||
|
buttonStyle = {
|
||||||
|
display: 'inline-block',
|
||||||
|
background: 'var(--wn-primary, #7f54b3)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '14px 28px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const containerStyle: React.CSSProperties = {
|
const containerStyle: React.CSSProperties = {
|
||||||
textAlign: block.align || 'center',
|
textAlign: block.align || 'center',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (block.widthMode === 'full') {
|
// Width modes don't apply to plain links
|
||||||
buttonStyle.display = 'block';
|
if (block.style !== 'link') {
|
||||||
buttonStyle.width = '100%';
|
if (block.widthMode === 'full') {
|
||||||
buttonStyle.textAlign = 'center';
|
buttonStyle.display = 'block';
|
||||||
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
buttonStyle.width = '100%';
|
||||||
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
|
buttonStyle.textAlign = 'center';
|
||||||
buttonStyle.width = '100%';
|
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
||||||
|
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
|
||||||
|
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 +181,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 +199,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 && (
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
throw new Error(`Unknown block type: ${type}`);
|
throw new Error(`Unknown block type: ${type}`);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
onChange([...blocks, newBlock]);
|
onChange([...blocks, newBlock]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,10 +91,10 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
const moveBlock = (id: string, direction: 'up' | 'down') => {
|
const moveBlock = (id: string, direction: 'up' | 'down') => {
|
||||||
const index = blocks.findIndex(b => b.id === id);
|
const index = blocks.findIndex(b => b.id === id);
|
||||||
if (index === -1) return;
|
if (index === -1) return;
|
||||||
|
|
||||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||||
if (newIndex < 0 || newIndex >= blocks.length) return;
|
if (newIndex < 0 || newIndex >= blocks.length) return;
|
||||||
|
|
||||||
const newBlocks = [...blocks];
|
const newBlocks = [...blocks];
|
||||||
[newBlocks[index], newBlocks[newIndex]] = [newBlocks[newIndex], newBlocks[index]];
|
[newBlocks[index], newBlocks[newIndex]] = [newBlocks[newIndex], newBlocks[index]];
|
||||||
onChange(newBlocks);
|
onChange(newBlocks);
|
||||||
@@ -102,7 +102,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
|
|
||||||
const openEditDialog = (block: EmailBlock) => {
|
const openEditDialog = (block: EmailBlock) => {
|
||||||
setEditingBlockId(block.id);
|
setEditingBlockId(block.id);
|
||||||
|
|
||||||
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);
|
||||||
@@ -121,16 +121,16 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
setEditingCustomMaxWidth(block.customMaxWidth);
|
setEditingCustomMaxWidth(block.customMaxWidth);
|
||||||
setEditingAlign(block.align);
|
setEditingAlign(block.align);
|
||||||
}
|
}
|
||||||
|
|
||||||
setEditDialogOpen(true);
|
setEditDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEdit = () => {
|
const saveEdit = () => {
|
||||||
if (!editingBlockId) return;
|
if (!editingBlockId) return;
|
||||||
|
|
||||||
const newBlocks = blocks.map(block => {
|
const newBlocks = blocks.map(block => {
|
||||||
if (block.id !== editingBlockId) return block;
|
if (block.id !== editingBlockId) return block;
|
||||||
|
|
||||||
if (block.type === 'card') {
|
if (block.type === 'card') {
|
||||||
// Convert HTML from rich text editor back to markdown for storage
|
// Convert HTML from rich text editor back to markdown for storage
|
||||||
const markdownContent = htmlToMarkdown(editingContent);
|
const markdownContent = htmlToMarkdown(editingContent);
|
||||||
@@ -154,10 +154,10 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
align: editingAlign,
|
align: editingAlign,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return block;
|
return block;
|
||||||
});
|
});
|
||||||
|
|
||||||
onChange(newBlocks);
|
onChange(newBlocks);
|
||||||
setEditDialogOpen(false);
|
setEditDialogOpen(false);
|
||||||
setEditingBlockId(null);
|
setEditingBlockId(null);
|
||||||
@@ -269,29 +269,23 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
|
|
||||||
{/* Edit Dialog */}
|
{/* Edit Dialog */}
|
||||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="sm:max-w-2xl"
|
className="sm:max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||||
onInteractOutside={(e) => {
|
onInteractOutside={(e) => {
|
||||||
// Check if WordPress media modal is currently open
|
// Only prevent closing if WordPress media modal is open
|
||||||
const wpMediaOpen = document.querySelector('.media-modal');
|
const wpMediaOpen = document.querySelector('.media-modal');
|
||||||
|
|
||||||
if (wpMediaOpen) {
|
if (wpMediaOpen) {
|
||||||
// If WP media is open, ALWAYS prevent dialog from closing
|
|
||||||
// regardless of where the click happened
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// Otherwise, allow the dialog to close normally via outside click
|
||||||
// If WP media is not open, prevent closing dialog for outside clicks
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
}}
|
||||||
onEscapeKeyDown={(e) => {
|
onEscapeKeyDown={(e) => {
|
||||||
// Allow escape to close WP media modal
|
// Only prevent escape if WP media modal is open
|
||||||
const wpMediaOpen = document.querySelector('.media-modal');
|
const wpMediaOpen = document.querySelector('.media-modal');
|
||||||
if (wpMediaOpen) {
|
if (wpMediaOpen) {
|
||||||
return;
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
// Otherwise, allow escape to close dialog
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -305,7 +299,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 px-6 py-4">
|
||||||
{editingBlock?.type === 'card' && (
|
{editingBlock?.type === 'card' && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -359,7 +353,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
/>
|
/>
|
||||||
{variables.length > 0 && (
|
{variables.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{variables.filter(v => v.includes('_url')).map((variable) => (
|
{variables.filter(v => v.includes('_url') || v.includes('_link')).map((variable) => (
|
||||||
<code
|
<code
|
||||||
key={variable}
|
key={variable}
|
||||||
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
||||||
|
|||||||
@@ -56,27 +56,27 @@ export function blocksToMarkdown(blocks: EmailBlock[]): string {
|
|||||||
const cardSyntax = cardBlock.cardType !== 'default' ? `[card:${cardBlock.cardType}]` : '[card]';
|
const cardSyntax = cardBlock.cardType !== 'default' ? `[card:${cardBlock.cardType}]` : '[card]';
|
||||||
return `${cardSyntax}\n\n${cardBlock.content}\n\n[/card]`;
|
return `${cardSyntax}\n\n${cardBlock.content}\n\n[/card]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'button': {
|
case 'button': {
|
||||||
const buttonBlock = block as ButtonBlock;
|
const buttonBlock = block as ButtonBlock;
|
||||||
// Use new [button:style](url)Text[/button] syntax
|
// Use new [button:style](url)Text[/button] syntax
|
||||||
const style = buttonBlock.style || 'solid';
|
const style = buttonBlock.style || 'solid';
|
||||||
return `[button:${style}](${buttonBlock.link})${buttonBlock.text}[/button]`;
|
return `[button:${style}](${buttonBlock.link})${buttonBlock.text}[/button]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'image': {
|
case 'image': {
|
||||||
const imageBlock = block as ImageBlock;
|
const imageBlock = block as ImageBlock;
|
||||||
return `[image src="${imageBlock.src}" alt="${imageBlock.alt || ''}" width="${imageBlock.widthMode}" align="${imageBlock.align}"]`;
|
return `[image src="${imageBlock.src}" alt="${imageBlock.alt || ''}" width="${imageBlock.widthMode}" align="${imageBlock.align}"]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'divider':
|
case 'divider':
|
||||||
return '---';
|
return '---';
|
||||||
|
|
||||||
case 'spacer': {
|
case 'spacer': {
|
||||||
const spacerBlock = block as SpacerBlock;
|
const spacerBlock = block as SpacerBlock;
|
||||||
return `[spacer height="${spacerBlock.height}"]`;
|
return `[spacer height="${spacerBlock.height}"]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
|
|||||||
return `[card]\n${block.content}\n[/card]`;
|
return `[card]\n${block.content}\n[/card]`;
|
||||||
}
|
}
|
||||||
return `[card type="${block.cardType}"]\n${block.content}\n[/card]`;
|
return `[card type="${block.cardType}"]\n${block.content}\n[/card]`;
|
||||||
|
|
||||||
case 'button': {
|
case 'button': {
|
||||||
const buttonClass = block.style === 'solid' ? 'button' : 'button-outline';
|
const buttonClass = block.style === 'solid' ? 'button' : 'button-outline';
|
||||||
const align = block.align || 'center';
|
const align = block.align || 'center';
|
||||||
@@ -118,13 +118,13 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
|
|||||||
}
|
}
|
||||||
return `<p style="${wrapperStyle}"><img src="${block.src}" alt="${block.alt || ''}" style="${imgStyle}" /></p>`;
|
return `<p style="${wrapperStyle}"><img src="${block.src}" alt="${block.alt || ''}" style="${imgStyle}" /></p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'divider':
|
case 'divider':
|
||||||
return `<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />`;
|
return `<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />`;
|
||||||
|
|
||||||
case 'spacer':
|
case 'spacer':
|
||||||
return `<div style="height: ${block.height}px;"></div>`;
|
return `<div style="height: ${block.height}px;"></div>`;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -137,39 +137,39 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
|
|||||||
export function htmlToBlocks(html: string): EmailBlock[] {
|
export function htmlToBlocks(html: string): EmailBlock[] {
|
||||||
const blocks: EmailBlock[] = [];
|
const blocks: EmailBlock[] = [];
|
||||||
let blockId = 0;
|
let blockId = 0;
|
||||||
|
|
||||||
// Match both [card] syntax and <div class="card"> HTML
|
// Match both [card] syntax and <div class="card"> HTML
|
||||||
const cardRegex = /(?:\[card([^\]]*)\]([\s\S]*?)\[\/card\]|<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>)/gs;
|
const cardRegex = /(?:\[card([^\]]*)\]([\s\S]*?)\[\/card\]|<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>)/gs;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
while ((match = cardRegex.exec(html)) !== null) {
|
while ((match = cardRegex.exec(html)) !== null) {
|
||||||
// Add content before card
|
// Add content before card
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
const beforeContent = html.substring(lastIndex, match.index).trim();
|
const beforeContent = html.substring(lastIndex, match.index).trim();
|
||||||
if (beforeContent) parts.push(beforeContent);
|
if (beforeContent) parts.push(beforeContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add card
|
// Add card
|
||||||
parts.push(match[0]);
|
parts.push(match[0]);
|
||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining content
|
// Add remaining content
|
||||||
if (lastIndex < html.length) {
|
if (lastIndex < html.length) {
|
||||||
const remaining = html.substring(lastIndex).trim();
|
const remaining = html.substring(lastIndex).trim();
|
||||||
if (remaining) parts.push(remaining);
|
if (remaining) parts.push(remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each part
|
// Process each part
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
const id = `block-${Date.now()}-${blockId++}`;
|
const id = `block-${Date.now()}-${blockId++}`;
|
||||||
|
|
||||||
// Check if it's a card - match [card:type], [card type="..."], and <div class="card">
|
// Check if it's a card - match [card:type], [card type="..."], and <div class="card">
|
||||||
let content = '';
|
let content = '';
|
||||||
let cardType = 'default';
|
let cardType = 'default';
|
||||||
|
|
||||||
// Try new [card:type] syntax first
|
// Try new [card:type] syntax first
|
||||||
let cardMatch = part.match(/\[card:(\w+)\]([\s\S]*?)\[\/card\]/s);
|
let cardMatch = part.match(/\[card:(\w+)\]([\s\S]*?)\[\/card\]/s);
|
||||||
if (cardMatch) {
|
if (cardMatch) {
|
||||||
@@ -185,7 +185,7 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
cardType = (typeMatch ? typeMatch[1] : 'default');
|
cardType = (typeMatch ? typeMatch[1] : 'default');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cardMatch) {
|
if (!cardMatch) {
|
||||||
// <div class="card"> HTML syntax
|
// <div class="card"> HTML syntax
|
||||||
const htmlCardMatch = part.match(/<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>/s);
|
const htmlCardMatch = part.match(/<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>/s);
|
||||||
@@ -194,7 +194,7 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
content = htmlCardMatch[2].trim();
|
content = htmlCardMatch[2].trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (content) {
|
if (content) {
|
||||||
// Convert HTML content to markdown for clean editing
|
// Convert HTML content to markdown for clean editing
|
||||||
// But only if it actually contains HTML tags
|
// But only if it actually contains HTML tags
|
||||||
@@ -208,14 +208,14 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a button - try new syntax first
|
// Check if it's a button - try new syntax first
|
||||||
let buttonMatch = part.match(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/);
|
let buttonMatch = part.match(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/);
|
||||||
if (buttonMatch) {
|
if (buttonMatch) {
|
||||||
const style = buttonMatch[1] as ButtonStyle;
|
const style = buttonMatch[1] as ButtonStyle;
|
||||||
const url = buttonMatch[2];
|
const url = buttonMatch[2];
|
||||||
const text = buttonMatch[3].trim();
|
const text = buttonMatch[3].trim();
|
||||||
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id,
|
id,
|
||||||
type: 'button',
|
type: 'button',
|
||||||
@@ -227,14 +227,14 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try old [button url="..."] syntax
|
// Try old [button url="..."] syntax
|
||||||
buttonMatch = part.match(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](\w+)["'])?\]([^\[]+)\[\/button\]/);
|
buttonMatch = part.match(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](\w+)["'])?\]([^\[]+)\[\/button\]/);
|
||||||
if (buttonMatch) {
|
if (buttonMatch) {
|
||||||
const url = buttonMatch[1];
|
const url = buttonMatch[1];
|
||||||
const style = (buttonMatch[2] || 'solid') as ButtonStyle;
|
const style = (buttonMatch[2] || 'solid') as ButtonStyle;
|
||||||
const text = buttonMatch[3].trim();
|
const text = buttonMatch[3].trim();
|
||||||
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id,
|
id,
|
||||||
type: 'button',
|
type: 'button',
|
||||||
@@ -246,7 +246,7 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check HTML button syntax
|
// Check HTML button syntax
|
||||||
if (part.includes('class="button"') || part.includes('class="button-outline"')) {
|
if (part.includes('class="button"') || part.includes('class="button-outline"')) {
|
||||||
const buttonMatch = part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*style="([^"]*)"[^>]*>([^<]*)<\/a>/) ||
|
const buttonMatch = part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*style="([^"]*)"[^>]*>([^<]*)<\/a>/) ||
|
||||||
@@ -286,13 +286,13 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a divider
|
// Check if it's a divider
|
||||||
if (part.includes('<hr')) {
|
if (part.includes('<hr')) {
|
||||||
blocks.push({ id, type: 'divider' });
|
blocks.push({ id, type: 'divider' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a spacer
|
// Check if it's a spacer
|
||||||
const spacerMatch = part.match(/height:\s*(\d+)px/);
|
const spacerMatch = part.match(/height:\s*(\d+)px/);
|
||||||
if (spacerMatch && part.includes('<div')) {
|
if (spacerMatch && part.includes('<div')) {
|
||||||
@@ -300,7 +300,7 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,30 +310,47 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
export function markdownToBlocks(markdown: string): EmailBlock[] {
|
export function markdownToBlocks(markdown: string): EmailBlock[] {
|
||||||
const blocks: EmailBlock[] = [];
|
const blocks: EmailBlock[] = [];
|
||||||
let blockId = 0;
|
let blockId = 0;
|
||||||
|
|
||||||
// Parse markdown respecting [card]...[/card] and [button]...[/button] boundaries
|
// Parse markdown respecting [card]...[/card] and [button]...[/button] boundaries
|
||||||
let remaining = markdown;
|
let remaining = markdown;
|
||||||
|
|
||||||
while (remaining.length > 0) {
|
while (remaining.length > 0) {
|
||||||
remaining = remaining.trim();
|
remaining = remaining.trim();
|
||||||
if (!remaining) break;
|
if (!remaining) break;
|
||||||
|
|
||||||
const id = `block-${Date.now()}-${blockId++}`;
|
const id = `block-${Date.now()}-${blockId++}`;
|
||||||
|
|
||||||
// Check for [card] blocks - match with proper boundaries
|
// Check for [card] blocks - NEW syntax [card:type]...[/card]
|
||||||
|
const newCardMatch = remaining.match(/^\[card:(\w+)\]([\s\S]*?)\[\/card\]/);
|
||||||
|
if (newCardMatch) {
|
||||||
|
const cardType = newCardMatch[1] as CardType;
|
||||||
|
const content = newCardMatch[2].trim();
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
id,
|
||||||
|
type: 'card',
|
||||||
|
cardType,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
remaining = remaining.substring(newCardMatch[0].length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for [card] blocks - OLD syntax [card type="..."]...[/card]
|
||||||
const cardMatch = remaining.match(/^\[card([^\]]*)\]([\s\S]*?)\[\/card\]/);
|
const cardMatch = remaining.match(/^\[card([^\]]*)\]([\s\S]*?)\[\/card\]/);
|
||||||
if (cardMatch) {
|
if (cardMatch) {
|
||||||
const attributes = cardMatch[1].trim();
|
const attributes = cardMatch[1].trim();
|
||||||
const content = cardMatch[2].trim();
|
const content = cardMatch[2].trim();
|
||||||
|
|
||||||
// Extract card type
|
// Extract card type
|
||||||
const typeMatch = attributes.match(/type\s*=\s*["']([^"']+)["']/);
|
const typeMatch = attributes.match(/type\s*=\s*["']([^"']+)["']/);
|
||||||
const cardType = (typeMatch?.[1] || 'default') as CardType;
|
const cardType = (typeMatch?.[1] || 'default') as CardType;
|
||||||
|
|
||||||
// Extract background
|
// Extract background
|
||||||
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
||||||
const bg = bgMatch?.[1];
|
const bg = bgMatch?.[1];
|
||||||
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id,
|
id,
|
||||||
type: 'card',
|
type: 'card',
|
||||||
@@ -341,13 +358,30 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
|
|||||||
content,
|
content,
|
||||||
bg,
|
bg,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Advance past this card
|
// Advance past this card
|
||||||
remaining = remaining.substring(cardMatch[0].length);
|
remaining = remaining.substring(cardMatch[0].length);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for [button] blocks
|
// Check for [button] blocks - NEW syntax [button:style](url)Text[/button]
|
||||||
|
const newButtonMatch = remaining.match(/^\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/);
|
||||||
|
if (newButtonMatch) {
|
||||||
|
blocks.push({
|
||||||
|
id,
|
||||||
|
type: 'button',
|
||||||
|
text: newButtonMatch[3].trim(),
|
||||||
|
link: newButtonMatch[2],
|
||||||
|
style: newButtonMatch[1] as ButtonStyle,
|
||||||
|
align: 'center',
|
||||||
|
widthMode: 'fit',
|
||||||
|
});
|
||||||
|
|
||||||
|
remaining = remaining.substring(newButtonMatch[0].length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for [button] blocks - OLD syntax [button url="..." style="..."]Text[/button]
|
||||||
const buttonMatch = remaining.match(/^\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/);
|
const buttonMatch = remaining.match(/^\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/);
|
||||||
if (buttonMatch) {
|
if (buttonMatch) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
@@ -359,11 +393,11 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
widthMode: 'fit',
|
widthMode: 'fit',
|
||||||
});
|
});
|
||||||
|
|
||||||
remaining = remaining.substring(buttonMatch[0].length);
|
remaining = remaining.substring(buttonMatch[0].length);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for [image] blocks
|
// Check for [image] blocks
|
||||||
const imageMatch = remaining.match(/^\[image\s+src=["']([^"']+)["'](?:\s+alt=["']([^"']*)["'])?(?:\s+width=["']([^"']+)["'])?(?:\s+align=["']([^"']+)["'])?\]/);
|
const imageMatch = remaining.match(/^\[image\s+src=["']([^"']+)["'](?:\s+alt=["']([^"']*)["'])?(?:\s+width=["']([^"']+)["'])?(?:\s+align=["']([^"']+)["'])?\]/);
|
||||||
if (imageMatch) {
|
if (imageMatch) {
|
||||||
@@ -375,11 +409,11 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
|
|||||||
widthMode: (imageMatch[3] || 'fit') as ContentWidth,
|
widthMode: (imageMatch[3] || 'fit') as ContentWidth,
|
||||||
align: (imageMatch[4] || 'center') as ContentAlign,
|
align: (imageMatch[4] || 'center') as ContentAlign,
|
||||||
});
|
});
|
||||||
|
|
||||||
remaining = remaining.substring(imageMatch[0].length);
|
remaining = remaining.substring(imageMatch[0].length);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for [spacer] blocks
|
// Check for [spacer] blocks
|
||||||
const spacerMatch = remaining.match(/^\[spacer\s+height=["'](\d+)["']\]/);
|
const spacerMatch = remaining.match(/^\[spacer\s+height=["'](\d+)["']\]/);
|
||||||
if (spacerMatch) {
|
if (spacerMatch) {
|
||||||
@@ -388,25 +422,25 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
|
|||||||
type: 'spacer',
|
type: 'spacer',
|
||||||
height: parseInt(spacerMatch[1]),
|
height: parseInt(spacerMatch[1]),
|
||||||
});
|
});
|
||||||
|
|
||||||
remaining = remaining.substring(spacerMatch[0].length);
|
remaining = remaining.substring(spacerMatch[0].length);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for horizontal rule
|
// Check for horizontal rule
|
||||||
if (remaining.startsWith('---')) {
|
if (remaining.startsWith('---')) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id,
|
id,
|
||||||
type: 'divider',
|
type: 'divider',
|
||||||
});
|
});
|
||||||
|
|
||||||
remaining = remaining.substring(3);
|
remaining = remaining.substring(3);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If nothing matches, skip this character to avoid infinite loop
|
// If nothing matches, skip this character to avoid infinite loop
|
||||||
remaining = remaining.substring(1);
|
remaining = remaining.substring(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type BlockType = 'card' | 'button' | 'divider' | 'spacer' | 'image';
|
|||||||
|
|
||||||
export type CardType = 'default' | 'success' | 'info' | 'warning' | 'hero' | 'basic';
|
export type CardType = 'default' | 'success' | 'info' | 'warning' | 'hero' | 'basic';
|
||||||
|
|
||||||
export type ButtonStyle = 'solid' | 'outline';
|
export type ButtonStyle = 'solid' | 'outline' | 'link';
|
||||||
|
|
||||||
export type ContentWidth = 'fit' | 'full' | 'custom';
|
export type ContentWidth = 'fit' | 'full' | 'custom';
|
||||||
|
|
||||||
|
|||||||
10
admin-spa/src/components/LegacyCampaignRedirect.tsx
Normal file
10
admin-spa/src/components/LegacyCampaignRedirect.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Navigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy redirect for campaign details
|
||||||
|
* Redirects /marketing/campaigns/:id -> /marketing/newsletter/campaigns/:id
|
||||||
|
*/
|
||||||
|
export function LegacyCampaignRedirect() {
|
||||||
|
const { id } = useParams();
|
||||||
|
return <Navigate to={`/marketing/newsletter/campaigns/${id}`} replace />;
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ interface PageHeaderProps {
|
|||||||
hideOnDesktop?: boolean;
|
hideOnDesktop?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { DocLink } from '@/components/DocLink';
|
||||||
|
|
||||||
export function PageHeader({ fullscreen = false, hideOnDesktop = false }: PageHeaderProps) {
|
export function PageHeader({ fullscreen = false, hideOnDesktop = false }: PageHeaderProps) {
|
||||||
const { title, action } = usePageHeader();
|
const { title, action } = usePageHeader();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -24,8 +26,9 @@ export function PageHeader({ fullscreen = false, hideOnDesktop = false }: PageHe
|
|||||||
return (
|
return (
|
||||||
<div className={`sticky top-0 z-20 border-b bg-background ${hideOnDesktop ? 'md:hidden' : ''}`}>
|
<div className={`sticky top-0 z-20 border-b bg-background ${hideOnDesktop ? 'md:hidden' : ''}`}>
|
||||||
<div className={`${containerClass} px-4 py-3 flex items-center justify-between min-w-0`}>
|
<div className={`${containerClass} px-4 py-3 flex items-center justify-between min-w-0`}>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 flex items-center">
|
||||||
<h1 className="text-lg font-semibold truncate">{title}</h1>
|
<h1 className="text-lg font-semibold truncate">{title}</h1>
|
||||||
|
<DocLink />
|
||||||
</div>
|
</div>
|
||||||
{action && <div className="flex-shrink-0 ml-4">{action}</div>}
|
{action && <div className="flex-shrink-0 ml-4">{action}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function fmt(d: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DateRange({ value, onChange }: Props) {
|
export default function DateRange({ value, onChange }: Props) {
|
||||||
const [preset, setPreset] = useState<string>(() => "last7");
|
const [preset, setPreset] = useState<string>(() => "last30");
|
||||||
const [start, setStart] = useState<string | undefined>(value?.date_start);
|
const [start, setStart] = useState<string | undefined>(value?.date_start);
|
||||||
const [end, setEnd] = useState<string | undefined>(value?.date_end);
|
const [end, setEnd] = useState<string | undefined>(value?.date_end);
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ export default function DateRange({ value, onChange }: Props) {
|
|||||||
return {
|
return {
|
||||||
today: { date_start: todayStr, date_end: todayStr },
|
today: { date_start: todayStr, date_end: todayStr },
|
||||||
last7: { date_start: fmt(last7), date_end: todayStr },
|
last7: { date_start: fmt(last7), date_end: todayStr },
|
||||||
last30:{ date_start: fmt(last30), date_end: todayStr },
|
last30: { date_start: fmt(last30), date_end: todayStr },
|
||||||
custom:{ date_start: start, date_end: end },
|
custom: { date_start: start, date_end: end },
|
||||||
};
|
};
|
||||||
}, [start, end]);
|
}, [start, end]);
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export default function DateRange({ value, onChange }: Props) {
|
|||||||
if (preset === "custom") {
|
if (preset === "custom") {
|
||||||
onChange?.({ date_start: start, date_end: end, preset });
|
onChange?.({ date_start: start, date_end: end, preset });
|
||||||
} else {
|
} else {
|
||||||
const pr = (presets as any)[preset] || presets.last7;
|
const pr = (presets as any)[preset] || presets.last30;
|
||||||
onChange?.({ ...pr, preset });
|
onChange?.({ ...pr, preset });
|
||||||
setStart(pr.date_start);
|
setStart(pr.date_start);
|
||||||
setEnd(pr.date_end);
|
setEnd(pr.date_end);
|
||||||
@@ -53,7 +53,7 @@ export default function DateRange({ value, onChange }: Props) {
|
|||||||
<div className="flex flex-col lg:flex-row gap-2 w-full">
|
<div className="flex flex-col lg:flex-row gap-2 w-full">
|
||||||
<Select value={preset} onValueChange={(v) => setPreset(v)}>
|
<Select value={preset} onValueChange={(v) => setPreset(v)}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder={__("Last 7 days")} />
|
<SelectValue placeholder={__("Last 30 days")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper" className="z-[1000]">
|
<SelectContent position="popper" className="z-[1000]">
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
|
|||||||
@@ -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 = ({
|
||||||
|
|||||||
@@ -30,25 +30,53 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => {
|
||||||
<DialogPortal>
|
// Get or create portal container inside the app for proper CSS scoping
|
||||||
<DialogOverlay />
|
const getPortalContainer = () => {
|
||||||
<DialogPrimitive.Content
|
const appContainer = document.getElementById('woonoow-admin-app');
|
||||||
ref={ref}
|
if (!appContainer) return document.body;
|
||||||
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",
|
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
|
||||||
{children}
|
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
portalRoot.className = themeClass;
|
||||||
<X className="h-4 w-4" />
|
appContainer.appendChild(portalRoot);
|
||||||
<span className="sr-only">Close</span>
|
} else {
|
||||||
</DialogPrimitive.Close>
|
// Update theme class in case it changed
|
||||||
</DialogPrimitive.Content>
|
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||||
</DialogPortal>
|
if (!portalRoot.classList.contains(themeClass)) {
|
||||||
))
|
portalRoot.classList.remove('light', 'dark');
|
||||||
|
portalRoot.classList.add(themeClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return portalRoot;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogPortal container={getPortalContainer()}>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
onPointerDownOutside={(e) => e.preventDefault()}
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-[99999] flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border bg-background 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}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground z-10">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
})
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({
|
||||||
@@ -57,7 +85,7 @@ const DialogHeader = ({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
"flex flex-col space-y-1.5 text-center sm:text-left px-6 pt-6 pb-4 border-b",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -71,7 +99,7 @@ const DialogFooter = ({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 px-6 py-4 border-t mt-auto",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -106,6 +134,20 @@ const DialogDescription = React.forwardRef<
|
|||||||
))
|
))
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const DialogBody = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 overflow-y-auto px-6 py-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogBody.displayName = "DialogBody"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
@@ -117,4 +159,5 @@ export {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogBody,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { Button } from './button';
|
|||||||
import { Input } from './input';
|
import { Input } from './input';
|
||||||
import { Label } from './label';
|
import { Label } from './label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogBody } from './dialog';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
interface RichTextEditorProps {
|
interface RichTextEditorProps {
|
||||||
@@ -45,10 +45,13 @@ export function RichTextEditor({
|
|||||||
}: RichTextEditorProps) {
|
}: RichTextEditorProps) {
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
// StarterKit 3.10+ includes Link by default, disable since we configure separately
|
||||||
|
StarterKit.configure({ link: false }),
|
||||||
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: {
|
||||||
@@ -64,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 }) => {
|
||||||
@@ -75,14 +77,6 @@ export function RichTextEditor({
|
|||||||
class:
|
class:
|
||||||
'prose prose-sm max-w-none focus:outline-none min-h-[200px] px-4 py-3 [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-4 [&_h1]:mb-2 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-3 [&_h2]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-2 [&_h3]:mb-1 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-2 [&_h4]:mb-1',
|
'prose prose-sm max-w-none focus:outline-none min-h-[200px] px-4 py-3 [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-4 [&_h1]:mb-2 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-3 [&_h2]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-2 [&_h3]:mb-1 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-2 [&_h4]:mb-1',
|
||||||
},
|
},
|
||||||
handleClick: (view, pos, event) => {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (target.tagName === 'A' || target.closest('a')) {
|
|
||||||
event.preventDefault();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,7 +86,6 @@ export function RichTextEditor({
|
|||||||
const currentContent = editor.getHTML();
|
const currentContent = editor.getHTML();
|
||||||
// Only update if content is different (avoid infinite loops)
|
// Only update if content is different (avoid infinite loops)
|
||||||
if (content !== currentContent) {
|
if (content !== currentContent) {
|
||||||
console.log('RichTextEditor: Updating content', { content, currentContent });
|
|
||||||
editor.commands.setContent(content);
|
editor.commands.setContent(content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,11 +112,13 @@ export function RichTextEditor({
|
|||||||
const [buttonDialogOpen, setButtonDialogOpen] = useState(false);
|
const [buttonDialogOpen, setButtonDialogOpen] = useState(false);
|
||||||
const [buttonText, setButtonText] = useState('Click Here');
|
const [buttonText, setButtonText] = useState('Click Here');
|
||||||
const [buttonHref, setButtonHref] = useState('{order_url}');
|
const [buttonHref, setButtonHref] = useState('{order_url}');
|
||||||
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline'>('solid');
|
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline' | 'link'>('solid');
|
||||||
|
const [isEditingButton, setIsEditingButton] = useState(false);
|
||||||
|
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
|
||||||
|
|
||||||
const addImage = () => {
|
const addImage = () => {
|
||||||
openWPMediaImage((file) => {
|
openWPMediaImage((file) => {
|
||||||
editor.chain().focus().setImage({
|
editor.chain().focus().setImage({
|
||||||
src: file.url,
|
src: file.url,
|
||||||
alt: file.alt || file.title,
|
alt: file.alt || file.title,
|
||||||
title: file.title,
|
title: file.title,
|
||||||
@@ -135,12 +130,81 @@ export function RichTextEditor({
|
|||||||
setButtonText('Click Here');
|
setButtonText('Click Here');
|
||||||
setButtonHref('{order_url}');
|
setButtonHref('{order_url}');
|
||||||
setButtonStyle('solid');
|
setButtonStyle('solid');
|
||||||
|
setIsEditingButton(false);
|
||||||
|
setEditingButtonPos(null);
|
||||||
setButtonDialogOpen(true);
|
setButtonDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle clicking on buttons in the editor to edit them
|
||||||
|
const handleEditorClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const buttonEl = target.closest('a[data-button]') as HTMLElement | null;
|
||||||
|
|
||||||
|
if (buttonEl && editor) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Get button attributes
|
||||||
|
const text = buttonEl.getAttribute('data-text') || buttonEl.textContent?.replace('🔘 ', '') || 'Click Here';
|
||||||
|
const href = buttonEl.getAttribute('data-href') || '#';
|
||||||
|
const style = (buttonEl.getAttribute('data-style') as 'solid' | 'outline') || 'solid';
|
||||||
|
|
||||||
|
// Find the position of this button node
|
||||||
|
const { state } = editor.view;
|
||||||
|
let foundPos: number | null = null;
|
||||||
|
|
||||||
|
state.doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name === 'button' &&
|
||||||
|
node.attrs.text === text &&
|
||||||
|
node.attrs.href === href) {
|
||||||
|
foundPos = pos;
|
||||||
|
return false; // Stop iteration
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open dialog in edit mode
|
||||||
|
setButtonText(text);
|
||||||
|
setButtonHref(href);
|
||||||
|
setButtonStyle(style);
|
||||||
|
setIsEditingButton(true);
|
||||||
|
setEditingButtonPos(foundPos);
|
||||||
|
setButtonDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const insertButton = () => {
|
const insertButton = () => {
|
||||||
editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
|
if (isEditingButton && editingButtonPos !== null && editor) {
|
||||||
|
// Delete old button and insert new one at same position
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
|
||||||
|
.insertContentAt(editingButtonPos, {
|
||||||
|
type: 'button',
|
||||||
|
attrs: { text: buttonText, href: buttonHref, style: buttonStyle },
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
// Insert new button
|
||||||
|
editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
|
||||||
|
}
|
||||||
setButtonDialogOpen(false);
|
setButtonDialogOpen(false);
|
||||||
|
setIsEditingButton(false);
|
||||||
|
setEditingButtonPos(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteButton = () => {
|
||||||
|
if (editingButtonPos !== null && editor) {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
|
||||||
|
.run();
|
||||||
|
setButtonDialogOpen(false);
|
||||||
|
setIsEditingButton(false);
|
||||||
|
setEditingButtonPos(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActiveHeading = () => {
|
const getActiveHeading = () => {
|
||||||
@@ -292,97 +356,175 @@ export function RichTextEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
<div className="overflow-y-auto max-h-[400px] min-h-[200px]">
|
<div onClick={handleEditorClick}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Variables Dropdown */}
|
{/* Variables - Collapsible and Categorized */}
|
||||||
{variables.length > 0 && (
|
{variables.length > 0 && (
|
||||||
<div className="border-t bg-muted/30 p-3">
|
<details className="border-t bg-muted/30">
|
||||||
<div className="flex items-center gap-2">
|
<summary className="p-3 text-xs text-muted-foreground cursor-pointer hover:bg-muted/50 flex items-center gap-2 select-none">
|
||||||
<Label htmlFor="variable-select" className="text-xs text-muted-foreground whitespace-nowrap">
|
<span className="text-[10px]">▶</span>
|
||||||
{__('Insert Variable:')}
|
{__('Insert Variable')}
|
||||||
</Label>
|
<span className="text-[10px] opacity-60">({variables.length})</span>
|
||||||
<Select onValueChange={(value) => insertVariable(value)}>
|
</summary>
|
||||||
<SelectTrigger id="variable-select" className="h-8 text-xs">
|
<div className="p-3 pt-0 space-y-3">
|
||||||
<SelectValue placeholder={__('Choose a variable...')} />
|
{/* Order Variables */}
|
||||||
</SelectTrigger>
|
{variables.some(v => v.startsWith('order')) && (
|
||||||
<SelectContent>
|
<div>
|
||||||
{variables.map((variable) => (
|
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Order')}</div>
|
||||||
<SelectItem key={variable} value={variable} className="text-xs">
|
<div className="flex flex-wrap gap-1">
|
||||||
{`{${variable}}`}
|
{variables.filter(v => v.startsWith('order')).map((variable) => (
|
||||||
</SelectItem>
|
<button
|
||||||
))}
|
key={variable}
|
||||||
</SelectContent>
|
type="button"
|
||||||
</Select>
|
onClick={() => insertVariable(variable)}
|
||||||
|
className="text-[11px] px-1.5 py-0.5 bg-blue-50 text-blue-700 rounded hover:bg-blue-100 transition-colors"
|
||||||
|
>
|
||||||
|
{`{${variable}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Subscriber/Customer Variables */}
|
||||||
|
{variables.some(v => v.startsWith('customer') || v.startsWith('subscriber') || (v.includes('_name') && !v.startsWith('order') && !v.startsWith('site') && !v.startsWith('store'))) && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Subscriber')}</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{variables.filter(v => v.startsWith('customer') || v.startsWith('subscriber') || (v.includes('address') && !v.startsWith('shipping'))).map((variable) => (
|
||||||
|
<button
|
||||||
|
key={variable}
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertVariable(variable)}
|
||||||
|
className="text-[11px] px-1.5 py-0.5 bg-green-50 text-green-700 rounded hover:bg-green-100 transition-colors"
|
||||||
|
>
|
||||||
|
{`{${variable}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Shipping/Payment Variables */}
|
||||||
|
{variables.some(v => v.startsWith('shipping') || v.startsWith('payment') || v.startsWith('tracking')) && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Shipping & Payment')}</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{variables.filter(v => v.startsWith('shipping') || v.startsWith('payment') || v.startsWith('tracking')).map((variable) => (
|
||||||
|
<button
|
||||||
|
key={variable}
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertVariable(variable)}
|
||||||
|
className="text-[11px] px-1.5 py-0.5 bg-orange-50 text-orange-700 rounded hover:bg-orange-100 transition-colors"
|
||||||
|
>
|
||||||
|
{`{${variable}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Store/Site Variables */}
|
||||||
|
{variables.some(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('current') || v.includes('_url') || v.startsWith('support') || v.startsWith('review')) && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Store & Links')}</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{variables.filter(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('current') || v.startsWith('my_account') || v.startsWith('support') || v.startsWith('review') || (v.includes('_url') && !v.startsWith('order') && !v.startsWith('tracking') && !v.startsWith('payment'))).map((variable) => (
|
||||||
|
<button
|
||||||
|
key={variable}
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertVariable(variable)}
|
||||||
|
className="text-[11px] px-1.5 py-0.5 bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
|
||||||
|
>
|
||||||
|
{`{${variable}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Button Dialog */}
|
{/* Button Dialog */}
|
||||||
<Dialog open={buttonDialogOpen} onOpenChange={setButtonDialogOpen}>
|
<Dialog open={buttonDialogOpen} onOpenChange={(open) => {
|
||||||
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
|
setButtonDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setIsEditingButton(false);
|
||||||
|
setEditingButtonPos(null);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{__('Insert Button')}</DialogTitle>
|
<DialogTitle>{isEditingButton ? __('Edit Button') : __('Insert Button')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{__('Add a styled button to your content. Use variables for dynamic links.')}
|
{isEditingButton
|
||||||
|
? __('Edit the button properties below. Click on the button to save.')
|
||||||
|
: __('Add a styled button to your content. Use variables for dynamic links.')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<DialogBody>
|
||||||
<div className="space-y-2">
|
<div className="space-y-4 !p-4">
|
||||||
<Label htmlFor="btn-text">{__('Button Text')}</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label htmlFor="btn-text">{__('Button Text')}</Label>
|
||||||
id="btn-text"
|
<Input
|
||||||
value={buttonText}
|
id="btn-text"
|
||||||
onChange={(e) => setButtonText(e.target.value)}
|
value={buttonText}
|
||||||
placeholder={__('e.g., View Order')}
|
onChange={(e) => setButtonText(e.target.value)}
|
||||||
/>
|
placeholder={__('e.g., View Order')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="btn-href">{__('Button Link')}</Label>
|
||||||
|
<Input
|
||||||
|
id="btn-href"
|
||||||
|
value={buttonHref}
|
||||||
|
onChange={(e) => setButtonHref(e.target.value)}
|
||||||
|
placeholder="{order_url}"
|
||||||
|
/>
|
||||||
|
{variables.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{variables.filter(v => v.includes('_url') || v.includes('_link')).map((variable) => (
|
||||||
|
<code
|
||||||
|
key={variable}
|
||||||
|
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
||||||
|
onClick={() => setButtonHref(buttonHref + `{${variable}}`)}
|
||||||
|
>
|
||||||
|
{`{${variable}}`}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="btn-style">{__('Button Style')}</Label>
|
||||||
|
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline' | 'link') => setButtonStyle(value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
|
||||||
|
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
|
||||||
|
<SelectItem value="link">{__('Plain Link')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</DialogBody>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="btn-href">{__('Button Link')}</Label>
|
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||||
<Input
|
{isEditingButton && (
|
||||||
id="btn-href"
|
<Button variant="destructive" onClick={deleteButton} className="sm:mr-auto">
|
||||||
value={buttonHref}
|
{__('Delete')}
|
||||||
onChange={(e) => setButtonHref(e.target.value)}
|
</Button>
|
||||||
placeholder="{order_url}"
|
)}
|
||||||
/>
|
|
||||||
{variables.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
|
||||||
{variables.filter(v => v.includes('_url')).map((variable) => (
|
|
||||||
<code
|
|
||||||
key={variable}
|
|
||||||
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
|
||||||
onClick={() => setButtonHref(buttonHref + `{${variable}}`)}
|
|
||||||
>
|
|
||||||
{`{${variable}}`}
|
|
||||||
</code>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="btn-style">{__('Button Style')}</Label>
|
|
||||||
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline') => setButtonStyle(value)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
|
|
||||||
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
|
||||||
{__('Cancel')}
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={insertButton}>
|
<Button onClick={insertButton}>
|
||||||
{__('Insert Button')}
|
{isEditingButton ? __('Update Button') : __('Insert Button')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -69,33 +69,49 @@ SelectScrollDownButton.displayName =
|
|||||||
const SelectContent = React.forwardRef<
|
const SelectContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
>(({ className, children, position = "popper", ...props }, ref) => {
|
||||||
<SelectPrimitive.Portal>
|
// Get or create portal container inside the app for proper CSS scoping
|
||||||
<SelectPrimitive.Content
|
const getPortalContainer = () => {
|
||||||
ref={ref}
|
const appContainer = document.getElementById('woonoow-admin-app');
|
||||||
className={cn(
|
if (!appContainer) return document.body;
|
||||||
"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]",
|
|
||||||
position === "popper" &&
|
let portalRoot = document.getElementById('woonoow-select-portal');
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
if (!portalRoot) {
|
||||||
className
|
portalRoot = document.createElement('div');
|
||||||
)}
|
portalRoot.id = 'woonoow-select-portal';
|
||||||
position={position}
|
appContainer.appendChild(portalRoot);
|
||||||
{...props}
|
}
|
||||||
>
|
return portalRoot;
|
||||||
<SelectScrollUpButton />
|
};
|
||||||
<SelectPrimitive.Viewport
|
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal container={getPortalContainer()}>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"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" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
"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
|
||||||
)}
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
<SelectScrollUpButton />
|
||||||
</SelectPrimitive.Viewport>
|
<SelectPrimitive.Viewport
|
||||||
<SelectScrollDownButton />
|
className={cn(
|
||||||
</SelectPrimitive.Content>
|
"p-1",
|
||||||
</SelectPrimitive.Portal>
|
position === "popper" &&
|
||||||
))
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
})
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
const SelectLabel = React.forwardRef<
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface ButtonOptions {
|
|||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
button: {
|
button: {
|
||||||
setButton: (options: { text: string; href: string; style?: 'solid' | 'outline' }) => ReturnType;
|
setButton: (options: { text: string; href: string; style?: 'solid' | 'outline' | 'link' }) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,54 +37,60 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
|||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
tag: 'a[data-button]',
|
||||||
|
priority: 100, // Higher priority than Link extension (default 50)
|
||||||
|
getAttrs: (node: HTMLElement) => ({
|
||||||
|
text: node.getAttribute('data-text') || node.textContent || 'Click Here',
|
||||||
|
href: node.getAttribute('data-href') || node.getAttribute('href') || '#',
|
||||||
|
style: node.getAttribute('data-style') || 'solid',
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tag: 'a.button',
|
tag: 'a.button',
|
||||||
|
priority: 100,
|
||||||
|
getAttrs: (node: HTMLElement) => ({
|
||||||
|
text: node.textContent || 'Click Here',
|
||||||
|
href: node.getAttribute('href') || '#',
|
||||||
|
style: 'solid',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'a.button-outline',
|
tag: 'a.button-outline',
|
||||||
|
priority: 100,
|
||||||
|
getAttrs: (node: HTMLElement) => ({
|
||||||
|
text: node.textContent || 'Click Here',
|
||||||
|
href: node.getAttribute('href') || '#',
|
||||||
|
style: 'outline',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
const { text, href, style } = HTMLAttributes;
|
const { text, href, style } = HTMLAttributes;
|
||||||
const className = style === 'outline' ? 'button-outline' : 'button';
|
|
||||||
|
// Different styling based on button style
|
||||||
const buttonStyle: Record<string, string> = style === 'solid'
|
let inlineStyle: string;
|
||||||
? {
|
if (style === 'link') {
|
||||||
display: 'inline-block',
|
// Plain link - just underlined text, no button-like appearance
|
||||||
background: '#7f54b3',
|
inlineStyle = 'color: #7f54b3; text-decoration: underline; cursor: pointer;';
|
||||||
color: '#fff',
|
} else {
|
||||||
padding: '14px 28px',
|
// Solid/Outline buttons - show as styled link with background hint
|
||||||
borderRadius: '6px',
|
inlineStyle = 'color: #7f54b3; text-decoration: underline; cursor: pointer; font-weight: 600; background: rgba(127,84,179,0.1); padding: 2px 6px; border-radius: 3px;';
|
||||||
textDecoration: 'none',
|
}
|
||||||
fontWeight: '600',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
display: 'inline-block',
|
|
||||||
background: 'transparent',
|
|
||||||
color: '#7f54b3',
|
|
||||||
padding: '12px 26px',
|
|
||||||
border: '2px solid #7f54b3',
|
|
||||||
borderRadius: '6px',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: '600',
|
|
||||||
cursor: 'pointer',
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'a',
|
'a',
|
||||||
mergeAttributes(this.options.HTMLAttributes, {
|
mergeAttributes(this.options.HTMLAttributes, {
|
||||||
href,
|
href,
|
||||||
class: className,
|
class: style === 'link' ? 'link-node' : 'button-node',
|
||||||
style: Object.entries(buttonStyle)
|
style: inlineStyle,
|
||||||
.map(([key, value]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value}`)
|
|
||||||
.join('; '),
|
|
||||||
'data-button': '',
|
'data-button': '',
|
||||||
'data-text': text,
|
'data-text': text,
|
||||||
'data-href': href,
|
'data-href': href,
|
||||||
'data-style': style,
|
'data-style': style,
|
||||||
|
title: style === 'link' ? `Link: ${text}` : `Button: ${text} → ${href}`,
|
||||||
}),
|
}),
|
||||||
text,
|
text,
|
||||||
];
|
];
|
||||||
@@ -94,12 +100,12 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
|||||||
return {
|
return {
|
||||||
setButton:
|
setButton:
|
||||||
(options) =>
|
(options) =>
|
||||||
({ commands }) => {
|
({ commands }) => {
|
||||||
return commands.insertContent({
|
return commands.insertContent({
|
||||||
type: this.name,
|
type: this.name,
|
||||||
attrs: options,
|
attrs: options,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
58
admin-spa/src/config/docRoutes.ts
Normal file
58
admin-spa/src/config/docRoutes.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* docRoutes.ts
|
||||||
|
*
|
||||||
|
* Maps Admin SPA routes to external documentation URLs.
|
||||||
|
* Used by the DocLink component to provide contextual help.
|
||||||
|
*/
|
||||||
|
export const docRoutes: Record<string, string> = {
|
||||||
|
// Marketing Suite
|
||||||
|
// '/marketing': 'https://docs.woonoow.com/docs/marketing', // No general marketing doc yet
|
||||||
|
'/marketing/coupons': 'https://docs.woonoow.com/docs/marketing/coupons',
|
||||||
|
'/marketing/newsletter': 'https://docs.woonoow.com/docs/marketing/newsletter',
|
||||||
|
'/marketing/wishlist': 'https://docs.woonoow.com/docs/marketing/wishlist',
|
||||||
|
|
||||||
|
// Settings - Modules
|
||||||
|
'/settings/modules/wishlist': 'https://docs.woonoow.com/docs/marketing/wishlist',
|
||||||
|
'/settings/modules/newsletter': 'https://docs.woonoow.com/docs/marketing/newsletter',
|
||||||
|
|
||||||
|
// Builder
|
||||||
|
'/appearance/header': 'https://docs.woonoow.com/docs/builder/header-footer#header',
|
||||||
|
'/appearance/footer': 'https://docs.woonoow.com/docs/builder/header-footer#footer',
|
||||||
|
|
||||||
|
// Store Management
|
||||||
|
'/products': 'https://docs.woonoow.com/docs/store/products',
|
||||||
|
'/orders': 'https://docs.woonoow.com/docs/store/orders',
|
||||||
|
'/customers': 'https://docs.woonoow.com/docs/store/customers',
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
'/settings': 'https://docs.woonoow.com/docs/configuration/general',
|
||||||
|
'/settings/store': 'https://docs.woonoow.com/docs/configuration/general',
|
||||||
|
'/settings/payments': 'https://docs.woonoow.com/docs/configuration/payment-shipping',
|
||||||
|
'/settings/shipping': 'https://docs.woonoow.com/docs/configuration/payment-shipping',
|
||||||
|
'/settings/tax': 'https://docs.woonoow.com/docs/configuration/general', // Fallback
|
||||||
|
'/settings/customers': 'https://docs.woonoow.com/docs/store/customers',
|
||||||
|
'/settings/security': 'https://docs.woonoow.com/docs/configuration/security',
|
||||||
|
'/settings/notifications': 'https://docs.woonoow.com/docs/configuration/email',
|
||||||
|
'/settings/modules': 'https://docs.woonoow.com/docs/configuration/modules',
|
||||||
|
'/appearance/themes': 'https://docs.woonoow.com/docs/configuration/appearance',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get doc URL for a specific path
|
||||||
|
*
|
||||||
|
* Can be enhanced with regex matching if needed
|
||||||
|
*/
|
||||||
|
export const getDocUrl = (path: string): string | null => {
|
||||||
|
// 1. Direct match
|
||||||
|
if (docRoutes[path]) return docRoutes[path];
|
||||||
|
|
||||||
|
// 2. Partial match (longest match first)
|
||||||
|
const sortedKeys = Object.keys(docRoutes).sort((a, b) => b.length - a.length);
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
if (path.startsWith(key)) {
|
||||||
|
return docRoutes[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Import design tokens for UI sizing and control defaults */
|
/* Import design tokens for UI sizing and control defaults */
|
||||||
@import './components/ui/tokens.css';
|
@import './components/ui/tokens.css';
|
||||||
|
|
||||||
|
/* stylelint-disable at-rule-no-unknown */
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@@ -34,6 +35,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,17 +65,70 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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 */
|
||||||
|
/* Override WordPress common.css focus/active styles */
|
||||||
|
/* Reverting this override as it causes issues with our custom button styles
|
||||||
a:focus,
|
a:focus,
|
||||||
a:active {
|
a:active {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
WordPress Admin Override Fixes
|
||||||
|
These rules use high specificity + !important
|
||||||
|
to override WordPress admin CSS conflicts
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Fix SVG icon styling - WordPress sets fill:currentColor on all SVGs */
|
||||||
|
#woonoow-admin-app svg {
|
||||||
|
fill: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* But allow explicit fill-current class to work for filled icons */
|
||||||
|
#woonoow-admin-app svg.fill-current,
|
||||||
|
#woonoow-admin-app .fill-current svg,
|
||||||
|
#woonoow-admin-app [class*="fill-"] svg {
|
||||||
|
fill: currentColor !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix radio button indicator - WordPress overrides circle fill */
|
||||||
|
#woonoow-admin-app [data-radix-radio-group-item] svg,
|
||||||
|
#woonoow-admin-app [role="radio"] svg {
|
||||||
|
fill: currentColor !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix font-weight inheritance - prevent WordPress bold overrides */
|
||||||
|
#woonoow-admin-app text,
|
||||||
|
#woonoow-admin-app tspan {
|
||||||
|
font-weight: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset form element styling that WordPress overrides */
|
||||||
|
#woonoow-admin-app input[type="radio"],
|
||||||
|
#woonoow-admin-app input[type="checkbox"] {
|
||||||
|
appearance: none !important;
|
||||||
|
-webkit-appearance: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Command palette input: remove native borders/shadows to match shadcn */
|
/* Command palette input: remove native borders/shadows to match shadcn */
|
||||||
@@ -89,11 +144,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,
|
||||||
@@ -102,44 +160,169 @@
|
|||||||
#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)
|
||||||
|
These classes are used dynamically and styled via @media print rules below */
|
||||||
|
|
||||||
/* Optional page presets (opt-in by adding the class to a wrapper before printing) */
|
|
||||||
.print-a4 { }
|
|
||||||
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Letter format - extend as needed */
|
||||||
|
|
||||||
/* 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,
|
||||||
|
|||||||
@@ -5,26 +5,68 @@
|
|||||||
|
|
||||||
export function htmlToMarkdown(html: string): string {
|
export function htmlToMarkdown(html: string): string {
|
||||||
if (!html) return '';
|
if (!html) return '';
|
||||||
|
|
||||||
let markdown = html;
|
let markdown = html;
|
||||||
|
|
||||||
// Headings
|
// Store aligned headings for preservation
|
||||||
markdown = markdown.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n');
|
const alignedHeadings: { [key: string]: string } = {};
|
||||||
markdown = markdown.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n');
|
let headingIndex = 0;
|
||||||
markdown = markdown.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n');
|
|
||||||
markdown = markdown.replace(/<h4>(.*?)<\/h4>/gi, '#### $1\n\n');
|
// Process headings with potential style attributes
|
||||||
|
for (let level = 1; level <= 4; level++) {
|
||||||
|
const hashes = '#'.repeat(level);
|
||||||
|
markdown = markdown.replace(new RegExp(`<h${level}([^>]*)>(.*?)</h${level}>`, '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();
|
||||||
|
const placeholder = `[[HEADING${headingIndex}]]`;
|
||||||
|
alignedHeadings[placeholder] = `<h${level} style="text-align: ${align};">${content}</h${level}>`;
|
||||||
|
headingIndex++;
|
||||||
|
return placeholder + '\n\n';
|
||||||
|
}
|
||||||
|
// No alignment, convert to markdown
|
||||||
|
return `${hashes} ${content}\n\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Bold
|
// Bold
|
||||||
markdown = markdown.replace(/<strong>(.*?)<\/strong>/gi, '**$1**');
|
markdown = markdown.replace(/<strong>(.*?)<\/strong>/gi, '**$1**');
|
||||||
markdown = markdown.replace(/<b>(.*?)<\/b>/gi, '**$1**');
|
markdown = markdown.replace(/<b>(.*?)<\/b>/gi, '**$1**');
|
||||||
|
|
||||||
// Italic
|
// Italic
|
||||||
markdown = markdown.replace(/<em>(.*?)<\/em>/gi, '*$1*');
|
markdown = markdown.replace(/<em>(.*?)<\/em>/gi, '*$1*');
|
||||||
markdown = markdown.replace(/<i>(.*?)<\/i>/gi, '*$1*');
|
markdown = markdown.replace(/<i>(.*?)<\/i>/gi, '*$1*');
|
||||||
|
|
||||||
// Links
|
// TipTap buttons - detect by data-button attribute, BEFORE generic links
|
||||||
|
// Format: <a data-button data-style="solid" data-href="..." data-text="...">text</a>
|
||||||
|
// or: <a href="..." class="button..." data-button ...>text</a>
|
||||||
|
markdown = markdown.replace(/<a[^>]*data-button[^>]*>(.*?)<\/a>/gi, (match, text) => {
|
||||||
|
// Extract style from data-style or class
|
||||||
|
let style = 'solid';
|
||||||
|
const styleMatch = match.match(/data-style=["'](\w+)["']/);
|
||||||
|
if (styleMatch) {
|
||||||
|
style = styleMatch[1];
|
||||||
|
} else if (match.includes('button-outline') || match.includes('outline')) {
|
||||||
|
style = 'outline';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract href from data-href or href attribute
|
||||||
|
let url = '#';
|
||||||
|
const dataHrefMatch = match.match(/data-href=["']([^"']+)["']/);
|
||||||
|
const hrefMatch = match.match(/href=["']([^"']+)["']/);
|
||||||
|
if (dataHrefMatch) {
|
||||||
|
url = dataHrefMatch[1];
|
||||||
|
} else if (hrefMatch) {
|
||||||
|
url = hrefMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[button:${style}](${url})${text.trim()}[/button]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regular links (not buttons)
|
||||||
markdown = markdown.replace(/<a\s+href="([^"]+)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
|
markdown = markdown.replace(/<a\s+href="([^"]+)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
|
||||||
|
|
||||||
// Lists
|
// Lists
|
||||||
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, (match, content) => {
|
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, (match, content) => {
|
||||||
const items = content.match(/<li[^>]*>(.*?)<\/li>/gis) || [];
|
const items = content.match(/<li[^>]*>(.*?)<\/li>/gis) || [];
|
||||||
@@ -33,7 +75,7 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
return `- ${text}`;
|
return `- ${text}`;
|
||||||
}).join('\n') + '\n\n';
|
}).join('\n') + '\n\n';
|
||||||
});
|
});
|
||||||
|
|
||||||
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, (match, content) => {
|
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, (match, content) => {
|
||||||
const items = content.match(/<li[^>]*>(.*?)<\/li>/gis) || [];
|
const items = content.match(/<li[^>]*>(.*?)<\/li>/gis) || [];
|
||||||
return items.map((item: string, index: number) => {
|
return items.map((item: string, index: number) => {
|
||||||
@@ -41,24 +83,49 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
return `${index + 1}. ${text}`;
|
return `${index + 1}. ${text}`;
|
||||||
}).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');
|
||||||
|
|
||||||
// Horizontal rules
|
// Horizontal rules
|
||||||
markdown = markdown.replace(/<hr\s*\/?>/gi, '\n---\n\n');
|
markdown = markdown.replace(/<hr\s*\/?>/gi, '\n---\n\n');
|
||||||
|
|
||||||
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore aligned headings
|
||||||
|
Object.entries(alignedHeadings).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');
|
||||||
|
|
||||||
// Trim
|
// Trim
|
||||||
markdown = markdown.trim();
|
markdown = markdown.trim();
|
||||||
|
|
||||||
return markdown;
|
return markdown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function markdownToHtml(markdown: string): string {
|
|||||||
const parsedContent = parseMarkdownBasics(content.trim());
|
const parsedContent = parseMarkdownBasics(content.trim());
|
||||||
return `<div class="${cardClass}">${parsedContent}</div>`;
|
return `<div class="${cardClass}">${parsedContent}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse [card type="..."] blocks (old syntax - backward compatibility)
|
// Parse [card type="..."] blocks (old syntax - backward compatibility)
|
||||||
html = html.replace(/\[card(?:\s+type="([^"]+)")?\]([\s\S]*?)\[\/card\]/g, (match, type, content) => {
|
html = html.replace(/\[card(?:\s+type="([^"]+)")?\]([\s\S]*?)\[\/card\]/g, (match, type, content) => {
|
||||||
const cardClass = type ? `card card-${type}` : 'card';
|
const cardClass = type ? `card card-${type}` : 'card';
|
||||||
@@ -96,15 +96,22 @@ export function markdownToHtml(markdown: string): string {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Parse [button:style](url)Text[/button] (new syntax)
|
// Parse [button:style](url)Text[/button] (new syntax)
|
||||||
|
// Buttons are inline in TipTap, so don't wrap in <p>
|
||||||
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
||||||
|
if (style === 'link') {
|
||||||
|
return `<a href="${url}" class="text-link">${text.trim()}</a>`;
|
||||||
|
}
|
||||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
return `<a href="${url}" class="${buttonClass}">${text.trim()}</a>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
||||||
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||||
|
if (style === 'link') {
|
||||||
|
return `<a href="${url}" class="text-link">${text.trim()}</a>`;
|
||||||
|
}
|
||||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
return `<a href="${url}" class="${buttonClass}">${text.trim()}</a>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse remaining markdown
|
// Parse remaining markdown
|
||||||
@@ -151,15 +158,23 @@ export function parseMarkdownBasics(text: string): string {
|
|||||||
|
|
||||||
// Parse [button:style](url)Text[/button] (new syntax) - must come before images
|
// Parse [button:style](url)Text[/button] (new syntax) - must come before images
|
||||||
// Allow whitespace and newlines between parts
|
// Allow whitespace and newlines between parts
|
||||||
|
// Include data-button attributes for TipTap recognition
|
||||||
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => {
|
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => {
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
if (style === 'link') {
|
||||||
|
return `<a href="${url}" class="text-link" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="link">${trimmedText}</a>`;
|
||||||
|
}
|
||||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
return `<a href="${url}" class="${buttonClass}" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="${style}">${trimmedText}</a>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
||||||
|
// Include data-button attributes for TipTap recognition
|
||||||
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
const buttonStyle = style || 'solid';
|
||||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
const buttonClass = buttonStyle === 'outline' ? 'button-outline' : 'button';
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
return `<a href="${url}" class="${buttonClass}" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="${buttonStyle}">${trimmedText}</a>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Images (must come before links)
|
// Images (must come before links)
|
||||||
@@ -267,8 +282,33 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Convert buttons back to [button] syntax
|
// Convert buttons back to [button] syntax
|
||||||
|
// TipTap button format with data attributes: <a data-button data-href="..." data-style="..." data-text="...">text</a>
|
||||||
|
markdown = markdown.replace(/<a[^>]*data-button[^>]*data-href="([^"]+)"[^>]*data-style="([^"]*)"[^>]*>([^<]+)<\/a>/gi, (match, url, style, text) => {
|
||||||
|
const styleAttr = style === 'outline' ? ' style="outline"' : ' style="solid"';
|
||||||
|
return `[button url="${url}"${styleAttr}]${text.trim()}[/button]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alternate order: data-style before data-href
|
||||||
|
markdown = markdown.replace(/<a[^>]*data-button[^>]*data-style="([^"]*)"[^>]*data-href="([^"]+)"[^>]*>([^<]+)<\/a>/gi, (match, style, url, text) => {
|
||||||
|
const styleAttr = style === 'outline' ? ' style="outline"' : ' style="solid"';
|
||||||
|
return `[button url="${url}"${styleAttr}]${text.trim()}[/button]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple data-button fallback (just has href and class)
|
||||||
|
markdown = markdown.replace(/<a[^>]*href="([^"]+)"[^>]*class="(button[^"]*)"[^>]*data-button[^>]*>([^<]+)<\/a>/gi, (match, url, className, text) => {
|
||||||
|
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
|
||||||
|
return `[button url="${url}"${style}]${text.trim()}[/button]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buttons wrapped in p tags (from preview HTML): <p><a href="..." class="button...">text</a></p>
|
||||||
markdown = markdown.replace(/<p[^>]*><a href="([^"]+)" class="(button[^"]*)"[^>]*>([^<]+)<\/a><\/p>/g, (match, url, className, text) => {
|
markdown = markdown.replace(/<p[^>]*><a href="([^"]+)" class="(button[^"]*)"[^>]*>([^<]+)<\/a><\/p>/g, (match, url, className, text) => {
|
||||||
const style = className.includes('outline') ? ' style="outline"' : '';
|
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
|
||||||
|
return `[button url="${url}"${style}]${text.trim()}[/button]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Direct button links without p wrapper
|
||||||
|
markdown = markdown.replace(/<a href="([^"]+)" class="(button[^"]*)"[^>]*>([^<]+)<\/a>/g, (match, url, className, text) => {
|
||||||
|
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
|
||||||
return `[button url="${url}"${style}]${text.trim()}[/button]`;
|
return `[button url="${url}"${style}]${text.trim()}[/button]`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Plus, X } from 'lucide-react';
|
import { Plus, X, Upload, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useModules } from '@/hooks/useModules';
|
import { useModules } from '@/hooks/useModules';
|
||||||
|
import { MediaUploader } from '@/components/MediaUploader';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
interface SocialLink {
|
interface SocialLink {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,18 +38,37 @@ interface ContactData {
|
|||||||
show_address: boolean;
|
show_address: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PaymentMethod {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppearanceFooter() {
|
export default function AppearanceFooter() {
|
||||||
const { isEnabled, isLoading: modulesLoading } = useModules();
|
const { isEnabled, isLoading: modulesLoading } = useModules();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [columns, setColumns] = useState('4');
|
const [columns, setColumns] = useState('4');
|
||||||
const [style, setStyle] = useState('detailed');
|
const [style, setStyle] = useState('detailed');
|
||||||
const [copyrightText, setCopyrightText] = useState('© 2024 WooNooW. All rights reserved.');
|
|
||||||
|
const [copyright, setCopyright] = useState({
|
||||||
|
enabled: true,
|
||||||
|
text: '© 2024 WooNooW. All rights reserved.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [payment, setPayment] = useState<{
|
||||||
|
enabled: boolean;
|
||||||
|
title: string;
|
||||||
|
methods: PaymentMethod[];
|
||||||
|
}>({
|
||||||
|
enabled: true,
|
||||||
|
title: 'We accept',
|
||||||
|
methods: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legacy elements toggle (only for newsletter, social, menu, contact)
|
||||||
const [elements, setElements] = useState({
|
const [elements, setElements] = useState({
|
||||||
newsletter: true,
|
newsletter: true,
|
||||||
social: true,
|
social: true,
|
||||||
payment: true,
|
|
||||||
copyright: true,
|
|
||||||
menu: true,
|
menu: true,
|
||||||
contact: true,
|
contact: true,
|
||||||
});
|
});
|
||||||
@@ -62,19 +83,16 @@ export default function AppearanceFooter() {
|
|||||||
show_phone: true,
|
show_phone: true,
|
||||||
show_address: true,
|
show_address: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultSections: FooterSection[] = [
|
const defaultSections: FooterSection[] = [
|
||||||
{ id: '1', title: 'Contact', type: 'contact', content: '', visible: true },
|
{ id: '1', title: 'Contact', type: 'contact', content: '', visible: true },
|
||||||
{ id: '2', title: 'Quick Links', type: 'menu', content: '', visible: true },
|
{ id: '2', title: 'Quick Links', type: 'menu', content: '', visible: true },
|
||||||
{ id: '3', title: 'Follow Us', type: 'social', content: '', visible: true },
|
{ id: '3', title: 'Follow Us', type: 'social', content: '', visible: true },
|
||||||
{ id: '4', title: 'Newsletter', type: 'newsletter', content: '', visible: true },
|
{ id: '4', title: 'Newsletter', type: 'newsletter', content: '', visible: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Only keeping newsletter_description, titles are now managed per column
|
||||||
const [labels, setLabels] = useState({
|
const [labels, setLabels] = useState({
|
||||||
contact_title: 'Contact',
|
|
||||||
menu_title: 'Quick Links',
|
|
||||||
social_title: 'Follow Us',
|
|
||||||
newsletter_title: 'Newsletter',
|
|
||||||
newsletter_description: 'Subscribe to get updates',
|
newsletter_description: 'Subscribe to get updates',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,12 +101,34 @@ export default function AppearanceFooter() {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get('/appearance/settings');
|
const response = await api.get('/appearance/settings');
|
||||||
const footer = response.data?.footer;
|
const footer = response.data?.footer;
|
||||||
|
|
||||||
if (footer) {
|
if (footer) {
|
||||||
if (footer.columns) setColumns(footer.columns);
|
if (footer.columns) setColumns(footer.columns);
|
||||||
if (footer.style) setStyle(footer.style);
|
if (footer.style) setStyle(footer.style);
|
||||||
if (footer.copyright_text) setCopyrightText(footer.copyright_text);
|
|
||||||
if (footer.elements) setElements(footer.elements);
|
// Handle new structure vs backward compatibility
|
||||||
|
if (footer.copyright) {
|
||||||
|
setCopyright(footer.copyright);
|
||||||
|
} else if (footer.copyright_text) {
|
||||||
|
// Migration fallback
|
||||||
|
setCopyright({
|
||||||
|
enabled: footer.elements?.copyright ?? true,
|
||||||
|
text: footer.copyright_text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footer.payment) {
|
||||||
|
setPayment(footer.payment);
|
||||||
|
} else if (footer.elements?.payment) {
|
||||||
|
// Migration fallback
|
||||||
|
setPayment(prev => ({ ...prev, enabled: footer.elements.payment }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footer.elements) {
|
||||||
|
const { payment, copyright, ...rest } = footer.elements;
|
||||||
|
setElements(prev => ({ ...prev, ...rest }));
|
||||||
|
}
|
||||||
|
|
||||||
if (footer.social_links) setSocialLinks(footer.social_links);
|
if (footer.social_links) setSocialLinks(footer.social_links);
|
||||||
if (footer.sections && footer.sections.length > 0) {
|
if (footer.sections && footer.sections.length > 0) {
|
||||||
setSections(footer.sections);
|
setSections(footer.sections);
|
||||||
@@ -96,11 +136,15 @@ export default function AppearanceFooter() {
|
|||||||
setSections(defaultSections);
|
setSections(defaultSections);
|
||||||
}
|
}
|
||||||
if (footer.contact_data) setContactData(footer.contact_data);
|
if (footer.contact_data) setContactData(footer.contact_data);
|
||||||
if (footer.labels) setLabels(footer.labels);
|
|
||||||
|
// Only sync description if it exists
|
||||||
|
if (footer.labels?.newsletter_description) {
|
||||||
|
setLabels({ newsletter_description: footer.labels.newsletter_description });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setSections(defaultSections);
|
setSections(defaultSections);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch store identity data
|
// Fetch store identity data
|
||||||
try {
|
try {
|
||||||
const identityResponse = await api.get('/settings/store-identity');
|
const identityResponse = await api.get('/settings/store-identity');
|
||||||
@@ -122,7 +166,7 @@ export default function AppearanceFooter() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -152,7 +196,7 @@ export default function AppearanceFooter() {
|
|||||||
...sections,
|
...sections,
|
||||||
{
|
{
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
title: 'New Section',
|
title: 'New Column',
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
content: '',
|
content: '',
|
||||||
visible: true,
|
visible: true,
|
||||||
@@ -168,12 +212,34 @@ export default function AppearanceFooter() {
|
|||||||
setSections(sections.map(s => s.id === id ? { ...s, [field]: value } : s));
|
setSections(sections.map(s => s.id === id ? { ...s, [field]: value } : s));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addPaymentMethod = () => {
|
||||||
|
setPayment({
|
||||||
|
...payment,
|
||||||
|
methods: [...payment.methods, { id: Date.now().toString(), url: '', label: '' }]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePaymentMethod = (id: string) => {
|
||||||
|
setPayment({
|
||||||
|
...payment,
|
||||||
|
methods: payment.methods.filter(m => m.id !== id)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePaymentMethod = (id: string, field: keyof PaymentMethod, value: string) => {
|
||||||
|
setPayment({
|
||||||
|
...payment,
|
||||||
|
methods: payment.methods.map(m => m.id === id ? { ...m, [field]: value } : m)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
columns,
|
columns,
|
||||||
style,
|
style,
|
||||||
copyrightText,
|
copyright,
|
||||||
|
payment,
|
||||||
elements,
|
elements,
|
||||||
socialLinks,
|
socialLinks,
|
||||||
sections,
|
sections,
|
||||||
@@ -227,177 +293,127 @@ export default function AppearanceFooter() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Content & Contact */}
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Section Labels"
|
title="Content & Contact"
|
||||||
description="Customize footer section headings and text"
|
description="Manage footer content and contact details"
|
||||||
>
|
>
|
||||||
<SettingsSection label="Contact Title" htmlFor="contact-title">
|
<div className="space-y-6">
|
||||||
<Input
|
<div>
|
||||||
id="contact-title"
|
<h3 className="text-lg font-medium mb-4">Contact Information</h3>
|
||||||
value={labels.contact_title}
|
<SettingsSection label="Email" htmlFor="contact-email">
|
||||||
onChange={(e) => setLabels({ ...labels, contact_title: e.target.value })}
|
<Input
|
||||||
placeholder="Contact"
|
id="contact-email"
|
||||||
/>
|
type="email"
|
||||||
</SettingsSection>
|
value={contactData.email}
|
||||||
|
onChange={(e) => setContactData({ ...contactData, email: e.target.value })}
|
||||||
<SettingsSection label="Menu Title" htmlFor="menu-title">
|
placeholder="info@store.com"
|
||||||
<Input
|
/>
|
||||||
id="menu-title"
|
<div className="flex items-center gap-2 mt-2">
|
||||||
value={labels.menu_title}
|
<Switch
|
||||||
onChange={(e) => setLabels({ ...labels, menu_title: e.target.value })}
|
checked={contactData.show_email}
|
||||||
placeholder="Quick Links"
|
onCheckedChange={(checked) => setContactData({ ...contactData, show_email: checked })}
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection label="Social Title" htmlFor="social-title">
|
|
||||||
<Input
|
|
||||||
id="social-title"
|
|
||||||
value={labels.social_title}
|
|
||||||
onChange={(e) => setLabels({ ...labels, social_title: e.target.value })}
|
|
||||||
placeholder="Follow Us"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection label="Newsletter Title" htmlFor="newsletter-title">
|
|
||||||
<Input
|
|
||||||
id="newsletter-title"
|
|
||||||
value={labels.newsletter_title}
|
|
||||||
onChange={(e) => setLabels({ ...labels, newsletter_title: e.target.value })}
|
|
||||||
placeholder="Newsletter"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection label="Newsletter Description" htmlFor="newsletter-desc">
|
|
||||||
<Input
|
|
||||||
id="newsletter-desc"
|
|
||||||
value={labels.newsletter_description}
|
|
||||||
onChange={(e) => setLabels({ ...labels, newsletter_description: e.target.value })}
|
|
||||||
placeholder="Subscribe to get updates"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
{/* Contact Data */}
|
|
||||||
<SettingsCard
|
|
||||||
title="Contact Information"
|
|
||||||
description="Manage contact details from Store Identity"
|
|
||||||
>
|
|
||||||
<SettingsSection label="Email" htmlFor="contact-email">
|
|
||||||
<Input
|
|
||||||
id="contact-email"
|
|
||||||
type="email"
|
|
||||||
value={contactData.email}
|
|
||||||
onChange={(e) => setContactData({ ...contactData, email: e.target.value })}
|
|
||||||
placeholder="info@store.com"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Switch
|
|
||||||
checked={contactData.show_email}
|
|
||||||
onCheckedChange={(checked) => setContactData({ ...contactData, show_email: checked })}
|
|
||||||
/>
|
|
||||||
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
|
||||||
</div>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection label="Phone" htmlFor="contact-phone">
|
|
||||||
<Input
|
|
||||||
id="contact-phone"
|
|
||||||
type="tel"
|
|
||||||
value={contactData.phone}
|
|
||||||
onChange={(e) => setContactData({ ...contactData, phone: e.target.value })}
|
|
||||||
placeholder="(123) 456-7890"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Switch
|
|
||||||
checked={contactData.show_phone}
|
|
||||||
onCheckedChange={(checked) => setContactData({ ...contactData, show_phone: checked })}
|
|
||||||
/>
|
|
||||||
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
|
||||||
</div>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection label="Address" htmlFor="contact-address">
|
|
||||||
<Textarea
|
|
||||||
id="contact-address"
|
|
||||||
value={contactData.address}
|
|
||||||
onChange={(e) => setContactData({ ...contactData, address: e.target.value })}
|
|
||||||
placeholder="123 Main St, City, State 12345"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Switch
|
|
||||||
checked={contactData.show_address}
|
|
||||||
onCheckedChange={(checked) => setContactData({ ...contactData, show_address: checked })}
|
|
||||||
/>
|
|
||||||
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
|
||||||
</div>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<SettingsCard
|
|
||||||
title="Content"
|
|
||||||
description="Customize footer content"
|
|
||||||
>
|
|
||||||
<SettingsSection label="Copyright Text" htmlFor="copyright">
|
|
||||||
<Textarea
|
|
||||||
id="copyright"
|
|
||||||
value={copyrightText}
|
|
||||||
onChange={(e) => setCopyrightText(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
placeholder="© 2024 Your Store. All rights reserved."
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label>Social Media Links</Label>
|
|
||||||
<Button onClick={addSocialLink} variant="outline" size="sm">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Add Link
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{socialLinks.map((link) => (
|
|
||||||
<div key={link.id} className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Platform (e.g., Facebook)"
|
|
||||||
value={link.platform}
|
|
||||||
onChange={(e) => updateSocialLink(link.id, 'platform', e.target.value)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||||
placeholder="URL"
|
</div>
|
||||||
value={link.url}
|
</SettingsSection>
|
||||||
onChange={(e) => updateSocialLink(link.id, 'url', e.target.value)}
|
|
||||||
className="flex-1"
|
<SettingsSection label="Phone" htmlFor="contact-phone">
|
||||||
|
<Input
|
||||||
|
id="contact-phone"
|
||||||
|
type="tel"
|
||||||
|
value={contactData.phone}
|
||||||
|
onChange={(e) => setContactData({ ...contactData, phone: e.target.value })}
|
||||||
|
placeholder="(123) 456-7890"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Switch
|
||||||
|
checked={contactData.show_phone}
|
||||||
|
onCheckedChange={(checked) => setContactData({ ...contactData, show_phone: checked })}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||||
onClick={() => removeSocialLink(link.id)}
|
</div>
|
||||||
variant="ghost"
|
</SettingsSection>
|
||||||
size="icon"
|
|
||||||
>
|
<SettingsSection label="Address" htmlFor="contact-address">
|
||||||
<X className="h-4 w-4" />
|
<Textarea
|
||||||
|
id="contact-address"
|
||||||
|
value={contactData.address}
|
||||||
|
onChange={(e) => setContactData({ ...contactData, address: e.target.value })}
|
||||||
|
placeholder="123 Main St, City, State 12345"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Switch
|
||||||
|
checked={contactData.show_address}
|
||||||
|
onCheckedChange={(checked) => setContactData({ ...contactData, show_address: checked })}
|
||||||
|
/>
|
||||||
|
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<h3 className="text-lg font-medium mb-4">General Content</h3>
|
||||||
|
<SettingsSection label="Newsletter Description" htmlFor="newsletter-desc">
|
||||||
|
<Input
|
||||||
|
id="newsletter-desc"
|
||||||
|
value={labels.newsletter_description}
|
||||||
|
onChange={(e) => setLabels({ ...labels, newsletter_description: e.target.value })}
|
||||||
|
placeholder="Subscribe to get updates"
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Social Media Links</Label>
|
||||||
|
<Button onClick={addSocialLink} variant="outline" size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Link
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{socialLinks.map((link) => (
|
||||||
|
<div key={link.id} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Platform (e.g., Facebook)"
|
||||||
|
value={link.platform}
|
||||||
|
onChange={(e) => updateSocialLink(link.id, 'platform', e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="URL"
|
||||||
|
value={link.url}
|
||||||
|
onChange={(e) => updateSocialLink(link.id, 'url', e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => removeSocialLink(link.id)}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
{/* Custom Sections Builder */}
|
{/* Custom Columns (was Custom Sections) */}
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Custom Sections"
|
title="Custom Columns"
|
||||||
description="Build custom footer sections with flexible content"
|
description="Build footer columns with flexible content"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Footer Sections</Label>
|
<Label>Footer Columns</Label>
|
||||||
<Button onClick={addSection} variant="outline" size="sm">
|
<Button onClick={addSection} variant="outline" size="sm">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Section
|
Add Column
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,7 +421,7 @@ export default function AppearanceFooter() {
|
|||||||
<div key={section.id} className="border rounded-lg p-4 space-y-3">
|
<div key={section.id} className="border rounded-lg p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Section Title"
|
placeholder="Column Title"
|
||||||
value={section.title}
|
value={section.title}
|
||||||
onChange={(e) => updateSection(section.id, 'title', e.target.value)}
|
onChange={(e) => updateSection(section.id, 'title', e.target.value)}
|
||||||
className="flex-1 mr-2"
|
className="flex-1 mr-2"
|
||||||
@@ -458,11 +474,122 @@ export default function AppearanceFooter() {
|
|||||||
|
|
||||||
{sections.length === 0 && (
|
{sections.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
No custom sections yet. Click "Add Section" to create one.
|
No custom columns yet. Click "Add Column" to create one.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
|
{/* Payment Methods */}
|
||||||
|
<SettingsCard
|
||||||
|
title="Payment Methods"
|
||||||
|
description="Configure accepted payment methods display"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Show Payment Methods</Label>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={payment.enabled}
|
||||||
|
onCheckedChange={(checked) => setPayment({ ...payment, enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{payment.enabled && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsSection label="Section Title" htmlFor="payment-title">
|
||||||
|
<Input
|
||||||
|
id="payment-title"
|
||||||
|
value={payment.title}
|
||||||
|
onChange={(e) => setPayment({ ...payment, title: e.target.value })}
|
||||||
|
placeholder="We accept"
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Payment Logos</Label>
|
||||||
|
<Button onClick={addPaymentMethod} variant="outline" size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Method
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{payment.methods.map((method) => (
|
||||||
|
<div key={method.id} className="flex gap-3 items-center border p-3 rounded-lg">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<MediaUploader
|
||||||
|
onSelect={(url) => updatePaymentMethod(method.id, 'url', url)}
|
||||||
|
>
|
||||||
|
{method.url ? (
|
||||||
|
<div className="w-12 h-8 border rounded overflow-hidden relative group cursor-pointer">
|
||||||
|
<img src={method.url} alt={method.label} className="w-full h-full object-contain" />
|
||||||
|
<div className="absolute inset-0 bg-black/40 hidden group-hover:flex items-center justify-center">
|
||||||
|
<Upload className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-8 border rounded bg-muted flex items-center justify-center cursor-pointer hover:bg-muted/80">
|
||||||
|
<Upload className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MediaUploader>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="Label (e.g., Visa)"
|
||||||
|
value={method.label}
|
||||||
|
onChange={(e) => updatePaymentMethod(method.id, 'label', e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => removePaymentMethod(method.id)}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{payment.methods.length === 0 && (
|
||||||
|
<div className="text-sm text-center py-4 text-muted-foreground bg-muted/20 rounded-lg border border-dashed">
|
||||||
|
No payment methods added.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
{/* Copyright Section */}
|
||||||
|
<SettingsCard
|
||||||
|
title="Copyright"
|
||||||
|
description="Configure copyright notice"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Show Copyright</Label>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={copyright.enabled}
|
||||||
|
onCheckedChange={(checked) => setCopyright({ ...copyright, enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{copyright.enabled && (
|
||||||
|
<SettingsSection label="Copyright Text" htmlFor="copyright-text">
|
||||||
|
<Textarea
|
||||||
|
id="copyright-text"
|
||||||
|
value={copyright.text}
|
||||||
|
onChange={(e) => setCopyright({ ...copyright, text: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
placeholder="© 2024 Your Store. All rights reserved."
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
</SettingsCard>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,24 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface WordPressPage {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppearanceGeneral() {
|
export default function AppearanceGeneral() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [spaMode, setSpaMode] = useState<'disabled' | 'checkout_only' | 'full'>('full');
|
const [spaMode, setSpaMode] = useState<'disabled' | 'checkout_only' | 'full'>('full');
|
||||||
|
const [spaPage, setSpaPage] = useState(0);
|
||||||
|
const [availablePages, setAvailablePages] = useState<WordPressPage[]>([]);
|
||||||
const [toastPosition, setToastPosition] = useState('top-right');
|
const [toastPosition, setToastPosition] = useState('top-right');
|
||||||
const [typographyMode, setTypographyMode] = useState<'predefined' | 'custom_google'>('predefined');
|
const [typographyMode, setTypographyMode] = useState<'predefined' | 'custom_google'>('predefined');
|
||||||
const [predefinedPair, setPredefinedPair] = useState('modern');
|
const [predefinedPair, setPredefinedPair] = useState('modern');
|
||||||
const [customHeading, setCustomHeading] = useState('');
|
const [customHeading, setCustomHeading] = useState('');
|
||||||
const [customBody, setCustomBody] = useState('');
|
const [customBody, setCustomBody] = useState('');
|
||||||
const [fontScale, setFontScale] = useState([1.0]);
|
const [fontScale, setFontScale] = useState([1.0]);
|
||||||
|
const [containerWidth, setContainerWidth] = useState<'boxed' | 'fullwidth'>('boxed');
|
||||||
|
|
||||||
const fontPairs = {
|
const fontPairs = {
|
||||||
modern: { name: 'Modern & Clean', fonts: 'Inter' },
|
modern: { name: 'Modern & Clean', fonts: 'Inter' },
|
||||||
@@ -28,23 +37,27 @@ 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(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
|
// 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.toast_position) setToastPosition(general.toast_position);
|
if (general.toast_position) setToastPosition(general.toast_position);
|
||||||
if (general.typography) {
|
if (general.typography) {
|
||||||
setTypographyMode(general.typography.mode || 'predefined');
|
setTypographyMode(general.typography.mode || 'predefined');
|
||||||
@@ -53,6 +66,9 @@ export default function AppearanceGeneral() {
|
|||||||
setCustomBody(general.typography.custom?.body || '');
|
setCustomBody(general.typography.custom?.body || '');
|
||||||
setFontScale([general.typography.scale || 1.0]);
|
setFontScale([general.typography.scale || 1.0]);
|
||||||
}
|
}
|
||||||
|
if (general.container_width) {
|
||||||
|
setContainerWidth(general.container_width);
|
||||||
|
}
|
||||||
if (general.colors) {
|
if (general.colors) {
|
||||||
setColors({
|
setColors({
|
||||||
primary: general.colors.primary || '#1a1a1a',
|
primary: general.colors.primary || '#1a1a1a',
|
||||||
@@ -60,23 +76,37 @@ 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
|
||||||
|
const pagesResponse = await api.get('/pages/list');
|
||||||
|
console.log('Pages API response:', pagesResponse);
|
||||||
|
if (pagesResponse.data) {
|
||||||
|
console.log('Pages loaded:', pagesResponse.data);
|
||||||
|
setAvailablePages(pagesResponse.data);
|
||||||
|
} else {
|
||||||
|
console.warn('No pages data in response:', pagesResponse);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load settings:', error);
|
console.error('Failed to load settings:', error);
|
||||||
|
console.error('Error details:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
await api.post('/appearance/general', {
|
await api.post('/appearance/general', {
|
||||||
spa_mode: spaMode,
|
spaMode,
|
||||||
|
spaPage,
|
||||||
toastPosition,
|
toastPosition,
|
||||||
typography: {
|
typography: {
|
||||||
mode: typographyMode,
|
mode: typographyMode,
|
||||||
@@ -84,9 +114,10 @@ export default function AppearanceGeneral() {
|
|||||||
custom: typographyMode === 'custom_google' ? { heading: customHeading, body: customBody } : undefined,
|
custom: typographyMode === 'custom_google' ? { heading: customHeading, body: customBody } : undefined,
|
||||||
scale: fontScale[0],
|
scale: fontScale[0],
|
||||||
},
|
},
|
||||||
|
containerWidth,
|
||||||
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);
|
||||||
@@ -113,11 +144,11 @@ export default function AppearanceGeneral() {
|
|||||||
Disabled
|
Disabled
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Use WordPress default pages (no SPA functionality)
|
SPA never loads (use WordPress default pages)
|
||||||
</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">
|
||||||
@@ -125,11 +156,11 @@ export default function AppearanceGeneral() {
|
|||||||
Checkout Only
|
Checkout Only
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
SPA for checkout flow only (cart, checkout, thank you)
|
SPA starts at cart page (cart → checkout → thank you → account)
|
||||||
</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">
|
||||||
@@ -137,13 +168,83 @@ export default function AppearanceGeneral() {
|
|||||||
Full SPA
|
Full SPA
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Entire customer-facing site uses SPA (recommended)
|
SPA starts at shop page (shop → product → cart → checkout → account)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
|
{/* SPA Page */}
|
||||||
|
<SettingsCard
|
||||||
|
title="SPA Page"
|
||||||
|
description="Select the page where the SPA will load (e.g., /store)"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
This page will render the full SPA to the body element with no theme interference.
|
||||||
|
The SPA Mode above determines the initial route (shop or cart). React Router handles navigation via /#/ routing.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<SettingsSection label="SPA Entry Page" htmlFor="spa-page">
|
||||||
|
<Select
|
||||||
|
value={spaPage.toString()}
|
||||||
|
onValueChange={(value) => setSpaPage(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="spa-page">
|
||||||
|
<SelectValue placeholder="Select a page..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">— None —</SelectItem>
|
||||||
|
{availablePages.map((page) => (
|
||||||
|
<SelectItem key={page.id} value={page.id.toString()}>
|
||||||
|
{page.title}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
<strong>Full SPA:</strong> Loads shop page initially<br />
|
||||||
|
<strong>Checkout Only:</strong> Loads cart page initially<br />
|
||||||
|
<strong>Tip:</strong> You can set this page as your homepage in Settings → Reading
|
||||||
|
</p>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection label="Container Width" htmlFor="container-width">
|
||||||
|
<RadioGroup value={containerWidth} onValueChange={(value: any) => setContainerWidth(value)}>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<RadioGroupItem value="boxed" id="width-boxed" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="width-boxed" className="font-medium cursor-pointer">
|
||||||
|
Boxed
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Content centered with max-width (recommended)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<RadioGroupItem value="fullwidth" id="width-full" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="width-full" className="font-medium cursor-pointer">
|
||||||
|
Full Width
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Content fills entire screen width
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Default width for all pages (can be overridden per page)
|
||||||
|
</p>
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
{/* Toast Notifications */}
|
{/* Toast Notifications */}
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Toast Notifications"
|
title="Toast Notifications"
|
||||||
@@ -184,7 +285,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">
|
||||||
@@ -222,7 +323,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">
|
||||||
@@ -235,7 +336,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">
|
||||||
@@ -259,7 +360,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
|
||||||
@@ -283,18 +384,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,284 @@
|
|||||||
|
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;
|
||||||
|
containerWidth?: 'boxed' | 'fullwidth' | 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
containerWidth = 'default',
|
||||||
|
}: 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 === 'mobile' ? 'max-w-sm' : (
|
||||||
|
containerWidth === 'fullwidth' ? 'max-w-full mx-4' : 'max-w-6xl'
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
<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,808 @@
|
|||||||
|
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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
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;
|
||||||
|
containerWidth?: 'boxed' | 'fullwidth';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
onContainerWidthChange?: (width: 'boxed' | 'fullwidth') => 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,
|
||||||
|
onContainerWidthChange,
|
||||||
|
}: 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Container Width */}
|
||||||
|
{!isTemplate && page && onContainerWidthChange && (
|
||||||
|
<div className="pt-2 border-t mt-2">
|
||||||
|
<Label className="text-xs text-gray-500 uppercase tracking-wider block mb-2">{__('Container Width')}</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={page.containerWidth || 'boxed'}
|
||||||
|
onValueChange={(val: any) => onContainerWidthChange(val)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="boxed" id="cw-boxed" />
|
||||||
|
<Label htmlFor="cw-boxed" className="text-sm font-normal cursor-pointer">{__('Boxed')}</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="fullwidth" id="cw-full" />
|
||||||
|
<Label htmlFor="cw-full" className="text-sm font-normal cursor-pointer">{__('Full Width')}</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="default" id="cw-default" />
|
||||||
|
<Label htmlFor="cw-default" className="text-sm font-normal cursor-pointer text-gray-500">{__('Default (SPA Settings)')}</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</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';
|
||||||
398
admin-spa/src/routes/Appearance/Pages/index.tsx
Normal file
398
admin-spa/src/routes/Appearance/Pages/index.tsx
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch global settings for defaults
|
||||||
|
const { data: globalSettings } = useQuery({
|
||||||
|
queryKey: ['appearance-settings'],
|
||||||
|
queryFn: async () => api.get('/appearance/settings'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
// Sync containerWidth
|
||||||
|
if (pageData?.container_width && currentPage) {
|
||||||
|
setCurrentPage({ ...currentPage, containerWidth: pageData.container_width });
|
||||||
|
}
|
||||||
|
// 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}
|
||||||
|
containerWidth={
|
||||||
|
currentPage?.containerWidth && currentPage.containerWidth !== 'default'
|
||||||
|
? currentPage.containerWidth
|
||||||
|
: ((globalSettings as any)?.data?.general?.container_width || 'boxed')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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}
|
||||||
|
onContainerWidthChange={(width) => {
|
||||||
|
if (currentPage) {
|
||||||
|
setCurrentPage({ ...currentPage, containerWidth: width });
|
||||||
|
markAsSaved(); // Mark as changed so save button enables
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div >
|
||||||
|
|
||||||
|
{/* Create Page Modal */}
|
||||||
|
< CreatePageModal
|
||||||
|
open={showCreateModal}
|
||||||
|
onOpenChange={setShowCreateModal}
|
||||||
|
onCreated={(newPage) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
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;
|
||||||
|
containerWidth?: 'boxed' | 'fullwidth';
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
container_width: currentPage.containerWidth
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -11,27 +11,34 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { ErrorCard } from '@/components/ErrorCard';
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { RefreshCw, Trash2, Search, User, ChevronRight, Edit } from 'lucide-react';
|
import { RefreshCw, Trash2, Search, User, ChevronRight, Edit, MoreHorizontal, Eye } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { formatMoney } from '@/lib/currency';
|
import { formatMoney } from '@/lib/currency';
|
||||||
|
|
||||||
export default function CustomersIndex() {
|
export default function CustomersIndex() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
|
||||||
// FAB config - 'none' because submenu has 'New' tab (per SOP)
|
// FAB config - 'none' because submenu has 'New' tab (per SOP)
|
||||||
useFABConfig('none');
|
useFABConfig('none');
|
||||||
|
|
||||||
// Fetch customers
|
// Fetch customers
|
||||||
const customersQuery = useQuery({
|
const customersQuery = useQuery({
|
||||||
queryKey: ['customers', page, search],
|
queryKey: ['customers', page, search],
|
||||||
queryFn: () => CustomersApi.list({ page, per_page: 20, search }),
|
queryFn: () => CustomersApi.list({ page, per_page: 20, search }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete mutation
|
// Delete mutation
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (ids: number[]) => {
|
mutationFn: async (ids: number[]) => {
|
||||||
@@ -46,14 +53,14 @@ export default function CustomersIndex() {
|
|||||||
showErrorToast(error);
|
showErrorToast(error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const toggleSelection = (id: number) => {
|
const toggleSelection = (id: number) => {
|
||||||
setSelectedIds(prev =>
|
setSelectedIds(prev =>
|
||||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAll = () => {
|
const toggleAll = () => {
|
||||||
if (selectedIds.length === customers.length) {
|
if (selectedIds.length === customers.length) {
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
@@ -61,21 +68,21 @@ export default function CustomersIndex() {
|
|||||||
setSelectedIds(customers.map(c => c.id));
|
setSelectedIds(customers.map(c => c.id));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (selectedIds.length === 0) return;
|
if (selectedIds.length === 0) return;
|
||||||
if (!confirm(__('Are you sure you want to delete the selected customers? This action cannot be undone.'))) return;
|
if (!confirm(__('Are you sure you want to delete the selected customers? This action cannot be undone.'))) return;
|
||||||
deleteMutation.mutate(selectedIds);
|
deleteMutation.mutate(selectedIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
const customers = customersQuery.data?.data || [];
|
const customers = customersQuery.data?.data || [];
|
||||||
const pagination = customersQuery.data?.pagination;
|
const pagination = customersQuery.data?.pagination;
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (customersQuery.isLoading) {
|
if (customersQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -85,7 +92,7 @@ export default function CustomersIndex() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state
|
// Error state
|
||||||
if (customersQuery.isError) {
|
if (customersQuery.isError) {
|
||||||
return (
|
return (
|
||||||
@@ -96,7 +103,7 @@ export default function CustomersIndex() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Mobile: Search */}
|
{/* Mobile: Search */}
|
||||||
@@ -130,7 +137,7 @@ export default function CustomersIndex() {
|
|||||||
{__('Delete')} ({selectedIds.length})
|
{__('Delete')} ({selectedIds.length})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={customersQuery.isFetching}
|
disabled={customersQuery.isFetching}
|
||||||
@@ -140,7 +147,7 @@ export default function CustomersIndex() {
|
|||||||
{__('Refresh')}
|
{__('Refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Search */}
|
{/* Right: Search */}
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -158,7 +165,7 @@ export default function CustomersIndex() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop: Table */}
|
{/* Desktop: Table */}
|
||||||
<div className="hidden md:block rounded-lg border overflow-hidden">
|
<div className="hidden md:block rounded-lg border overflow-hidden">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
@@ -212,9 +219,8 @@ export default function CustomersIndex() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-sm text-muted-foreground">{customer.email}</td>
|
<td className="p-3 text-sm text-muted-foreground">{customer.email}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${customer.role === 'customer' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
|
||||||
customer.role === 'customer' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
|
}`}>
|
||||||
}`}>
|
|
||||||
{customer.role === 'customer' ? __('Member') : __('Guest')}
|
{customer.role === 'customer' ? __('Member') : __('Guest')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -225,14 +231,37 @@ export default function CustomersIndex() {
|
|||||||
<td className="p-3 text-sm text-muted-foreground">
|
<td className="p-3 text-sm text-muted-foreground">
|
||||||
{new Date(customer.registered).toLocaleDateString()}
|
{new Date(customer.registered).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3 text-center">
|
||||||
<button
|
<DropdownMenu>
|
||||||
onClick={() => navigate(`/customers/${customer.id}/edit`)}
|
<DropdownMenuTrigger asChild>
|
||||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
>
|
<span className="sr-only">{__('Open menu')}</span>
|
||||||
<Edit className="w-4 h-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
{__('Edit')}
|
</Button>
|
||||||
</button>
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => navigate(`/customers/${customer.id}`)}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
{__('View Details')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => navigate(`/customers/${customer.id}/edit`)}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
{__('Edit')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(__('Are you sure you want to delete this customer?'))) {
|
||||||
|
deleteMutation.mutate([customer.id]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{__('Delete')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
@@ -240,7 +269,7 @@ export default function CustomersIndex() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: Cards */}
|
{/* Mobile: Cards */}
|
||||||
<div className="md:hidden space-y-3">
|
<div className="md:hidden space-y-3">
|
||||||
{customers.length === 0 ? (
|
{customers.length === 0 ? (
|
||||||
@@ -257,7 +286,7 @@ export default function CustomersIndex() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Checkbox */}
|
{/* Checkbox */}
|
||||||
<div
|
<div
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -302,7 +331,7 @@ export default function CustomersIndex() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{pagination && pagination.total_pages > 1 && (
|
{pagination && pagination.total_pages > 1 && (
|
||||||
<div className="flex justify-center gap-2">
|
<div className="flex justify-center gap-2">
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
405
admin-spa/src/routes/Marketing/Campaigns/Edit.tsx
Normal file
405
admin-spa/src/routes/Marketing/Campaigns/Edit.tsx
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Send,
|
||||||
|
Eye,
|
||||||
|
TestTube,
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface Campaign {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
subject: string;
|
||||||
|
content: string;
|
||||||
|
status: string;
|
||||||
|
scheduled_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CampaignEdit() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isNew = id === 'new';
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [previewHtml, setPreviewHtml] = useState('');
|
||||||
|
const [showTestDialog, setShowTestDialog] = useState(false);
|
||||||
|
const [testEmail, setTestEmail] = useState('');
|
||||||
|
const [showSendConfirm, setShowSendConfirm] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Fetch campaign if editing
|
||||||
|
const { data: campaign, isLoading } = useQuery({
|
||||||
|
queryKey: ['campaign', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get(`/campaigns/${id}`);
|
||||||
|
return response.data as Campaign;
|
||||||
|
},
|
||||||
|
enabled: !isNew && !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate form when campaign loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (campaign) {
|
||||||
|
setTitle(campaign.title || '');
|
||||||
|
setSubject(campaign.subject || '');
|
||||||
|
setContent(campaign.content || '');
|
||||||
|
}
|
||||||
|
}, [campaign]);
|
||||||
|
|
||||||
|
// Save mutation
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (data: { title: string; subject: string; content: string; status?: string }) => {
|
||||||
|
if (isNew) {
|
||||||
|
return api.post('/campaigns', data);
|
||||||
|
} else {
|
||||||
|
return api.put(`/campaigns/${id}`, data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||||
|
toast.success(isNew ? __('Campaign created') : __('Campaign saved'));
|
||||||
|
if (isNew && response?.data?.id) {
|
||||||
|
navigate(`/marketing/campaigns/${response.data.id}`, { replace: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to save campaign'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preview mutation
|
||||||
|
const previewMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
// First save, then preview
|
||||||
|
let campaignId = id;
|
||||||
|
if (isNew || !id) {
|
||||||
|
const saveResponse = await api.post('/campaigns', { title, subject, content, status: 'draft' });
|
||||||
|
campaignId = saveResponse?.data?.id;
|
||||||
|
if (campaignId) {
|
||||||
|
navigate(`/marketing/campaigns/${campaignId}`, { replace: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await api.put(`/campaigns/${id}`, { title, subject, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.get(`/campaigns/${campaignId}/preview`);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
setPreviewHtml(response?.html || response?.data?.html || '');
|
||||||
|
setShowPreview(true);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to generate preview'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test email mutation
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: async (email: string) => {
|
||||||
|
// First save
|
||||||
|
if (!isNew && id) {
|
||||||
|
await api.put(`/campaigns/${id}`, { title, subject, content });
|
||||||
|
}
|
||||||
|
return api.post(`/campaigns/${id}/test`, { email });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(__('Test email sent'));
|
||||||
|
setShowTestDialog(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to send test email'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send mutation
|
||||||
|
const sendMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
// First save
|
||||||
|
await api.put(`/campaigns/${id}`, { title, subject, content });
|
||||||
|
return api.post(`/campaigns/${id}/send`);
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaign', id] });
|
||||||
|
toast.success(response?.message || __('Campaign sent successfully'));
|
||||||
|
setShowSendConfirm(false);
|
||||||
|
navigate('/marketing/campaigns');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error?.response?.data?.error || __('Failed to send campaign'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
toast.error(__('Please enter a title'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await saveMutation.mutateAsync({ title, subject, content, status: 'draft' });
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSend = !isNew && id && campaign?.status !== 'sent' && campaign?.status !== 'sending';
|
||||||
|
|
||||||
|
if (!isNew && isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-medium">
|
||||||
|
{isNew ? __('New Campaign') : __('Edit Campaign')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isNew ? __('Create a new email campaign') : campaign?.title || ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={() => navigate('/marketing/newsletter/campaigns')}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
{__('Back to Campaigns')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Campaign Details */}
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Campaign Details')}
|
||||||
|
description={__('Basic information about your campaign')}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">{__('Campaign Title')}</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
placeholder={__('e.g., Holiday Sale Announcement')}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('Internal name for this campaign (not shown to subscribers)')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subject">{__('Email Subject')}</Label>
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
placeholder={__('e.g., 🎄 Exclusive Holiday Deals Inside!')}
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('The subject line subscribers will see in their inbox')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
{/* Campaign Content */}
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Campaign Content')}
|
||||||
|
description={__('Write your newsletter content. The design template is configured in Settings > Notifications.')}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="content">{__('Email Content')}</Label>
|
||||||
|
<RichTextEditor
|
||||||
|
content={content}
|
||||||
|
onChange={setContent}
|
||||||
|
placeholder={__('Write your newsletter content here...')}
|
||||||
|
variables={['site_name', 'current_date', 'subscriber_email', 'current_year', 'store_name', 'unsubscribe_url']}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('Use the toolbar to format text. The design wrapper will be applied from your campaign email template.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 sm:justify-between">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => previewMutation.mutate()}
|
||||||
|
disabled={previewMutation.isPending || !title.trim()}
|
||||||
|
>
|
||||||
|
{previewMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{__('Preview')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowTestDialog(true)}
|
||||||
|
disabled={!id}
|
||||||
|
>
|
||||||
|
<TestTube className="mr-2 h-4 w-4" />
|
||||||
|
{__('Send Test')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !title.trim()}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{__('Save Draft')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{canSend && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowSendConfirm(true)}
|
||||||
|
disabled={sendMutation.isPending}
|
||||||
|
>
|
||||||
|
{sendMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{__('Send Now')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Dialog */}
|
||||||
|
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{__('Email Preview')}</DialogTitle>
|
||||||
|
<DialogDescription>{__('Preview how your email will look to subscribers')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="border rounded-lg overflow-hidden bg-gray-100">
|
||||||
|
<iframe
|
||||||
|
srcDoc={previewHtml}
|
||||||
|
className="w-full min-h-[600px] bg-white"
|
||||||
|
title={__('Email Preview')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Test Email Dialog */}
|
||||||
|
<Dialog open={showTestDialog} onOpenChange={setShowTestDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{__('Send Test Email')}</DialogTitle>
|
||||||
|
<DialogDescription>{__('Send a test email to verify your campaign before sending to all subscribers')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="test-email">{__('Email Address')}</Label>
|
||||||
|
<Input
|
||||||
|
id="test-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
value={testEmail}
|
||||||
|
onChange={(e) => setTestEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={() => setShowTestDialog(false)}>
|
||||||
|
{__('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => testMutation.mutate(testEmail)}
|
||||||
|
disabled={!testEmail || testMutation.isPending}
|
||||||
|
>
|
||||||
|
{testMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{__('Send Test')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Send Confirmation Dialog */}
|
||||||
|
<AlertDialog open={showSendConfirm} onOpenChange={setShowSendConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Send Campaign')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('Are you sure you want to send this campaign to all newsletter subscribers? This action cannot be undone.')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => sendMutation.mutate()}
|
||||||
|
disabled={sendMutation.isPending}
|
||||||
|
>
|
||||||
|
{sendMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{__('Send to All Subscribers')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
293
admin-spa/src/routes/Marketing/Campaigns/index.tsx
Normal file
293
admin-spa/src/routes/Marketing/Campaigns/index.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
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";
|
||||||
|
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||||
|
|
||||||
|
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 CampaignsList() {
|
||||||
|
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: '', // Would need to fetch full 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 (
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Campaigns')}
|
||||||
|
description={__('Create and send email campaigns to your newsletter subscribers')}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header with count */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">{__('All Campaigns')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{campaigns.length} {__('campaigns total')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
{/* New Campaign button removed - available in sidebar */}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</SettingsCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { CouponsApi, type Coupon } from '@/lib/api/coupons';
|
import { CouponsApi, type Coupon } from '@/lib/api/coupons';
|
||||||
@@ -8,10 +9,18 @@ import { ErrorCard } from '@/components/ErrorCard';
|
|||||||
import { LoadingState } from '@/components/LoadingState';
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Trash2, RefreshCw, Edit, Tag, Search, SlidersHorizontal } from 'lucide-react';
|
import { Trash2, RefreshCw, Edit, Tag, Search, SlidersHorizontal, MoreHorizontal } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||||
import { CouponFilterSheet } from './components/CouponFilterSheet';
|
import { CouponFilterSheet } from './components/CouponFilterSheet';
|
||||||
import { CouponCard } from './components/CouponCard';
|
import { CouponCard } from './components/CouponCard';
|
||||||
@@ -28,17 +37,23 @@ export default function CouponsIndex() {
|
|||||||
// Configure FAB to navigate to new coupon page
|
// Configure FAB to navigate to new coupon page
|
||||||
useFABConfig('coupons');
|
useFABConfig('coupons');
|
||||||
|
|
||||||
|
// Set page header for contextual link
|
||||||
|
const { setPageHeader } = usePageHeader();
|
||||||
|
React.useEffect(() => {
|
||||||
|
setPageHeader(__('Coupons'));
|
||||||
|
}, [setPageHeader]);
|
||||||
|
|
||||||
// Count active filters
|
// Count active filters
|
||||||
const activeFiltersCount = discountType && discountType !== 'all' ? 1 : 0;
|
const activeFiltersCount = discountType && discountType !== 'all' ? 1 : 0;
|
||||||
|
|
||||||
// Fetch coupons
|
// Fetch coupons
|
||||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
queryKey: ['coupons', page, search, discountType],
|
queryKey: ['coupons', page, search, discountType],
|
||||||
queryFn: () => CouponsApi.list({
|
queryFn: () => CouponsApi.list({
|
||||||
page,
|
page,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
search,
|
search,
|
||||||
discount_type: discountType && discountType !== 'all' ? discountType : undefined
|
discount_type: discountType && discountType !== 'all' ? discountType : undefined
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,7 +73,7 @@ export default function CouponsIndex() {
|
|||||||
// Bulk delete
|
// Bulk delete
|
||||||
const handleBulkDelete = async () => {
|
const handleBulkDelete = async () => {
|
||||||
if (!confirm(__('Are you sure you want to delete the selected coupons?'))) return;
|
if (!confirm(__('Are you sure you want to delete the selected coupons?'))) return;
|
||||||
|
|
||||||
for (const id of selectedIds) {
|
for (const id of selectedIds) {
|
||||||
await deleteMutation.mutateAsync(id);
|
await deleteMutation.mutateAsync(id);
|
||||||
}
|
}
|
||||||
@@ -149,7 +164,7 @@ export default function CouponsIndex() {
|
|||||||
{/* Desktop Toolbar */}
|
{/* Desktop Toolbar */}
|
||||||
<div className="hidden md:block rounded-lg border border-border p-4 bg-card">
|
<div className="hidden md:block rounded-lg border border-border p-4 bg-card">
|
||||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
||||||
|
|
||||||
{/* Left: Bulk Actions */}
|
{/* Left: Bulk Actions */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{/* Delete - Show only when items selected */}
|
{/* Delete - Show only when items selected */}
|
||||||
@@ -173,7 +188,7 @@ export default function CouponsIndex() {
|
|||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
{__('Refresh')}
|
{__('Refresh')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* New Coupon - Desktop only */}
|
{/* New Coupon - Desktop only */}
|
||||||
<button
|
<button
|
||||||
className="border rounded-md px-3 py-2 text-sm bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-2"
|
className="border rounded-md px-3 py-2 text-sm bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-2"
|
||||||
@@ -264,7 +279,7 @@ export default function CouponsIndex() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Link to={`/coupons/${coupon.id}/edit`} className="font-medium hover:underline">
|
<Link to={`/coupons/${coupon.id}/edit`} className="font-medium hover:underline">
|
||||||
{coupon.code}
|
{coupon.code}
|
||||||
</Link>
|
</Link>
|
||||||
{coupon.description && (
|
{coupon.description && (
|
||||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||||
@@ -289,13 +304,32 @@ export default function CouponsIndex() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-center">
|
<td className="p-3 text-center">
|
||||||
<button
|
<DropdownMenu>
|
||||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
|
<DropdownMenuTrigger asChild>
|
||||||
onClick={() => navigate(`/coupons/${coupon.id}/edit`)}
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
>
|
<span className="sr-only">{__('Open menu')}</span>
|
||||||
<Edit className="w-4 h-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
{__('Edit')}
|
</Button>
|
||||||
</button>
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => navigate(`/coupons/${coupon.id}/edit`)}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
{__('Edit')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(__('Are you sure you want to delete this coupon?'))) {
|
||||||
|
deleteMutation.mutate(coupon.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{__('Delete')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
281
admin-spa/src/routes/Marketing/Newsletter/Campaigns.tsx
Normal file
281
admin-spa/src/routes/Marketing/Newsletter/Campaigns.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
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">
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx
Normal file
255
admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
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 { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Download, Trash2, Search, MoreHorizontal } 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';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
|
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())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Checkbox logic
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]); // Email strings
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (selectedIds.length === filteredSubscribers.length) {
|
||||||
|
setSelectedIds([]);
|
||||||
|
} else {
|
||||||
|
setSelectedIds(filteredSubscribers.map((s: any) => s.email));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRow = (email: string) => {
|
||||||
|
setSelectedIds(prev =>
|
||||||
|
prev.includes(email) ? prev.filter(e => e !== email) : [...prev, email]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
if (!confirm(__('Are you sure you want to delete selected subscribers?'))) return;
|
||||||
|
|
||||||
|
for (const email of selectedIds) {
|
||||||
|
await deleteSubscriber.mutateAsync(email);
|
||||||
|
}
|
||||||
|
setSelectedIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 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">
|
||||||
|
{selectedIds.length > 0 && (
|
||||||
|
<Button onClick={handleBulkDelete} variant="destructive" size="sm">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{__('Delete')} ({selectedIds.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={exportSubscribers} variant="outline" size="sm">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
{__('Export CSV')}
|
||||||
|
</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 className="w-12 p-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={filteredSubscribers.length > 0 && selectedIds.length === filteredSubscribers.length}
|
||||||
|
onCheckedChange={toggleAll}
|
||||||
|
aria-label={__('Select all')}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<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="p-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.includes(subscriber.email)}
|
||||||
|
onCheckedChange={() => toggleRow(subscriber.email)}
|
||||||
|
aria-label={__('Select subscriber')}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<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">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">{__('Open menu')}</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(__('Are you sure you want to remove this subscriber?'))) {
|
||||||
|
deleteSubscriber.mutate(subscriber.email);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{__('Remove')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* 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 >
|
||||||
|
);
|
||||||
|
}
|
||||||
114
admin-spa/src/routes/Marketing/Newsletter/index.tsx
Normal file
114
admin-spa/src/routes/Marketing/Newsletter/index.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate, useLocation, Outlet, Link } from 'react-router-dom';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Mail, Users, Send } from 'lucide-react';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { useModules } from '@/hooks/useModules';
|
||||||
|
import { cn } from '@/lib/utils'; // Assuming cn exists, widely used in ShadCN
|
||||||
|
|
||||||
|
import { DocLink } from '@/components/DocLink';
|
||||||
|
|
||||||
|
export default function NewsletterLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { isEnabled } = useModules();
|
||||||
|
|
||||||
|
// Show disabled state if newsletter module is off
|
||||||
|
if (!isEnabled('newsletter')) {
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{__('Newsletter')}</h1>
|
||||||
|
<DocLink />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2">{__('Newsletter module is disabled')}</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
id: 'subscribers',
|
||||||
|
label: __('Subscribers'),
|
||||||
|
icon: Users,
|
||||||
|
path: '/marketing/newsletter/subscribers',
|
||||||
|
isActive: (path: string) => path.includes('/subscribers')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'campaigns',
|
||||||
|
label: __('Campaigns'),
|
||||||
|
icon: Send,
|
||||||
|
path: '/marketing/newsletter/campaigns',
|
||||||
|
isActive: (path: string) => path.includes('/campaigns')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{__('Newsletter')}</h1>
|
||||||
|
<DocLink />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2">{__('Manage subscribers and send email campaigns')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-6">
|
||||||
|
{/* Sidebar Navigation */}
|
||||||
|
<div className="w-full lg:w-56 flex-shrink-0 space-y-4">
|
||||||
|
<nav className="space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = item.isActive(location.pathname);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
to={item.path}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-4 py-2.5 rounded-md text-sm font-medium transition-colors flex items-center gap-3',
|
||||||
|
// Focus styles matching ShadCN buttons (ring only on keyboard focus)
|
||||||
|
'outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
active
|
||||||
|
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground active:bg-muted active:text-foreground focus:bg-muted focus:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
className="w-full justify-start"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate('/marketing/newsletter/campaigns/new')}
|
||||||
|
>
|
||||||
|
<span className="mr-2">+</span>
|
||||||
|
{__('New Campaign')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,63 @@
|
|||||||
import { Navigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Mail, Tag } from 'lucide-react';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface MarketingCard {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards: MarketingCard[] = [
|
||||||
|
{
|
||||||
|
title: __('Newsletter'),
|
||||||
|
description: __('Manage subscribers and send email campaigns'),
|
||||||
|
icon: Mail,
|
||||||
|
to: '/marketing/newsletter',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: __('Coupons'),
|
||||||
|
description: __('Discounts, promotions, and coupon codes'),
|
||||||
|
icon: Tag,
|
||||||
|
to: '/marketing/coupons',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
import { DocLink } from '@/components/DocLink';
|
||||||
|
|
||||||
export default function Marketing() {
|
export default function Marketing() {
|
||||||
return <Navigate to="/marketing/newsletter" replace />;
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{__('Marketing')}</h1>
|
||||||
|
<DocLink />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2">{__('Newsletter, campaigns, and promotions')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<button
|
||||||
|
key={card.to}
|
||||||
|
onClick={() => navigate(card.to)}
|
||||||
|
className="flex items-start gap-4 p-6 rounded-lg border bg-card hover:bg-accent transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||||||
|
<card.icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium">{card.title}</div>
|
||||||
|
<div className="text-sm text-muted-foreground mt-1">
|
||||||
|
{card.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } 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';
|
||||||
@@ -16,10 +16,10 @@ interface MenuItem {
|
|||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
icon: <Tag className="w-5 h-5" />,
|
icon: <Megaphone className="w-5 h-5" />,
|
||||||
label: __('Coupons'),
|
label: __('Marketing'),
|
||||||
description: __('Manage discount codes and promotions'),
|
description: __('Newsletter, coupons, and promotions'),
|
||||||
to: '/coupons'
|
to: '/marketing'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Palette className="w-5 h-5" />,
|
icon: <Palette className="w-5 h-5" />,
|
||||||
@@ -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'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -40,7 +46,7 @@ export default function MorePage() {
|
|||||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||||
const { isStandalone, exitFullscreen } = useApp();
|
const { isStandalone, exitFullscreen } = useApp();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageHeader(__('More'));
|
setPageHeader(__('More'));
|
||||||
return () => clearPageHeader();
|
return () => clearPageHeader();
|
||||||
@@ -56,7 +62,7 @@ export default function MorePage() {
|
|||||||
// Clear auth and redirect to login
|
// Clear auth and redirect to login
|
||||||
window.location.href = window.WNW_CONFIG?.wpAdminUrl || '/wp-admin';
|
window.location.href = window.WNW_CONFIG?.wpAdminUrl || '/wp-admin';
|
||||||
};
|
};
|
||||||
|
|
||||||
const themeOptions = [
|
const themeOptions = [
|
||||||
{ value: 'light', icon: <Sun className="w-5 h-5" />, label: __('Light') },
|
{ value: 'light', icon: <Sun className="w-5 h-5" />, label: __('Light') },
|
||||||
{ value: 'dark', icon: <Moon className="w-5 h-5" />, label: __('Dark') },
|
{ value: 'dark', icon: <Moon className="w-5 h-5" />, label: __('Dark') },
|
||||||
@@ -78,7 +84,7 @@ export default function MorePage() {
|
|||||||
<button
|
<button
|
||||||
key={item.to}
|
key={item.to}
|
||||||
onClick={() => navigate(item.to)}
|
onClick={() => navigate(item.to)}
|
||||||
className="w-full flex items-center gap-4 py-4 hover:bg-accent transition-colors"
|
className="w-full flex items-center gap-4 py-4 hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||||||
{item.icon}
|
{item.icon}
|
||||||
@@ -102,11 +108,10 @@ export default function MorePage() {
|
|||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => setTheme(option.value as 'light' | 'dark' | 'system')}
|
onClick={() => setTheme(option.value as 'light' | 'dark' | 'system')}
|
||||||
className={`flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-colors ${
|
className={`flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-colors ${theme === option.value
|
||||||
theme === option.value
|
? 'border-primary bg-primary/10'
|
||||||
? 'border-primary bg-primary/10'
|
: 'border-border hover:border-primary/50'
|
||||||
: 'border-border hover:border-primary/50'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{option.icon}
|
{option.icon}
|
||||||
<span className="text-xs font-medium">{option.label}</span>
|
<span className="text-xs font-medium">{option.label}</span>
|
||||||
@@ -117,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'}
|
||||||
@@ -127,7 +141,7 @@ export default function MorePage() {
|
|||||||
{__('Go to WP Admin')}
|
{__('Go to WP Admin')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isStandalone ? (
|
{isStandalone ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -39,7 +39,7 @@ function StatusBadge({ status }: { status?: string }) {
|
|||||||
return <span className={`${cls} ${tone}`}>{status ? status[0].toUpperCase() + status.slice(1) : '—'}</span>;
|
return <span className={`${cls} ${tone}`}>{status ? status[0].toUpperCase() + status.slice(1) : '—'}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = ['pending', 'processing', 'completed', 'on-hold', 'cancelled', 'refunded', 'failed'];
|
const STATUS_OPTIONS = ['pending', 'processing', 'completed', 'on-hold', 'cancelled', 'refunded', 'failed', 'draft'];
|
||||||
|
|
||||||
export default function OrderShow() {
|
export default function OrderShow() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -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"
|
||||||
>
|
>
|
||||||
@@ -354,6 +315,69 @@ export default function OrderShow() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Related Items (Subscription & Licenses) */}
|
||||||
|
{(order.related_subscription || (order.related_licenses && order.related_licenses.length > 0)) && (
|
||||||
|
<div className="rounded border overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b font-medium">{__('Related Items')}</div>
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Related Subscription */}
|
||||||
|
{order.related_subscription && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4 text-muted-foreground" />
|
||||||
|
{__('Subscription')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{order.related_subscription.billing_schedule} • <span className="capitalize">{order.related_subscription.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link to={`/subscriptions/${order.related_subscription.id}`}>
|
||||||
|
<Button variant="outline" size="sm" className="h-8">
|
||||||
|
#{order.related_subscription.id}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Separator if both exist */}
|
||||||
|
{order.related_subscription && order.related_licenses && order.related_licenses.length > 0 && (
|
||||||
|
<div className="border-t"></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Related Licenses */}
|
||||||
|
{order.related_licenses && order.related_licenses.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{order.related_licenses.map((lic: any) => (
|
||||||
|
<div key={lic.id} className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Ticket className="w-4 h-4 text-muted-foreground" />
|
||||||
|
{__('License Key')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded mt-1.5 inline-block break-all select-all">
|
||||||
|
{lic.license_key}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1 truncate">
|
||||||
|
{lic.product_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border uppercase ${lic.status === 'active' ? 'bg-green-100 text-green-800 border-green-200' :
|
||||||
|
lic.status === 'expired' ? 'bg-red-100 text-red-800 border-red-200' :
|
||||||
|
'bg-gray-100 text-gray-700 border-gray-200'
|
||||||
|
}`}>
|
||||||
|
{lic.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Items */}
|
{/* Items */}
|
||||||
<div className="rounded border overflow-hidden">
|
<div className="rounded border overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b font-medium">{__('Items')}</div>
|
<div className="px-4 py-3 border-b font-medium">{__('Items')}</div>
|
||||||
@@ -473,84 +497,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,31 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Filter, PackageOpen, Trash2, RefreshCw } from 'lucide-react';
|
import { Filter, PackageOpen, Trash2, RefreshCw, MoreHorizontal, Eye, Edit } from 'lucide-react';
|
||||||
import { ErrorCard } from '@/components/ErrorCard';
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||||
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 { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -94,8 +101,8 @@ export default function Orders() {
|
|||||||
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
||||||
const [dateStart, setDateStart] = useState<string | undefined>(initial.date_start || undefined);
|
const [dateStart, setDateStart] = useState<string | undefined>(initial.date_start || undefined);
|
||||||
const [dateEnd, setDateEnd] = useState<string | undefined>(initial.date_end || undefined);
|
const [dateEnd, setDateEnd] = useState<string | undefined>(initial.date_end || undefined);
|
||||||
const [orderby, setOrderby] = useState<'date'|'id'|'modified'|'total'>((initial.orderby as any) || 'date');
|
const [orderby, setOrderby] = useState<'date' | 'id' | 'modified' | 'total'>((initial.orderby as any) || 'date');
|
||||||
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
|
const [order, setOrder] = useState<'asc' | 'desc'>((initial.order as any) || 'desc');
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||||||
@@ -136,7 +143,7 @@ export default function Orders() {
|
|||||||
const rows = data?.rows;
|
const rows = data?.rows;
|
||||||
if (!rows) return [];
|
if (!rows) return [];
|
||||||
if (!searchQuery.trim()) return rows;
|
if (!searchQuery.trim()) return rows;
|
||||||
|
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return rows.filter((order: any) =>
|
return rows.filter((order: any) =>
|
||||||
order.number?.toString().includes(query) ||
|
order.number?.toString().includes(query) ||
|
||||||
@@ -255,8 +262,8 @@ export default function Orders() {
|
|||||||
{__('Delete')} ({selectedIds.length})
|
{__('Delete')} ({selectedIds.length})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={q.isLoading || isRefreshing}
|
disabled={q.isLoading || isRefreshing}
|
||||||
@@ -305,7 +312,7 @@ export default function Orders() {
|
|||||||
setOrder((v.order ?? 'desc') as 'asc' | 'desc');
|
setOrder((v.order ?? 'desc') as 'asc' | 'desc');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeFiltersCount > 0 && (
|
{activeFiltersCount > 0 && (
|
||||||
<button
|
<button
|
||||||
className="text-sm text-muted-foreground hover:text-foreground underline text-nowrap"
|
className="text-sm text-muted-foreground hover:text-foreground underline text-nowrap"
|
||||||
@@ -432,7 +439,7 @@ export default function Orders() {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Link className="underline underline-offset-2" to={`/orders/${row.id}`}>#{row.number}</Link>
|
<Link className="font-medium hover:underline" to={`/orders/${row.id}`}>#{row.number}</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 min-w-32">
|
<td className="p-3 min-w-32">
|
||||||
<span title={row.date ?? ""}>
|
<span title={row.date ?? ""}>
|
||||||
@@ -454,9 +461,36 @@ export default function Orders() {
|
|||||||
decimals: store.decimals,
|
decimals: store.decimals,
|
||||||
})}
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-center space-x-2">
|
<td className="p-3 text-center">
|
||||||
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}`}>{__('Open')}</Link>
|
<DropdownMenu>
|
||||||
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}/edit`}>{__('Edit')}</Link>
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">{__('Open menu')}</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => nav(`/orders/${row.id}`)}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
{__('View Details')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => nav(`/orders/${row.id}/edit`)}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
{__('Edit Order')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedIds([row.id]);
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{__('Delete')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,7 @@ export default function ProductEdit() {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
formRef={formRef}
|
formRef={formRef}
|
||||||
hideSubmitButton={true}
|
hideSubmitButton={true}
|
||||||
|
productId={product.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Level 1 compatibility: Custom meta fields from plugins */}
|
{/* Level 1 compatibility: Custom meta fields from plugins */}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Filter, Package, Trash2, RefreshCw } from 'lucide-react';
|
import { Filter, Package, Trash2, RefreshCw, MoreHorizontal, Eye, Edit } from 'lucide-react';
|
||||||
import { ErrorCard } from '@/components/ErrorCard';
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||||
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 { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -27,6 +27,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
@@ -45,7 +52,7 @@ function StockBadge({ value, quantity }: { value?: string; quantity?: number })
|
|||||||
const v = (value || '').toLowerCase();
|
const v = (value || '').toLowerCase();
|
||||||
const cls = stockStatusStyle[v] || 'bg-slate-100 text-slate-800';
|
const cls = stockStatusStyle[v] || 'bg-slate-100 text-slate-800';
|
||||||
const label = v === 'instock' ? __('In Stock') : v === 'outofstock' ? __('Out of Stock') : v === 'onbackorder' ? __('On Backorder') : v;
|
const label = v === 'instock' ? __('In Stock') : v === 'outofstock' ? __('Out of Stock') : v === 'onbackorder' ? __('On Backorder') : v;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${cls}`}>
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${cls}`}>
|
||||||
{label}
|
{label}
|
||||||
@@ -62,8 +69,8 @@ export default function Products() {
|
|||||||
const [type, setType] = useState<string | undefined>(initial.type || undefined);
|
const [type, setType] = useState<string | undefined>(initial.type || undefined);
|
||||||
const [stockStatus, setStockStatus] = useState<string | undefined>(initial.stock_status || undefined);
|
const [stockStatus, setStockStatus] = useState<string | undefined>(initial.stock_status || undefined);
|
||||||
const [category, setCategory] = useState<string | undefined>(initial.category || undefined);
|
const [category, setCategory] = useState<string | undefined>(initial.category || undefined);
|
||||||
const [orderby, setOrderby] = useState<'date'|'title'|'id'|'modified'>((initial.orderby as any) || 'date');
|
const [orderby, setOrderby] = useState<'date' | 'title' | 'id' | 'modified'>((initial.orderby as any) || 'date');
|
||||||
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
|
const [order, setOrder] = useState<'asc' | 'desc'>((initial.order as any) || 'desc');
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||||||
@@ -113,7 +120,7 @@ export default function Products() {
|
|||||||
const rows = data?.rows;
|
const rows = data?.rows;
|
||||||
if (!rows) return [];
|
if (!rows) return [];
|
||||||
if (!searchQuery.trim()) return rows;
|
if (!searchQuery.trim()) return rows;
|
||||||
|
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return rows.filter((product: any) =>
|
return rows.filter((product: any) =>
|
||||||
product.name?.toLowerCase().includes(query) ||
|
product.name?.toLowerCase().includes(query) ||
|
||||||
@@ -227,7 +234,7 @@ export default function Products() {
|
|||||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
|
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
|
||||||
onClick={handleDeleteClick}
|
onClick={handleDeleteClick}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
@@ -236,8 +243,8 @@ export default function Products() {
|
|||||||
{__('Delete')} ({selectedIds.length})
|
{__('Delete')} ({selectedIds.length})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={q.isLoading || isRefreshing}
|
disabled={q.isLoading || isRefreshing}
|
||||||
@@ -412,9 +419,37 @@ export default function Products() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
<Link to={`/products/${product.id}/edit`} className="text-sm text-primary hover:underline">
|
<DropdownMenu>
|
||||||
{__('Edit')}
|
<DropdownMenuTrigger asChild>
|
||||||
</Link>
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">{__('Open menu')}</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => nav(`/products/${product.id}/edit`)}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
{__('Edit')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{product.permalink && (
|
||||||
|
<DropdownMenuItem onClick={() => window.open(product.permalink, '_blank')}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
{__('View')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedIds([product.id]);
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{__('Delete')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
|
|||||||
215
admin-spa/src/routes/Products/partials/DirectCartLinks.tsx
Normal file
215
admin-spa/src/routes/Products/partials/DirectCartLinks.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Copy, Check, ExternalLink } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface DirectCartLinksProps {
|
||||||
|
productId: number;
|
||||||
|
productType: 'simple' | 'variable';
|
||||||
|
variations?: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
attributes: Record<string, string>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DirectCartLinks({ productId, productType, variations = [] }: DirectCartLinksProps) {
|
||||||
|
const [quantity, setQuantity] = useState(1);
|
||||||
|
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 generateLink = (variationId?: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('add-to-cart', productId.toString());
|
||||||
|
if (variationId) {
|
||||||
|
params.set('variation_id', variationId.toString());
|
||||||
|
}
|
||||||
|
if (quantity > 1) {
|
||||||
|
params.set('quantity', quantity.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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const LinkRow = ({
|
||||||
|
label,
|
||||||
|
link,
|
||||||
|
description
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
link: string;
|
||||||
|
description?: string;
|
||||||
|
}) => {
|
||||||
|
const isCopied = copiedLink === link;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">{label}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copyToClipboard(link, label)}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="h-4 w-4 mr-1" />
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => window.open(link, '_blank')}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={link}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-xs"
|
||||||
|
onClick={(e) => e.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Direct-to-Cart Links</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Generate copyable links that add this product to cart and redirect to cart or checkout page.
|
||||||
|
Perfect for landing pages, email campaigns, and social media.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Quantity Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="link-quantity">Default Quantity</Label>
|
||||||
|
<Input
|
||||||
|
id="link-quantity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Set quantity to 1 to exclude from URL (cleaner links)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simple Product Links */}
|
||||||
|
{productType === 'simple' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-b pb-2">
|
||||||
|
<h4 className="font-medium">Simple Product Links</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LinkRow
|
||||||
|
label="Add to Cart"
|
||||||
|
link={generateLink(undefined, 'cart')}
|
||||||
|
description="Adds product to cart and shows cart page"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LinkRow
|
||||||
|
label="Direct to Checkout"
|
||||||
|
link={generateLink(undefined, 'checkout')}
|
||||||
|
description="Adds product to cart and goes directly to checkout"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Variable Product Links */}
|
||||||
|
{productType === 'variable' && variations.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-b pb-2">
|
||||||
|
<h4 className="font-medium">Variable Product Links</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{variations.length} variation(s) - Select a variation to generate links
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{variations.map((variation, index) => (
|
||||||
|
<details key={variation.id} className="group border rounded-lg">
|
||||||
|
<summary className="cursor-pointer p-3 hover:bg-muted/50 flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="font-medium text-sm">{variation.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
(ID: {variation.id})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 transition-transform group-open:rotate-180"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="p-4 pt-0 space-y-3 border-t">
|
||||||
|
<LinkRow
|
||||||
|
label="Add to Cart"
|
||||||
|
link={generateLink(variation.id, 'cart')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LinkRow
|
||||||
|
label="Direct to Checkout"
|
||||||
|
link={generateLink(variation.id, 'checkout')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* URL Parameters Reference */}
|
||||||
|
<div className="mt-6 p-4 bg-muted rounded-lg">
|
||||||
|
<h4 className="font-medium text-sm mb-2">URL Parameters Reference</h4>
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
<div><code className="bg-background px-1 py-0.5 rounded">add-to-cart</code> - Product ID (required)</div>
|
||||||
|
<div><code className="bg-background px-1 py-0.5 rounded">variation_id</code> - Variation ID (for variable products)</div>
|
||||||
|
<div><code className="bg-background px-1 py-0.5 rounded">quantity</code> - Quantity (default: 1)</div>
|
||||||
|
<div><code className="bg-background px-1 py-0.5 rounded">redirect</code> - Destination: <code>cart</code> or <code>checkout</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = {
|
||||||
@@ -41,6 +56,7 @@ type Props = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
formRef?: React.RefObject<HTMLFormElement>;
|
formRef?: React.RefObject<HTMLFormElement>;
|
||||||
hideSubmitButton?: boolean;
|
hideSubmitButton?: boolean;
|
||||||
|
productId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProductFormTabbed({
|
export function ProductFormTabbed({
|
||||||
@@ -50,6 +66,7 @@ export function ProductFormTabbed({
|
|||||||
className,
|
className,
|
||||||
formRef,
|
formRef,
|
||||||
hideSubmitButton = false,
|
hideSubmitButton = false,
|
||||||
|
productId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// Form state
|
// Form state
|
||||||
const [name, setName] = useState(initial?.name || '');
|
const [name, setName] = useState(initial?.name || '');
|
||||||
@@ -73,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)
|
||||||
@@ -97,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]);
|
||||||
|
|
||||||
@@ -153,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);
|
||||||
@@ -167,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" /> },
|
||||||
];
|
];
|
||||||
@@ -201,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>
|
||||||
|
|
||||||
@@ -216,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">
|
||||||
@@ -225,6 +315,7 @@ export function ProductFormTabbed({
|
|||||||
variations={variations}
|
variations={variations}
|
||||||
setVariations={setVariations}
|
setVariations={setVariations}
|
||||||
regularPrice={regularPrice}
|
regularPrice={regularPrice}
|
||||||
|
productId={productId}
|
||||||
/>
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
)}
|
)}
|
||||||
@@ -248,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>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Plus, X, Layers, Image as ImageIcon } from 'lucide-react';
|
import { Plus, X, Layers, Image as ImageIcon, Copy, Check, ExternalLink } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getStoreCurrency } from '@/lib/currency';
|
import { getStoreCurrency } from '@/lib/currency';
|
||||||
import { openWPMediaImage } from '@/lib/wp-media';
|
import { openWPMediaImage } from '@/lib/wp-media';
|
||||||
@@ -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 = {
|
||||||
@@ -30,6 +31,7 @@ type VariationsTabProps = {
|
|||||||
variations: ProductVariant[];
|
variations: ProductVariant[];
|
||||||
setVariations: (value: ProductVariant[]) => void;
|
setVariations: (value: ProductVariant[]) => void;
|
||||||
regularPrice: string;
|
regularPrice: string;
|
||||||
|
productId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VariationsTab({
|
export function VariationsTab({
|
||||||
@@ -38,9 +40,34 @@ export function VariationsTab({
|
|||||||
variations,
|
variations,
|
||||||
setVariations,
|
setVariations,
|
||||||
regularPrice,
|
regularPrice,
|
||||||
|
productId,
|
||||||
}: VariationsTabProps) {
|
}: VariationsTabProps) {
|
||||||
const store = getStoreCurrency();
|
const store = getStoreCurrency();
|
||||||
|
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 generateLink = (variationId: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||||
|
if (!productId) return '';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('add-to-cart', productId.toString());
|
||||||
|
params.set('variation_id', variationId.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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addAttribute = () => {
|
const addAttribute = () => {
|
||||||
setAttributes([...attributes, { name: '', options: [], variation: false }]);
|
setAttributes([...attributes, { name: '', options: [], variation: false }]);
|
||||||
};
|
};
|
||||||
@@ -57,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;
|
||||||
@@ -250,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')}
|
||||||
@@ -305,6 +352,45 @@ export function VariationsTab({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Direct Cart Links */}
|
||||||
|
{productId && variation.id && (
|
||||||
|
<div className="mt-4 pt-4 border-t space-y-2">
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">
|
||||||
|
{__('Direct-to-Cart Links')}
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copyToClipboard(generateLink(variation.id!, 'cart'), 'Cart')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{copiedLink === generateLink(variation.id!, '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(generateLink(variation.id!, 'checkout'), 'Checkout')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{copiedLink === generateLink(variation.id!, '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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
255
admin-spa/src/routes/ResetPassword.tsx
Normal file
255
admin-spa/src/routes/ResetPassword.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
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 { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Loader2, CheckCircle, AlertCircle, Eye, EyeOff, Lock } from 'lucide-react';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export default function ResetPassword() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const key = searchParams.get('key') || '';
|
||||||
|
const login = searchParams.get('login') || '';
|
||||||
|
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isValidating, setIsValidating] = useState(true);
|
||||||
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
// Validate the reset key on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const validateKey = async () => {
|
||||||
|
if (!key || !login) {
|
||||||
|
setError(__('Invalid password reset link. Please request a new one.'));
|
||||||
|
setIsValidating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${window.WNW_CONFIG?.restUrl || '/wp-json/'}woonoow/v1/auth/validate-reset-key`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key, login }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.valid) {
|
||||||
|
setIsValid(true);
|
||||||
|
} else {
|
||||||
|
setError(data.message || __('This password reset link has expired or is invalid. Please request a new one.'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(__('Unable to validate reset link. Please try again later.'));
|
||||||
|
} finally {
|
||||||
|
setIsValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
validateKey();
|
||||||
|
}, [key, login]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Validate passwords match
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError(__('Passwords do not match'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password strength
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError(__('Password must be at least 8 characters long'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${window.WNW_CONFIG?.restUrl || '/wp-json/'}woonoow/v1/auth/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key, login, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSuccess(true);
|
||||||
|
} else {
|
||||||
|
setError(data.message || __('Failed to reset password. Please try again.'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(__('An error occurred. Please try again later.'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Password strength indicator
|
||||||
|
const getPasswordStrength = (pwd: string) => {
|
||||||
|
if (pwd.length === 0) return { label: '', color: '' };
|
||||||
|
if (pwd.length < 8) return { label: __('Too short'), color: 'text-red-500' };
|
||||||
|
|
||||||
|
let strength = 0;
|
||||||
|
if (pwd.length >= 8) strength++;
|
||||||
|
if (pwd.length >= 12) strength++;
|
||||||
|
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) strength++;
|
||||||
|
if (/\d/.test(pwd)) strength++;
|
||||||
|
if (/[!@#$%^&*(),.?":{}|<>]/.test(pwd)) strength++;
|
||||||
|
|
||||||
|
if (strength <= 2) return { label: __('Weak'), color: 'text-orange-500' };
|
||||||
|
if (strength <= 3) return { label: __('Medium'), color: 'text-yellow-500' };
|
||||||
|
return { label: __('Strong'), color: 'text-green-500' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordStrength = getPasswordStrength(password);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isValidating) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardContent className="flex flex-col items-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
|
||||||
|
<p className="text-muted-foreground">{__('Validating reset link...')}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success state
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardContent className="flex flex-col items-center py-8">
|
||||||
|
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
|
||||||
|
<h2 className="text-xl font-semibold mb-2">{__('Password Reset Successful')}</h2>
|
||||||
|
<p className="text-muted-foreground text-center mb-6">
|
||||||
|
{__('Your password has been updated. You can now log in with your new password.')}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/login')}>
|
||||||
|
{__('Go to Login')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state (invalid key)
|
||||||
|
if (!isValid && error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardContent className="flex flex-col items-center py-8">
|
||||||
|
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
|
||||||
|
<h2 className="text-xl font-semibold mb-2">{__('Invalid Reset Link')}</h2>
|
||||||
|
<p className="text-muted-foreground text-center mb-6">{error}</p>
|
||||||
|
<Button variant="outline" onClick={() => window.location.href = window.WNW_CONFIG?.siteUrl + '/my-account/lost-password/'}>
|
||||||
|
{__('Request New Reset Link')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="rounded-full bg-primary/10 p-3">
|
||||||
|
<Lock className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl text-center">{__('Reset Your Password')}</CardTitle>
|
||||||
|
<CardDescription className="text-center">
|
||||||
|
{__('Enter your new password below')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">{__('New Password')}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={__('Enter new password')}
|
||||||
|
required
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{password && (
|
||||||
|
<p className={`text-sm ${passwordStrength.color}`}>
|
||||||
|
{__('Strength')}: {passwordStrength.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">{__('Confirm Password')}</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder={__('Confirm new password')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{confirmPassword && password !== confirmPassword && (
|
||||||
|
<p className="text-sm text-red-500">{__('Passwords do not match')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{__('Resetting...')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
__('Reset Password')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +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;
|
||||||
wishlist_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';
|
||||||
@@ -25,7 +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,
|
||||||
wishlist_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',
|
||||||
@@ -131,7 +131,7 @@ export default function CustomersSettings() {
|
|||||||
checked={settings.auto_register_members}
|
checked={settings.auto_register_members}
|
||||||
onCheckedChange={(checked) => setSettings({ ...settings, auto_register_members: checked })}
|
onCheckedChange={(checked) => setSettings({ ...settings, auto_register_members: checked })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToggleField
|
<ToggleField
|
||||||
id="multiple_addresses_enabled"
|
id="multiple_addresses_enabled"
|
||||||
label={__('Enable multiple saved addresses')}
|
label={__('Enable multiple saved addresses')}
|
||||||
@@ -139,14 +139,16 @@ export default function CustomersSettings() {
|
|||||||
checked={settings.multiple_addresses_enabled}
|
checked={settings.multiple_addresses_enabled}
|
||||||
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
|
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToggleField
|
<ToggleField
|
||||||
id="wishlist_enabled"
|
id="allow_custom_avatar"
|
||||||
label={__('Enable wishlist')}
|
label={__('Allow custom profile photo')}
|
||||||
description={__('Allow customers to save products to their wishlist for later purchase. Customers can add products to wishlist from product cards and manage them in their account.')}
|
description={__('Allow customers to upload their own profile photo. When disabled, customer avatars will use Gravatar or default initials.')}
|
||||||
checked={settings.wishlist_enabled}
|
checked={settings.allow_custom_avatar}
|
||||||
onCheckedChange={(checked) => setSettings({ ...settings, wishlist_enabled: checked })}
|
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';
|
||||||
@@ -18,7 +19,7 @@ import { markdownToHtml } from '@/lib/markdown-utils';
|
|||||||
export default function EditTemplate() {
|
export default function EditTemplate() {
|
||||||
// Mobile responsive check
|
// Mobile responsive check
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||||
checkMobile();
|
checkMobile();
|
||||||
@@ -28,70 +29,32 @@ export default function EditTemplate() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const eventId = searchParams.get('event');
|
const eventId = searchParams.get('event');
|
||||||
const channelId = searchParams.get('channel');
|
const channelId = searchParams.get('channel');
|
||||||
const recipientType = searchParams.get('recipient') || 'customer'; // Default to customer
|
const recipientType = searchParams.get('recipient') || 'customer'; // Default to customer
|
||||||
|
|
||||||
const [subject, setSubject] = useState('');
|
const [subject, setSubject] = useState('');
|
||||||
const [markdownContent, setMarkdownContent] = useState(''); // Source of truth: Markdown
|
const [markdownContent, setMarkdownContent] = useState(''); // Source of truth: Markdown
|
||||||
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');
|
||||||
|
|
||||||
// All available template variables
|
|
||||||
const availableVariables = [
|
|
||||||
// Order variables
|
|
||||||
'order_number',
|
|
||||||
'order_id',
|
|
||||||
'order_date',
|
|
||||||
'order_total',
|
|
||||||
'order_subtotal',
|
|
||||||
'order_tax',
|
|
||||||
'order_shipping',
|
|
||||||
'order_discount',
|
|
||||||
'order_status',
|
|
||||||
'order_url',
|
|
||||||
'order_items_table',
|
|
||||||
'completion_date',
|
|
||||||
'estimated_delivery',
|
|
||||||
// Customer variables
|
|
||||||
'customer_name',
|
|
||||||
'customer_first_name',
|
|
||||||
'customer_last_name',
|
|
||||||
'customer_email',
|
|
||||||
'customer_phone',
|
|
||||||
'billing_address',
|
|
||||||
'shipping_address',
|
|
||||||
// Payment variables
|
|
||||||
'payment_method',
|
|
||||||
'payment_status',
|
|
||||||
'payment_date',
|
|
||||||
'transaction_id',
|
|
||||||
'payment_retry_url',
|
|
||||||
// Shipping/Tracking variables
|
|
||||||
'tracking_number',
|
|
||||||
'tracking_url',
|
|
||||||
'shipping_carrier',
|
|
||||||
'shipping_method',
|
|
||||||
// URL variables
|
|
||||||
'review_url',
|
|
||||||
'shop_url',
|
|
||||||
'my_account_url',
|
|
||||||
// Store variables
|
|
||||||
'site_name',
|
|
||||||
'site_title',
|
|
||||||
'store_name',
|
|
||||||
'store_url',
|
|
||||||
'support_email',
|
|
||||||
'current_year',
|
|
||||||
];
|
|
||||||
|
|
||||||
// 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],
|
||||||
@@ -101,20 +64,20 @@ export default function EditTemplate() {
|
|||||||
console.log('API Response:', response);
|
console.log('API Response:', response);
|
||||||
console.log('API Response.data:', response.data);
|
console.log('API Response.data:', response.data);
|
||||||
console.log('API Response type:', typeof response);
|
console.log('API Response type:', typeof response);
|
||||||
|
|
||||||
// The api.get might already unwrap response.data
|
// The api.get might already unwrap response.data
|
||||||
// Return the response directly if it has the template fields
|
// Return the response directly if it has the template fields
|
||||||
if (response && (response.subject !== undefined || response.body !== undefined)) {
|
if (response && (response.subject !== undefined || response.body !== undefined)) {
|
||||||
console.log('Returning response directly:', response);
|
console.log('Returning response directly:', response);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise return response.data
|
// Otherwise return response.data
|
||||||
if (response && response.data) {
|
if (response && response.data) {
|
||||||
console.log('Returning response.data:', response.data);
|
console.log('Returning response.data:', response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
enabled: !!eventId && !!channelId,
|
enabled: !!eventId && !!channelId,
|
||||||
@@ -123,11 +86,11 @@ export default function EditTemplate() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (template) {
|
if (template) {
|
||||||
setSubject(template.subject || '');
|
setSubject(template.subject || '');
|
||||||
|
|
||||||
// Always treat body as markdown (source of truth)
|
// Always treat body as markdown (source of truth)
|
||||||
const markdown = template.body || '';
|
const markdown = template.body || '';
|
||||||
setMarkdownContent(markdown);
|
setMarkdownContent(markdown);
|
||||||
|
|
||||||
// Convert to blocks for visual mode
|
// Convert to blocks for visual mode
|
||||||
const initialBlocks = markdownToBlocks(markdown);
|
const initialBlocks = markdownToBlocks(markdown);
|
||||||
setBlocks(initialBlocks);
|
setBlocks(initialBlocks);
|
||||||
@@ -151,7 +114,7 @@ export default function EditTemplate() {
|
|||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
if (!confirm(__('Are you sure you want to reset this template to default?'))) return;
|
if (!confirm(__('Are you sure you want to reset this template to default?'))) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.del(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
|
await api.del(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
|
||||||
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
|
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
|
||||||
@@ -162,13 +125,39 @@ 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);
|
||||||
const markdown = blocksToMarkdown(newBlocks);
|
const markdown = blocksToMarkdown(newBlocks);
|
||||||
setMarkdownContent(markdown); // Update markdown (source of truth)
|
setMarkdownContent(markdown); // Update markdown (source of truth)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Markdown mode: Update markdown → Blocks (for visual sync)
|
// Markdown mode: Update markdown → Blocks (for visual sync)
|
||||||
const handleMarkdownChange = (newMarkdown: string) => {
|
const handleMarkdownChange = (newMarkdown: string) => {
|
||||||
setMarkdownContent(newMarkdown); // Update source of truth
|
setMarkdownContent(newMarkdown); // Update source of truth
|
||||||
@@ -176,9 +165,11 @@ export default function EditTemplate() {
|
|||||||
setBlocks(newBlocks); // Keep blocks in sync
|
setBlocks(newBlocks); // Keep blocks in sync
|
||||||
};
|
};
|
||||||
|
|
||||||
// Variable keys for the rich text editor dropdown
|
// Variable keys for the rich text editor dropdown - from API (contextual per event)
|
||||||
const variableKeys = availableVariables;
|
const variableKeys = template?.available_variables
|
||||||
|
? Object.keys(template.available_variables).map(k => k.replace(/^\{|}$/g, ''))
|
||||||
|
: [];
|
||||||
|
|
||||||
// Parse [card] tags and [button] shortcodes for preview
|
// Parse [card] tags and [button] shortcodes for preview
|
||||||
const parseCardsForPreview = (content: string) => {
|
const parseCardsForPreview = (content: string) => {
|
||||||
// Parse card blocks - new [card:type] syntax
|
// Parse card blocks - new [card:type] syntax
|
||||||
@@ -187,7 +178,7 @@ export default function EditTemplate() {
|
|||||||
const htmlContent = markdownToHtml(cardContent.trim());
|
const htmlContent = markdownToHtml(cardContent.trim());
|
||||||
return `<div class="${cardClass}">${htmlContent}</div>`;
|
return `<div class="${cardClass}">${htmlContent}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse card blocks - old [card type="..."] syntax (backward compatibility)
|
// Parse card blocks - old [card type="..."] syntax (backward compatibility)
|
||||||
parsed = parsed.replace(/\[card([^\]]*)\](.*?)\[\/card\]/gs, (match, attributes, cardContent) => {
|
parsed = parsed.replace(/\[card([^\]]*)\](.*?)\[\/card\]/gs, (match, attributes, cardContent) => {
|
||||||
let cardClass = 'card';
|
let cardClass = 'card';
|
||||||
@@ -195,27 +186,27 @@ export default function EditTemplate() {
|
|||||||
if (typeMatch) {
|
if (typeMatch) {
|
||||||
cardClass += ` card-${typeMatch[1]}`;
|
cardClass += ` card-${typeMatch[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
||||||
const bgStyle = bgMatch ? `background-image: url(${bgMatch[1]}); background-size: cover; background-position: center;` : '';
|
const bgStyle = bgMatch ? `background-image: url(${bgMatch[1]}); background-size: cover; background-position: center;` : '';
|
||||||
|
|
||||||
// Convert markdown inside card to HTML
|
// Convert markdown inside card to HTML
|
||||||
const htmlContent = markdownToHtml(cardContent.trim());
|
const htmlContent = markdownToHtml(cardContent.trim());
|
||||||
return `<div class="${cardClass}" style="${bgStyle}">${htmlContent}</div>`;
|
return `<div class="${cardClass}" style="${bgStyle}">${htmlContent}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse button shortcodes - new [button:style](url)Text[/button] syntax
|
// Parse button shortcodes - new [button:style](url)Text[/button] syntax
|
||||||
parsed = parsed.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
parsed = parsed.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
||||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse button shortcodes - old [button url="..."]Text[/button] syntax (backward compatibility)
|
// Parse button shortcodes - old [button url="..."]Text[/button] syntax (backward compatibility)
|
||||||
parsed = parsed.replace(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
parsed = parsed.replace(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
return parsed;
|
return parsed;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,19 +214,19 @@ export default function EditTemplate() {
|
|||||||
const generatePreviewHTML = () => {
|
const generatePreviewHTML = () => {
|
||||||
// Convert markdown to HTML for preview
|
// Convert markdown to HTML for preview
|
||||||
let previewBody = parseCardsForPreview(markdownContent);
|
let previewBody = parseCardsForPreview(markdownContent);
|
||||||
|
|
||||||
// Replace store-identity variables with actual data
|
// Replace store-identity variables with actual data
|
||||||
const storeVariables: { [key: string]: string } = {
|
const storeVariables: { [key: string]: string } = {
|
||||||
store_name: 'My WordPress Store',
|
store_name: 'My WordPress Store',
|
||||||
store_url: window.location.origin,
|
site_url: window.location.origin,
|
||||||
store_email: 'store@example.com',
|
store_email: 'store@example.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.entries(storeVariables).forEach(([key, value]) => {
|
Object.entries(storeVariables).forEach(([key, value]) => {
|
||||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||||
previewBody = previewBody.replace(regex, value);
|
previewBody = previewBody.replace(regex, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Replace dynamic variables with sample data (not just highlighting)
|
// Replace dynamic variables with sample data (not just highlighting)
|
||||||
const sampleData: { [key: string]: string } = {
|
const sampleData: { [key: string]: string } = {
|
||||||
order_number: '12345',
|
order_number: '12345',
|
||||||
@@ -307,50 +298,61 @@ export default function EditTemplate() {
|
|||||||
current_year: new Date().getFullYear().toString(),
|
current_year: new Date().getFullYear().toString(),
|
||||||
site_name: 'My WordPress Store',
|
site_name: 'My WordPress Store',
|
||||||
store_name: 'My WordPress Store',
|
store_name: 'My WordPress Store',
|
||||||
store_url: '#',
|
site_url: '#',
|
||||||
store_email: 'store@example.com',
|
store_email: 'store@example.com',
|
||||||
support_email: 'support@example.com',
|
support_email: 'support@example.com',
|
||||||
|
// Account-related URLs and variables
|
||||||
|
login_url: '#',
|
||||||
|
reset_link: '#',
|
||||||
|
reset_key: 'abc123xyz',
|
||||||
|
user_login: 'johndoe',
|
||||||
|
user_email: 'john@example.com',
|
||||||
|
user_temp_password: '••••••••',
|
||||||
|
customer_first_name: 'John',
|
||||||
|
customer_last_name: 'Doe',
|
||||||
|
// Campaign/Newsletter variables
|
||||||
|
content: '<p>This is sample content that would be replaced with your actual campaign content.</p>',
|
||||||
|
campaign_title: 'Newsletter Campaign',
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.keys(sampleData).forEach((key) => {
|
Object.keys(sampleData).forEach((key) => {
|
||||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||||
previewBody = previewBody.replace(regex, sampleData[key]);
|
previewBody = previewBody.replace(regex, sampleData[key]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Highlight variables that don't have sample data
|
// Highlight variables that don't have sample data
|
||||||
availableVariables.forEach(key => {
|
// Use plain text [variable] instead of HTML spans to avoid breaking href attributes
|
||||||
|
variableKeys.forEach((key: string) => {
|
||||||
if (!storeVariables[key] && !sampleData[key]) {
|
if (!storeVariables[key] && !sampleData[key]) {
|
||||||
const sampleValue = `<span style="background: #fef3c7; padding: 2px 4px; border-radius: 2px;">[${key}]</span>`;
|
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), `[${key}]`);
|
||||||
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), sampleValue);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse [card] tags
|
// Get email settings for preview - use UNIFIED appearance settings for colors
|
||||||
previewBody = parseCardsForPreview(previewBody);
|
|
||||||
|
|
||||||
// Get email settings for preview
|
|
||||||
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 || '';
|
||||||
const headerText = settings.header_text || 'My WordPress Store';
|
const headerText = settings.header_text || 'My WordPress Store';
|
||||||
const footerText = settings.footer_text || `© ${new Date().getFullYear()} My WordPress Store. All rights reserved.`;
|
const footerText = settings.footer_text || `© ${new Date().getFullYear()} My WordPress Store. All rights reserved.`;
|
||||||
const socialLinks = settings.social_links || [];
|
const socialLinks = settings.social_links || [];
|
||||||
|
|
||||||
// Replace {current_year} in footer
|
// Replace {current_year} in footer
|
||||||
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) => `
|
||||||
@@ -360,7 +362,7 @@ export default function EditTemplate() {
|
|||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
` : '';
|
` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -380,14 +382,13 @@ export default function EditTemplate() {
|
|||||||
.header { padding: 20px 16px; }
|
.header { padding: 20px 16px; }
|
||||||
.footer { padding: 20px 16px; }
|
.footer { padding: 20px 16px; }
|
||||||
}
|
}
|
||||||
.card-success { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
.card-success { background-color: #f0fdf4; }
|
||||||
.card-success * { color: ${heroTextColor} !important; }
|
|
||||||
.card-highlight { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
.card-highlight { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
||||||
.card-highlight * { color: ${heroTextColor} !important; }
|
.card-highlight * { color: ${heroTextColor} !important; }
|
||||||
.card-hero { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
.card-hero { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
||||||
.card-hero * { color: ${heroTextColor} !important; }
|
.card-hero * { color: ${heroTextColor} !important; }
|
||||||
.card-info { background: #f0f7ff; border: 1px solid #0071e3; }
|
.card-info { background-color: #f0f7ff; }
|
||||||
.card-warning { background: #fff8e1; border: 1px solid #ff9800; }
|
.card-warning { background-color: #fff8e1; }
|
||||||
.card-basic { background: none; border: none; padding: 0; margin: 16px 0; }
|
.card-basic { background: none; border: none; padding: 0; margin: 16px 0; }
|
||||||
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||||
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||||
@@ -395,6 +396,7 @@ export default function EditTemplate() {
|
|||||||
p { font-size: 16px; line-height: 1.6; color: #555; margin-bottom: 16px; }
|
p { font-size: 16px; line-height: 1.6; color: #555; margin-bottom: 16px; }
|
||||||
.button { display: inline-block; background: ${primaryColor}; color: ${buttonTextColor} !important; padding: 14px 28px; border-radius: 6px; text-decoration: none; font-weight: 600; }
|
.button { display: inline-block; background: ${primaryColor}; color: ${buttonTextColor} !important; padding: 14px 28px; border-radius: 6px; text-decoration: none; font-weight: 600; }
|
||||||
.button-outline { display: inline-block; background: transparent; color: ${secondaryColor} !important; padding: 12px 26px; border: 2px solid ${secondaryColor}; border-radius: 6px; text-decoration: none; font-weight: 600; }
|
.button-outline { display: inline-block; background: transparent; color: ${secondaryColor} !important; padding: 12px 26px; border: 2px solid ${secondaryColor}; border-radius: 6px; text-decoration: none; font-weight: 600; }
|
||||||
|
.text-link { color: ${primaryColor}; text-decoration: underline; }
|
||||||
.info-box { background: #f6f6f6; border-radius: 6px; padding: 20px; margin: 16px 0; }
|
.info-box { background: #f6f6f6; border-radius: 6px; padding: 20px; margin: 16px 0; }
|
||||||
.footer { padding: 32px; text-align: center; color: #888; font-size: 13px; }
|
.footer { padding: 32px; text-align: center; color: #888; font-size: 13px; }
|
||||||
</style>
|
</style>
|
||||||
@@ -416,7 +418,7 @@ export default function EditTemplate() {
|
|||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get social icon emoji
|
// Helper function to get social icon emoji
|
||||||
const getSocialIcon = (platform: string) => {
|
const getSocialIcon = (platform: string) => {
|
||||||
const icons: Record<string, string> = {
|
const icons: Record<string, string> = {
|
||||||
@@ -455,43 +457,54 @@ 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"
|
||||||
|
onClick={() => setTestEmailDialogOpen(true)}
|
||||||
|
className="gap-2"
|
||||||
|
title={__('Send Test')}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{__('Send Test')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
<CardContent className="pt-6 space-y-6">
|
<CardContent className="pt-6 space-y-6">
|
||||||
{/* Subject */}
|
{/* Subject */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -531,7 +544,7 @@ export default function EditTemplate() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Tab */}
|
{/* Preview Tab */}
|
||||||
{activeTab === 'preview' && (
|
{activeTab === 'preview' && (
|
||||||
<div className="border rounded-md overflow-hidden">
|
<div className="border rounded-md overflow-hidden">
|
||||||
@@ -542,7 +555,7 @@ export default function EditTemplate() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Visual Tab */}
|
{/* Visual Tab */}
|
||||||
{activeTab === 'visual' && (
|
{activeTab === 'visual' && (
|
||||||
<div>
|
<div>
|
||||||
@@ -556,7 +569,7 @@ export default function EditTemplate() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Markdown Tab */}
|
{/* Markdown Tab */}
|
||||||
{activeTab === 'markdown' && (
|
{activeTab === 'markdown' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -577,6 +590,42 @@ export default function EditTemplate() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</SettingsLayout>
|
</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 p-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>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setTestEmailDialogOpen(false)}>
|
||||||
|
{__('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>
|
||||||
|
|||||||
@@ -96,12 +96,12 @@ export default function TemplateEditor({
|
|||||||
|
|
||||||
// Get variable keys for the rich text editor
|
// Get variable keys for the rich text editor
|
||||||
const variableKeys = Object.keys(variables);
|
const variableKeys = Object.keys(variables);
|
||||||
|
|
||||||
// Parse [card] tags for preview
|
// Parse [card] tags for preview
|
||||||
const parseCardsForPreview = (content: string) => {
|
const parseCardsForPreview = (content: string) => {
|
||||||
// Match [card ...] ... [/card] patterns
|
// Match [card ...] ... [/card] patterns
|
||||||
const cardRegex = /\[card([^\]]*)\](.*?)\[\/card\]/gs;
|
const cardRegex = /\[card([^\]]*)\](.*?)\[\/card\]/gs;
|
||||||
|
|
||||||
return content.replace(cardRegex, (match, attributes, cardContent) => {
|
return content.replace(cardRegex, (match, attributes, cardContent) => {
|
||||||
// Parse attributes
|
// Parse attributes
|
||||||
let cardClass = 'card';
|
let cardClass = 'card';
|
||||||
@@ -109,10 +109,10 @@ export default function TemplateEditor({
|
|||||||
if (typeMatch) {
|
if (typeMatch) {
|
||||||
cardClass += ` card-${typeMatch[1]}`;
|
cardClass += ` card-${typeMatch[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
||||||
const bgStyle = bgMatch ? `background-image: url(${bgMatch[1]}); background-size: cover; background-position: center;` : '';
|
const bgStyle = bgMatch ? `background-image: url(${bgMatch[1]}); background-size: cover; background-position: center;` : '';
|
||||||
|
|
||||||
return `<div class="${cardClass}" style="${bgStyle}">${cardContent}</div>`;
|
return `<div class="${cardClass}" style="${bgStyle}">${cardContent}</div>`;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -120,18 +120,18 @@ export default function TemplateEditor({
|
|||||||
// Generate preview HTML
|
// Generate preview HTML
|
||||||
const generatePreviewHTML = () => {
|
const generatePreviewHTML = () => {
|
||||||
let previewBody = body;
|
let previewBody = body;
|
||||||
|
|
||||||
// Replace store-identity variables with actual data
|
// Replace store-identity variables with actual data
|
||||||
const storeVariables: { [key: string]: string } = {
|
const storeVariables: { [key: string]: string } = {
|
||||||
store_name: 'My WordPress Store',
|
store_name: 'My WordPress Store',
|
||||||
store_url: window.location.origin,
|
site_url: window.location.origin,
|
||||||
store_email: 'store@example.com',
|
store_email: 'store@example.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.entries(storeVariables).forEach(([key, value]) => {
|
Object.entries(storeVariables).forEach(([key, value]) => {
|
||||||
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
|
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Highlight dynamic variables (non-store variables)
|
// Highlight dynamic variables (non-store variables)
|
||||||
Object.keys(variables).forEach(key => {
|
Object.keys(variables).forEach(key => {
|
||||||
if (!storeVariables[key]) {
|
if (!storeVariables[key]) {
|
||||||
@@ -139,10 +139,10 @@ export default function TemplateEditor({
|
|||||||
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), sampleValue);
|
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), sampleValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse [card] tags
|
// Parse [card] tags
|
||||||
previewBody = parseCardsForPreview(previewBody);
|
previewBody = parseCardsForPreview(previewBody);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -153,11 +153,11 @@ export default function TemplateEditor({
|
|||||||
.header { padding: 32px; text-align: center; background: #f8f8f8; }
|
.header { padding: 32px; text-align: center; background: #f8f8f8; }
|
||||||
.card-gutter { padding: 0 16px; }
|
.card-gutter { padding: 0 16px; }
|
||||||
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; padding: 32px 40px; }
|
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; padding: 32px 40px; }
|
||||||
.card-success { background: #e8f5e9; border: 1px solid #4caf50; }
|
.card-success { background-color: #f0fdf4; }
|
||||||
.card-highlight { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
|
.card-highlight { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
|
||||||
.card-highlight * { color: #fff !important; }
|
.card-highlight * { color: #fff !important; }
|
||||||
.card-info { background: #f0f7ff; border: 1px solid #0071e3; }
|
.card-info { background-color: #f0f7ff; }
|
||||||
.card-warning { background: #fff8e1; border: 1px solid #ff9800; }
|
.card-warning { background-color: #fff8e1; }
|
||||||
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||||
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||||
h3 { font-size: 16px; margin-top: 0; margin-bottom: 8px; color: #333; }
|
h3 { font-size: 16px; margin-top: 0; margin-bottom: 8px; color: #333; }
|
||||||
@@ -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')}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user