Compare commits
65 Commits
v1.0-pre-s
...
3357fbfcf1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3357fbfcf1 | ||
|
|
d3ec580ec8 | ||
|
|
942fb48a0b | ||
|
|
e04f1fd93f | ||
|
|
c6489b6b05 | ||
|
|
7a45b243cb | ||
|
|
0e561d9e8c | ||
|
|
e8c60b3a09 | ||
|
|
26faa008cb | ||
|
|
56b0040f7a | ||
|
|
533cf5e7d2 | ||
|
|
f518d7e589 | ||
|
|
f6b778c7fc | ||
|
|
906ad38a36 | ||
|
|
274c3d35e1 | ||
|
|
6694d9e0c4 | ||
|
|
2939ebfe6b | ||
|
|
786e01c8f6 | ||
|
|
83836298ec | ||
|
|
068fbe3a26 | ||
|
|
ab0eb3ab28 | ||
|
|
740cfcbb94 | ||
|
|
687e51654b | ||
|
|
a0e580878e | ||
|
|
e66f260e75 | ||
|
|
a52f5fc707 | ||
|
|
5170aea882 | ||
|
|
d262bd3ae8 | ||
|
|
9204189448 | ||
|
|
a4a055a98e | ||
|
|
d7b132d9d9 | ||
|
|
3a08e80c1f | ||
|
|
2cc20ff760 | ||
|
|
f334e018fa | ||
|
|
984f4e2db4 | ||
|
|
b44c8b767d | ||
|
|
2b94f26cae | ||
|
|
1cef11a1d2 | ||
|
|
40aee67c46 | ||
|
|
2efc6a7605 | ||
|
|
60d749cd65 | ||
|
|
26ab626966 | ||
|
|
3d2bab90ec | ||
|
|
b367c1fcf8 | ||
|
|
663e6c13e6 | ||
|
|
86dca3e9c2 | ||
|
|
51c759a4f5 | ||
|
|
6c8cbb93e6 | ||
|
|
0f542ad452 | ||
|
|
befacf9d29 | ||
|
|
d9878c8b20 | ||
|
|
d65259db8a | ||
|
|
54a1ec1c88 | ||
|
|
3a8c436839 | ||
|
|
bfb961ccbe | ||
|
|
f49dde9484 | ||
|
|
b64a979a61 | ||
|
|
0e38b0eb5f | ||
|
|
68c3423f50 | ||
|
|
1206117df1 | ||
|
|
7c2f21f7a2 | ||
|
|
7c15850c8f | ||
|
|
670bd7d351 | ||
|
|
75a82cf16c | ||
|
|
45fcbf9d29 |
241
.agent/plans/affiliate-module.md
Normal file
241
.agent/plans/affiliate-module.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Affiliate Module Plan
|
||||
|
||||
## Overview
|
||||
Referral tracking with hybrid customer/affiliate roles, integrated as a core plugin module.
|
||||
|
||||
---
|
||||
|
||||
## Module Architecture
|
||||
|
||||
### Core Features
|
||||
- **Affiliate registration**: Customers can become affiliates
|
||||
- **Approval workflow**: Manual or auto-approval of affiliates
|
||||
- **Unique referral links/codes**: Each affiliate gets unique tracking
|
||||
- **Commission tracking**: Track referrals and calculate earnings
|
||||
- **Tiered commission rates**: Different rates per product/category/affiliate level
|
||||
- **Payout management**: Track and process affiliate payouts
|
||||
- **Affiliate dashboard**: Self-service stats and link generator
|
||||
|
||||
### Hybrid Roles
|
||||
- A customer can also be an affiliate
|
||||
- No separate user type; affiliate data linked to existing user
|
||||
- Affiliates can still make purchases (self-referral rules configurable)
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Table: `woonoow_affiliates`
|
||||
```sql
|
||||
CREATE TABLE woonoow_affiliates (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL UNIQUE,
|
||||
status ENUM('pending', 'active', 'rejected', 'suspended') DEFAULT 'pending',
|
||||
referral_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
commission_rate DECIMAL(5,2) DEFAULT NULL, -- Override global rate
|
||||
tier_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
payment_email VARCHAR(255) DEFAULT NULL,
|
||||
payment_method VARCHAR(50) DEFAULT NULL,
|
||||
total_earnings DECIMAL(15,2) DEFAULT 0,
|
||||
total_unpaid DECIMAL(15,2) DEFAULT 0,
|
||||
total_paid DECIMAL(15,2) DEFAULT 0,
|
||||
referral_count INT UNSIGNED DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_referral_code (referral_code)
|
||||
);
|
||||
```
|
||||
|
||||
### Table: `woonoow_referrals`
|
||||
```sql
|
||||
CREATE TABLE woonoow_referrals (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
affiliate_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
customer_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
subtotal DECIMAL(15,2) NOT NULL,
|
||||
commission_rate DECIMAL(5,2) NOT NULL,
|
||||
commission_amount DECIMAL(15,2) NOT NULL,
|
||||
status ENUM('pending', 'approved', 'rejected', 'paid') DEFAULT 'pending',
|
||||
referral_type ENUM('link', 'code', 'coupon') DEFAULT 'link',
|
||||
ip_address VARCHAR(45) DEFAULT NULL,
|
||||
user_agent TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
approved_at DATETIME DEFAULT NULL,
|
||||
paid_at DATETIME DEFAULT NULL,
|
||||
INDEX idx_affiliate (affiliate_id),
|
||||
INDEX idx_order (order_id),
|
||||
INDEX idx_status (status)
|
||||
);
|
||||
```
|
||||
|
||||
### Table: `woonoow_payouts`
|
||||
```sql
|
||||
CREATE TABLE woonoow_payouts (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
affiliate_id BIGINT UNSIGNED NOT NULL,
|
||||
amount DECIMAL(15,2) NOT NULL,
|
||||
method VARCHAR(50) DEFAULT NULL,
|
||||
reference VARCHAR(255) DEFAULT NULL, -- Bank ref, PayPal transaction, etc.
|
||||
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME DEFAULT NULL,
|
||||
INDEX idx_affiliate (affiliate_id),
|
||||
INDEX idx_status (status)
|
||||
);
|
||||
```
|
||||
|
||||
### Table: `woonoow_affiliate_tiers` (Optional)
|
||||
```sql
|
||||
CREATE TABLE woonoow_affiliate_tiers (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
commission_rate DECIMAL(5,2) NOT NULL,
|
||||
min_referrals INT UNSIGNED DEFAULT 0, -- Auto-promote at X referrals
|
||||
min_earnings DECIMAL(15,2) DEFAULT 0, -- Auto-promote at X earnings
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Structure
|
||||
|
||||
```
|
||||
includes/Modules/Affiliate/
|
||||
├── AffiliateModule.php # Bootstrap, hooks, tracking
|
||||
├── AffiliateManager.php # Core logic
|
||||
├── ReferralTracker.php # Track referral cookies/links
|
||||
└── AffiliateSettings.php # Settings schema
|
||||
|
||||
includes/Api/AffiliatesController.php # REST endpoints
|
||||
```
|
||||
|
||||
### AffiliateManager Methods
|
||||
```php
|
||||
- register($user_id) # Register user as affiliate
|
||||
- approve($affiliate_id) # Approve pending affiliate
|
||||
- reject($affiliate_id, $reason) # Reject application
|
||||
- suspend($affiliate_id) # Suspend affiliate
|
||||
- track_referral($order, $affiliate) # Create referral record
|
||||
- calculate_commission($order, $affiliate) # Calculate earnings
|
||||
- approve_referral($referral_id) # Approve pending referral
|
||||
- create_payout($affiliate_id, $amount) # Create payout request
|
||||
- process_payout($payout_id) # Mark payout complete
|
||||
- get_affiliate_stats($affiliate_id) # Dashboard stats
|
||||
```
|
||||
|
||||
### Referral Tracking
|
||||
1. Affiliate shares link: `yourstore.com/?ref=CODE`
|
||||
2. ReferralTracker sets cookie: `wnw_ref=CODE` (30 days default)
|
||||
3. On checkout, check cookie and link to affiliate
|
||||
4. On order completion, create referral record
|
||||
|
||||
---
|
||||
|
||||
## REST API Endpoints
|
||||
|
||||
### Admin Endpoints
|
||||
```
|
||||
GET /affiliates # List affiliates
|
||||
GET /affiliates/{id} # Affiliate details
|
||||
PUT /affiliates/{id} # Update affiliate
|
||||
POST /affiliates/{id}/approve # Approve affiliate
|
||||
POST /affiliates/{id}/reject # Reject affiliate
|
||||
GET /affiliates/referrals # All referrals
|
||||
GET /affiliates/payouts # All payouts
|
||||
POST /affiliates/payouts/{id}/complete # Complete payout
|
||||
```
|
||||
|
||||
### Customer/Affiliate Endpoints
|
||||
```
|
||||
GET /my-affiliate # Check if affiliate
|
||||
POST /my-affiliate/register # Register as affiliate
|
||||
GET /my-affiliate/stats # Dashboard stats
|
||||
GET /my-affiliate/referrals # My referrals
|
||||
GET /my-affiliate/payouts # My payouts
|
||||
POST /my-affiliate/payout-request # Request payout
|
||||
GET /my-affiliate/links # Generate referral links
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Admin SPA
|
||||
|
||||
### Affiliates List (`/marketing/affiliates`)
|
||||
- Table: Name, Email, Status, Referrals, Earnings, Actions
|
||||
- Filters: Status, Date range
|
||||
- Actions: Approve, Reject, View
|
||||
|
||||
### Affiliate Detail (`/marketing/affiliates/:id`)
|
||||
- Affiliate info card
|
||||
- Stats summary
|
||||
- Referrals list
|
||||
- Payouts history
|
||||
- Action buttons
|
||||
|
||||
### Referrals List (`/marketing/affiliates/referrals`)
|
||||
- All referrals across affiliates
|
||||
- Filters: Status, Affiliate, Date
|
||||
|
||||
### Payouts (`/marketing/affiliates/payouts`)
|
||||
- Payout requests
|
||||
- Process payouts
|
||||
- Payment history
|
||||
|
||||
---
|
||||
|
||||
## Customer SPA
|
||||
|
||||
### Become an Affiliate (`/my-account/affiliate`)
|
||||
- Registration form (if not affiliate)
|
||||
- Dashboard (if affiliate)
|
||||
|
||||
### Affiliate Dashboard
|
||||
- Stats: Total Referrals, Pending, Approved, Earnings
|
||||
- Referral link generator
|
||||
- Recent referrals
|
||||
- Payout request button
|
||||
|
||||
### My Referrals
|
||||
- List of referrals with status
|
||||
- Commission amount
|
||||
|
||||
### My Payouts
|
||||
- Payout history
|
||||
- Pending amount
|
||||
- Request payout form
|
||||
|
||||
---
|
||||
|
||||
## Settings Schema
|
||||
|
||||
```php
|
||||
return [
|
||||
'enabled' => true,
|
||||
'registration_type' => 'open', // open, approval, invite
|
||||
'auto_approve' => false,
|
||||
'default_commission_rate' => 10, // 10%
|
||||
'commission_type' => 'percentage', // percentage, flat
|
||||
'cookie_duration' => 30, // days
|
||||
'min_payout_amount' => 50,
|
||||
'payout_methods' => ['bank_transfer', 'paypal'],
|
||||
'allow_self_referral' => false,
|
||||
'referral_approval' => 'auto', // auto, manual
|
||||
'approval_delay_days' => 14, // Wait X days before auto-approve
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
1. Database tables and AffiliateManager
|
||||
2. ReferralTracker (cookie-based tracking)
|
||||
3. Order hook to create referrals
|
||||
4. Admin SPA affiliates management
|
||||
5. Customer SPA affiliate dashboard
|
||||
6. Payout management
|
||||
7. Tier system (optional)
|
||||
84
.agent/plans/shipping-label.md
Normal file
84
.agent/plans/shipping-label.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Shipping Label Plan
|
||||
|
||||
## Overview
|
||||
Standardized waybill data structure for shipping label generation.
|
||||
|
||||
## Problem
|
||||
- Different shipping carrier addons (JNE, JNT, SiCepat, etc.) store data differently
|
||||
- No standard structure for label generation
|
||||
- Label button needs waybill data to function
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### 1. Standardized Meta Key
|
||||
Order meta: `_shipping_waybill`
|
||||
|
||||
### 2. Data Structure
|
||||
```json
|
||||
{
|
||||
"tracking_number": "JNE123456789",
|
||||
"carrier": "jne",
|
||||
"carrier_name": "JNE Express",
|
||||
"service": "REG",
|
||||
"estimated_days": 3,
|
||||
"sender": {
|
||||
"name": "Store Name",
|
||||
"address": "Full address line 1",
|
||||
"city": "Jakarta",
|
||||
"postcode": "12345",
|
||||
"phone": "08123456789"
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Customer Name",
|
||||
"address": "Full address line 1",
|
||||
"city": "Bandung",
|
||||
"postcode": "40123",
|
||||
"phone": "08987654321"
|
||||
},
|
||||
"package": {
|
||||
"weight": "1.5",
|
||||
"weight_unit": "kg",
|
||||
"dimensions": "20x15x10",
|
||||
"dimensions_unit": "cm"
|
||||
},
|
||||
"label_url": null,
|
||||
"barcode": "JNE123456789",
|
||||
"barcode_type": "128",
|
||||
"created_at": "2026-01-05T12:00:00+07:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Addon Integration Contract
|
||||
|
||||
Shipping addons MUST:
|
||||
1. Call `update_post_meta($order_id, '_shipping_waybill', $waybill_data)`
|
||||
2. Use the standard structure above
|
||||
3. Set `label_url` if carrier provides downloadable PDF
|
||||
4. Set `barcode` for local label generation
|
||||
|
||||
### 4. Label Button Behavior
|
||||
1. Check if `_shipping_waybill` meta exists on order
|
||||
2. If `label_url` → open carrier's PDF
|
||||
3. Otherwise → generate printable label from meta data
|
||||
|
||||
### 5. UI Behavior
|
||||
- Label button hidden if order is virtual-only
|
||||
- Label button shows "Generate Label" if no waybill yet
|
||||
- Label button shows "Print Label" if waybill exists
|
||||
|
||||
## API Endpoint (Future)
|
||||
```
|
||||
POST /woonoow/v1/orders/{id}/generate-waybill
|
||||
- Calls shipping carrier API
|
||||
- Stores waybill in standardized format
|
||||
- Returns waybill data
|
||||
|
||||
GET /woonoow/v1/orders/{id}/waybill
|
||||
- Returns current waybill data
|
||||
```
|
||||
|
||||
## Implementation Priority
|
||||
1. Define standard structure (this document)
|
||||
2. Implement Label UI conditional logic
|
||||
3. Create waybill API endpoint
|
||||
4. Document for addon developers
|
||||
191
.agent/plans/subscription-module.md
Normal file
191
.agent/plans/subscription-module.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Subscription Module Plan
|
||||
|
||||
## Overview
|
||||
Recurring product subscriptions with flexible billing, integrated as a core plugin module (like Newsletter/Wishlist/Licensing).
|
||||
|
||||
---
|
||||
|
||||
## Module Architecture
|
||||
|
||||
### Core Features
|
||||
- **Recurring billing**: Weekly, monthly, yearly, custom intervals
|
||||
- **Free trials**: X days free before billing starts
|
||||
- **Sign-up fees**: One-time fee on first subscription
|
||||
- **Automatic renewals**: Process payment on renewal date
|
||||
- **Manual renewal**: Allow customers to renew manually
|
||||
- **Proration**: Calculate prorated amounts on plan changes
|
||||
- **Pause/Resume**: Allow customers to pause subscriptions
|
||||
|
||||
### Product Integration
|
||||
- Checkbox under "Additional Options": **Enable subscription for this product**
|
||||
- When enabled, show subscription settings:
|
||||
- Billing period (weekly/monthly/yearly/custom)
|
||||
- Billing interval (every X periods)
|
||||
- Free trial days
|
||||
- Sign-up fee
|
||||
- Subscription length (0 = unlimited)
|
||||
- Variable products: Variation-level subscription settings (different durations/prices per variation)
|
||||
|
||||
### Integration with Licensing
|
||||
- Licenses can be bound to subscriptions
|
||||
- When subscription is active → license is valid
|
||||
- When subscription expires/cancelled → license is revoked
|
||||
- Auto-renewal keeps license active
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Table: `woonoow_subscriptions`
|
||||
```sql
|
||||
CREATE TABLE woonoow_subscriptions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
product_id BIGINT UNSIGNED NOT NULL,
|
||||
variation_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
status ENUM('pending', 'active', 'on-hold', 'cancelled', 'expired', 'pending-cancel') DEFAULT 'pending',
|
||||
billing_period ENUM('day', 'week', 'month', 'year') NOT NULL,
|
||||
billing_interval INT UNSIGNED DEFAULT 1,
|
||||
start_date DATETIME NOT NULL,
|
||||
trial_end_date DATETIME DEFAULT NULL,
|
||||
next_payment_date DATETIME DEFAULT NULL,
|
||||
end_date DATETIME DEFAULT NULL,
|
||||
last_payment_date DATETIME DEFAULT NULL,
|
||||
payment_method VARCHAR(100) DEFAULT NULL,
|
||||
payment_meta LONGTEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_order_id (order_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_next_payment (next_payment_date)
|
||||
);
|
||||
```
|
||||
|
||||
### Table: `woonoow_subscription_orders`
|
||||
Links subscription to renewal orders:
|
||||
```sql
|
||||
CREATE TABLE woonoow_subscription_orders (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
subscription_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
order_type ENUM('parent', 'renewal', 'switch', 'resubscribe') DEFAULT 'renewal',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_subscription (subscription_id),
|
||||
INDEX idx_order (order_id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Structure
|
||||
|
||||
```
|
||||
includes/Modules/Subscription/
|
||||
├── SubscriptionModule.php # Bootstrap, hooks, product meta
|
||||
├── SubscriptionManager.php # Core logic
|
||||
├── SubscriptionScheduler.php # Cron jobs for renewals
|
||||
└── SubscriptionSettings.php # Settings schema
|
||||
|
||||
includes/Api/SubscriptionsController.php # REST endpoints
|
||||
```
|
||||
|
||||
### SubscriptionManager Methods
|
||||
```php
|
||||
- create($order, $product, $user) # Create subscription from order
|
||||
- renew($subscription_id) # Process renewal
|
||||
- cancel($subscription_id, $reason) # Cancel subscription
|
||||
- pause($subscription_id) # Pause subscription
|
||||
- resume($subscription_id) # Resume paused subscription
|
||||
- switch($subscription_id, $new_product) # Switch plan
|
||||
- get_next_payment_date($subscription) # Calculate next date
|
||||
- process_renewal_payment($subscription) # Charge payment
|
||||
```
|
||||
|
||||
### Cron Jobs
|
||||
- `woonoow_process_subscription_renewals`: Run daily, process due renewals
|
||||
- `woonoow_check_expired_subscriptions`: Mark expired subscriptions
|
||||
|
||||
---
|
||||
|
||||
## REST API Endpoints
|
||||
|
||||
### Admin Endpoints
|
||||
```
|
||||
GET /subscriptions # List all subscriptions
|
||||
GET /subscriptions/{id} # Get subscription details
|
||||
PUT /subscriptions/{id} # Update subscription
|
||||
POST /subscriptions/{id}/cancel # Cancel subscription
|
||||
POST /subscriptions/{id}/renew # Force renewal
|
||||
```
|
||||
|
||||
### Customer Endpoints
|
||||
```
|
||||
GET /my-subscriptions # Customer's subscriptions
|
||||
GET /my-subscriptions/{id} # Subscription detail
|
||||
POST /my-subscriptions/{id}/cancel # Request cancellation
|
||||
POST /my-subscriptions/{id}/pause # Pause subscription
|
||||
POST /my-subscriptions/{id}/resume # Resume subscription
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Admin SPA
|
||||
|
||||
### Subscriptions List (`/subscriptions`)
|
||||
- Table: ID, Customer, Product, Status, Next Payment, Actions
|
||||
- Filters: Status, Product, Date range
|
||||
- Actions: View, Cancel, Renew
|
||||
|
||||
### Subscription Detail (`/subscriptions/:id`)
|
||||
- Subscription info card
|
||||
- Related orders list
|
||||
- Payment history
|
||||
- Action buttons: Cancel, Pause, Renew
|
||||
|
||||
---
|
||||
|
||||
## Customer SPA
|
||||
|
||||
### My Subscriptions (`/my-account/subscriptions`)
|
||||
- List of active/past subscriptions
|
||||
- Status badges
|
||||
- Next payment info
|
||||
- Actions: Cancel, Pause, View
|
||||
|
||||
### Subscription Detail
|
||||
- Product info
|
||||
- Billing schedule
|
||||
- Payment history
|
||||
- Management actions
|
||||
|
||||
---
|
||||
|
||||
## Settings Schema
|
||||
|
||||
```php
|
||||
return [
|
||||
'default_status' => 'active',
|
||||
'button_text_subscribe' => 'Subscribe Now',
|
||||
'button_text_renew' => 'Renew Subscription',
|
||||
'allow_customer_cancel' => true,
|
||||
'allow_customer_pause' => true,
|
||||
'max_pause_count' => 3,
|
||||
'renewal_retry_days' => [1, 3, 5], // Retry failed payments
|
||||
'expire_after_failed_attempts' => 3,
|
||||
'send_renewal_reminder' => true,
|
||||
'reminder_days_before' => 3,
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
1. Database tables and SubscriptionManager
|
||||
2. Product meta fields for subscription settings
|
||||
3. Order hook to create subscription
|
||||
4. Renewal cron job
|
||||
5. Admin SPA list/detail pages
|
||||
6. Customer SPA pages
|
||||
7. Integration with Licensing module
|
||||
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
|
||||
1472
admin-spa/package-lock.json
generated
1472
admin-spa/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -49,8 +49,10 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"recharts": "^3.3.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
|
||||
@@ -13,12 +13,16 @@ import OrdersIndex from '@/routes/Orders';
|
||||
import OrderNew from '@/routes/Orders/New';
|
||||
import OrderEdit from '@/routes/Orders/Edit';
|
||||
import OrderDetail from '@/routes/Orders/Detail';
|
||||
import OrderInvoice from '@/routes/Orders/Invoice';
|
||||
import OrderLabel from '@/routes/Orders/Label';
|
||||
import ProductsIndex from '@/routes/Products';
|
||||
import ProductNew from '@/routes/Products/New';
|
||||
import ProductEdit from '@/routes/Products/Edit';
|
||||
import ProductCategories from '@/routes/Products/Categories';
|
||||
import ProductTags from '@/routes/Products/Tags';
|
||||
import ProductAttributes from '@/routes/Products/Attributes';
|
||||
import Licenses from '@/routes/Products/Licenses';
|
||||
import LicenseDetail from '@/routes/Products/Licenses/Detail';
|
||||
import CouponsIndex from '@/routes/Marketing/Coupons';
|
||||
import CouponNew from '@/routes/Marketing/Coupons/New';
|
||||
import CouponEdit from '@/routes/Marketing/Coupons/Edit';
|
||||
@@ -194,7 +198,7 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
|
||||
const navTree = (window as any).WNW_NAV_TREE || [];
|
||||
|
||||
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">
|
||||
{navTree.map((item: any) => {
|
||||
const IconComponent = iconMap[item.icon] || Package;
|
||||
@@ -243,6 +247,7 @@ import EmailConfiguration from '@/routes/Settings/Notifications/EmailConfigurati
|
||||
import PushConfiguration from '@/routes/Settings/Notifications/PushConfiguration';
|
||||
import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomization';
|
||||
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
|
||||
import ActivityLog from '@/routes/Settings/Notifications/ActivityLog';
|
||||
import SettingsDeveloper from '@/routes/Settings/Developer';
|
||||
import SettingsModules from '@/routes/Settings/Modules';
|
||||
import ModuleSettings from '@/routes/Settings/ModuleSettings';
|
||||
@@ -257,10 +262,10 @@ import AppearanceCheckout from '@/routes/Appearance/Checkout';
|
||||
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||
import MarketingIndex from '@/routes/Marketing';
|
||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
|
||||
import CampaignsList from '@/routes/Marketing/Campaigns';
|
||||
import Newsletter from '@/routes/Marketing/Newsletter';
|
||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||
import MorePage from '@/routes/More';
|
||||
import Help from '@/routes/Help';
|
||||
|
||||
// Addon Route Component - Dynamically loads addon components
|
||||
function AddonRoute({ config }: { config: any }) {
|
||||
@@ -462,6 +467,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
||||
>
|
||||
<span>{__('WordPress')}</span>
|
||||
</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
|
||||
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"
|
||||
@@ -471,6 +487,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
||||
</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 />
|
||||
{showToggle && (
|
||||
<button
|
||||
@@ -518,12 +545,16 @@ function AppRoutes() {
|
||||
<Route path="/products/categories" element={<ProductCategories />} />
|
||||
<Route path="/products/tags" element={<ProductTags />} />
|
||||
<Route path="/products/attributes" element={<ProductAttributes />} />
|
||||
<Route path="/products/licenses" element={<Licenses />} />
|
||||
<Route path="/products/licenses/:id" element={<LicenseDetail />} />
|
||||
|
||||
{/* Orders */}
|
||||
<Route path="/orders" element={<OrdersIndex />} />
|
||||
<Route path="/orders/new" element={<OrderNew />} />
|
||||
<Route path="/orders/:id" element={<OrderDetail />} />
|
||||
<Route path="/orders/:id/edit" element={<OrderEdit />} />
|
||||
<Route path="/orders/:id/invoice" element={<OrderInvoice />} />
|
||||
<Route path="/orders/:id/label" element={<OrderLabel />} />
|
||||
|
||||
{/* Coupons (under Marketing) */}
|
||||
<Route path="/coupons" element={<CouponsIndex />} />
|
||||
@@ -560,6 +591,7 @@ function AppRoutes() {
|
||||
<Route path="/settings/notifications/channels/push" element={<PushConfiguration />} />
|
||||
<Route path="/settings/notifications/email-customization" element={<EmailCustomization />} />
|
||||
<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/developer" element={<SettingsDeveloper />} />
|
||||
<Route path="/settings/modules" element={<SettingsModules />} />
|
||||
@@ -579,9 +611,11 @@ function AppRoutes() {
|
||||
|
||||
{/* Marketing */}
|
||||
<Route path="/marketing" element={<MarketingIndex />} />
|
||||
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
|
||||
<Route path="/marketing/campaigns" element={<CampaignsList />} />
|
||||
<Route path="/marketing/campaigns/:id" element={<CampaignEdit />} />
|
||||
<Route path="/marketing/newsletter" element={<Newsletter />} />
|
||||
<Route path="/marketing/newsletter/campaigns/:id" element={<CampaignEdit />} />
|
||||
|
||||
{/* Help - Main menu route with no submenu */}
|
||||
<Route path="/help" element={<Help />} />
|
||||
|
||||
{/* Dynamic Addon Routes */}
|
||||
{addonRoutes.map((route: any) => (
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Portal container={document.getElementById("woonoow-admin-app")}>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
@@ -63,9 +64,22 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* { @apply border-border; }
|
||||
body { @apply bg-background text-foreground; }
|
||||
h1, h2, h3, h4, h5, h6 { @apply text-foreground; }
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
/* Override WordPress common.css focus/active styles */
|
||||
a:focus,
|
||||
@@ -126,11 +140,14 @@
|
||||
|
||||
/* Page defaults for print */
|
||||
@page {
|
||||
size: auto; /* let the browser choose */
|
||||
margin: 12mm; /* comfortable default */
|
||||
size: auto;
|
||||
/* let the browser choose */
|
||||
margin: 12mm;
|
||||
/* comfortable default */
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
||||
/* Hide WordPress admin chrome */
|
||||
#adminmenuback,
|
||||
#adminmenuwrap,
|
||||
@@ -139,44 +156,173 @@
|
||||
#wpfooter,
|
||||
#screen-meta,
|
||||
.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 */
|
||||
html, body, #wpwrap, #wpcontent { background: #fff !important; margin: 0 !important; padding: 0 !important; }
|
||||
#woonoow-admin-app, #woonoow-admin-app > div { margin: 0 !important; padding: 0 !important; max-width: 100% !important; }
|
||||
html,
|
||||
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 */
|
||||
.no-print { display: none !important; }
|
||||
.print-only { display: block !important; }
|
||||
/* Ensure print content is visible and takes full page */
|
||||
.print-a4 {
|
||||
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 */
|
||||
.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 */
|
||||
.print-only { display: none; }
|
||||
.print-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 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 .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) */
|
||||
.print-a4 { }
|
||||
.print-letter { }
|
||||
.print-4x6 { }
|
||||
.print-a4 {}
|
||||
|
||||
.print-letter {}
|
||||
|
||||
.print-4x6 {}
|
||||
|
||||
@media print {
|
||||
.print-a4 { }
|
||||
.print-letter { }
|
||||
|
||||
/* A4 Invoice layout */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.print-a4 {
|
||||
width: 210mm !important;
|
||||
min-height: 297mm !important;
|
||||
padding: 20mm !important;
|
||||
margin: 0 auto !important;
|
||||
box-sizing: border-box !important;
|
||||
background: white !important;
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
.print-a4 * {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
/* Ensure backgrounds print */
|
||||
.print-a4 .bg-gray-50 {
|
||||
background-color: #f9fafb !important;
|
||||
}
|
||||
|
||||
.print-a4 .bg-gray-900 {
|
||||
background-color: #111827 !important;
|
||||
}
|
||||
|
||||
.print-a4 .text-white {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.print-letter {}
|
||||
|
||||
/* Thermal label (4x6in) with minimal margins */
|
||||
.print-4x6 { width: 6in; }
|
||||
.print-4x6 * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.print-4x6 {
|
||||
width: 6in;
|
||||
}
|
||||
|
||||
.print-4x6 * {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- WooNooW: Popper menus & fullscreen fixes --- */
|
||||
[data-radix-popper-content-wrapper] { z-index: 2147483647 !important; }
|
||||
body.woonoow-fullscreen .woonoow-app { overflow: visible; }
|
||||
[data-radix-popper-content-wrapper] {
|
||||
z-index: 2147483647 !important;
|
||||
}
|
||||
|
||||
body.woonoow-fullscreen .woonoow-app {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* --- WooCommerce Admin Notices --- */
|
||||
.woocommerce-message,
|
||||
|
||||
167
admin-spa/src/routes/Help/DocContent.tsx
Normal file
167
admin-spa/src/routes/Help/DocContent.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import type { DocContent as DocContentType } from './types';
|
||||
|
||||
interface DocContentProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default function DocContent({ slug }: DocContentProps) {
|
||||
const [doc, setDoc] = useState<DocContentType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDoc = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const nonce = (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '';
|
||||
const response = await fetch(`/wp-json/woonoow/v1/docs/${slug}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setDoc(data.doc);
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to load document');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load document');
|
||||
setDoc(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDoc();
|
||||
}, [slug]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-32 w-full mt-6" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="prose prose-slate dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// Custom heading with anchor links
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-3xl font-bold mb-6 pb-4 border-b">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-xl font-medium mt-8 mb-3">{children}</h3>
|
||||
),
|
||||
// Styled tables
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto my-6">
|
||||
<table className="min-w-full border-collapse border border-border rounded-lg">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-border bg-muted px-4 py-2 text-left font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-border px-4 py-2">{children}</td>
|
||||
),
|
||||
// Styled code blocks
|
||||
code: ({ className, children }) => {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className={className}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-muted p-4 rounded-lg overflow-x-auto my-4">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
// Styled blockquotes for notes
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary bg-primary/5 pl-4 py-2 my-4 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
// Links
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary hover:underline"
|
||||
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// Lists
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-6 my-4 space-y-2">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-6 my-4 space-y-2">{children}</ol>
|
||||
),
|
||||
// Horizontal rule
|
||||
hr: () => <hr className="my-8 border-border" />,
|
||||
}}
|
||||
>
|
||||
{doc.content}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
212
admin-spa/src/routes/Help/index.tsx
Normal file
212
admin-spa/src/routes/Help/index.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Book, ChevronRight, FileText, Settings, Layers, Puzzle, Menu, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import DocContent from './DocContent';
|
||||
import type { DocSection } from './types';
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
'book-open': <Book className="w-4 h-4" />,
|
||||
'file-text': <FileText className="w-4 h-4" />,
|
||||
'settings': <Settings className="w-4 h-4" />,
|
||||
'layers': <Layers className="w-4 h-4" />,
|
||||
'puzzle': <Puzzle className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
export default function Help() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [sections, setSections] = useState<DocSection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const currentSlug = searchParams.get('doc') || 'getting-started';
|
||||
|
||||
// Fetch documentation registry
|
||||
useEffect(() => {
|
||||
const fetchDocs = async () => {
|
||||
try {
|
||||
const nonce = (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '';
|
||||
const response = await fetch('/wp-json/woonoow/v1/docs', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setSections(data.sections);
|
||||
const expanded: Record<string, boolean> = {};
|
||||
data.sections.forEach((section: DocSection) => {
|
||||
expanded[section.key] = true;
|
||||
});
|
||||
setExpandedSections(expanded);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch docs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDocs();
|
||||
}, []);
|
||||
|
||||
const toggleSection = (key: string) => {
|
||||
setExpandedSections(prev => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
const selectDoc = (slug: string) => {
|
||||
setSearchParams({ doc: slug });
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
const isActive = (slug: string) => slug === currentSlug;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile menu button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden fixed bottom-20 right-4 z-50 bg-primary text-primary-foreground shadow-lg rounded-full w-12 h-12"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</Button>
|
||||
|
||||
{/* Backdrop for mobile sidebar */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar - fixed overlay */}
|
||||
<aside
|
||||
className={cn(
|
||||
"lg:hidden fixed left-0 top-0 bottom-0 z-40 w-72 bg-background border-r overflow-y-auto",
|
||||
sidebarOpen ? "block" : "hidden"
|
||||
)}
|
||||
>
|
||||
<SidebarContent
|
||||
loading={loading}
|
||||
sections={sections}
|
||||
expandedSections={expandedSections}
|
||||
toggleSection={toggleSection}
|
||||
selectDoc={selectDoc}
|
||||
isActive={isActive}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Desktop layout - simple flexbox, no sticky */}
|
||||
<div className="hidden lg:flex gap-0">
|
||||
{/* Desktop sidebar - flex-shrink-0 keeps it visible */}
|
||||
<aside className="w-72 flex-shrink-0 border-r bg-muted/30 min-h-[600px]">
|
||||
<SidebarContent
|
||||
loading={loading}
|
||||
sections={sections}
|
||||
expandedSections={expandedSections}
|
||||
toggleSection={toggleSection}
|
||||
selectDoc={selectDoc}
|
||||
isActive={isActive}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Desktop content */}
|
||||
<main className="flex-1 min-w-0">
|
||||
<div className="max-w-4xl mx-auto py-6 px-10">
|
||||
<DocContent slug={currentSlug} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile content - shown when sidebar is hidden */}
|
||||
<div className="lg:hidden">
|
||||
<div className="max-w-4xl mx-auto py-6 px-6">
|
||||
<DocContent slug={currentSlug} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Extracted sidebar content to avoid duplication
|
||||
function SidebarContent({
|
||||
loading,
|
||||
sections,
|
||||
expandedSections,
|
||||
toggleSection,
|
||||
selectDoc,
|
||||
isActive,
|
||||
}: {
|
||||
loading: boolean;
|
||||
sections: DocSection[];
|
||||
expandedSections: Record<string, boolean>;
|
||||
toggleSection: (key: string) => void;
|
||||
selectDoc: (slug: string) => void;
|
||||
isActive: (slug: string) => boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="p-4 border-b bg-muted/30">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Book className="w-5 h-5" />
|
||||
Documentation
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">Help & Guides</p>
|
||||
</div>
|
||||
|
||||
<nav className="p-2">
|
||||
{loading ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
|
||||
) : sections.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">No documentation available</div>
|
||||
) : (
|
||||
sections.map((section) => (
|
||||
<div key={section.key} className="mb-2">
|
||||
<button
|
||||
onClick={() => toggleSection(section.key)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-foreground hover:bg-muted rounded-md"
|
||||
>
|
||||
{iconMap[section.icon] || <FileText className="w-4 h-4" />}
|
||||
<span className="flex-1 text-left">{section.label}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"w-4 h-4 transition-transform",
|
||||
expandedSections[section.key] && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{expandedSections[section.key] && (
|
||||
<div className="ml-4 mt-1 space-y-1">
|
||||
{section.items.map((item) => (
|
||||
<button
|
||||
key={item.slug}
|
||||
onClick={() => selectDoc(item.slug)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-1.5 text-sm rounded-md transition-colors",
|
||||
isActive(item.slug)
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
admin-spa/src/routes/Help/types.ts
Normal file
31
admin-spa/src/routes/Help/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Documentation Types
|
||||
*/
|
||||
|
||||
export interface DocItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface DocSection {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
items: DocItem[];
|
||||
}
|
||||
|
||||
export interface DocContent {
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DocsRegistryResponse {
|
||||
success: boolean;
|
||||
sections: DocSection[];
|
||||
}
|
||||
|
||||
export interface DocContentResponse {
|
||||
success: boolean;
|
||||
doc: DocContent;
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Download, Trash2, Mail, Search } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
export default function NewsletterSubscribers() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { isEnabled } = useModules();
|
||||
|
||||
// Always call ALL hooks before any conditional returns
|
||||
const { data: subscribersData, isLoading } = useQuery({
|
||||
queryKey: ['newsletter-subscribers'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/newsletter/subscribers');
|
||||
return response.data;
|
||||
},
|
||||
enabled: isEnabled('newsletter'), // Only fetch when module is enabled
|
||||
});
|
||||
|
||||
const deleteSubscriber = useMutation({
|
||||
mutationFn: async (email: string) => {
|
||||
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
|
||||
toast.success('Subscriber removed successfully');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to remove subscriber');
|
||||
},
|
||||
});
|
||||
|
||||
const exportSubscribers = () => {
|
||||
if (!subscribersData?.subscribers) return;
|
||||
|
||||
const csv = ['Email,Subscribed Date'].concat(
|
||||
subscribersData.subscribers.map((sub: any) =>
|
||||
`${sub.email},${sub.subscribed_at || 'N/A'}`
|
||||
)
|
||||
).join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `newsletter-subscribers-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const subscribers = subscribersData?.subscribers || [];
|
||||
const filteredSubscribers = subscribers.filter((sub: any) =>
|
||||
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
if (!isEnabled('newsletter')) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Newsletter Subscribers"
|
||||
description="Newsletter module is disabled"
|
||||
>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
|
||||
<Mail className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-lg mb-2">Newsletter Module Disabled</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The newsletter module is currently disabled. Enable it in Settings > Modules to use this feature.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/settings/modules')}>
|
||||
Go to Module Settings
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Newsletter Subscribers"
|
||||
description="Manage your newsletter subscribers and send campaigns"
|
||||
>
|
||||
<SettingsCard
|
||||
title="Subscribers List"
|
||||
description={`Total subscribers: ${subscribersData?.count || 0}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Actions Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Filter subscribers..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={exportSubscribers} variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Send Campaign
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscribers Table */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Loading subscribers...
|
||||
</div>
|
||||
) : filteredSubscribers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{searchQuery ? 'No subscribers found matching your search' : 'No subscribers yet'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Subscribed Date</TableHead>
|
||||
<TableHead>WP User</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSubscribers.map((subscriber: any) => (
|
||||
<TableRow key={subscriber.email}>
|
||||
<TableCell className="font-medium">{subscriber.email}</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{subscriber.status || 'Active'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{subscriber.subscribed_at
|
||||
? new Date(subscriber.subscribed_at).toLocaleDateString()
|
||||
: 'N/A'
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{subscriber.user_id ? (
|
||||
<span className="text-xs text-blue-600">Yes (ID: {subscriber.user_id})</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteSubscriber.mutate(subscriber.email)}
|
||||
disabled={deleteSubscriber.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Email Template Settings */}
|
||||
<SettingsCard
|
||||
title="Email Templates"
|
||||
description="Customize newsletter email templates using the email builder"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border rounded-lg bg-muted/50">
|
||||
<h4 className="font-medium mb-2">Newsletter Welcome Email</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Welcome email sent when someone subscribes to your newsletter
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_welcome&channel=email&recipient=customer')}
|
||||
>
|
||||
Edit Template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg bg-muted/50">
|
||||
<h4 className="font-medium mb-2">New Subscriber Notification (Admin)</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Admin notification when someone subscribes to newsletter
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_subscribed_admin&channel=email&recipient=staff')}
|
||||
>
|
||||
Edit Template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
289
admin-spa/src/routes/Marketing/Newsletter/Campaigns.tsx
Normal file
289
admin-spa/src/routes/Marketing/Newsletter/Campaigns.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
Edit,
|
||||
MoreHorizontal,
|
||||
Copy
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface Campaign {
|
||||
id: number;
|
||||
title: string;
|
||||
subject: string;
|
||||
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed';
|
||||
recipient_count: number;
|
||||
sent_count: number;
|
||||
failed_count: number;
|
||||
scheduled_at: string | null;
|
||||
sent_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
draft: { label: 'Draft', icon: Edit, className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300' },
|
||||
scheduled: { label: 'Scheduled', icon: Clock, className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
|
||||
sending: { label: 'Sending', icon: Send, className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
|
||||
sent: { label: 'Sent', icon: CheckCircle2, className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
failed: { label: 'Failed', icon: AlertCircle, className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
|
||||
};
|
||||
|
||||
export default function Campaigns() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['campaigns'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/campaigns');
|
||||
return response.data as Campaign[];
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await api.del(`/campaigns/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
toast.success(__('Campaign deleted'));
|
||||
setDeleteId(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to delete campaign'));
|
||||
},
|
||||
});
|
||||
|
||||
const duplicateMutation = useMutation({
|
||||
mutationFn: async (campaign: Campaign) => {
|
||||
const response = await api.post('/campaigns', {
|
||||
title: `${campaign.title} (Copy)`,
|
||||
subject: campaign.subject,
|
||||
content: '',
|
||||
status: 'draft',
|
||||
});
|
||||
return response;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
toast.success(__('Campaign duplicated'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to duplicate campaign'));
|
||||
},
|
||||
});
|
||||
|
||||
const campaigns = data || [];
|
||||
const filteredCampaigns = campaigns.filter((c) =>
|
||||
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.subject?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsCard
|
||||
title={__('All Campaigns')}
|
||||
description={`${campaigns.length} ${__('campaigns total')}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Actions Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder={__('Search campaigns...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{__('New Campaign')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Campaigns Table */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{__('Loading campaigns...')}
|
||||
</div>
|
||||
) : filteredCampaigns.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery ? __('No campaigns found matching your search') : (
|
||||
<div className="space-y-4">
|
||||
<Send className="h-12 w-12 mx-auto opacity-50" />
|
||||
<p>{__('No campaigns yet')}</p>
|
||||
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{__('Create your first campaign')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{__('Title')}</TableHead>
|
||||
<TableHead>{__('Status')}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{__('Recipients')}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{__('Date')}</TableHead>
|
||||
<TableHead className="text-right">{__('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCampaigns.map((campaign) => {
|
||||
const status = statusConfig[campaign.status] || statusConfig.draft;
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<TableRow key={campaign.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{campaign.title}</div>
|
||||
{campaign.subject && (
|
||||
<div className="text-sm text-muted-foreground truncate max-w-[200px]">
|
||||
{campaign.subject}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${status.className}`}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{__(status.label)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{campaign.status === 'sent' ? (
|
||||
<span>
|
||||
{campaign.sent_count}/{campaign.recipient_count}
|
||||
{campaign.failed_count > 0 && (
|
||||
<span className="text-red-500 ml-1">
|
||||
({campaign.failed_count} {__('failed')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||
{campaign.sent_at
|
||||
? formatDate(campaign.sent_at)
|
||||
: campaign.scheduled_at
|
||||
? `${__('Scheduled')}: ${formatDate(campaign.scheduled_at)}`
|
||||
: formatDate(campaign.created_at)
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/marketing/newsletter/campaigns/${campaign.id}`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{__('Edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => duplicateMutation.mutate(campaign)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{__('Duplicate')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteId(campaign.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{__('Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{__('Delete Campaign')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__('Are you sure you want to delete this campaign? This action cannot be undone.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{__('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx
Normal file
192
admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Download, Trash2, Search } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
export default function Subscribers() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: subscribersData, isLoading } = useQuery({
|
||||
queryKey: ['newsletter-subscribers'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/newsletter/subscribers');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const deleteSubscriber = useMutation({
|
||||
mutationFn: async (email: string) => {
|
||||
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
|
||||
toast.success(__('Subscriber removed successfully'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to remove subscriber'));
|
||||
},
|
||||
});
|
||||
|
||||
const exportSubscribers = () => {
|
||||
if (!subscribersData?.subscribers) return;
|
||||
|
||||
const csv = ['Email,Subscribed Date'].concat(
|
||||
subscribersData.subscribers.map((sub: any) =>
|
||||
`${sub.email},${sub.subscribed_at || 'N/A'}`
|
||||
)
|
||||
).join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `newsletter-subscribers-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const subscribers = subscribersData?.subscribers || [];
|
||||
const filteredSubscribers = subscribers.filter((sub: any) =>
|
||||
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsCard
|
||||
title={__('Subscribers List')}
|
||||
description={`${__('Total subscribers')}: ${subscribersData?.count || 0}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Actions Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder={__('Filter subscribers...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={exportSubscribers} variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{__('Export CSV')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Subscribers Table */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{__('Loading subscribers...')}
|
||||
</div>
|
||||
) : filteredSubscribers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{__('Email')}</TableHead>
|
||||
<TableHead>{__('Status')}</TableHead>
|
||||
<TableHead>{__('Subscribed Date')}</TableHead>
|
||||
<TableHead>{__('WP User')}</TableHead>
|
||||
<TableHead className="text-right">{__('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSubscribers.map((subscriber: any) => (
|
||||
<TableRow key={subscriber.email}>
|
||||
<TableCell className="font-medium">{subscriber.email}</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
|
||||
{subscriber.status || __('Active')}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{subscriber.subscribed_at
|
||||
? new Date(subscriber.subscribed_at).toLocaleDateString()
|
||||
: 'N/A'
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{subscriber.user_id ? (
|
||||
<span className="text-xs text-blue-600">{__('Yes')} (ID: {subscriber.user_id})</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{__('No')}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteSubscriber.mutate(subscriber.email)}
|
||||
disabled={deleteSubscriber.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Email Template Settings */}
|
||||
<SettingsCard
|
||||
title={__('Email Templates')}
|
||||
description={__('Customize newsletter email templates using the email builder')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border rounded-lg bg-muted/50">
|
||||
<h4 className="font-medium mb-2">{__('Newsletter Welcome Email')}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{__('Welcome email sent when someone subscribes to your newsletter')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_welcome&channel=email&recipient=customer')}
|
||||
>
|
||||
{__('Edit Template')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg bg-muted/50">
|
||||
<h4 className="font-medium mb-2">{__('New Subscriber Notification (Admin)')}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{__('Admin notification when someone subscribes to newsletter')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_subscribed_admin&channel=email&recipient=staff')}
|
||||
>
|
||||
{__('Edit Template')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
admin-spa/src/routes/Marketing/Newsletter/index.tsx
Normal file
74
admin-spa/src/routes/Marketing/Newsletter/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import Subscribers from './Subscribers';
|
||||
import Campaigns from './Campaigns';
|
||||
|
||||
export default function Newsletter() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState('subscribers');
|
||||
const navigate = useNavigate();
|
||||
const { isEnabled } = useModules();
|
||||
|
||||
// Check for tab query param
|
||||
useEffect(() => {
|
||||
const tabParam = searchParams.get('tab');
|
||||
if (tabParam && ['subscribers', 'campaigns'].includes(tabParam)) {
|
||||
setActiveTab(tabParam);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Update URL when tab changes
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
setSearchParams({ tab: value });
|
||||
};
|
||||
|
||||
// Show disabled state if newsletter module is off
|
||||
if (!isEnabled('newsletter')) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={__('Newsletter')}
|
||||
description={__('Newsletter module is disabled')}
|
||||
>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
|
||||
<Mail className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-lg mb-2">{__('Newsletter Module Disabled')}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{__('The newsletter module is currently disabled. Enable it in Settings > Modules to use this feature.')}
|
||||
</p>
|
||||
<Button onClick={() => navigate('/settings/modules')}>
|
||||
{__('Go to Module Settings')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={__('Newsletter')}
|
||||
description={__('Manage subscribers and send email campaigns')}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="subscribers">{__('Subscribers')}</TabsTrigger>
|
||||
<TabsTrigger value="campaigns">{__('Campaigns')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="subscribers" className="space-y-4 mt-6">
|
||||
<Subscribers />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="campaigns" className="space-y-4 mt-6">
|
||||
<Campaigns />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { Mail, Send, Tag } from 'lucide-react';
|
||||
import { Mail, Tag } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface MarketingCard {
|
||||
@@ -13,16 +13,10 @@ interface MarketingCard {
|
||||
const cards: MarketingCard[] = [
|
||||
{
|
||||
title: __('Newsletter'),
|
||||
description: __('Manage subscribers and email templates'),
|
||||
description: __('Manage subscribers and send email campaigns'),
|
||||
icon: Mail,
|
||||
to: '/marketing/newsletter',
|
||||
},
|
||||
{
|
||||
title: __('Campaigns'),
|
||||
description: __('Create and send email campaigns'),
|
||||
icon: Send,
|
||||
to: '/marketing/campaigns',
|
||||
},
|
||||
{
|
||||
title: __('Coupons'),
|
||||
description: __('Discounts, promotions, and coupon codes'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, Mail, Megaphone } from 'lucide-react';
|
||||
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, Mail, Megaphone, HelpCircle } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||
import { useApp } from '@/contexts/AppContext';
|
||||
@@ -32,6 +32,12 @@ const menuItems: MenuItem[] = [
|
||||
label: __('Settings'),
|
||||
description: __('Configure your store settings'),
|
||||
to: '/settings'
|
||||
},
|
||||
{
|
||||
icon: <HelpCircle className="w-5 h-5" />,
|
||||
label: __('Help & Docs'),
|
||||
description: __('Documentation and guides'),
|
||||
to: '/help'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, OrdersApi } from '@/lib/api';
|
||||
import { formatRelativeOrDate } from '@/lib/dates';
|
||||
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 {
|
||||
AlertDialog,
|
||||
@@ -48,28 +48,7 @@ export default function OrderShow() {
|
||||
const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW';
|
||||
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 qrRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const q = useQuery({
|
||||
queryKey: ['order', id],
|
||||
enabled: !!id,
|
||||
@@ -154,7 +133,7 @@ export default function OrderShow() {
|
||||
|
||||
// Set contextual header with Back button and Edit action
|
||||
useEffect(() => {
|
||||
if (!order || isPrintMode) {
|
||||
if (!order) {
|
||||
clearPageHeader();
|
||||
return;
|
||||
}
|
||||
@@ -178,39 +157,21 @@ export default function OrderShow() {
|
||||
);
|
||||
|
||||
return () => clearPageHeader();
|
||||
}, [order, isPrintMode, 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]);
|
||||
}, [order, id, setPageHeader, clearPageHeader, nav]);
|
||||
|
||||
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 */}
|
||||
<div className="hidden md: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')}>
|
||||
<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')}>
|
||||
<Link to={`/orders/${id}/invoice`} className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 hover:bg-gray-50">
|
||||
<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>
|
||||
{!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>
|
||||
|
||||
@@ -473,84 +434,6 @@ export default function OrderShow() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
211
admin-spa/src/routes/Orders/Invoice.tsx
Normal file
211
admin-spa/src/routes/Orders/Invoice.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/currency';
|
||||
import { ArrowLeft, Printer } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InlineLoadingState } from '@/components/LoadingState';
|
||||
import { ErrorCard } from '@/components/ErrorCard';
|
||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
function Money({ value, currency, symbol }: { value?: number; currency?: string; symbol?: string }) {
|
||||
return <>{formatMoney(value, { currency, symbol })}</>;
|
||||
}
|
||||
|
||||
export default function Invoice() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW';
|
||||
const qrRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['order', id],
|
||||
enabled: !!id,
|
||||
queryFn: () => api.get(`/orders/${id}`),
|
||||
});
|
||||
|
||||
const order = q.data;
|
||||
|
||||
// Check if all items are virtual
|
||||
const isVirtualOnly = React.useMemo(() => {
|
||||
if (!order?.items || order.items.length === 0) return false;
|
||||
return order.items.every((item: any) => item.virtual || item.downloadable);
|
||||
}, [order?.items]);
|
||||
|
||||
// Generate QR code
|
||||
useEffect(() => {
|
||||
if (!qrRef.current || !order) return;
|
||||
(async () => {
|
||||
try {
|
||||
const mod = await import('qrcode');
|
||||
const QR = (mod as any).default || (mod as any);
|
||||
const text = `ORDER:${order.number || id}`;
|
||||
await QR.toCanvas(qrRef.current, text, { width: 96, margin: 1 });
|
||||
} catch (_) {
|
||||
// QR library not available
|
||||
}
|
||||
})();
|
||||
}, [order, id]);
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
if (q.isLoading) {
|
||||
return <InlineLoadingState message={__('Loading invoice...')} />;
|
||||
}
|
||||
|
||||
if (q.isError) {
|
||||
return (
|
||||
<ErrorCard
|
||||
title={__('Failed to load order')}
|
||||
message={getPageLoadErrorMessage(q.error)}
|
||||
onRetry={() => q.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Actions bar - hidden on print */}
|
||||
<div className="no-print bg-white border-b sticky top-0 z-10">
|
||||
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<Link to={`/orders/${id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{__('Back to Order')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button onClick={handlePrint} size="sm">
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
{__('Print Invoice')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice content */}
|
||||
<div className="py-8 print:py-0 print:bg-white">
|
||||
<div className="print-a4 bg-white shadow-lg print:shadow-none mx-auto max-w-3xl p-8 print:max-w-none print:p-0">
|
||||
{/* Invoice Header */}
|
||||
<div className="flex items-start justify-between mb-8 pb-6 border-b-2 border-gray-200">
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-gray-900">{__('INVOICE')}</div>
|
||||
<div className="text-sm text-gray-600 mt-1">#{order.number}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-semibold text-gray-900">{siteTitle}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">{window.location.origin}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Meta */}
|
||||
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">{__('Invoice Date')}</div>
|
||||
<div className="text-sm text-gray-900">{new Date((order.date_ts || 0) * 1000).toLocaleDateString()}</div>
|
||||
{order.payment_method && (
|
||||
<>
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2 mt-4">{__('Payment Method')}</div>
|
||||
<div className="text-sm text-gray-900">{order.payment_method}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<canvas ref={qrRef} className="w-24 h-24" style={{ border: '1px solid #e5e7eb' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing & Shipping */}
|
||||
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">{__('Bill To')}</div>
|
||||
<div className="text-sm text-gray-900 font-medium">{order.billing?.name || '—'}</div>
|
||||
{order.billing?.email && <div className="text-sm text-gray-600">{order.billing.email}</div>}
|
||||
{order.billing?.phone && <div className="text-sm text-gray-600">{order.billing.phone}</div>}
|
||||
<div className="text-sm text-gray-600 mt-2" dangerouslySetInnerHTML={{ __html: order.billing?.address || '' }} />
|
||||
</div>
|
||||
{!isVirtualOnly && order.shipping?.name && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">{__('Ship To')}</div>
|
||||
<div className="text-sm text-gray-900 font-medium">{order.shipping?.name || '—'}</div>
|
||||
<div className="text-sm text-gray-600 mt-2" dangerouslySetInnerHTML={{ __html: order.shipping?.address || '' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Items Table */}
|
||||
<table className="w-full mb-8" style={{ borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr className="bg-gray-900 text-white">
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold">{__('Product')}</th>
|
||||
<th className="text-center py-3 px-4 text-sm font-semibold w-20">{__('Qty')}</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-semibold w-32">{__('Price')}</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-semibold w-32">{__('Total')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(order.items || []).map((it: any, idx: number) => (
|
||||
<tr key={it.id} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="py-3 px-4 text-sm">
|
||||
<div className="font-medium text-gray-900">{it.name}</div>
|
||||
{it.sku && <div className="text-xs text-gray-500">SKU: {it.sku}</div>}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-center text-gray-600">{it.qty}</td>
|
||||
<td className="py-3 px-4 text-sm text-right text-gray-600">
|
||||
<Money value={it.subtotal / it.qty} currency={order.currency} symbol={order.currency_symbol} />
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-right font-medium text-gray-900">
|
||||
<Money value={it.total} currency={order.currency} symbol={order.currency_symbol} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="flex justify-end mb-8">
|
||||
<div className="w-72">
|
||||
<div className="flex justify-between py-2 text-sm">
|
||||
<span className="text-gray-600">{__('Subtotal')}</span>
|
||||
<span className="text-gray-900"><Money value={order.totals?.subtotal} currency={order.currency} symbol={order.currency_symbol} /></span>
|
||||
</div>
|
||||
{(order.totals?.discount || 0) > 0 && (
|
||||
<div className="flex justify-between py-2 text-sm">
|
||||
<span className="text-gray-600">{__('Discount')}</span>
|
||||
<span className="text-green-600">-<Money value={order.totals?.discount} currency={order.currency} symbol={order.currency_symbol} /></span>
|
||||
</div>
|
||||
)}
|
||||
{(order.totals?.shipping || 0) > 0 && (
|
||||
<div className="flex justify-between py-2 text-sm">
|
||||
<span className="text-gray-600">{__('Shipping')}</span>
|
||||
<span className="text-gray-900"><Money value={order.totals?.shipping} currency={order.currency} symbol={order.currency_symbol} /></span>
|
||||
</div>
|
||||
)}
|
||||
{(order.totals?.tax || 0) > 0 && (
|
||||
<div className="flex justify-between py-2 text-sm">
|
||||
<span className="text-gray-600">{__('Tax')}</span>
|
||||
<span className="text-gray-900"><Money value={order.totals?.tax} currency={order.currency} symbol={order.currency_symbol} /></span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between py-3 mt-2 border-t-2 border-gray-900">
|
||||
<span className="text-lg font-bold text-gray-900">{__('Total')}</span>
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
<Money value={order.totals?.total} currency={order.currency} symbol={order.currency_symbol} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-auto pt-8 border-t border-gray-200 text-center text-xs text-gray-500">
|
||||
<p>{__('Thank you for your business!')}</p>
|
||||
<p className="mt-1">{siteTitle} • {window.location.origin}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
136
admin-spa/src/routes/Orders/Label.tsx
Normal file
136
admin-spa/src/routes/Orders/Label.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { ArrowLeft, Printer } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InlineLoadingState } from '@/components/LoadingState';
|
||||
import { ErrorCard } from '@/components/ErrorCard';
|
||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
export default function Label() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const qrRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['order', id],
|
||||
enabled: !!id,
|
||||
queryFn: () => api.get(`/orders/${id}`),
|
||||
});
|
||||
|
||||
const order = q.data;
|
||||
|
||||
// Generate QR code
|
||||
useEffect(() => {
|
||||
if (!qrRef.current || !order) return;
|
||||
(async () => {
|
||||
try {
|
||||
const mod = await import('qrcode');
|
||||
const QR = (mod as any).default || (mod as any);
|
||||
const text = `ORDER:${order.number || id}`;
|
||||
await QR.toCanvas(qrRef.current, text, { width: 128, margin: 1 });
|
||||
} catch (_) {
|
||||
// QR library not available
|
||||
}
|
||||
})();
|
||||
}, [order, id]);
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
if (q.isLoading) {
|
||||
return <InlineLoadingState message={__('Loading label...')} />;
|
||||
}
|
||||
|
||||
if (q.isError) {
|
||||
return (
|
||||
<ErrorCard
|
||||
title={__('Failed to load order')}
|
||||
message={getPageLoadErrorMessage(q.error)}
|
||||
onRetry={() => q.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Actions bar - hidden on print */}
|
||||
<div className="no-print bg-white border-b sticky top-0 z-10">
|
||||
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<Link to={`/orders/${id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{__('Back to Order')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button onClick={handlePrint} size="sm">
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
{__('Print Label')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label content - 4x6 inch thermal label size */}
|
||||
<div className="py-8 print:py-0 flex justify-center">
|
||||
<div
|
||||
className="print-4x6 bg-white shadow-lg print:shadow-none"
|
||||
style={{
|
||||
width: '4in',
|
||||
height: '6in',
|
||||
padding: '0.5in',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
{/* Order Number & QR */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">#{order.number}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{new Date((order.date_ts || 0) * 1000).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<canvas ref={qrRef} className="w-24 h-24" style={{ border: '1px solid #e5e7eb' }} />
|
||||
</div>
|
||||
|
||||
{/* Ship To */}
|
||||
<div className="mb-4 p-3 border-2 border-gray-900 rounded">
|
||||
<div className="text-xs font-bold text-gray-500 uppercase tracking-wide mb-2">{__('SHIP TO')}</div>
|
||||
<div className="text-lg font-bold text-gray-900">{order.shipping?.name || order.billing?.name || '—'}</div>
|
||||
{(order.shipping?.phone || order.billing?.phone) && (
|
||||
<div className="text-sm text-gray-700 mt-1">{order.shipping?.phone || order.billing?.phone}</div>
|
||||
)}
|
||||
<div
|
||||
className="text-sm text-gray-700 mt-2 leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: order.shipping?.address || order.billing?.address || '' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="mb-4">
|
||||
<div className="text-xs font-bold text-gray-500 uppercase tracking-wide mb-2">{__('ITEMS')}</div>
|
||||
<ul className="text-sm space-y-1">
|
||||
{(order.items || []).filter((it: any) => !it.virtual && !it.downloadable).map((it: any) => (
|
||||
<li key={it.id} className="flex justify-between">
|
||||
<span className="truncate">{it.name}</span>
|
||||
<span className="font-medium ml-2">×{it.qty}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Shipping Method */}
|
||||
{order.shipping_method && (
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<div className="text-xs font-bold text-gray-500 uppercase tracking-wide mb-1">{__('SHIPPING METHOD')}</div>
|
||||
<div className="text-sm font-medium text-gray-900">{order.shipping_method}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||
import { DynamicCheckoutField, type CheckoutField } from '@/components/DynamicCheckoutField';
|
||||
|
||||
// --- Types ------------------------------------------------------------
|
||||
export type CountryOption = { code: string; name: string };
|
||||
@@ -79,6 +80,14 @@ export type ExistingOrderDTO = {
|
||||
customer_note?: string;
|
||||
currency?: string;
|
||||
currency_symbol?: string;
|
||||
totals?: {
|
||||
total_items?: number;
|
||||
total_shipping?: number;
|
||||
total_tax?: number;
|
||||
total_discount?: number;
|
||||
total?: number;
|
||||
shipping?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type OrderPayload = {
|
||||
@@ -91,6 +100,7 @@ export type OrderPayload = {
|
||||
customer_note?: string;
|
||||
register_as_member?: boolean;
|
||||
coupons?: string[];
|
||||
custom_fields?: Record<string, string>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -113,7 +123,7 @@ type Props = {
|
||||
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 --------------------------------------------------------
|
||||
export default function OrderForm({
|
||||
@@ -167,11 +177,11 @@ export default function OrderForm({
|
||||
const only = countries[0]?.code || '';
|
||||
if (shipDiff) {
|
||||
if (only && shippingData.country !== only) {
|
||||
setShippingData({...shippingData, country: only});
|
||||
setShippingData({ ...shippingData, country: only });
|
||||
}
|
||||
} else {
|
||||
// keep shipping synced to billing when not different
|
||||
setShippingData({...shippingData, country: bCountry});
|
||||
setShippingData({ ...shippingData, country: bCountry });
|
||||
}
|
||||
}
|
||||
}, [oneCountryOnly, countries, shipDiff, bCountry, shippingData.country]);
|
||||
@@ -189,6 +199,9 @@ export default function OrderForm({
|
||||
const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]);
|
||||
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
|
||||
const { data: checkoutFields } = useQuery({
|
||||
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,
|
||||
});
|
||||
|
||||
// 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)
|
||||
const effectiveShippingAddress = React.useMemo(() => {
|
||||
// Get destination_id from custom fields (Rajaongkir)
|
||||
const destinationId = shipDiff
|
||||
? customFieldData['shipping_destination_id']
|
||||
: customFieldData['billing_destination_id'];
|
||||
|
||||
if (shipDiff) {
|
||||
return shippingData;
|
||||
return {
|
||||
...shippingData,
|
||||
destination_id: destinationId || undefined,
|
||||
};
|
||||
}
|
||||
// Use billing address
|
||||
return {
|
||||
@@ -214,22 +263,19 @@ export default function OrderForm({
|
||||
postcode: bPost,
|
||||
address_1: bAddr1,
|
||||
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
|
||||
// Should match customer checkout: just needs country to fetch (destination_id can provide more specific rates)
|
||||
const isShippingAddressComplete = React.useMemo(() => {
|
||||
const addr = effectiveShippingAddress;
|
||||
// Need at minimum: country, state (if applicable), city
|
||||
if (!addr.country) return false;
|
||||
if (!addr.city) return false;
|
||||
// If country has states, require state
|
||||
const countryStates = states[addr.country];
|
||||
if (countryStates && Object.keys(countryStates).length > 0 && !addr.state) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [effectiveShippingAddress, states]);
|
||||
// Need at minimum: country OR destination_id
|
||||
// destination_id from Rajaongkir is sufficient to calculate shipping
|
||||
if (addr.destination_id) return true;
|
||||
return !!addr.country;
|
||||
}, [effectiveShippingAddress]);
|
||||
|
||||
// Debounce city input to avoid hitting backend on every keypress
|
||||
const [debouncedCity, setDebouncedCity] = React.useState(effectiveShippingAddress.city);
|
||||
@@ -244,7 +290,7 @@ export default function OrderForm({
|
||||
|
||||
// Calculate shipping rates dynamically
|
||||
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 () => {
|
||||
return api.post('/shipping/calculate', {
|
||||
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)
|
||||
? raw
|
||||
: Array.isArray(raw?.data)
|
||||
? raw.data
|
||||
: Array.isArray(raw?.rows)
|
||||
? raw.rows
|
||||
: [];
|
||||
? raw.data
|
||||
: Array.isArray(raw?.rows)
|
||||
? raw.rows
|
||||
: [];
|
||||
|
||||
const customersRaw = customersQ.data as any;
|
||||
const customers: any[] = Array.isArray(customersRaw) ? customersRaw : [];
|
||||
@@ -408,7 +454,7 @@ export default function OrderForm({
|
||||
|
||||
// Keep shipping country synced to billing when unchecked
|
||||
React.useEffect(() => {
|
||||
if (!shipDiff) setShippingData({...shippingData, country: bCountry});
|
||||
if (!shipDiff) setShippingData({ ...shippingData, country: bCountry });
|
||||
}, [shipDiff, bCountry]);
|
||||
|
||||
// Clamp states when country changes
|
||||
@@ -417,13 +463,50 @@ export default function OrderForm({
|
||||
}, [bCountry]);
|
||||
React.useEffect(() => {
|
||||
if (shippingData.state && !states[shippingData.country]?.[shippingData.state]) {
|
||||
setShippingData({...shippingData, state: ''});
|
||||
setShippingData({ ...shippingData, state: '' });
|
||||
}
|
||||
}, [shippingData.country]);
|
||||
|
||||
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 }));
|
||||
|
||||
// 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) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -453,6 +536,7 @@ export default function OrderForm({
|
||||
customer_note: note || undefined,
|
||||
items: itemsEditable ? items : undefined,
|
||||
coupons: showCoupons ? validatedCoupons.map(c => c.code) : undefined,
|
||||
custom_fields: Object.keys(customFieldData).length > 0 ? customFieldData : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -729,10 +813,10 @@ export default function OrderForm({
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 p-4">
|
||||
{selectedProduct.variations?.map((variation) => {
|
||||
const variationLabel = Object.entries(variation.attributes)
|
||||
.map(([key, value]) => `${key}: ${value || ''}`)
|
||||
// Build formatted label with styled key:value pairs
|
||||
const variationParts = Object.entries(variation.attributes)
|
||||
.filter(([_, value]) => value) // Remove empty values
|
||||
.join(', ');
|
||||
.map(([key, value]) => ({ key, value: value || '' }));
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -759,7 +843,7 @@ export default function OrderForm({
|
||||
product_id: selectedProduct.id,
|
||||
variation_id: variation.id,
|
||||
name: selectedProduct.name,
|
||||
variation_name: variationLabel,
|
||||
variation_name: variationParts.map(p => `${p.key}: ${p.value}`).join(', '),
|
||||
price: variation.price,
|
||||
regular_price: variation.regular_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-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 && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
SKU: {variation.sku}
|
||||
@@ -827,10 +919,10 @@ export default function OrderForm({
|
||||
</DrawerHeader>
|
||||
<div className="p-4 space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{selectedProduct.variations?.map((variation) => {
|
||||
const variationLabel = Object.entries(variation.attributes)
|
||||
.map(([key, value]) => `${key}: ${value || ''}`)
|
||||
// Build formatted label with styled key:value pairs
|
||||
const variationParts = Object.entries(variation.attributes)
|
||||
.filter(([_, value]) => value) // Remove empty values
|
||||
.join(', ');
|
||||
.map(([key, value]) => ({ key, value: value || '' }));
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -857,7 +949,7 @@ export default function OrderForm({
|
||||
product_id: selectedProduct.id,
|
||||
variation_id: variation.id,
|
||||
name: selectedProduct.name,
|
||||
variation_name: variationLabel,
|
||||
variation_name: variationParts.map(p => `${p.key}: ${p.value}`).join(', '),
|
||||
price: variation.price,
|
||||
regular_price: variation.regular_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-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 && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
SKU: {variation.sku}
|
||||
@@ -990,141 +1090,229 @@ export default function OrderForm({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Billing address - only show full address for physical products */}
|
||||
<div className="rounded border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">{__('Billing address')}</h3>
|
||||
{mode === 'create' && (
|
||||
<SearchableSelect
|
||||
options={customers.map((c: any) => ({
|
||||
value: String(c.id),
|
||||
label: (
|
||||
<div className="leading-tight">
|
||||
<div className="font-medium">{c.name || c.email}</div>
|
||||
<div className="text-xs text-muted-foreground">{c.email}</div>
|
||||
</div>
|
||||
),
|
||||
searchText: `${c.name} ${c.email}`,
|
||||
customer: c,
|
||||
}))}
|
||||
value={undefined}
|
||||
onChange={async (val: string) => {
|
||||
const customer = customers.find((c: any) => String(c.id) === val);
|
||||
if (!customer) return;
|
||||
{/* Billing address - only show when items are added (so checkout fields API is loaded) */}
|
||||
{items.length > 0 && (
|
||||
<div className="rounded border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">{__('Billing address')}</h3>
|
||||
{mode === 'create' && (
|
||||
<SearchableSelect
|
||||
options={customers.map((c: any) => ({
|
||||
value: String(c.id),
|
||||
label: (
|
||||
<div className="leading-tight">
|
||||
<div className="font-medium">{c.name || c.email}</div>
|
||||
<div className="text-xs text-muted-foreground">{c.email}</div>
|
||||
</div>
|
||||
),
|
||||
searchText: `${c.name} ${c.email}`,
|
||||
customer: c,
|
||||
}))}
|
||||
value={undefined}
|
||||
onChange={async (val: string) => {
|
||||
const customer = customers.find((c: any) => String(c.id) === val);
|
||||
if (!customer) return;
|
||||
|
||||
// Fetch full customer data
|
||||
try {
|
||||
const data = await CustomersApi.searchByEmail(customer.email);
|
||||
if (data.found && data.billing) {
|
||||
// Always fill name, email, phone
|
||||
setBFirst(data.billing.first_name || data.first_name || '');
|
||||
setBLast(data.billing.last_name || data.last_name || '');
|
||||
setBEmail(data.email || '');
|
||||
setBPhone(data.billing.phone || '');
|
||||
// Fetch full customer data
|
||||
try {
|
||||
const data = await CustomersApi.searchByEmail(customer.email);
|
||||
if (data.found && data.billing) {
|
||||
// Always fill name, email, phone
|
||||
setBFirst(data.billing.first_name || data.first_name || '');
|
||||
setBLast(data.billing.last_name || data.last_name || '');
|
||||
setBEmail(data.email || '');
|
||||
setBPhone(data.billing.phone || '');
|
||||
|
||||
// Only fill address fields if cart has physical products
|
||||
if (hasPhysicalProduct) {
|
||||
setBAddr1(data.billing.address_1 || '');
|
||||
setBCity(data.billing.city || '');
|
||||
setBPost(data.billing.postcode || '');
|
||||
setBCountry(data.billing.country || bCountry);
|
||||
setBState(data.billing.state || '');
|
||||
// Only fill address fields if cart has physical products
|
||||
if (hasPhysicalProduct) {
|
||||
setBAddr1(data.billing.address_1 || '');
|
||||
setBCity(data.billing.city || '');
|
||||
setBPost(data.billing.postcode || '');
|
||||
setBCountry(data.billing.country || bCountry);
|
||||
setBState(data.billing.state || '');
|
||||
|
||||
// Autofill shipping if available
|
||||
if (data.shipping && data.shipping.address_1) {
|
||||
setShipDiff(true);
|
||||
setShippingData({
|
||||
first_name: data.shipping.first_name || '',
|
||||
last_name: data.shipping.last_name || '',
|
||||
address_1: data.shipping.address_1 || '',
|
||||
city: data.shipping.city || '',
|
||||
postcode: data.shipping.postcode || '',
|
||||
country: data.shipping.country || bCountry,
|
||||
state: data.shipping.state || '',
|
||||
});
|
||||
// Autofill shipping if available
|
||||
if (data.shipping && data.shipping.address_1) {
|
||||
setShipDiff(true);
|
||||
setShippingData({
|
||||
first_name: data.shipping.first_name || '',
|
||||
last_name: data.shipping.last_name || '',
|
||||
address_1: data.shipping.address_1 || '',
|
||||
city: data.shipping.city || '',
|
||||
postcode: data.shipping.postcode || '',
|
||||
country: data.shipping.country || bCountry,
|
||||
state: data.shipping.state || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mark customer as selected
|
||||
setSelectedCustomerId(data.user_id);
|
||||
}
|
||||
|
||||
// Mark customer as selected
|
||||
setSelectedCustomerId(data.user_id);
|
||||
} catch (e) {
|
||||
console.error('Customer autofill error:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Customer autofill error:', e);
|
||||
}
|
||||
|
||||
setCustomerSearchQ('');
|
||||
}}
|
||||
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)} />
|
||||
setCustomerSearchQ('');
|
||||
}}
|
||||
onSearch={setCustomerSearchQ}
|
||||
placeholder={__('Search customer...')}
|
||||
className="w-64"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Last name')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bLast} onChange={e=>setBLast(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('Email')}</Label>
|
||||
<Input
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
className="rounded-md border px-3 py-2 appearance-none"
|
||||
value={bEmail}
|
||||
onChange={e=>setBEmail(e.target.value)}
|
||||
/>
|
||||
</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 className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Dynamic billing fields - respects API visibility, labels, required status */}
|
||||
{getBillingField('billing_first_name') && (
|
||||
<div className={isBillingFieldWide('billing_first_name') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_first_name')?.label || __('First name')}
|
||||
{getBillingField('billing_first_name')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bFirst}
|
||||
onChange={e => setBFirst(e.target.value)}
|
||||
required={getBillingField('billing_first_name')?.required}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{__('State/Province')}</Label>
|
||||
<Select value={bState} onValueChange={setBState}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder={__('Select state')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{bStateOptions.length ? bStateOptions.map(o => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
)) : (
|
||||
<SelectItem value="__none__" disabled>{__('N/A')}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{getBillingField('billing_last_name') && (
|
||||
<div className={isBillingFieldWide('billing_last_name') ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{getBillingField('billing_last_name')?.label || __('Last name')}
|
||||
{getBillingField('billing_last_name')?.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
className="rounded-md border px-3 py-2"
|
||||
value={bLast}
|
||||
onChange={e => setBLast(e.target.value)}
|
||||
required={getBillingField('billing_last_name')?.required}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Conditional: Only show address fields and shipping for physical products */}
|
||||
{!hasPhysicalProduct && (
|
||||
@@ -1137,7 +1325,7 @@ export default function OrderForm({
|
||||
{hasPhysicalProduct && (
|
||||
<div className="pt-2 mt-4">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1149,22 +1337,38 @@ export default function OrderForm({
|
||||
<h3 className="text-sm font-medium">{__('Shipping address')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{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))
|
||||
.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_', '');
|
||||
|
||||
// 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 (
|
||||
<div key={field.key} className={isWide ? 'md:col-span-2' : ''}>
|
||||
<Label>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.type === 'select' && field.options ? (
|
||||
{field.type === 'select' && field.options && field.key !== 'shipping_state' ? (
|
||||
<Select
|
||||
value={shippingData[fieldKey] || ''}
|
||||
onValueChange={(v) => setShippingData({...shippingData, [fieldKey]: v})}
|
||||
onValueChange={(v) => setShippingData({ ...shippingData, [fieldKey]: v })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={field.placeholder || field.label} />
|
||||
@@ -1179,14 +1383,34 @@ export default function OrderForm({
|
||||
<SearchableSelect
|
||||
options={countryOptions}
|
||||
value={shippingData.country || ''}
|
||||
onChange={(v) => setShippingData({...shippingData, country: v})}
|
||||
onChange={(v) => setShippingData({ ...shippingData, country: v })}
|
||||
placeholder={field.placeholder || __('Select country')}
|
||||
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' ? (
|
||||
<Textarea
|
||||
value={shippingData[fieldKey] || ''}
|
||||
onChange={(e) => setShippingData({...shippingData, [fieldKey]: e.target.value})}
|
||||
value={field.custom ? customFieldData[field.key] || '' : shippingData[fieldKey] || ''}
|
||||
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}
|
||||
required={field.required}
|
||||
/>
|
||||
@@ -1194,7 +1418,7 @@ export default function OrderForm({
|
||||
<Input
|
||||
type={field.type === 'email' ? 'email' : field.type === 'tel' ? 'tel' : 'text'}
|
||||
value={shippingData[fieldKey] || ''}
|
||||
onChange={(e) => setShippingData({...shippingData, [fieldKey]: e.target.value})}
|
||||
onChange={(e) => setShippingData({ ...shippingData, [fieldKey]: e.target.value })}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
/>
|
||||
@@ -1250,7 +1474,12 @@ export default function OrderForm({
|
||||
{hasPhysicalProduct && (
|
||||
<div>
|
||||
<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>
|
||||
) : shippingRates?.methods && shippingRates.methods.length > 0 ? (
|
||||
<Select value={shippingMethod} onValueChange={setShippingMethod}>
|
||||
@@ -1263,17 +1492,9 @@ export default function OrderForm({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : shippingData.country ? (
|
||||
<div className="text-sm text-muted-foreground py-2">{__('No shipping methods available')}</div>
|
||||
) : (
|
||||
<Select value={shippingMethod} onValueChange={setShippingMethod}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder={shippings.length ? __('Select shipping') : __('No methods')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{shippings.map(s => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/* Address is complete but no methods returned */
|
||||
<div className="text-sm text-muted-foreground py-2">{__('No shipping methods available for this address')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1281,7 +1502,7 @@ export default function OrderForm({
|
||||
|
||||
<div className="rounded border p-4 space-y-2">
|
||||
<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>
|
||||
|
||||
{!hideSubmitButton && (
|
||||
@@ -1297,6 +1518,6 @@ export default function OrderForm({
|
||||
|
||||
function isEmptyAddress(a: any) {
|
||||
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]);
|
||||
}
|
||||
233
admin-spa/src/routes/Products/Licenses/Detail.tsx
Normal file
233
admin-spa/src/routes/Products/Licenses/Detail.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table';
|
||||
import { ArrowLeft, Key, Monitor, Globe, Clock } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface Activation {
|
||||
id: number;
|
||||
license_id: number;
|
||||
domain: string | null;
|
||||
ip_address: string | null;
|
||||
machine_id: string | null;
|
||||
user_agent: string | null;
|
||||
status: 'active' | 'deactivated';
|
||||
activated_at: string;
|
||||
deactivated_at: string | null;
|
||||
}
|
||||
|
||||
interface LicenseDetail {
|
||||
id: number;
|
||||
license_key: string;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
order_id: number;
|
||||
user_id: number;
|
||||
user_email: string;
|
||||
user_name: string;
|
||||
status: 'active' | 'revoked' | 'expired';
|
||||
activation_limit: number;
|
||||
activation_count: number;
|
||||
activations_remaining: number;
|
||||
expires_at: string | null;
|
||||
is_expired: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
activations: Activation[];
|
||||
}
|
||||
|
||||
export default function LicenseDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: license, isLoading } = useQuery<LicenseDetail>({
|
||||
queryKey: ['license', id],
|
||||
queryFn: () => api.get(`/licenses/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!license) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">{__('License not found')}</p>
|
||||
<Button variant="link" onClick={() => navigate('/products/licenses')}>
|
||||
{__('Back to Licenses')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusBadge = () => {
|
||||
if (license.status === 'revoked') {
|
||||
return <Badge variant="destructive">{__('Revoked')}</Badge>;
|
||||
}
|
||||
if (license.is_expired) {
|
||||
return <Badge variant="secondary">{__('Expired')}</Badge>;
|
||||
}
|
||||
return <Badge variant="default">{__('Active')}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/products/licenses')}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Key className="h-6 w-6" />
|
||||
{__('License Details')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground font-mono text-sm">{license.license_key}</p>
|
||||
</div>
|
||||
<div className="ml-auto">{getStatusBadge()}</div>
|
||||
</div>
|
||||
|
||||
{/* Info Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">{__('Product')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-semibold">{license.product_name}</p>
|
||||
<p className="text-xs text-muted-foreground">Order #{license.order_id}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">{__('Customer')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-semibold">{license.user_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{license.user_email}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">{__('Activations')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-semibold">
|
||||
{license.activation_count} / {license.activation_limit === 0 ? '∞' : license.activation_limit}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{license.activations_remaining === -1 ? __('Unlimited') : `${license.activations_remaining} ${__('remaining')}`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">{__('Dates')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">{__('Created')}</p>
|
||||
<p>{new Date(license.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{__('Expires')}</p>
|
||||
<p className={license.is_expired ? 'text-red-500' : ''}>
|
||||
{license.expires_at ? new Date(license.expires_at).toLocaleDateString() : __('Never')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Activations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{__('Activation History')}</CardTitle>
|
||||
<CardDescription>
|
||||
{__('All activations and deactivations for this license')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{license.activations.length === 0 ? (
|
||||
<p className="text-center py-8 text-muted-foreground">
|
||||
{__('No activations yet')}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{__('Domain/Machine')}</TableHead>
|
||||
<TableHead>{__('IP Address')}</TableHead>
|
||||
<TableHead>{__('Activated')}</TableHead>
|
||||
<TableHead>{__('Status')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{license.activations.map((activation) => (
|
||||
<TableRow key={activation.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{activation.domain ? (
|
||||
<>
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{activation.domain}</span>
|
||||
</>
|
||||
) : activation.machine_id ? (
|
||||
<>
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-mono text-xs">{activation.machine_id}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{__('Unknown')}</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">{activation.ip_address || '-'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
{new Date(activation.activated_at).toLocaleString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{activation.status === 'active' ? (
|
||||
<Badge variant="default">{__('Active')}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">{__('Deactivated')}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
292
admin-spa/src/routes/Products/Licenses/index.tsx
Normal file
292
admin-spa/src/routes/Products/Licenses/index.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Search, Key, Ban, Eye, Copy, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface License {
|
||||
id: number;
|
||||
license_key: string;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
order_id: number;
|
||||
user_id: number;
|
||||
user_email: string;
|
||||
user_name: string;
|
||||
status: 'active' | 'revoked' | 'expired';
|
||||
activation_limit: number;
|
||||
activation_count: number;
|
||||
activations_remaining: number;
|
||||
expires_at: string | null;
|
||||
is_expired: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface LicensesResponse {
|
||||
licenses: License[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
export default function Licenses() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery<LicensesResponse>({
|
||||
queryKey: ['licenses', { search, status, page }],
|
||||
queryFn: () => api.get('/licenses', {
|
||||
params: { search, status: status || undefined, page, per_page: 20 }
|
||||
}),
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (id: number) => api.del(`/licenses/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['licenses'] });
|
||||
toast.success(__('License revoked'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to revoke license'));
|
||||
},
|
||||
});
|
||||
|
||||
const copyToClipboard = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
toast.success(__('License key copied'));
|
||||
};
|
||||
|
||||
const getStatusBadge = (license: License) => {
|
||||
if (license.status === 'revoked') {
|
||||
return <Badge variant="destructive">{__('Revoked')}</Badge>;
|
||||
}
|
||||
if (license.is_expired) {
|
||||
return <Badge variant="secondary">{__('Expired')}</Badge>;
|
||||
}
|
||||
return <Badge variant="default">{__('Active')}</Badge>;
|
||||
};
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / data.per_page) : 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Key className="h-6 w-6" />
|
||||
{__('Licenses')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{__('Manage software licenses for your digital products')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={__('Search license keys...')}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={status} onValueChange={(v) => { setStatus(v); setPage(1); }}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={__('All Statuses')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{__('All Statuses')}</SelectItem>
|
||||
<SelectItem value="active">{__('Active')}</SelectItem>
|
||||
<SelectItem value="revoked">{__('Revoked')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{__('License Key')}</TableHead>
|
||||
<TableHead>{__('Product')}</TableHead>
|
||||
<TableHead>{__('Customer')}</TableHead>
|
||||
<TableHead>{__('Activations')}</TableHead>
|
||||
<TableHead>{__('Status')}</TableHead>
|
||||
<TableHead>{__('Expires')}</TableHead>
|
||||
<TableHead className="w-[100px]">{__('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
{__('Loading...')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data?.licenses.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
{__('No licenses found')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data?.licenses.map((license) => (
|
||||
<TableRow key={license.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
|
||||
{license.license_key}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(license.license_key)}
|
||||
>
|
||||
{copiedKey === license.license_key ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium">{license.product_name}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{license.user_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{license.user_email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={license.activations_remaining === 0 ? 'text-red-500' : ''}>
|
||||
{license.activation_count} / {license.activation_limit === 0 ? '∞' : license.activation_limit}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(license)}</TableCell>
|
||||
<TableCell>
|
||||
{license.expires_at ? (
|
||||
<span className={license.is_expired ? 'text-red-500' : ''}>
|
||||
{new Date(license.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{__('Never')}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/products/licenses/${license.id}`)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{license.status === 'active' && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Ban className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{__('Revoke License')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__('This will permanently revoke the license. The customer will no longer be able to use it.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => revokeMutation.mutate(license.id)}
|
||||
className="bg-destructive text-destructive-foreground"
|
||||
>
|
||||
{__('Revoke')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Showing')} {((page - 1) * 20) + 1} - {Math.min(page * 20, data?.total || 0)} {__('of')} {data?.total || 0}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
{__('Previous')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
{__('Next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
|
||||
const siteUrl = window.location.origin;
|
||||
const spaPagePath = '/store'; // This should ideally come from settings
|
||||
const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
|
||||
|
||||
const generateLink = (variationId?: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -4,12 +4,13 @@ import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { GeneralTab } from './tabs/GeneralTab';
|
||||
import { InventoryTab } from './tabs/InventoryTab';
|
||||
import { VariationsTab, ProductVariant } from './tabs/VariationsTab';
|
||||
import { OrganizationTab } from './tabs/OrganizationTab';
|
||||
import { DownloadsTab, DownloadableFile } from './tabs/DownloadsTab';
|
||||
|
||||
// Types
|
||||
export type ProductFormData = {
|
||||
@@ -32,6 +33,13 @@ export type ProductFormData = {
|
||||
virtual?: boolean;
|
||||
downloadable?: boolean;
|
||||
featured?: boolean;
|
||||
downloads?: DownloadableFile[];
|
||||
download_limit?: string;
|
||||
download_expiry?: string;
|
||||
// Licensing
|
||||
licensing_enabled?: boolean;
|
||||
license_activation_limit?: string;
|
||||
license_duration_days?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -75,6 +83,12 @@ export function ProductFormTabbed({
|
||||
const [virtual, setVirtual] = useState(initial?.virtual || false);
|
||||
const [downloadable, setDownloadable] = useState(initial?.downloadable || 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 [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Update form state when initial data changes (for edit mode)
|
||||
@@ -99,6 +113,12 @@ export function ProductFormTabbed({
|
||||
setVirtual(initial.virtual || false);
|
||||
setDownloadable(initial.downloadable || 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 || '');
|
||||
}
|
||||
}, [initial, mode]);
|
||||
|
||||
@@ -155,6 +175,12 @@ export function ProductFormTabbed({
|
||||
virtual,
|
||||
downloadable,
|
||||
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,
|
||||
};
|
||||
|
||||
await onSubmit(payload);
|
||||
@@ -169,6 +195,7 @@ export function ProductFormTabbed({
|
||||
const tabs = [
|
||||
{ id: 'general', label: __('General'), icon: <Package 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" /> }] : []),
|
||||
{ id: 'organization', label: __('Organization'), icon: <Tag className="w-4 h-4" /> },
|
||||
];
|
||||
@@ -203,6 +230,13 @@ export function ProductFormTabbed({
|
||||
setRegularPrice={setRegularPrice}
|
||||
salePrice={salePrice}
|
||||
setSalePrice={setSalePrice}
|
||||
productId={productId}
|
||||
licensingEnabled={licensingEnabled}
|
||||
setLicensingEnabled={setLicensingEnabled}
|
||||
licenseActivationLimit={licenseActivationLimit}
|
||||
setLicenseActivationLimit={setLicenseActivationLimit}
|
||||
licenseDurationDays={licenseDurationDays}
|
||||
setLicenseDurationDays={setLicenseDurationDays}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
@@ -218,6 +252,20 @@ export function ProductFormTabbed({
|
||||
/>
|
||||
</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) */}
|
||||
{type === 'variable' && (
|
||||
<FormSection id="variations">
|
||||
@@ -251,8 +299,8 @@ export function ProductFormTabbed({
|
||||
{submitting
|
||||
? __('Saving...')
|
||||
: mode === 'create'
|
||||
? __('Create Product')
|
||||
: __('Update Product')}
|
||||
? __('Create Product')
|
||||
: __('Update Product')}
|
||||
</Button>
|
||||
</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 { Input } from '@/components/ui/input';
|
||||
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 { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
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 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getStoreCurrency } from '@/lib/currency';
|
||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||
import { openWPMediaGallery } from '@/lib/wp-media';
|
||||
@@ -40,6 +41,15 @@ type GeneralTabProps = {
|
||||
setRegularPrice: (value: string) => void;
|
||||
salePrice: string;
|
||||
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;
|
||||
};
|
||||
|
||||
export function GeneralTab({
|
||||
@@ -67,6 +77,13 @@ export function GeneralTab({
|
||||
setRegularPrice,
|
||||
salePrice,
|
||||
setSalePrice,
|
||||
productId,
|
||||
licensingEnabled,
|
||||
setLicensingEnabled,
|
||||
licenseActivationLimit,
|
||||
setLicenseActivationLimit,
|
||||
licenseDurationDays,
|
||||
setLicenseDurationDays,
|
||||
}: GeneralTabProps) {
|
||||
const savingsPercent =
|
||||
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
|
||||
@@ -75,6 +92,30 @@ export function GeneralTab({
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -387,8 +428,104 @@ export function GeneralTab({
|
||||
{__('Featured product (show in featured sections)')}
|
||||
</Label>
|
||||
</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>
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -26,6 +27,7 @@ export function OrganizationTab({
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
}: OrganizationTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
const [creatingCategory, setCreatingCategory] = useState(false);
|
||||
@@ -46,7 +48,8 @@ export function OrganizationTab({
|
||||
if (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) {
|
||||
toast.error(error.message || __('Failed to create category'));
|
||||
} finally {
|
||||
@@ -183,11 +186,10 @@ export function OrganizationTab({
|
||||
setSelectedTags([...selectedTags, tag.id]);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1 rounded-full text-sm border transition-colors ${
|
||||
selectedTags.includes(tag.id)
|
||||
className={`px-3 py-1 rounded-full text-sm border transition-colors ${selectedTags.includes(tag.id)
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background border-border hover:bg-accent'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
|
||||
@@ -22,6 +22,7 @@ export type ProductVariant = {
|
||||
manage_stock?: boolean;
|
||||
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
|
||||
image?: string;
|
||||
license_duration_days?: string;
|
||||
};
|
||||
|
||||
type VariationsTabProps = {
|
||||
@@ -45,7 +46,7 @@ export function VariationsTab({
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
|
||||
const siteUrl = window.location.origin;
|
||||
const spaPagePath = '/store';
|
||||
const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
|
||||
|
||||
const generateLink = (variationId: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||
if (!productId) return '';
|
||||
@@ -276,6 +277,26 @@ export function VariationsTab({
|
||||
)}
|
||||
</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">
|
||||
<Input
|
||||
placeholder={__('SKU')}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
||||
interface CustomerSettings {
|
||||
auto_register_members: boolean;
|
||||
multiple_addresses_enabled: boolean;
|
||||
allow_custom_avatar: boolean;
|
||||
vip_min_spent: number;
|
||||
vip_min_orders: number;
|
||||
vip_timeframe: 'all' | '30' | '90' | '365';
|
||||
@@ -24,6 +25,7 @@ export default function CustomersSettings() {
|
||||
const [settings, setSettings] = useState<CustomerSettings>({
|
||||
auto_register_members: false,
|
||||
multiple_addresses_enabled: true,
|
||||
allow_custom_avatar: false,
|
||||
vip_min_spent: 1000,
|
||||
vip_min_orders: 10,
|
||||
vip_timeframe: 'all',
|
||||
@@ -138,6 +140,14 @@ export default function CustomersSettings() {
|
||||
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
|
||||
/>
|
||||
|
||||
<ToggleField
|
||||
id="allow_custom_avatar"
|
||||
label={__('Allow custom profile photo')}
|
||||
description={__('Allow customers to upload their own profile photo. When disabled, customer avatars will use Gravatar or default initials.')}
|
||||
checked={settings.allow_custom_avatar}
|
||||
onCheckedChange={(checked) => setSettings({ ...settings, allow_custom_avatar: checked })}
|
||||
/>
|
||||
|
||||
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -154,12 +154,14 @@ export default function NotificationsSettings() {
|
||||
</p>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{__('Coming soon')}
|
||||
{__('Sent, Failed, Pending')}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
{__('View Log')}
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<Link to="/settings/notifications/activity-log">
|
||||
<Button variant="outline" size="sm">
|
||||
{__('View Log')}
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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">
|
||||
<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="events">{__('Events')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function EmailConfiguration() {
|
||||
}
|
||||
>
|
||||
<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="connection">{__('Connection Settings')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function PushConfiguration() {
|
||||
}
|
||||
>
|
||||
<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="connection">{__('Connection Settings')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function StaffNotifications() {
|
||||
}
|
||||
>
|
||||
<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="events">{__('Events')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -206,7 +206,7 @@ export default function TemplateEditor({
|
||||
{/* Body - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<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">
|
||||
<Edit className="h-4 w-4" />
|
||||
{__('Editor')}
|
||||
|
||||
@@ -31,7 +31,8 @@ interface ShippingZone {
|
||||
|
||||
export default function ShippingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const wcAdminUrl = (window as any).WNW_CONFIG?.wpAdminUrl || '/wp-admin';
|
||||
// Use siteUrl + /wp-admin since wpAdminUrl already includes admin.php?page=woonoow
|
||||
const wcAdminUrl = ((window as any).WNW_CONFIG?.siteUrl || '') + '/wp-admin';
|
||||
const [togglingMethod, setTogglingMethod] = useState<string | null>(null);
|
||||
const [selectedZone, setSelectedZone] = useState<any | null>(null);
|
||||
const [showAddMethod, setShowAddMethod] = useState(false);
|
||||
@@ -287,98 +288,98 @@ export default function ShippingPage() {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{zones.map((zone: any) => (
|
||||
<div
|
||||
key={zone.id}
|
||||
className="border rounded-lg p-3 md:p-4 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 md:gap-3 mb-3 md:mb-4">
|
||||
<div className="flex items-start gap-2 md:gap-3 flex-1 min-w-0">
|
||||
<div className="p-1.5 md:p-2 bg-primary/10 rounded-lg text-primary flex-shrink-0">
|
||||
<Globe className="h-4 w-4 md:h-5 md:w-5" />
|
||||
<div
|
||||
key={zone.id}
|
||||
className="border rounded-lg p-3 md:p-4 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 md:gap-3 mb-3 md:mb-4">
|
||||
<div className="flex items-start gap-2 md:gap-3 flex-1 min-w-0">
|
||||
<div className="p-1.5 md:p-2 bg-primary/10 rounded-lg text-primary flex-shrink-0">
|
||||
<Globe className="h-4 w-4 md:h-5 md:w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-sm md:text-lg">{zone.name}</h3>
|
||||
<p className="text-xs md:text-sm text-muted-foreground truncate">
|
||||
<span className="font-medium">{__('Available to:')}</span> {zone.regions}
|
||||
</p>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">
|
||||
{zone.rates.length} {zone.rates.length === 1 ? __('delivery option') : __('delivery options')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-sm md:text-lg">{zone.name}</h3>
|
||||
<p className="text-xs md:text-sm text-muted-foreground truncate">
|
||||
<span className="font-medium">{__('Available to:')}</span> {zone.regions}
|
||||
</p>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">
|
||||
{zone.rates.length} {zone.rates.length === 1 ? __('delivery option') : __('delivery options')}
|
||||
</p>
|
||||
<div className="flex gap-1 md:gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingZone(zone)}
|
||||
title={__('Edit zone name and regions')}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeletingZone(zone)}
|
||||
title={__('Delete zone')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedZone(zone)}
|
||||
title={__('Manage delivery options')}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 md:gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingZone(zone)}
|
||||
title={__('Edit zone name and regions')}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeletingZone(zone)}
|
||||
title={__('Delete zone')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedZone(zone)}
|
||||
title={__('Manage delivery options')}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Rates */}
|
||||
<div className="pl-0 md:pl-11 space-y-2">
|
||||
{zone.rates?.map((rate: any) => (
|
||||
<div
|
||||
key={rate.id}
|
||||
className="flex items-center justify-between gap-2 py-2 px-2 md:px-3 bg-muted/50 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
|
||||
<div className={`p-1 rounded flex-shrink-0 ${rate.enabled ? 'bg-green-500/20 text-green-500' : 'bg-primary/10 text-primary'}`}>
|
||||
<Truck className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
{/* Shipping Rates */}
|
||||
<div className="pl-0 md:pl-11 space-y-2">
|
||||
{zone.rates?.map((rate: any) => (
|
||||
<div
|
||||
key={rate.id}
|
||||
className="flex items-center justify-between gap-2 py-2 px-2 md:px-3 bg-muted/50 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 md:gap-2 flex-1 min-w-0">
|
||||
<div className={`p-1 rounded flex-shrink-0 ${rate.enabled ? 'bg-green-500/20 text-green-500' : 'bg-primary/10 text-primary'}`}>
|
||||
<Truck className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span
|
||||
className="text-xs md:text-sm font-medium line-clamp-1"
|
||||
dangerouslySetInnerHTML={{ __html: rate.name }}
|
||||
/>
|
||||
{rate.transitTime && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
• {rate.transitTime}
|
||||
</span>
|
||||
)}
|
||||
{rate.condition && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
• {rate.condition}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 md:gap-3 flex-shrink-0">
|
||||
<span
|
||||
className="text-xs md:text-sm font-medium line-clamp-1"
|
||||
dangerouslySetInnerHTML={{ __html: rate.name }}
|
||||
className="text-xs md:text-sm font-semibold whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{ __html: rate.price }}
|
||||
/>
|
||||
<ToggleField
|
||||
id={`${zone.id}-${rate.instance_id}`}
|
||||
label=""
|
||||
checked={rate.enabled}
|
||||
onCheckedChange={(checked) => handleToggle(zone.id, rate.instance_id, checked)}
|
||||
disabled={togglingMethod === `${zone.id}-${rate.instance_id}`}
|
||||
/>
|
||||
{rate.transitTime && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
• {rate.transitTime}
|
||||
</span>
|
||||
)}
|
||||
{rate.condition && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
• {rate.condition}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:gap-3 flex-shrink-0">
|
||||
<span
|
||||
className="text-xs md:text-sm font-semibold whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{ __html: rate.price }}
|
||||
/>
|
||||
<ToggleField
|
||||
id={`${zone.id}-${rate.instance_id}`}
|
||||
label=""
|
||||
checked={rate.enabled}
|
||||
onCheckedChange={(checked) => handleToggle(zone.id, rate.instance_id, checked)}
|
||||
disabled={togglingMethod === `${zone.id}-${rate.instance_id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
@@ -481,11 +482,10 @@ export default function ShippingPage() {
|
||||
<div className="font-medium" dangerouslySetInnerHTML={{ __html: rate.name }} />
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
rate.enabled
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${rate.enabled
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
}`}>
|
||||
{rate.enabled ? __('On') : __('Off')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -695,11 +695,10 @@ export default function ShippingPage() {
|
||||
<div className="font-medium text-sm line-clamp-1" dangerouslySetInnerHTML={{ __html: rate.name }} />
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
|
||||
<span className={`px-1.5 py-0.5 rounded-full whitespace-nowrap ${
|
||||
rate.enabled
|
||||
<span className={`px-1.5 py-0.5 rounded-full whitespace-nowrap ${rate.enabled
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
}`}>
|
||||
{rate.enabled ? __('On') : __('Off')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -972,10 +971,10 @@ export default function ShippingPage() {
|
||||
{availableLocations.filter((location: any) =>
|
||||
location.label.toLowerCase().includes(regionSearch.toLowerCase())
|
||||
).length === 0 && (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{__('No regions found')}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{__('No regions found')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
6
admin-spa/src/types/window.d.ts
vendored
6
admin-spa/src/types/window.d.ts
vendored
@@ -41,6 +41,10 @@ interface WNW_CONFIG {
|
||||
decimalSeparator: string;
|
||||
decimals: number;
|
||||
};
|
||||
storeUrl?: string;
|
||||
customerSpaEnabled?: boolean;
|
||||
nonce?: string;
|
||||
pluginUrl?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -52,4 +56,4 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
export { };
|
||||
|
||||
@@ -40,7 +40,7 @@ rsync -av --progress \
|
||||
--exclude='admin-spa' \
|
||||
--exclude='examples' \
|
||||
--exclude='*.sh' \
|
||||
--exclude='*.md' \
|
||||
--exclude='/*.md' \
|
||||
--exclude='archive' \
|
||||
--exclude='test-*.php' \
|
||||
--exclude='check-*.php' \
|
||||
|
||||
53
customer-spa/package-lock.json
generated
53
customer-spa/package-lock.json
generated
@@ -25,9 +25,11 @@
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.547.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -3596,6 +3598,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -4927,6 +4945,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -6214,6 +6241,26 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-fast-compare": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-helmet-async": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-2.0.5.tgz",
|
||||
"integrity": "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"shallowequal": "^1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.6.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.66.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
|
||||
@@ -6658,6 +6705,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/shallowequal": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -27,9 +27,11 @@
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.547.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { HashRouter, BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { Toaster } from 'sonner';
|
||||
|
||||
// Theme
|
||||
@@ -58,16 +59,12 @@ const getAppearanceSettings = () => {
|
||||
const getInitialRoute = () => {
|
||||
const appEl = document.getElementById('woonoow-customer-app');
|
||||
const initialRoute = appEl?.getAttribute('data-initial-route');
|
||||
console.log('[WooNooW Customer] Initial route from data attribute:', initialRoute);
|
||||
console.log('[WooNooW Customer] App element:', appEl);
|
||||
console.log('[WooNooW Customer] All data attributes:', appEl?.dataset);
|
||||
return initialRoute || '/shop'; // Default to shop if not specified
|
||||
};
|
||||
|
||||
// Router wrapper component that uses hooks requiring Router context
|
||||
function AppRoutes() {
|
||||
const initialRoute = getInitialRoute();
|
||||
console.log('[WooNooW Customer] Using initial route:', initialRoute);
|
||||
|
||||
return (
|
||||
<BaseLayout>
|
||||
@@ -102,22 +99,44 @@ function AppRoutes() {
|
||||
);
|
||||
}
|
||||
|
||||
// Get router config from WordPress
|
||||
const getRouterConfig = () => {
|
||||
const config = (window as any).woonoowCustomer;
|
||||
return {
|
||||
useBrowserRouter: config?.useBrowserRouter ?? true,
|
||||
basePath: config?.basePath || '/store',
|
||||
};
|
||||
};
|
||||
|
||||
// Router wrapper that conditionally uses BrowserRouter or HashRouter
|
||||
function RouterProvider({ children }: { children: React.ReactNode }) {
|
||||
const { useBrowserRouter, basePath } = getRouterConfig();
|
||||
|
||||
if (useBrowserRouter) {
|
||||
return <BrowserRouter basename={basePath}>{children}</BrowserRouter>;
|
||||
}
|
||||
|
||||
return <HashRouter>{children}</HashRouter>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const themeConfig = getThemeConfig();
|
||||
const appearanceSettings = getAppearanceSettings();
|
||||
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider config={themeConfig}>
|
||||
<HashRouter>
|
||||
<AppRoutes />
|
||||
</HashRouter>
|
||||
<HelmetProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider config={themeConfig}>
|
||||
<RouterProvider>
|
||||
<AppRoutes />
|
||||
</RouterProvider>
|
||||
|
||||
{/* Toast notifications - position from settings */}
|
||||
<Toaster position={toastPosition} richColors />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
{/* Toast notifications - position from settings */}
|
||||
<Toaster position={toastPosition} richColors />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</HelmetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
296
customer-spa/src/components/DynamicCheckoutField.tsx
Normal file
296
customer-spa/src/components/DynamicCheckoutField.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||
import { api } from '@/lib/api/client';
|
||||
|
||||
interface CheckoutField {
|
||||
key: string;
|
||||
fieldset: 'billing' | 'shipping' | 'account' | 'order';
|
||||
type: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
hidden: boolean;
|
||||
class?: string[];
|
||||
priority: number;
|
||||
options?: Record<string, string> | null;
|
||||
custom: boolean;
|
||||
autocomplete?: string;
|
||||
validate?: string[];
|
||||
input_class?: string[];
|
||||
custom_attributes?: Record<string, string>;
|
||||
default?: string;
|
||||
// For searchable_select type
|
||||
search_endpoint?: string | null;
|
||||
search_param?: string;
|
||||
min_chars?: number;
|
||||
}
|
||||
|
||||
interface DynamicCheckoutFieldProps {
|
||||
field: CheckoutField;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
countryOptions?: { value: string; label: string }[];
|
||||
stateOptions?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
interface SearchOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function DynamicCheckoutField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
countryOptions = [],
|
||||
stateOptions = [],
|
||||
}: DynamicCheckoutFieldProps) {
|
||||
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// For searchable_select with API endpoint
|
||||
useEffect(() => {
|
||||
if (field.type !== 'searchable_select' || !field.search_endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have a value but no options yet, we might need to load it
|
||||
// This handles pre-selected values
|
||||
}, [field.type, field.search_endpoint, value]);
|
||||
|
||||
// Handle API search for searchable_select
|
||||
const handleApiSearch = async (searchTerm: string) => {
|
||||
if (!field.search_endpoint) return;
|
||||
|
||||
const minChars = field.min_chars || 2;
|
||||
if (searchTerm.length < minChars) {
|
||||
setSearchOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const param = field.search_param || 'search';
|
||||
const results = await api.get<SearchOption[]>(field.search_endpoint, { [param]: searchTerm });
|
||||
setSearchOptions(Array.isArray(results) ? results : []);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setSearchOptions([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render hidden fields
|
||||
if (field.hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get field key without prefix (billing_, shipping_)
|
||||
const fieldName = field.key.replace(/^(billing_|shipping_)/, '');
|
||||
|
||||
// Determine CSS classes
|
||||
const isWide = ['address_1', 'address_2', 'email'].includes(fieldName) ||
|
||||
field.class?.includes('form-row-wide');
|
||||
const wrapperClass = isWide ? 'md:col-span-2' : '';
|
||||
|
||||
// Render based on type
|
||||
const renderInput = () => {
|
||||
switch (field.type) {
|
||||
case 'country':
|
||||
return (
|
||||
<SearchableSelect
|
||||
options={countryOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={field.placeholder || 'Select country'}
|
||||
disabled={countryOptions.length <= 1}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'state':
|
||||
return stateOptions.length > 0 ? (
|
||||
<SearchableSelect
|
||||
options={stateOptions}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={field.placeholder || 'Select state'}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
autoComplete={field.autocomplete}
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
if (field.options && Object.keys(field.options).length > 0) {
|
||||
const options = Object.entries(field.options).map(([val, label]) => ({
|
||||
value: val,
|
||||
label: String(label),
|
||||
}));
|
||||
return (
|
||||
<SearchableSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={field.placeholder || `Select ${field.label}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'searchable_select':
|
||||
return (
|
||||
<SearchableSelect
|
||||
options={searchOptions}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
onChange(v);
|
||||
// Also store label for display
|
||||
const selected = searchOptions.find(o => o.value === v);
|
||||
if (selected) {
|
||||
// Store label in a hidden field with _label suffix
|
||||
const event = new CustomEvent('woonoow:field_label', {
|
||||
detail: { key: field.key + '_label', value: selected.label }
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
}}
|
||||
onSearch={handleApiSearch}
|
||||
isSearching={isSearching}
|
||||
placeholder={field.placeholder || `Search ${field.label}...`}
|
||||
emptyLabel={
|
||||
isSearching
|
||||
? 'Searching...'
|
||||
: `Type at least ${field.min_chars || 2} characters to search`
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className="w-full border !rounded-lg px-4 py-2 min-h-[100px]"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value === '1' || value === 'true'}
|
||||
onChange={(e) => onChange(e.target.checked ? '1' : '0')}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
case 'radio':
|
||||
if (field.options) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(field.options).map(([val, label]) => (
|
||||
<label key={val} className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={field.key}
|
||||
value={val}
|
||||
checked={value === val}
|
||||
onChange={() => onChange(val)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>{String(label)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'email':
|
||||
return (
|
||||
<input
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
autoComplete={field.autocomplete || 'email'}
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'tel':
|
||||
return (
|
||||
<input
|
||||
type="tel"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
autoComplete={field.autocomplete || 'tel'}
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'password':
|
||||
return (
|
||||
<input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
);
|
||||
|
||||
// Default: text input
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
autoComplete={field.autocomplete}
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render label for checkbox (it's inline)
|
||||
if (field.type === 'checkbox') {
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
{renderInput()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{renderInput()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { CheckoutField };
|
||||
68
customer-spa/src/components/SEOHead.tsx
Normal file
68
customer-spa/src/components/SEOHead.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
interface SEOHeadProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
url?: string;
|
||||
type?: 'website' | 'product' | 'article';
|
||||
product?: {
|
||||
price?: string;
|
||||
currency?: string;
|
||||
availability?: 'in stock' | 'out of stock';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SEOHead Component
|
||||
* Adds dynamic meta tags for social media sharing (Open Graph, Twitter Cards)
|
||||
* Used for link previews on Facebook, Twitter, Slack, etc.
|
||||
*/
|
||||
export function SEOHead({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
url,
|
||||
type = 'website',
|
||||
product,
|
||||
}: SEOHeadProps) {
|
||||
const config = (window as any).woonoowCustomer;
|
||||
const siteName = config?.siteName || 'Store';
|
||||
const siteUrl = config?.siteUrl || '';
|
||||
|
||||
const fullTitle = title ? `${title} | ${siteName}` : siteName;
|
||||
const fullUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
{/* Basic Meta Tags */}
|
||||
<title>{fullTitle}</title>
|
||||
{description && <meta name="description" content={description} />}
|
||||
|
||||
{/* Open Graph (Facebook, LinkedIn, etc.) */}
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
<meta property="og:title" content={title || siteName} />
|
||||
{description && <meta property="og:description" content={description} />}
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:url" content={fullUrl} />
|
||||
{image && <meta property="og:image" content={image} />}
|
||||
|
||||
{/* Twitter Card */}
|
||||
<meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'} />
|
||||
<meta name="twitter:title" content={title || siteName} />
|
||||
{description && <meta name="twitter:description" content={description} />}
|
||||
{image && <meta name="twitter:image" content={image} />}
|
||||
|
||||
{/* Product-specific meta tags */}
|
||||
{type === 'product' && product && (
|
||||
<>
|
||||
<meta property="product:price:amount" content={product.price} />
|
||||
<meta property="product:price:currency" content={product.currency} />
|
||||
<meta property="product:availability" content={product.availability} />
|
||||
</>
|
||||
)}
|
||||
</Helmet>
|
||||
);
|
||||
}
|
||||
|
||||
export default SEOHead;
|
||||
107
customer-spa/src/components/ui/command.tsx
Normal file
107
customer-spa/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-white text-gray-900",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-gray-400 disabled:cursor-not-allowed disabled:opacity-50 border-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-gray-900",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-gray-100 data-[selected=true]:text-gray-900 data-[disabled=true]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
}
|
||||
30
customer-spa/src/components/ui/popover.tsx
Normal file
30
customer-spa/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-white p-4 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
123
customer-spa/src/components/ui/searchable-select.tsx
Normal file
123
customer-spa/src/components/ui/searchable-select.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandItem,
|
||||
CommandEmpty,
|
||||
} from "@/components/ui/command";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
searchText?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
onChange?: (v: string) => void;
|
||||
options: Option[];
|
||||
placeholder?: string;
|
||||
emptyLabel?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
// For API-based search
|
||||
onSearch?: (searchTerm: string) => void;
|
||||
isSearching?: boolean;
|
||||
}
|
||||
|
||||
export function SearchableSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "Select...",
|
||||
emptyLabel = "No results found.",
|
||||
className,
|
||||
disabled = false,
|
||||
onSearch,
|
||||
isSearching = false,
|
||||
}: Props) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [searchValue, setSearchValue] = React.useState("");
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
|
||||
|
||||
// Handle search input changes
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchValue(value);
|
||||
if (onSearch) {
|
||||
onSearch(value);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine if we should use local filtering or API-based search
|
||||
const shouldFilter = !onSearch;
|
||||
|
||||
return (
|
||||
<Popover open={disabled ? false : open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between border !rounded-lg px-4 py-2 text-left bg-white hover:bg-gray-50 focus:outline-none focus:border-gray-400",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<span className={selected ? "text-gray-900" : "text-gray-400"}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<ChevronDown className="opacity-50 h-4 w-4 shrink-0" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0 w-[--radix-popover-trigger-width]"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<Command shouldFilter={shouldFilter}>
|
||||
<CommandInput
|
||||
placeholder="Search..."
|
||||
value={searchValue}
|
||||
onValueChange={handleSearchChange}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isSearching ? "Searching..." : emptyLabel}
|
||||
</CommandEmpty>
|
||||
{options.map((opt) => (
|
||||
<CommandItem
|
||||
key={opt.value}
|
||||
value={opt.searchText || opt.label || opt.value}
|
||||
onSelect={() => {
|
||||
onChange?.(opt.value);
|
||||
setOpen(false);
|
||||
setSearchValue("");
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 flex-shrink-0",
|
||||
opt.value === value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{opt.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -37,19 +37,9 @@ export function useAddToCartFromUrl() {
|
||||
|
||||
// Skip if already processed
|
||||
if (processedRef.current.has(requestKey)) {
|
||||
console.log('[WooNooW] Skipping duplicate add-to-cart:', requestKey);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WooNooW] Add to cart from URL:', {
|
||||
productId,
|
||||
variationId,
|
||||
quantity,
|
||||
redirect,
|
||||
fullUrl: window.location.href,
|
||||
requestKey,
|
||||
});
|
||||
|
||||
// Mark as processed
|
||||
processedRef.current.add(requestKey);
|
||||
|
||||
@@ -58,7 +48,6 @@ export function useAddToCartFromUrl() {
|
||||
// Update cart store with fresh data from API
|
||||
if (cartData) {
|
||||
setCart(cartData);
|
||||
console.log('[WooNooW] Cart updated with fresh data:', cartData);
|
||||
}
|
||||
|
||||
// Remove URL parameters after adding to cart
|
||||
@@ -68,7 +57,6 @@ export function useAddToCartFromUrl() {
|
||||
// Navigate based on redirect parameter
|
||||
const targetPage = redirect === 'checkout' ? '/checkout' : '/cart';
|
||||
if (!location.pathname.includes(targetPage)) {
|
||||
console.log(`[WooNooW] Navigating to ${targetPage}`);
|
||||
navigate(targetPage);
|
||||
}
|
||||
})
|
||||
@@ -98,8 +86,6 @@ async function addToCart(
|
||||
body.variation_id = parseInt(variationId, 10);
|
||||
}
|
||||
|
||||
console.log('[WooNooW] Adding to cart:', body);
|
||||
|
||||
const response = await fetch(`${apiRoot}/cart/add`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -116,7 +102,6 @@ async function addToCart(
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[WooNooW] Product added to cart:', data);
|
||||
|
||||
// API returns {message, cart_item_key, cart} on success
|
||||
if (data.cart_item_key && data.cart) {
|
||||
|
||||
22
customer-spa/src/hooks/usePageTitle.ts
Normal file
22
customer-spa/src/hooks/usePageTitle.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to set the document title dynamically
|
||||
* @param title - The page title to set
|
||||
* @param suffix - Optional suffix (default: store name from settings)
|
||||
*/
|
||||
export function usePageTitle(title: string, suffix?: string) {
|
||||
useEffect(() => {
|
||||
const storeName = (window as any).woonoowCustomer?.storeName || 'Store';
|
||||
const finalSuffix = suffix ?? storeName;
|
||||
|
||||
document.title = title ? `${title} | ${finalSuffix}` : finalSuffix;
|
||||
|
||||
// Cleanup: restore original title when component unmounts
|
||||
return () => {
|
||||
// Don't restore - let the next page set its own title
|
||||
};
|
||||
}, [title, suffix]);
|
||||
}
|
||||
|
||||
export default usePageTitle;
|
||||
@@ -134,8 +134,8 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Wishlist */}
|
||||
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
||||
{/* Wishlist - Only for guests (logged-in users use /my-account/wishlist) */}
|
||||
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && !user?.isLoggedIn && (
|
||||
<Link to="/wishlist" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span className="hidden lg:block">Wishlist</span>
|
||||
@@ -428,7 +428,8 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
||||
{/* Wishlist - Only for guests */}
|
||||
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && !user?.isLoggedIn && (
|
||||
<Link to="/wishlist" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<Heart className="h-4 w-4" /> Wishlist
|
||||
</Link>
|
||||
@@ -561,7 +562,8 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
<User className="h-4 w-4" /> Account
|
||||
</Link>
|
||||
))}
|
||||
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
||||
{/* Wishlist - Only for guests */}
|
||||
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && !user?.isLoggedIn && (
|
||||
<Link to="/wishlist" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<Heart className="h-4 w-4" /> Wishlist
|
||||
</Link>
|
||||
|
||||
@@ -109,3 +109,57 @@ export async function fetchCart(): Promise<Cart> {
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply coupon to cart via API
|
||||
*/
|
||||
export async function applyCoupon(couponCode: string): Promise<Cart> {
|
||||
const { apiRoot, nonce } = getApiConfig();
|
||||
|
||||
const response = await fetch(`${apiRoot}/cart/apply-coupon`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
coupon_code: couponCode,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to apply coupon');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove coupon from cart via API
|
||||
*/
|
||||
export async function removeCoupon(couponCode: string): Promise<Cart> {
|
||||
const { apiRoot, nonce } = getApiConfig();
|
||||
|
||||
const response = await fetch(`${apiRoot}/cart/remove-coupon`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
coupon_code: couponCode,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to remove coupon');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.cart;
|
||||
}
|
||||
|
||||
@@ -20,10 +20,18 @@ export interface Cart {
|
||||
tax: number;
|
||||
shipping: number;
|
||||
total: number;
|
||||
needs_shipping?: boolean;
|
||||
coupon?: {
|
||||
code: string;
|
||||
discount: number;
|
||||
};
|
||||
coupons?: {
|
||||
code: string;
|
||||
discount: number;
|
||||
type?: string;
|
||||
}[];
|
||||
discount_total?: number;
|
||||
shipping_total?: number;
|
||||
}
|
||||
|
||||
interface CartStore {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api/client';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
|
||||
interface AvatarSettings {
|
||||
allow_custom_avatar: boolean;
|
||||
current_avatar: string | null;
|
||||
gravatar_url: string;
|
||||
}
|
||||
|
||||
export default function AccountDetails() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -16,8 +23,14 @@ export default function AccountDetails() {
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
// Avatar state
|
||||
const [avatarSettings, setAvatarSettings] = useState<AvatarSettings | null>(null);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
loadAvatarSettings();
|
||||
}, []);
|
||||
|
||||
const loadProfile = async () => {
|
||||
@@ -36,6 +49,89 @@ export default function AccountDetails() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadAvatarSettings = async () => {
|
||||
try {
|
||||
const data = await api.get<AvatarSettings>('/account/avatar-settings');
|
||||
setAvatarSettings(data);
|
||||
} catch (error) {
|
||||
console.error('Load avatar settings error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error('Please upload a valid image (JPG, PNG, GIF, or WebP)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('Image size must be less than 2MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadingAvatar(true);
|
||||
|
||||
try {
|
||||
// Convert to base64
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = async () => {
|
||||
try {
|
||||
const result = await api.post<{ avatar_url: string }>('/account/avatar', {
|
||||
avatar: reader.result,
|
||||
});
|
||||
|
||||
setAvatarSettings(prev => prev ? {
|
||||
...prev,
|
||||
current_avatar: result.avatar_url,
|
||||
} : null);
|
||||
|
||||
// Dispatch event to sync sidebar avatar
|
||||
window.dispatchEvent(new CustomEvent('woonoow:avatar-updated', { detail: { avatar_url: result.avatar_url } }));
|
||||
|
||||
toast.success('Avatar uploaded successfully');
|
||||
} catch (error: any) {
|
||||
console.error('Upload avatar error:', error);
|
||||
toast.error(error.message || 'Failed to upload avatar');
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.error('Read file error:', error);
|
||||
toast.error('Failed to read image file');
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = async () => {
|
||||
setUploadingAvatar(true);
|
||||
|
||||
try {
|
||||
await api.delete('/account/avatar');
|
||||
setAvatarSettings(prev => prev ? {
|
||||
...prev,
|
||||
current_avatar: null,
|
||||
} : null);
|
||||
|
||||
// Dispatch event to sync sidebar avatar (will fall back to gravatar)
|
||||
window.dispatchEvent(new CustomEvent('woonoow:avatar-updated', { detail: { avatar_url: null } }));
|
||||
|
||||
toast.success('Avatar removed');
|
||||
} catch (error: any) {
|
||||
console.error('Remove avatar error:', error);
|
||||
toast.error(error.message || 'Failed to remove avatar');
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
@@ -91,6 +187,8 @@ export default function AccountDetails() {
|
||||
}
|
||||
};
|
||||
|
||||
const currentAvatarUrl = avatarSettings?.current_avatar || avatarSettings?.gravatar_url;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -101,8 +199,60 @@ export default function AccountDetails() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SEOHead title="Account Details" description="Edit your account information" />
|
||||
<h1 className="text-2xl font-bold mb-6">Account Details</h1>
|
||||
|
||||
{/* Avatar Section */}
|
||||
{avatarSettings?.allow_custom_avatar && (
|
||||
<div className="mb-8 pb-8 border-b">
|
||||
<h2 className="text-xl font-semibold mb-4">Profile Photo</h2>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={currentAvatarUrl || '/placeholder-avatar.png'}
|
||||
alt="Profile"
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
|
||||
/>
|
||||
{uploadingAvatar && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full">
|
||||
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
onChange={handleAvatarUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingAvatar}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{uploadingAvatar ? 'Uploading...' : 'Upload Photo'}
|
||||
</button>
|
||||
{avatarSettings?.current_avatar && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveAvatar}
|
||||
disabled={uploadingAvatar}
|
||||
className="px-4 py-2 border border-red-500 text-red-500 rounded-lg hover:bg-red-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Remove Photo
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
JPG, PNG, GIF or WebP. Max 2MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { MapPin, Plus, Edit, Trash2, Star } from 'lucide-react';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { DynamicCheckoutField } from '@/components/DynamicCheckoutField';
|
||||
|
||||
interface Address {
|
||||
id: number;
|
||||
@@ -19,6 +21,35 @@ interface Address {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
is_default: boolean;
|
||||
// Custom fields
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
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;
|
||||
search_endpoint?: string | null;
|
||||
search_param?: string;
|
||||
min_chars?: number;
|
||||
}
|
||||
|
||||
interface CountryOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function Addresses() {
|
||||
@@ -26,23 +57,94 @@ export default function Addresses() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingAddress, setEditingAddress] = useState<Address | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<Address>>({
|
||||
const [formData, setFormData] = useState<Record<string, any>>({
|
||||
label: '',
|
||||
type: 'both',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: 'ID',
|
||||
email: '',
|
||||
phone: '',
|
||||
is_default: false,
|
||||
});
|
||||
|
||||
// Checkout fields from API
|
||||
const [checkoutFields, setCheckoutFields] = useState<CheckoutField[]>([]);
|
||||
const [countryOptions, setCountryOptions] = useState<CountryOption[]>([]);
|
||||
const [stateOptions, setStateOptions] = useState<CountryOption[]>([]);
|
||||
const [loadingFields, setLoadingFields] = useState(true);
|
||||
|
||||
// Fetch checkout fields and countries
|
||||
useEffect(() => {
|
||||
const loadFieldsAndCountries = async () => {
|
||||
try {
|
||||
// Fetch checkout fields (POST method required by API)
|
||||
const fieldsResponse = await api.post<{ fields: CheckoutField[] }>('/checkout/fields', {});
|
||||
setCheckoutFields(fieldsResponse.fields || []);
|
||||
|
||||
// Fetch countries
|
||||
const countriesResponse = await api.get<{ countries: Record<string, string> }>('/countries');
|
||||
if (countriesResponse.countries) {
|
||||
const options = Object.entries(countriesResponse.countries).map(([code, name]) => ({
|
||||
value: code,
|
||||
label: String(name),
|
||||
}));
|
||||
setCountryOptions(options);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load checkout fields:', error);
|
||||
} finally {
|
||||
setLoadingFields(false);
|
||||
}
|
||||
};
|
||||
loadFieldsAndCountries();
|
||||
}, []);
|
||||
|
||||
// Listen for field label events from DynamicCheckoutField (searchable_select)
|
||||
// This captures the human-readable label alongside the ID value
|
||||
useEffect(() => {
|
||||
const handleFieldLabel = (event: CustomEvent<{ key: string; value: string }>) => {
|
||||
const { key, value } = event.detail;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
document.addEventListener('woonoow:field_label', handleFieldLabel as EventListener);
|
||||
return () => {
|
||||
document.removeEventListener('woonoow:field_label', handleFieldLabel as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch states when country changes
|
||||
useEffect(() => {
|
||||
const country = formData.country || formData.billing_country || '';
|
||||
if (!country) {
|
||||
setStateOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadStates = async () => {
|
||||
try {
|
||||
const response = await api.get<{ states: Record<string, string> }>(`/countries/${country}/states`);
|
||||
if (response.states) {
|
||||
const options = Object.entries(response.states).map(([code, name]) => ({
|
||||
value: code,
|
||||
label: String(name),
|
||||
}));
|
||||
setStateOptions(options);
|
||||
} else {
|
||||
setStateOptions([]);
|
||||
}
|
||||
} catch {
|
||||
setStateOptions([]);
|
||||
}
|
||||
};
|
||||
loadStates();
|
||||
}, [formData.country, formData.billing_country]);
|
||||
|
||||
// Filter billing fields - API already returns them sorted by priority
|
||||
const billingFields = useMemo(() => {
|
||||
return checkoutFields
|
||||
.filter(f => f.fieldset === 'billing' && !f.hidden && f.type !== 'hidden');
|
||||
}, [checkoutFields]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAddresses();
|
||||
}, []);
|
||||
@@ -75,40 +177,86 @@ export default function Addresses() {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get field value - handles both prefixed and non-prefixed keys
|
||||
const getFieldValue = (key: string): string => {
|
||||
// Try exact key first
|
||||
if (formData[key] !== undefined) return String(formData[key] || '');
|
||||
|
||||
// Try without prefix
|
||||
const unprefixed = key.replace(/^billing_/, '');
|
||||
if (formData[unprefixed] !== undefined) return String(formData[unprefixed] || '');
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
// Helper to set field value - stores both prefixed and unprefixed for compatibility
|
||||
const setFieldValue = (key: string, value: string) => {
|
||||
const unprefixed = key.replace(/^billing_/, '');
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
[unprefixed]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingAddress(null);
|
||||
setFormData({
|
||||
// Initialize with defaults from API fields
|
||||
const defaults: Record<string, any> = {
|
||||
label: '',
|
||||
type: 'both',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: 'ID',
|
||||
email: '',
|
||||
phone: '',
|
||||
is_default: false,
|
||||
};
|
||||
billingFields.forEach(field => {
|
||||
if (field.default) {
|
||||
const unprefixed = field.key.replace(/^billing_/, '');
|
||||
defaults[field.key] = field.default;
|
||||
defaults[unprefixed] = field.default;
|
||||
}
|
||||
});
|
||||
setFormData(defaults);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (address: Address) => {
|
||||
setEditingAddress(address);
|
||||
setFormData(address);
|
||||
// Map address fields to formData with both prefixed and unprefixed keys
|
||||
const data: Record<string, any> = { ...address };
|
||||
// Add billing_ prefixed versions
|
||||
Object.entries(address).forEach(([key, value]) => {
|
||||
data[`billing_${key}`] = value;
|
||||
});
|
||||
setFormData(data);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// Prepare payload with unprefixed keys
|
||||
const payload: Record<string, any> = {
|
||||
label: formData.label,
|
||||
type: formData.type,
|
||||
is_default: formData.is_default,
|
||||
};
|
||||
|
||||
// Add all fields (unprefixed)
|
||||
billingFields.forEach(field => {
|
||||
const unprefixed = field.key.replace(/^billing_/, '');
|
||||
payload[unprefixed] = getFieldValue(field.key);
|
||||
|
||||
// Also include _label fields if they exist (for searchable_select fields)
|
||||
const labelKey = field.key + '_label';
|
||||
if (formData[labelKey]) {
|
||||
const unprefixedLabel = unprefixed + '_label';
|
||||
payload[unprefixedLabel] = formData[labelKey];
|
||||
}
|
||||
});
|
||||
|
||||
if (editingAddress) {
|
||||
await api.put(`/account/addresses/${editingAddress.id}`, formData);
|
||||
await api.put(`/account/addresses/${editingAddress.id}`, payload);
|
||||
toast.success('Address updated successfully');
|
||||
} else {
|
||||
await api.post('/account/addresses', formData);
|
||||
await api.post('/account/addresses', payload);
|
||||
toast.success('Address added successfully');
|
||||
}
|
||||
setShowModal(false);
|
||||
@@ -143,6 +291,13 @@ export default function Addresses() {
|
||||
}
|
||||
};
|
||||
|
||||
// Check if a field should be wide (full width)
|
||||
const isFieldWide = (field: CheckoutField): boolean => {
|
||||
const fieldName = field.key.replace(/^billing_/, '');
|
||||
return ['address_1', 'address_2', 'email'].includes(fieldName) ||
|
||||
field.class?.includes('form-row-wide') || false;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -153,6 +308,7 @@ export default function Addresses() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SEOHead title="Addresses" description="Manage your shipping and billing addresses" />
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Addresses</h1>
|
||||
<button
|
||||
@@ -243,161 +399,69 @@ export default function Addresses() {
|
||||
{editingAddress ? 'Edit Address' : 'Add New Address'}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Label *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.label}
|
||||
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
|
||||
placeholder="e.g., Home, Office, Parents"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Address Type *</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as Address['type'] })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="both">Billing & Shipping</option>
|
||||
<option value="billing">Billing Only</option>
|
||||
<option value="shipping">Shipping Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{loadingFields ? (
|
||||
<div className="py-8 text-center text-gray-500">Loading form fields...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Label field - always shown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">First Name *</label>
|
||||
<label className="block text-sm font-medium mb-1">Label *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
value={formData.label || ''}
|
||||
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
|
||||
placeholder="e.g., Home, Office, Parents"
|
||||
className="w-full px-3 py-2 border !rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Company</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.company}
|
||||
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Address Line 1 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address_1}
|
||||
onChange={(e) => setFormData({ ...formData, address_1: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Address Line 2</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address_2}
|
||||
onChange={(e) => setFormData({ ...formData, address_2: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Address Type - always shown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">City *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.city}
|
||||
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
||||
<label className="block text-sm font-medium mb-1">Address Type *</label>
|
||||
<select
|
||||
value={formData.type || 'both'}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as Address['type'] })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
>
|
||||
<option value="both">Billing & Shipping</option>
|
||||
<option value="billing">Billing Only</option>
|
||||
<option value="shipping">Shipping Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">State/Province *</label>
|
||||
|
||||
{/* Dynamic fields from checkout API - DynamicCheckoutField renders its own labels */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{billingFields.map((field) => (
|
||||
<DynamicCheckoutField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={getFieldValue(field.key)}
|
||||
onChange={(v) => setFieldValue(field.key, v)}
|
||||
countryOptions={countryOptions}
|
||||
stateOptions={stateOptions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Set as default checkbox */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.state}
|
||||
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
type="checkbox"
|
||||
id="is_default"
|
||||
checked={formData.is_default || false}
|
||||
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="is_default" className="text-sm">Set as default address</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Postcode *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.postcode}
|
||||
onChange={(e) => setFormData({ ...formData, postcode: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.country}
|
||||
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_default"
|
||||
checked={formData.is_default}
|
||||
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="is_default" className="text-sm">Set as default address</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="font-[inherit] flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
disabled={loadingFields}
|
||||
className="font-[inherit] flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Save Address
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ShoppingBag, Package, MapPin, User } from 'lucide-react';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
|
||||
export default function Dashboard() {
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SEOHead title="My Account" description="Manage your account" />
|
||||
<h1 className="text-2xl font-bold mb-6">
|
||||
Hello {user?.display_name || 'there'}!
|
||||
</h1>
|
||||
|
||||
@@ -1,15 +1,160 @@
|
||||
import React from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, Loader2, FileText, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { toast } from 'sonner';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
|
||||
interface DownloadItem {
|
||||
download_id: string;
|
||||
download_url: string;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_url: string;
|
||||
download_name: string;
|
||||
order_id: number;
|
||||
order_key: string;
|
||||
downloads_remaining: string;
|
||||
access_expires: string | null;
|
||||
file: {
|
||||
name: string;
|
||||
file: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Downloads() {
|
||||
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDownloads = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await api.get<DownloadItem[]>('/account/downloads');
|
||||
setDownloads(data);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch downloads:', err);
|
||||
setError(err.message || 'Failed to load downloads');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDownloads();
|
||||
}, []);
|
||||
|
||||
const handleDownload = (downloadUrl: string, fileName: string) => {
|
||||
// Open download in new tab
|
||||
window.open(downloadUrl, '_blank');
|
||||
toast.success(`Downloading ${fileName}`);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
|
||||
<div className="text-center py-12">
|
||||
<Loader2 className="w-12 h-12 text-gray-400 mx-auto mb-4 animate-spin" />
|
||||
<p className="text-gray-600">Loading your downloads...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">{error}</p>
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (downloads.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
|
||||
<div className="text-center py-12">
|
||||
<Download className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-2">No downloads available</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Downloads will appear here after you purchase downloadable products.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SEOHead title="Downloads" description="Your purchased downloads" />
|
||||
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
|
||||
|
||||
<div className="text-center py-12">
|
||||
<Download className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-600">No downloads available</p>
|
||||
<div className="space-y-4">
|
||||
{downloads.map((download) => (
|
||||
<div
|
||||
key={`${download.download_id}-${download.order_id}`}
|
||||
className="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-gray-900 truncate">
|
||||
{download.product_name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 truncate">
|
||||
{download.download_name || download.file?.name || 'Download'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2 text-xs text-gray-500">
|
||||
<span className="bg-gray-100 px-2 py-1 rounded">
|
||||
Order #{download.order_id}
|
||||
</span>
|
||||
{download.downloads_remaining && download.downloads_remaining !== 'unlimited' && (
|
||||
<span className="bg-yellow-100 text-yellow-700 px-2 py-1 rounded">
|
||||
{download.downloads_remaining} downloads left
|
||||
</span>
|
||||
)}
|
||||
{download.access_expires && (
|
||||
<span className="bg-orange-100 text-orange-700 px-2 py-1 rounded">
|
||||
Expires: {new Date(download.access_expires).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleDownload(download.download_url, download.download_name || 'file')}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{downloads.length > 0 && (
|
||||
<p className="text-sm text-gray-500 mt-6 text-center">
|
||||
{downloads.length} {downloads.length === 1 ? 'download' : 'downloads'} available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
260
customer-spa/src/pages/Account/Licenses.tsx
Normal file
260
customer-spa/src/pages/Account/Licenses.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Key, Copy, Check, ChevronDown, ChevronUp, Monitor, Globe, Power } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
|
||||
interface Activation {
|
||||
id: number;
|
||||
domain: string | null;
|
||||
machine_id: string | null;
|
||||
ip_address: string | null;
|
||||
status: 'active' | 'deactivated';
|
||||
activated_at: string;
|
||||
}
|
||||
|
||||
interface License {
|
||||
id: number;
|
||||
license_key: string;
|
||||
product_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;
|
||||
activations: Activation[];
|
||||
}
|
||||
|
||||
export default function Licenses() {
|
||||
const queryClient = useQueryClient();
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const [expandedLicense, setExpandedLicense] = useState<number | null>(null);
|
||||
|
||||
const { data: licenses = [], isLoading } = useQuery<License[]>({
|
||||
queryKey: ['account-licenses'],
|
||||
queryFn: () => api.get('/account/licenses'),
|
||||
});
|
||||
|
||||
const deactivateMutation = useMutation({
|
||||
mutationFn: ({ licenseId, activationId }: { licenseId: number; activationId: number }) =>
|
||||
api.post(`/account/licenses/${licenseId}/deactivate`, { activation_id: activationId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-licenses'] });
|
||||
toast.success('Activation deactivated successfully');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to deactivate');
|
||||
},
|
||||
});
|
||||
|
||||
const copyToClipboard = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
toast.success('License key copied to clipboard');
|
||||
};
|
||||
|
||||
const getStatusStyle = (license: License) => {
|
||||
if (license.status === 'revoked') {
|
||||
return 'bg-red-100 text-red-800';
|
||||
}
|
||||
if (license.is_expired) {
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
return 'bg-green-100 text-green-800';
|
||||
};
|
||||
|
||||
const getStatusLabel = (license: License) => {
|
||||
if (license.status === 'revoked') return 'Revoked';
|
||||
if (license.is_expired) return 'Expired';
|
||||
return 'Active';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SEOHead title="Licenses" description="Manage your software licenses" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Key className="h-6 w-6" />
|
||||
My Licenses
|
||||
</h1>
|
||||
<p className="text-gray-500">
|
||||
Manage your software licenses and activations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{licenses.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border p-12 text-center">
|
||||
<Key className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500">You don't have any licenses yet.</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Purchase a product with licensing to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{licenses.map((license) => (
|
||||
<div key={license.id} className="bg-white rounded-lg border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">{license.product_name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded font-mono">
|
||||
{license.license_key}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(license.license_key)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
{copiedKey === license.license_key ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusStyle(license)}`}>
|
||||
{getStatusLabel(license)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setExpandedLicense(expandedLicense === license.id ? null : license.id)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
{expandedLicense === license.id ? (
|
||||
<ChevronUp className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Info */}
|
||||
<div className="grid grid-cols-3 gap-4 mt-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">Activations</p>
|
||||
<p className="font-medium">
|
||||
{license.activation_count} / {license.activation_limit === 0 ? '∞' : license.activation_limit}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Purchased</p>
|
||||
<p className="font-medium">
|
||||
{new Date(license.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Expires</p>
|
||||
<p className={`font-medium ${license.is_expired ? 'text-red-500' : ''}`}>
|
||||
{license.expires_at
|
||||
? new Date(license.expires_at).toLocaleDateString()
|
||||
: 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content - Activations */}
|
||||
{expandedLicense === license.id && (
|
||||
<div className="border-t p-4 bg-gray-50">
|
||||
<h4 className="font-medium mb-3">Active Devices</h4>
|
||||
{license.activations.filter(a => a.status === 'active').length === 0 ? (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">
|
||||
No active devices. Activate your license on a device to see it here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{license.activations
|
||||
.filter(a => a.status === 'active')
|
||||
.map((activation) => (
|
||||
<div
|
||||
key={activation.id}
|
||||
className="flex items-center justify-between bg-white p-3 rounded border"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{activation.domain ? (
|
||||
<>
|
||||
<Globe className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm">{activation.domain}</span>
|
||||
</>
|
||||
) : activation.machine_id ? (
|
||||
<>
|
||||
<Monitor className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-mono">
|
||||
{activation.machine_id.substring(0, 16)}...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">Unknown device</span>
|
||||
)}
|
||||
<span className="text-gray-400 text-xs">
|
||||
• {new Date(activation.activated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-600">
|
||||
<Power className="h-4 w-4 mr-1" />
|
||||
Deactivate
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Deactivate Device</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will deactivate the license on this device. You can reactivate it later if you have available activation slots.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deactivateMutation.mutate({
|
||||
licenseId: license.id,
|
||||
activationId: activation.id,
|
||||
})}
|
||||
>
|
||||
Deactivate
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,13 @@ interface OrderItem {
|
||||
image?: string;
|
||||
}
|
||||
|
||||
interface ShippingLine {
|
||||
id: number;
|
||||
method_title: string;
|
||||
method_id: string;
|
||||
total: string;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: number;
|
||||
order_number: string;
|
||||
@@ -26,6 +33,11 @@ interface Order {
|
||||
shipping: any;
|
||||
payment_method_title: string;
|
||||
needs_shipping: boolean;
|
||||
shipping_lines?: ShippingLine[];
|
||||
// Tracking info (may be added by shipping plugins)
|
||||
tracking_number?: string;
|
||||
tracking_url?: string;
|
||||
meta_data?: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
export default function OrderDetails() {
|
||||
@@ -211,6 +223,59 @@ export default function OrderDetails() {
|
||||
{order.payment_method_title || 'Not specified'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Method - only for physical product orders */}
|
||||
{order.needs_shipping && order.shipping_lines && order.shipping_lines.length > 0 && (
|
||||
<div className="mt-6 border rounded-lg">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b">
|
||||
<h2 className="text-base font-medium">Shipping Method</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{order.shipping_lines.map((line) => (
|
||||
<div key={line.id} className="flex justify-between text-sm">
|
||||
<span>{line.method_title}</span>
|
||||
<span className="font-medium">{line.total}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* AWB Tracking - show for processing or completed orders */}
|
||||
{(order.status === 'processing' || order.status === 'completed') && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
{order.tracking_number ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Tracking Number</span>
|
||||
<span className="font-medium font-mono">{order.tracking_number}</span>
|
||||
</div>
|
||||
{order.tracking_url ? (
|
||||
<a
|
||||
href={order.tracking_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Track Shipment
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
Use the tracking number above to track your shipment on your courier's website.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">
|
||||
<p>Your order is being processed. Tracking information will be available once your order has been shipped.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { Package, Eye } from 'lucide-react';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
|
||||
interface Order {
|
||||
id: number;
|
||||
@@ -86,6 +87,7 @@ export default function Orders() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SEOHead title="Orders" description="View your order history" />
|
||||
<h1 className="text-2xl font-bold mb-6">Orders</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { formatPrice } from '@/lib/currency';
|
||||
import { toast } from 'sonner';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { useModuleSettings } from '@/hooks/useModuleSettings';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
|
||||
interface WishlistItem {
|
||||
product_id: number;
|
||||
@@ -126,119 +127,120 @@ export default function Wishlist() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SEOHead title="Wishlist" description="Your saved products" />
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">My Wishlist</h1>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
{items.length} {items.length === 1 ? 'item' : 'items'}
|
||||
</p>
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="text-red-600 hover:text-red-700 hover:border-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">My Wishlist</h1>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
{items.length} {items.length === 1 ? 'item' : 'items'}
|
||||
</p>
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="text-red-600 hover:text-red-700 hover:border-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{items.length === 0 ? (
|
||||
<div className="text-center py-16 bg-white border rounded-lg">
|
||||
<Heart className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Your wishlist is empty</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Save your favorite products to buy them later
|
||||
</p>
|
||||
<Button onClick={() => navigate('/shop')}>
|
||||
Browse Products
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* Wishlist Grid */
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.product_id}
|
||||
className="bg-white border rounded-lg overflow-hidden hover:shadow-lg transition-shadow group"
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative aspect-square bg-gray-100">
|
||||
<button
|
||||
onClick={() => handleRemove(item.product_id)}
|
||||
className="absolute top-3 right-3 z-10 p-2 bg-white rounded-full shadow-md hover:bg-red-50 transition-colors"
|
||||
title="Remove from wishlist"
|
||||
>
|
||||
<X className="w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
{/* Empty State */}
|
||||
{items.length === 0 ? (
|
||||
<div className="text-center py-16 bg-white border rounded-lg">
|
||||
<Heart className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Your wishlist is empty</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Save your favorite products to buy them later
|
||||
</p>
|
||||
<Button onClick={() => navigate('/shop')}>
|
||||
Browse Products
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* Wishlist Grid */
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.product_id}
|
||||
className="bg-white border rounded-lg overflow-hidden hover:shadow-lg transition-shadow group"
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative aspect-square bg-gray-100">
|
||||
<button
|
||||
onClick={() => handleRemove(item.product_id)}
|
||||
className="absolute top-3 right-3 z-10 p-2 bg-white rounded-full shadow-md hover:bg-red-50 transition-colors"
|
||||
title="Remove from wishlist"
|
||||
>
|
||||
<X className="w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={item.image || '/placeholder.png'}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover cursor-pointer"
|
||||
onClick={() => navigate(`/product/${item.slug}`)}
|
||||
/>
|
||||
<img
|
||||
src={item.image || '/placeholder.png'}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover cursor-pointer"
|
||||
onClick={() => navigate(`/product/${item.slug}`)}
|
||||
/>
|
||||
|
||||
{item.on_sale && (
|
||||
<div className="absolute top-3 left-3 bg-red-600 text-white text-xs font-bold px-2 py-1 rounded">
|
||||
SALE
|
||||
</div>
|
||||
{item.on_sale && (
|
||||
<div className="absolute top-3 left-3 bg-red-600 text-white text-xs font-bold px-2 py-1 rounded">
|
||||
SALE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
<h3
|
||||
className="font-medium text-gray-900 mb-2 line-clamp-2 cursor-pointer hover:text-primary transition-colors"
|
||||
onClick={() => navigate(`/product/${item.slug}`)}
|
||||
>
|
||||
{item.name}
|
||||
</h3>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{item.on_sale && item.regular_price ? (
|
||||
<>
|
||||
<span className="text-lg font-bold text-primary">
|
||||
{formatPrice(item.sale_price || item.price)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 line-through">
|
||||
{formatPrice(item.regular_price)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
{formatPrice(item.price)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
<h3
|
||||
className="font-medium text-gray-900 mb-2 line-clamp-2 cursor-pointer hover:text-primary transition-colors"
|
||||
onClick={() => navigate(`/product/${item.slug}`)}
|
||||
{/* Actions */}
|
||||
{(wishlistSettings.show_add_to_cart_button ?? true) && (
|
||||
<Button
|
||||
onClick={() => handleAddToCart(item)}
|
||||
disabled={item.stock_status === 'outofstock'}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
{item.name}
|
||||
</h3>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{item.on_sale && item.regular_price ? (
|
||||
<>
|
||||
<span className="text-lg font-bold text-primary">
|
||||
{formatPrice(item.sale_price || item.price)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 line-through">
|
||||
{formatPrice(item.regular_price)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
{formatPrice(item.price)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(wishlistSettings.show_add_to_cart_button ?? true) && (
|
||||
<Button
|
||||
onClick={() => handleAddToCart(item)}
|
||||
disabled={item.stock_status === 'outofstock'}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4 mr-2" />
|
||||
{item.stock_status === 'outofstock'
|
||||
? 'Out of Stock'
|
||||
: item.type === 'variable'
|
||||
<ShoppingCart className="w-4 h-4 mr-2" />
|
||||
{item.stock_status === 'outofstock'
|
||||
? 'Out of Stock'
|
||||
: item.type === 'variable'
|
||||
? 'Select Options'
|
||||
: 'Add to Cart'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
import React, { ReactNode, useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut } from 'lucide-react';
|
||||
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut, Key } from 'lucide-react';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { api } from '@/lib/api/client';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -24,6 +25,27 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
const { isEnabled } = useModules();
|
||||
const wishlistEnabled = (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false;
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
|
||||
// Fetch avatar settings
|
||||
useEffect(() => {
|
||||
const fetchAvatar = async () => {
|
||||
try {
|
||||
const data = await api.get<{ current_avatar: string | null; gravatar_url: string }>('/account/avatar-settings');
|
||||
setAvatarUrl(data.current_avatar || data.gravatar_url);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch avatar:', error);
|
||||
}
|
||||
};
|
||||
fetchAvatar();
|
||||
|
||||
// Listen for avatar updates
|
||||
const handleAvatarUpdate = (e: CustomEvent) => {
|
||||
setAvatarUrl(e.detail?.avatar_url || null);
|
||||
};
|
||||
window.addEventListener('woonoow:avatar-updated' as any, handleAvatarUpdate);
|
||||
return () => window.removeEventListener('woonoow:avatar-updated' as any, handleAvatarUpdate);
|
||||
}, []);
|
||||
|
||||
const allMenuItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
|
||||
@@ -31,13 +53,16 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
|
||||
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
||||
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
|
||||
{ id: 'licenses', label: 'Licenses', path: '/my-account/licenses', icon: Key },
|
||||
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
||||
];
|
||||
|
||||
// Filter out wishlist if module disabled or settings disabled
|
||||
const menuItems = allMenuItems.filter(item =>
|
||||
item.id !== 'wishlist' || (isEnabled('wishlist') && wishlistEnabled)
|
||||
);
|
||||
// Filter out wishlist if module disabled or settings disabled, licenses if licensing disabled
|
||||
const menuItems = allMenuItems.filter(item => {
|
||||
if (item.id === 'wishlist') return isEnabled('wishlist') && wishlistEnabled;
|
||||
if (item.id === 'licenses') return isEnabled('licensing');
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
@@ -55,10 +80,12 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
});
|
||||
|
||||
// Full page reload to clear cookies and refresh state
|
||||
window.location.href = window.location.origin + '/store/';
|
||||
const basePath = (window as any).woonoowCustomer?.basePath || '/store';
|
||||
window.location.href = window.location.origin + basePath + '/';
|
||||
} catch (error) {
|
||||
// Even on error, try to redirect and let server handle session
|
||||
window.location.href = window.location.origin + '/store/';
|
||||
const basePath = (window as any).woonoowCustomer?.basePath || '/store';
|
||||
window.location.href = window.location.origin + basePath + '/';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,9 +133,17 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
<aside className="bg-white rounded-lg border p-4">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 pb-4 border-b">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User className="w-6 h-6 text-gray-600" />
|
||||
</div>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={user?.display_name || 'User'}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User className="w-6 h-6 text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-semibold">{user?.display_name || 'User'}</p>
|
||||
<p className="text-sm text-gray-500">{user?.email}</p>
|
||||
|
||||
@@ -9,6 +9,7 @@ import Downloads from './Downloads';
|
||||
import Addresses from './Addresses';
|
||||
import Wishlist from './Wishlist';
|
||||
import AccountDetails from './AccountDetails';
|
||||
import Licenses from './Licenses';
|
||||
|
||||
export default function Account() {
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
@@ -30,6 +31,7 @@ export default function Account() {
|
||||
<Route path="downloads" element={<Downloads />} />
|
||||
<Route path="addresses" element={<Addresses />} />
|
||||
<Route path="wishlist" element={<Wishlist />} />
|
||||
<Route path="licenses" element={<Licenses />} />
|
||||
<Route path="account-details" element={<AccountDetails />} />
|
||||
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
||||
</Routes>
|
||||
@@ -37,3 +39,4 @@ export default function Account() {
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCartStore, type CartItem } from '@/lib/cart/store';
|
||||
import { useCartSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart } from '@/lib/cart/api';
|
||||
import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart, applyCoupon, removeCoupon } from '@/lib/cart/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2, X, Tag } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Cart() {
|
||||
@@ -24,6 +25,8 @@ export default function Cart() {
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [couponCode, setCouponCode] = useState('');
|
||||
const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
|
||||
|
||||
// Fetch cart from server on mount to sync with WooCommerce
|
||||
useEffect(() => {
|
||||
@@ -92,6 +95,37 @@ export default function Cart() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyCoupon = async () => {
|
||||
if (!couponCode.trim()) return;
|
||||
|
||||
setIsApplyingCoupon(true);
|
||||
try {
|
||||
const updatedCart = await applyCoupon(couponCode.trim());
|
||||
setCart(updatedCart);
|
||||
setCouponCode('');
|
||||
toast.success('Coupon applied successfully');
|
||||
} catch (error: any) {
|
||||
console.error('Failed to apply coupon:', error);
|
||||
toast.error(error.message || 'Failed to apply coupon');
|
||||
} finally {
|
||||
setIsApplyingCoupon(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCoupon = async (code: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const updatedCart = await removeCoupon(code);
|
||||
setCart(updatedCart);
|
||||
toast.success('Coupon removed');
|
||||
} catch (error: any) {
|
||||
console.error('Failed to remove coupon:', error);
|
||||
toast.error(error.message || 'Failed to remove coupon');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading state while fetching cart
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -122,6 +156,7 @@ export default function Cart() {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<SEOHead title="Shopping Cart" description="Review your shopping cart" />
|
||||
<div className={`py-8 ${layout.style === 'boxed' ? 'max-w-5xl mx-auto' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
@@ -237,10 +272,43 @@ export default function Cart() {
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter coupon code"
|
||||
value={couponCode}
|
||||
onChange={(e) => setCouponCode(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleApplyCoupon()}
|
||||
className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isApplyingCoupon}
|
||||
/>
|
||||
<Button variant="outline" size="sm">Apply</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleApplyCoupon}
|
||||
disabled={isApplyingCoupon || !couponCode.trim()}
|
||||
>
|
||||
{isApplyingCoupon ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Applied Coupons */}
|
||||
{(cart as any).coupons?.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{(cart as any).coupons.map((coupon: { code: string; discount: number }) => (
|
||||
<div key={coupon.code} className="flex items-center justify-between p-2 bg-green-50 border border-green-200 rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-800">{coupon.code}</span>
|
||||
<span className="text-sm text-green-600">-{formatPrice(coupon.discount)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveCoupon(coupon.code)}
|
||||
className="text-green-600 hover:text-green-800 p-1"
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -262,15 +330,22 @@ export default function Cart() {
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
<span>{formatPrice((cart as any).subtotal || total)}</span>
|
||||
</div>
|
||||
{/* Show discount if coupons applied */}
|
||||
{(cart as any).discount_total > 0 && (
|
||||
<div className="flex justify-between text-green-600">
|
||||
<span>Discount</span>
|
||||
<span>-{formatPrice((cart as any).discount_total)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Shipping</span>
|
||||
<span>Calculated at checkout</span>
|
||||
<span>{(cart as any).shipping_total > 0 ? formatPrice((cart as any).shipping_total) : 'Calculated at checkout'}</span>
|
||||
</div>
|
||||
<div className="border-t pt-3 flex justify-between text-lg font-bold">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
<span>{formatPrice((cart as any).total || total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -12,6 +13,14 @@ export default function Login() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirectTo = searchParams.get('redirect') || '/my-account';
|
||||
|
||||
// Redirect logged-in users to account page
|
||||
useEffect(() => {
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
if (user?.isLoggedIn) {
|
||||
navigate(redirectTo, { replace: true });
|
||||
}
|
||||
}, [navigate, redirectTo]);
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -91,7 +100,8 @@ export default function Login() {
|
||||
|
||||
// Set the target URL with hash route, then force reload
|
||||
// The hash change alone doesn't reload the page, so cookies won't be refreshed
|
||||
const targetUrl = window.location.origin + '/store/#' + redirectTo;
|
||||
const basePath = (window as any).woonoowCustomer?.basePath || '/store';
|
||||
const targetUrl = window.location.origin + basePath + '/#' + redirectTo;
|
||||
window.location.href = targetUrl;
|
||||
// Force page reload to refresh cookies and server-side state
|
||||
window.location.reload();
|
||||
@@ -107,6 +117,7 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<SEOHead title="Login" description="Sign in to your account" />
|
||||
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Back link */}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ProductCard } from '@/components/ProductCard';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import type { Product as ProductType, ProductsResponse } from '@/types/product';
|
||||
|
||||
export default function Product() {
|
||||
@@ -105,8 +106,8 @@ export default function Product() {
|
||||
const attrNameLower = attrName.toLowerCase();
|
||||
|
||||
if (vKeyLower === `attribute_${attrNameLower}` ||
|
||||
vKeyLower === `attribute_pa_${attrNameLower}` ||
|
||||
vKeyLower === attrNameLower) {
|
||||
vKeyLower === `attribute_pa_${attrNameLower}` ||
|
||||
vKeyLower === attrNameLower) {
|
||||
|
||||
const varValueNormalized = String(vValue).toLowerCase().trim();
|
||||
if (varValueNormalized === normalizedValue) {
|
||||
@@ -257,6 +258,18 @@ export default function Product() {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* SEO Meta Tags for Social Sharing */}
|
||||
<SEOHead
|
||||
title={product.name}
|
||||
description={product.short_description?.replace(/<[^>]+>/g, '').slice(0, 160) || product.description?.replace(/<[^>]+>/g, '').slice(0, 160)}
|
||||
image={product.image || product.images?.[0]}
|
||||
type="product"
|
||||
product={{
|
||||
price: currentPrice,
|
||||
currency: (window as any).woonoowCustomer?.currency?.code || 'USD',
|
||||
availability: stockStatus === 'instock' ? 'in stock' : 'out of stock',
|
||||
}}
|
||||
/>
|
||||
<div className="max-w-6xl mx-auto py-8">
|
||||
{/* Breadcrumb */}
|
||||
{elements.breadcrumbs && (
|
||||
@@ -306,11 +319,10 @@ export default function Product() {
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
selectedImage === img
|
||||
className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
|
||||
? 'bg-primary w-6'
|
||||
: 'bg-gray-300 hover:bg-gray-400'
|
||||
}`}
|
||||
}`}
|
||||
aria-label={`View image ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
@@ -341,11 +353,10 @@ export default function Product() {
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${
|
||||
selectedImage === img
|
||||
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img
|
||||
? 'border-primary ring-4 ring-primary ring-offset-2'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
@@ -434,11 +445,10 @@ export default function Product() {
|
||||
<button
|
||||
key={optIndex}
|
||||
onClick={() => handleAttributeChange(attr.name, option)}
|
||||
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${
|
||||
isSelected
|
||||
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
|
||||
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
@@ -492,15 +502,13 @@ export default function Product() {
|
||||
{isModuleEnabled('wishlist') && wishlistEnabled && (
|
||||
<button
|
||||
onClick={() => product && toggleWishlist(product.id)}
|
||||
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${
|
||||
product && isInWishlist(product.id)
|
||||
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${product && isInWishlist(product.id)
|
||||
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
|
||||
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<Heart className={`h-5 w-5 ${
|
||||
product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
||||
}`} />
|
||||
<Heart className={`h-5 w-5 ${product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
||||
}`} />
|
||||
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
|
||||
</button>
|
||||
)}
|
||||
@@ -576,7 +584,7 @@ export default function Product() {
|
||||
className="w-9 h-9 rounded-full bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center transition-colors"
|
||||
title="Share on Facebook"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -587,7 +595,7 @@ export default function Product() {
|
||||
className="w-9 h-9 rounded-full bg-sky-500 hover:bg-sky-600 text-white flex items-center justify-center transition-colors"
|
||||
title="Share on Twitter"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -598,7 +606,7 @@ export default function Product() {
|
||||
className="w-9 h-9 rounded-full bg-green-600 hover:bg-green-700 text-white flex items-center justify-center transition-colors"
|
||||
title="Share on WhatsApp"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -682,160 +690,160 @@ export default function Product() {
|
||||
{elements.reviews && reviewSettings.placement === 'product_page' && (
|
||||
// Show reviews only if: 1) not hiding when empty, OR 2) has reviews
|
||||
(!reviewSettings.hide_if_empty || (product.review_count && product.review_count > 0)) && (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab === 'reviews' ? '' : 'reviews')}
|
||||
className="w-full flex items-center justify-between p-5 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Customer Reviews</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className={`w-5 h-5 ${star <= Number(product.average_rating || 0) ? 'text-yellow-400' : 'text-gray-300'} fill-current`} viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
{product.average_rating || 0} ({product.review_count || 0} reviews)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-6 h-6 transition-transform ${activeTab === 'reviews' ? '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>
|
||||
</button>
|
||||
{activeTab === 'reviews' && (
|
||||
<div className="p-6 bg-white space-y-6">
|
||||
{/* Review Summary */}
|
||||
<div className="flex items-start gap-8 pb-6 border-b">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-bold text-gray-900 mb-2">5.0</div>
|
||||
<div className="flex mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-5 h-5 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Based on 128 reviews</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{[5, 4, 3, 2, 1].map((rating) => (
|
||||
<div key={rating} className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600 w-8">{rating} ★</span>
|
||||
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-yellow-400"
|
||||
style={{ width: rating === 5 ? '95%' : rating === 4 ? '4%' : '1%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 w-12">{rating === 5 ? '122' : rating === 4 ? '5' : '1'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sample Reviews */}
|
||||
<div className="space-y-6">
|
||||
{/* Review 1 */}
|
||||
<div className="border-b pb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||
JD
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-900">John Doe</span>
|
||||
<span className="text-sm text-gray-500">• 2 days ago</span>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||
</div>
|
||||
<div className="flex mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-700 leading-relaxed mb-3">
|
||||
Absolutely love this product! The quality exceeded my expectations and it arrived quickly.
|
||||
The packaging was also very professional. Highly recommend!
|
||||
</p>
|
||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (24)</button>
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab === 'reviews' ? '' : 'reviews')}
|
||||
className="w-full flex items-center justify-between p-5 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Customer Reviews</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className={`w-5 h-5 ${star <= Number(product.average_rating || 0) ? 'text-yellow-400' : 'text-gray-300'} fill-current`} viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
{product.average_rating || 0} ({product.review_count || 0} reviews)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review 2 */}
|
||||
<div className="border-b pb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||
SM
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-900">Sarah Miller</span>
|
||||
<span className="text-sm text-gray-500">• 1 week ago</span>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||
</div>
|
||||
<div className="flex mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-700 leading-relaxed mb-3">
|
||||
Great value for money. Works exactly as described. Customer service was also very responsive
|
||||
when I had questions before purchasing.
|
||||
</p>
|
||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (18)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review 3 */}
|
||||
<div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||
MJ
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-900">Michael Johnson</span>
|
||||
<span className="text-sm text-gray-500">• 2 weeks ago</span>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||
</div>
|
||||
<div className="flex mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-700 leading-relaxed mb-3">
|
||||
Perfect! This is my third purchase and I keep coming back. The consistency in quality is impressive.
|
||||
Will definitely buy again.
|
||||
</p>
|
||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (32)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="w-full py-3 border-2 border-gray-200 rounded-xl font-semibold text-gray-900 hover:border-gray-400 transition-all">
|
||||
Load More Reviews
|
||||
<svg
|
||||
className={`w-6 h-6 transition-transform ${activeTab === 'reviews' ? '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>
|
||||
</button>
|
||||
{activeTab === 'reviews' && (
|
||||
<div className="p-6 bg-white space-y-6">
|
||||
{/* Review Summary */}
|
||||
<div className="flex items-start gap-8 pb-6 border-b">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-bold text-gray-900 mb-2">5.0</div>
|
||||
<div className="flex mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-5 h-5 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Based on 128 reviews</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{[5, 4, 3, 2, 1].map((rating) => (
|
||||
<div key={rating} className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600 w-8">{rating} ★</span>
|
||||
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-yellow-400"
|
||||
style={{ width: rating === 5 ? '95%' : rating === 4 ? '4%' : '1%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 w-12">{rating === 5 ? '122' : rating === 4 ? '5' : '1'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sample Reviews */}
|
||||
<div className="space-y-6">
|
||||
{/* Review 1 */}
|
||||
<div className="border-b pb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||
JD
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-900">John Doe</span>
|
||||
<span className="text-sm text-gray-500">• 2 days ago</span>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||
</div>
|
||||
<div className="flex mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-700 leading-relaxed mb-3">
|
||||
Absolutely love this product! The quality exceeded my expectations and it arrived quickly.
|
||||
The packaging was also very professional. Highly recommend!
|
||||
</p>
|
||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (24)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review 2 */}
|
||||
<div className="border-b pb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||
SM
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-900">Sarah Miller</span>
|
||||
<span className="text-sm text-gray-500">• 1 week ago</span>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||
</div>
|
||||
<div className="flex mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-700 leading-relaxed mb-3">
|
||||
Great value for money. Works exactly as described. Customer service was also very responsive
|
||||
when I had questions before purchasing.
|
||||
</p>
|
||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (18)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review 3 */}
|
||||
<div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||
MJ
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-900">Michael Johnson</span>
|
||||
<span className="text-sm text-gray-500">• 2 weeks ago</span>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||
</div>
|
||||
<div className="flex mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-700 leading-relaxed mb-3">
|
||||
Perfect! This is my third purchase and I keep coming back. The consistency in quality is impressive.
|
||||
Will definitely buy again.
|
||||
</p>
|
||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (32)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="w-full py-3 border-2 border-gray-200 rounded-xl font-semibold text-gray-900 hover:border-gray-400 transition-all">
|
||||
Load More Reviews
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Related Products */}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ProductCard } from '@/components/ProductCard';
|
||||
import { toast } from 'sonner';
|
||||
import { useTheme, useLayout } from '@/contexts/ThemeContext';
|
||||
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import type { ProductsResponse, ProductCategory, Product } from '@/types/product';
|
||||
|
||||
export default function Shop() {
|
||||
@@ -126,6 +127,11 @@ export default function Shop() {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* SEO Meta Tags for Social Sharing */}
|
||||
<SEOHead
|
||||
title="Shop"
|
||||
description="Browse our collection of products"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-2">Shop</h1>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useThankYouSettings } from '@/hooks/useAppearanceSettings';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { CheckCircle, ShoppingBag, Package, Truck, User, LogIn } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
@@ -74,6 +75,7 @@ export default function ThankYou() {
|
||||
if (template === 'receipt') {
|
||||
return (
|
||||
<div style={{ backgroundColor }}>
|
||||
<SEOHead title="Order Confirmed" description={`Order #${order?.number || orderId} confirmed`} />
|
||||
<Container>
|
||||
<div className="py-12 max-w-2xl mx-auto">
|
||||
{/* Receipt Container */}
|
||||
@@ -145,6 +147,12 @@ export default function ThankYou() {
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.tax_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
{parseFloat(order.discount_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-sm text-green-600">
|
||||
<span>DISCOUNT:</span>
|
||||
<span className="font-mono">-{formatPrice(parseFloat(order.discount_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-lg font-bold border-t-2 border-gray-900 pt-2 mt-2">
|
||||
<span>TOTAL:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.total || 0))}</span>
|
||||
@@ -261,6 +269,12 @@ export default function ThankYou() {
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.tax_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
{parseFloat(order.discount_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-sm text-green-600">
|
||||
<span>DISCOUNT:</span>
|
||||
<span className="font-mono">-{formatPrice(parseFloat(order.discount_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-lg font-bold border-t-2 border-gray-900 pt-2 mt-2">
|
||||
<span>TOTAL:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.total || 0))}</span>
|
||||
@@ -351,6 +365,7 @@ export default function ThankYou() {
|
||||
// Render basic style template (default)
|
||||
return (
|
||||
<div style={{ backgroundColor }}>
|
||||
<SEOHead title="Order Confirmed" description={`Order #${order?.number || orderId} confirmed`} />
|
||||
<Container>
|
||||
<div className="py-12 max-w-3xl mx-auto">
|
||||
{/* Success Header */}
|
||||
@@ -412,6 +427,12 @@ export default function ThankYou() {
|
||||
<span>{formatPrice(parseFloat(order.tax_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
{parseFloat(order.discount_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-green-600">
|
||||
<span>Discount</span>
|
||||
<span>-{formatPrice(parseFloat(order.discount_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-bold text-lg text-gray-900 pt-2 border-t">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(parseFloat(order.total || 0))}</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
|
||||
interface ProductData {
|
||||
id: number;
|
||||
@@ -106,6 +107,7 @@ export default function Wishlist() {
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<SEOHead title="Wishlist" description="Your saved products" />
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold">My Wishlist</h1>
|
||||
|
||||
@@ -66,16 +66,26 @@
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Font Sizes (8px base scale) */
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
--text-4xl: 2.25rem; /* 36px */
|
||||
--text-5xl: 3rem; /* 48px */
|
||||
--text-6xl: 3.75rem; /* 60px */
|
||||
--text-xs: 0.75rem;
|
||||
/* 12px */
|
||||
--text-sm: 0.875rem;
|
||||
/* 14px */
|
||||
--text-base: 1rem;
|
||||
/* 16px */
|
||||
--text-lg: 1.125rem;
|
||||
/* 18px */
|
||||
--text-xl: 1.25rem;
|
||||
/* 20px */
|
||||
--text-2xl: 1.5rem;
|
||||
/* 24px */
|
||||
--text-3xl: 1.875rem;
|
||||
/* 30px */
|
||||
--text-4xl: 2.25rem;
|
||||
/* 36px */
|
||||
--text-5xl: 3rem;
|
||||
/* 48px */
|
||||
--text-6xl: 3.75rem;
|
||||
/* 60px */
|
||||
|
||||
/* Line Heights */
|
||||
--line-height-none: 1;
|
||||
@@ -90,29 +100,46 @@
|
||||
* ======================================== */
|
||||
|
||||
--space-0: 0;
|
||||
--space-1: 0.5rem; /* 8px */
|
||||
--space-2: 1rem; /* 16px */
|
||||
--space-3: 1.5rem; /* 24px */
|
||||
--space-4: 2rem; /* 32px */
|
||||
--space-5: 2.5rem; /* 40px */
|
||||
--space-6: 3rem; /* 48px */
|
||||
--space-8: 4rem; /* 64px */
|
||||
--space-10: 5rem; /* 80px */
|
||||
--space-12: 6rem; /* 96px */
|
||||
--space-16: 8rem; /* 128px */
|
||||
--space-20: 10rem; /* 160px */
|
||||
--space-24: 12rem; /* 192px */
|
||||
--space-1: 0.5rem;
|
||||
/* 8px */
|
||||
--space-2: 1rem;
|
||||
/* 16px */
|
||||
--space-3: 1.5rem;
|
||||
/* 24px */
|
||||
--space-4: 2rem;
|
||||
/* 32px */
|
||||
--space-5: 2.5rem;
|
||||
/* 40px */
|
||||
--space-6: 3rem;
|
||||
/* 48px */
|
||||
--space-8: 4rem;
|
||||
/* 64px */
|
||||
--space-10: 5rem;
|
||||
/* 80px */
|
||||
--space-12: 6rem;
|
||||
/* 96px */
|
||||
--space-16: 8rem;
|
||||
/* 128px */
|
||||
--space-20: 10rem;
|
||||
/* 160px */
|
||||
--space-24: 12rem;
|
||||
/* 192px */
|
||||
|
||||
/* ========================================
|
||||
* BORDER RADIUS
|
||||
* ======================================== */
|
||||
|
||||
--radius-none: 0;
|
||||
--radius-sm: 0.25rem; /* 4px */
|
||||
--radius-md: 0.5rem; /* 8px */
|
||||
--radius-lg: 1rem; /* 16px */
|
||||
--radius-xl: 1.5rem; /* 24px */
|
||||
--radius-2xl: 2rem; /* 32px */
|
||||
--radius-sm: 0.25rem;
|
||||
/* 4px */
|
||||
--radius-md: 0.5rem;
|
||||
/* 8px */
|
||||
--radius-lg: 1rem;
|
||||
/* 16px */
|
||||
--radius-xl: 1.5rem;
|
||||
/* 24px */
|
||||
--radius-2xl: 2rem;
|
||||
/* 32px */
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ========================================
|
||||
@@ -205,7 +232,12 @@ body {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: var(--font-weight-heading);
|
||||
line-height: var(--line-height-tight);
|
||||
@@ -247,7 +279,7 @@ a:hover {
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-heading);
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
132
docs/_registry.php
Normal file
132
docs/_registry.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
/**
|
||||
* WooNooW Documentation Registry
|
||||
*
|
||||
* This file registers all core documentation.
|
||||
* Addons can extend using the 'woonoow_docs_registry' filter.
|
||||
*/
|
||||
|
||||
namespace WooNooW\Docs;
|
||||
|
||||
/**
|
||||
* Get all registered documentation
|
||||
*
|
||||
* @return array Documentation registry
|
||||
*/
|
||||
function get_docs_registry() {
|
||||
$docs_dir = dirname(__FILE__);
|
||||
|
||||
// Core WooNooW documentation
|
||||
$docs = [
|
||||
'core' => [
|
||||
'label' => 'WooNooW',
|
||||
'icon' => 'book-open',
|
||||
'items' => [
|
||||
[
|
||||
'slug' => 'getting-started',
|
||||
'title' => 'Getting Started',
|
||||
'file' => $docs_dir . '/getting-started.md',
|
||||
],
|
||||
[
|
||||
'slug' => 'installation',
|
||||
'title' => 'Installation',
|
||||
'file' => $docs_dir . '/installation.md',
|
||||
],
|
||||
[
|
||||
'slug' => 'troubleshooting',
|
||||
'title' => 'Troubleshooting',
|
||||
'file' => $docs_dir . '/troubleshooting.md',
|
||||
],
|
||||
[
|
||||
'slug' => 'faq',
|
||||
'title' => 'FAQ',
|
||||
'file' => $docs_dir . '/faq.md',
|
||||
],
|
||||
],
|
||||
],
|
||||
'configuration' => [
|
||||
'label' => 'Configuration',
|
||||
'icon' => 'settings',
|
||||
'items' => [
|
||||
[
|
||||
'slug' => 'configuration/appearance',
|
||||
'title' => 'Appearance Settings',
|
||||
'file' => $docs_dir . '/configuration/appearance.md',
|
||||
],
|
||||
[
|
||||
'slug' => 'configuration/spa-mode',
|
||||
'title' => 'SPA Mode',
|
||||
'file' => $docs_dir . '/configuration/spa-mode.md',
|
||||
],
|
||||
],
|
||||
],
|
||||
'features' => [
|
||||
'label' => 'Features',
|
||||
'icon' => 'layers',
|
||||
'items' => [
|
||||
[
|
||||
'slug' => 'features/shop',
|
||||
'title' => 'Shop Page',
|
||||
'file' => $docs_dir . '/features/shop.md',
|
||||
],
|
||||
[
|
||||
'slug' => 'features/checkout',
|
||||
'title' => 'Checkout',
|
||||
'file' => $docs_dir . '/features/checkout.md',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter: woonoow_docs_registry
|
||||
*
|
||||
* Allows addons to register their own documentation.
|
||||
*
|
||||
* @param array $docs Current documentation registry
|
||||
* @return array Modified documentation registry
|
||||
*
|
||||
* @example
|
||||
* add_filter('woonoow_docs_registry', function($docs) {
|
||||
* $docs['my-addon'] = [
|
||||
* 'label' => 'My Addon',
|
||||
* 'icon' => 'puzzle',
|
||||
* 'items' => [
|
||||
* [
|
||||
* 'slug' => 'my-addon/getting-started',
|
||||
* 'title' => 'Getting Started',
|
||||
* 'file' => __DIR__ . '/docs/getting-started.md',
|
||||
* ],
|
||||
* ],
|
||||
* ];
|
||||
* return $docs;
|
||||
* });
|
||||
*/
|
||||
return apply_filters('woonoow_docs_registry', $docs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single documentation item by slug
|
||||
*
|
||||
* @param string $slug Document slug
|
||||
* @return array|null Document data with content, or null if not found
|
||||
*/
|
||||
function get_doc_by_slug($slug) {
|
||||
$registry = get_docs_registry();
|
||||
|
||||
foreach ($registry as $section) {
|
||||
foreach ($section['items'] as $item) {
|
||||
if ($item['slug'] === $slug) {
|
||||
// Read file content
|
||||
if (file_exists($item['file'])) {
|
||||
$item['content'] = file_get_contents($item['file']);
|
||||
} else {
|
||||
$item['content'] = '# Document Not Found\n\nThis document is coming soon.';
|
||||
}
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
133
docs/configuration/appearance.md
Normal file
133
docs/configuration/appearance.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Appearance Settings
|
||||
|
||||
Customize the look and feel of your WooNooW store.
|
||||
|
||||
## Accessing Appearance Settings
|
||||
|
||||
Go to **WooNooW → Appearance** in the WordPress admin.
|
||||
|
||||
---
|
||||
|
||||
## General Settings
|
||||
|
||||
### Logo
|
||||
|
||||
Upload your store logo for display in the header.
|
||||
|
||||
- **Recommended size**: 200x60 pixels (width x height)
|
||||
- **Formats**: PNG (transparent background recommended), SVG, JPG
|
||||
- **Mobile**: Automatically resized for smaller screens
|
||||
|
||||
### SPA Page
|
||||
|
||||
Select which page hosts the WooNooW SPA. Default is "Store".
|
||||
|
||||
> **Note**: This page should contain the `[woonoow_spa]` shortcode.
|
||||
|
||||
### SPA Mode
|
||||
|
||||
Choose how WooNooW handles your store pages:
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| **Full** | All WooCommerce pages redirect to SPA |
|
||||
| **Disabled** | Native WooCommerce templates are used |
|
||||
|
||||
---
|
||||
|
||||
## Colors
|
||||
|
||||
### Primary Color
|
||||
|
||||
The main brand color used for:
|
||||
- Buttons
|
||||
- Links
|
||||
- Active states
|
||||
- Primary actions
|
||||
|
||||
**Default**: `#6366f1` (Indigo)
|
||||
|
||||
### Secondary Color
|
||||
|
||||
Secondary UI elements:
|
||||
- Less prominent buttons
|
||||
- Borders
|
||||
- Subtle backgrounds
|
||||
|
||||
**Default**: `#64748b` (Slate)
|
||||
|
||||
### Accent Color
|
||||
|
||||
Highlight color for:
|
||||
- Sale badges
|
||||
- Notifications
|
||||
- Call-to-action elements
|
||||
|
||||
**Default**: `#f59e0b` (Amber)
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Body Font
|
||||
|
||||
Font used for general text content.
|
||||
|
||||
**Options**: System fonts and Google Fonts
|
||||
- Inter
|
||||
- Open Sans
|
||||
- Roboto
|
||||
- Lato
|
||||
- Poppins
|
||||
- And more...
|
||||
|
||||
### Heading Font
|
||||
|
||||
Font used for titles and headings.
|
||||
|
||||
**Options**: Same as body fonts, plus:
|
||||
- Cormorant Garamond (Serif option)
|
||||
- Playfair Display
|
||||
- Merriweather
|
||||
|
||||
### Font Sizes
|
||||
|
||||
Font sizes are responsive and adjust automatically based on screen size.
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
### Container Width
|
||||
|
||||
Maximum width of the content area.
|
||||
|
||||
| Option | Width |
|
||||
|--------|-------|
|
||||
| Narrow | 1024px |
|
||||
| Default | 1280px |
|
||||
| Wide | 1536px |
|
||||
| Full | 100% |
|
||||
|
||||
### Header Style
|
||||
|
||||
Configure the header appearance:
|
||||
- **Fixed**: Stays at top when scrolling
|
||||
- **Static**: Scrolls with page
|
||||
|
||||
### Product Grid
|
||||
|
||||
Columns in the shop page grid:
|
||||
- Mobile: 1-2 columns
|
||||
- Tablet: 2-3 columns
|
||||
- Desktop: 3-4 columns
|
||||
|
||||
---
|
||||
|
||||
## Saving Changes
|
||||
|
||||
1. Make your changes
|
||||
2. Click **Save Changes** button
|
||||
3. Refresh your store page to see updates
|
||||
|
||||
> **Tip**: Open your store in another tab to preview changes quickly.
|
||||
139
docs/configuration/spa-mode.md
Normal file
139
docs/configuration/spa-mode.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# SPA Mode
|
||||
|
||||
Understanding and configuring WooNooW's SPA (Single Page Application) mode.
|
||||
|
||||
## What is SPA Mode?
|
||||
|
||||
SPA Mode controls how WooNooW handles your WooCommerce pages. It determines whether visitors experience the modern SPA interface or traditional WooCommerce templates.
|
||||
|
||||
---
|
||||
|
||||
## Available Modes
|
||||
|
||||
### Full Mode (Recommended)
|
||||
|
||||
**All WooCommerce pages redirect to the SPA.**
|
||||
|
||||
When a visitor navigates to:
|
||||
- `/shop` → Redirects to `/store/shop`
|
||||
- `/product/example` → Redirects to `/store/product/example`
|
||||
- `/cart` → Redirects to `/store/cart`
|
||||
- `/checkout` → Redirects to `/store/checkout`
|
||||
- `/my-account` → Redirects to `/store/my-account`
|
||||
|
||||
**Benefits**:
|
||||
- Instant page transitions
|
||||
- Modern, consistent UI
|
||||
- Better mobile experience
|
||||
- Smooth animations
|
||||
|
||||
**Best for**:
|
||||
- New stores
|
||||
- Stores wanting a modern look
|
||||
- Mobile-focused businesses
|
||||
|
||||
### Disabled Mode
|
||||
|
||||
**WooCommerce uses its native templates.**
|
||||
|
||||
WooCommerce pages work normally with your theme's templates. WooNooW admin features still work, but the customer-facing SPA is turned off.
|
||||
|
||||
**Benefits**:
|
||||
- Keep existing theme customizations
|
||||
- Compatibility with WooCommerce template overrides
|
||||
- Traditional page-by-page navigation
|
||||
|
||||
**Best for**:
|
||||
- Stores with heavy theme customizations
|
||||
- Testing before full rollout
|
||||
- Troubleshooting issues
|
||||
|
||||
---
|
||||
|
||||
## Switching Modes
|
||||
|
||||
### How to Switch
|
||||
|
||||
1. Go to **WooNooW → Appearance → General**
|
||||
2. Find **SPA Mode** setting
|
||||
3. Select your preferred mode
|
||||
4. Click **Save Changes**
|
||||
|
||||
### What Happens When Switching
|
||||
|
||||
**Switching to Full**:
|
||||
- WooCommerce pages start redirecting
|
||||
- SPA loads for shop experience
|
||||
- No data is changed
|
||||
|
||||
**Switching to Disabled**:
|
||||
- Redirects stop immediately
|
||||
- WooCommerce templates take over
|
||||
- No data is changed
|
||||
|
||||
> **Note**: All your products, orders, and settings remain unchanged when switching modes.
|
||||
|
||||
---
|
||||
|
||||
## URL Structure
|
||||
|
||||
### Full Mode URLs
|
||||
|
||||
```
|
||||
https://yourstore.com/store/ → Home/Shop
|
||||
https://yourstore.com/store/shop → Shop page
|
||||
https://yourstore.com/store/product/slug → Product page
|
||||
https://yourstore.com/store/cart → Cart
|
||||
https://yourstore.com/store/checkout → Checkout
|
||||
https://yourstore.com/store/my-account → Account
|
||||
```
|
||||
|
||||
### Disabled Mode URLs
|
||||
|
||||
Standard WooCommerce URLs:
|
||||
```
|
||||
https://yourstore.com/shop/ → Shop page
|
||||
https://yourstore.com/product/slug → Product page
|
||||
https://yourstore.com/cart/ → Cart
|
||||
https://yourstore.com/checkout/ → Checkout
|
||||
https://yourstore.com/my-account/ → Account
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SEO Considerations
|
||||
|
||||
### Full Mode SEO
|
||||
|
||||
- WooCommerce URLs (`/product/slug`) remain in sitemaps
|
||||
- When users click from search results, they're redirected to SPA
|
||||
- Meta tags are generated dynamically for social sharing
|
||||
- 302 (temporary) redirects preserve link equity
|
||||
|
||||
### Disabled Mode SEO
|
||||
|
||||
- Standard WooCommerce SEO applies
|
||||
- No redirects needed
|
||||
- Works with Yoast SEO, RankMath, etc.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Redirects Not Working
|
||||
|
||||
1. **Flush Permalinks**: Go to Settings → Permalinks → Save Changes
|
||||
2. **Check Store Page**: Ensure the Store page exists and has `[woonoow_spa]`
|
||||
3. **Clear Cache**: Purge all caching layers
|
||||
|
||||
### Blank Pages After Enabling
|
||||
|
||||
1. Verify SPA Mode is set to "Full"
|
||||
2. Clear browser cache
|
||||
3. Check for JavaScript errors in browser console
|
||||
|
||||
### Want to Test Before Enabling
|
||||
|
||||
1. Keep mode as "Disabled"
|
||||
2. Visit `/store/` directly to preview SPA
|
||||
3. Switch to "Full" when satisfied
|
||||
149
docs/faq.md
Normal file
149
docs/faq.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
Quick answers to common questions about WooNooW.
|
||||
|
||||
---
|
||||
|
||||
## General
|
||||
|
||||
### What is WooNooW?
|
||||
|
||||
WooNooW is a WooCommerce plugin that transforms your store into a modern Single Page Application (SPA). It provides instant page loads, a beautiful UI, and seamless shopping experience.
|
||||
|
||||
### Do I need WooCommerce?
|
||||
|
||||
Yes. WooNooW is an enhancement layer for WooCommerce. You need WooCommerce installed and activated.
|
||||
|
||||
### Will WooNooW affect my existing products?
|
||||
|
||||
No. WooNooW reads from WooCommerce. Your products, orders, and settings remain untouched.
|
||||
|
||||
---
|
||||
|
||||
## SPA Mode
|
||||
|
||||
### What's the difference between Full and Disabled mode?
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| **Full** | All WooCommerce pages redirect to SPA. Modern, fast experience. |
|
||||
| **Disabled** | WooCommerce pages use native templates. WooNooW admin still works. |
|
||||
|
||||
### Can I switch modes anytime?
|
||||
|
||||
Yes. Go to **WooNooW → Appearance → General** and change the SPA Mode. Changes take effect immediately.
|
||||
|
||||
### Which mode should I use?
|
||||
|
||||
- **Full**: For the best customer experience with instant loads
|
||||
- **Disabled**: If you have theme customizations you want to keep
|
||||
|
||||
---
|
||||
|
||||
## Compatibility
|
||||
|
||||
### Does WooNooW work with my theme?
|
||||
|
||||
WooNooW's SPA is independent of your WordPress theme. In Full mode, the SPA uses its own styling. Your theme affects the rest of your site normally.
|
||||
|
||||
### Does WooNooW work with page builders?
|
||||
|
||||
The SPA pages are self-contained. Page builders work on other pages of your site.
|
||||
|
||||
### Which payment gateways are supported?
|
||||
|
||||
WooNooW supports all WooCommerce-compatible payment gateways:
|
||||
- PayPal
|
||||
- Stripe
|
||||
- Bank Transfer (BACS)
|
||||
- Cash on Delivery
|
||||
- And more...
|
||||
|
||||
---
|
||||
|
||||
## SEO
|
||||
|
||||
### Is WooNooW SEO-friendly?
|
||||
|
||||
Yes. WooNooW uses:
|
||||
- Clean URLs (`/store/product/product-name`)
|
||||
- Dynamic meta tags for social sharing
|
||||
- Proper redirects (302) from WooCommerce URLs
|
||||
|
||||
### What about my existing SEO?
|
||||
|
||||
WooCommerce URLs remain the indexed source. WooNooW redirects users to the SPA but preserves SEO value.
|
||||
|
||||
### Will my product pages be indexed?
|
||||
|
||||
Yes. Search engines index the WooCommerce URLs. When users click from search results, they're redirected to the fast SPA experience.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### Is WooNooW faster than regular WooCommerce?
|
||||
|
||||
Yes, for navigation. After the initial load, page transitions are instant because the SPA doesn't reload the entire page.
|
||||
|
||||
### Will WooNooW slow down my site?
|
||||
|
||||
The initial load is similar to regular WooCommerce. Subsequent navigation is much faster.
|
||||
|
||||
### Does WooNooW work with caching?
|
||||
|
||||
Yes. Use page caching and object caching for best results.
|
||||
|
||||
---
|
||||
|
||||
## Customization
|
||||
|
||||
### Can I customize colors and fonts?
|
||||
|
||||
Yes. Go to **WooNooW → Appearance** to customize:
|
||||
- Primary, secondary, and accent colors
|
||||
- Body and heading fonts
|
||||
- Logo and layout options
|
||||
|
||||
### Can I add custom CSS?
|
||||
|
||||
Currently, use your theme's Additional CSS feature. A custom CSS field may be added in future versions.
|
||||
|
||||
### Can I modify the SPA templates?
|
||||
|
||||
The SPA is built with React. Advanced customizations require development knowledge.
|
||||
|
||||
---
|
||||
|
||||
## Addons
|
||||
|
||||
### What are WooNooW addons?
|
||||
|
||||
Addons extend WooNooW with additional features like loyalty points, advanced analytics, etc.
|
||||
|
||||
### How do I install addons?
|
||||
|
||||
Addons are installed as separate WordPress plugins. They integrate automatically with WooNooW.
|
||||
|
||||
### Do addons work when SPA is disabled?
|
||||
|
||||
Most addon features are for the SPA. When disabled, addon functionality may be limited.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### I see a blank page. What do I do?
|
||||
|
||||
1. Check SPA Mode is set to "Full"
|
||||
2. Flush permalinks (**Settings → Permalinks → Save**)
|
||||
3. Clear all caches
|
||||
4. See [Troubleshooting](troubleshooting) for more
|
||||
|
||||
### How do I report a bug?
|
||||
|
||||
Contact support with:
|
||||
- Steps to reproduce the issue
|
||||
- WordPress/WooCommerce/WooNooW versions
|
||||
- Any error messages
|
||||
- Screenshots if applicable
|
||||
145
docs/features/checkout.md
Normal file
145
docs/features/checkout.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Checkout
|
||||
|
||||
The WooNooW checkout provides a streamlined purchasing experience.
|
||||
|
||||
## Overview
|
||||
|
||||
The checkout process includes:
|
||||
|
||||
1. **Cart Review** - Verify items before checkout
|
||||
2. **Customer Information** - Billing and shipping details
|
||||
3. **Payment Method** - Select how to pay
|
||||
4. **Order Confirmation** - Complete the purchase
|
||||
|
||||
---
|
||||
|
||||
## Checkout Flow
|
||||
|
||||
### Step 1: Cart
|
||||
|
||||
Before checkout, customers review their cart:
|
||||
- Product list with images
|
||||
- Quantity adjustments
|
||||
- Remove items
|
||||
- Apply coupon codes
|
||||
- See subtotal, shipping, and total
|
||||
|
||||
### Step 2: Customer Details
|
||||
|
||||
Customers provide:
|
||||
- **Email address**
|
||||
- **Billing information**
|
||||
- Name
|
||||
- Address
|
||||
- Phone
|
||||
- **Shipping address** (if different from billing)
|
||||
|
||||
> **Note**: Logged-in customers have their details pre-filled.
|
||||
|
||||
### Step 3: Shipping Method
|
||||
|
||||
If physical products are in the cart:
|
||||
- Available shipping methods are shown
|
||||
- Shipping cost is calculated
|
||||
- Customer selects preferred method
|
||||
|
||||
### Step 4: Payment
|
||||
|
||||
Customers choose their payment method:
|
||||
- Credit/Debit Card (Stripe, PayPal, etc.)
|
||||
- Bank Transfer
|
||||
- Cash on Delivery
|
||||
- Other configured gateways
|
||||
|
||||
### Step 5: Place Order
|
||||
|
||||
After reviewing everything:
|
||||
- Click "Place Order"
|
||||
- Payment is processed
|
||||
- Confirmation page is shown
|
||||
- Email receipt is sent
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Guest Checkout
|
||||
|
||||
Allow customers to checkout without creating an account.
|
||||
|
||||
Configure in **WooCommerce → Settings → Accounts & Privacy**.
|
||||
|
||||
### Coupon Codes
|
||||
|
||||
Customers can apply discount codes:
|
||||
1. Enter code in the coupon field
|
||||
2. Click "Apply"
|
||||
3. Discount is reflected in total
|
||||
|
||||
### Order Notes
|
||||
|
||||
Optional field for customers to add special instructions.
|
||||
|
||||
---
|
||||
|
||||
## Payment Gateways
|
||||
|
||||
### Supported Gateways
|
||||
|
||||
WooNooW supports all WooCommerce payment gateways:
|
||||
|
||||
| Gateway | Type |
|
||||
|---------|------|
|
||||
| Bank Transfer (BACS) | Manual |
|
||||
| Check Payments | Manual |
|
||||
| Cash on Delivery | Manual |
|
||||
| PayPal | Card / PayPal |
|
||||
| Stripe | Card |
|
||||
| Square | Card |
|
||||
|
||||
### Configuring Gateways
|
||||
|
||||
1. Go to **WooNooW → Settings → Payments**
|
||||
2. Enable desired payment methods
|
||||
3. Configure API keys and settings
|
||||
4. Test with sandbox/test mode first
|
||||
|
||||
---
|
||||
|
||||
## After Checkout
|
||||
|
||||
### Order Confirmation Page
|
||||
|
||||
Shows:
|
||||
- Order number
|
||||
- Order summary
|
||||
- Next steps
|
||||
|
||||
### Confirmation Email
|
||||
|
||||
Automatically sent to customer with:
|
||||
- Order details
|
||||
- Payment confirmation
|
||||
- Shipping information (if applicable)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Place Order" Button Not Working
|
||||
|
||||
1. Check all required fields are filled
|
||||
2. Verify payment gateway is properly configured
|
||||
3. Check browser console for JavaScript errors
|
||||
|
||||
### Payment Declined
|
||||
|
||||
1. Customer should verify card details
|
||||
2. Check payment gateway dashboard for error details
|
||||
3. Ensure correct API keys are configured
|
||||
|
||||
### Shipping Not Showing
|
||||
|
||||
1. Verify shipping zones are configured in WooCommerce
|
||||
2. Check if products have weight/dimensions set
|
||||
3. Confirm customer's address is in a configured zone
|
||||
96
docs/features/shop.md
Normal file
96
docs/features/shop.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Shop Page
|
||||
|
||||
The shop page displays your product catalog with browsing and filtering options.
|
||||
|
||||
## Overview
|
||||
|
||||
The WooNooW shop page provides:
|
||||
|
||||
- **Product Grid** - Visual display of products
|
||||
- **Search** - Find products by name
|
||||
- **Filters** - Category and sorting options
|
||||
- **Pagination** - Navigate through products
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Product Cards
|
||||
|
||||
Each product displays:
|
||||
- Product image
|
||||
- Product name
|
||||
- Price (with sale price if applicable)
|
||||
- Add to Cart button
|
||||
- Wishlist button (if enabled)
|
||||
|
||||
### Search
|
||||
|
||||
Type in the search box to filter products by name. Search is instant and updates the grid as you type.
|
||||
|
||||
### Category Filter
|
||||
|
||||
Filter products by category using the dropdown. Shows:
|
||||
- All Categories
|
||||
- Individual categories with product count
|
||||
|
||||
### Sorting
|
||||
|
||||
Sort products by:
|
||||
- Default sorting
|
||||
- Popularity
|
||||
- Average rating
|
||||
- Latest
|
||||
- Price: Low to High
|
||||
- Price: High to Low
|
||||
|
||||
---
|
||||
|
||||
## Customization
|
||||
|
||||
### Grid Layout
|
||||
|
||||
Configure the product grid in **WooNooW → Appearance**:
|
||||
|
||||
| Device | Options |
|
||||
|--------|---------|
|
||||
| Mobile | 1-2 columns |
|
||||
| Tablet | 2-4 columns |
|
||||
| Desktop | 2-6 columns |
|
||||
|
||||
### Product Card Style
|
||||
|
||||
Product cards can display:
|
||||
- **Image** - Product featured image
|
||||
- **Title** - Product name
|
||||
- **Price** - Current price and sale price
|
||||
- **Rating** - Star rating (if reviews enabled)
|
||||
- **Add to Cart** - Quick add button
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
### Clicking a Product
|
||||
|
||||
Clicking a product card navigates to the full product page where customers can:
|
||||
- View all images
|
||||
- Select variations
|
||||
- Read description
|
||||
- Add to cart
|
||||
|
||||
### Back to Shop
|
||||
|
||||
From any product page, use the breadcrumb or browser back button to return to the shop.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### Lazy Loading
|
||||
|
||||
Product images load as they come into view, improving initial page load time.
|
||||
|
||||
### Infinite Scroll vs Pagination
|
||||
|
||||
Currently uses pagination. Infinite scroll may be added in future versions.
|
||||
54
docs/getting-started.md
Normal file
54
docs/getting-started.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Getting Started with WooNooW
|
||||
|
||||
Welcome to WooNooW! This guide will help you get up and running quickly.
|
||||
|
||||
## What is WooNooW?
|
||||
|
||||
WooNooW transforms your WooCommerce store into a modern, fast Single Page Application (SPA). It provides:
|
||||
|
||||
- ⚡ **Instant Page Loads** - No page refreshes between navigation
|
||||
- 🎨 **Modern UI** - Beautiful, responsive design out of the box
|
||||
- 🛠 **Easy Customization** - Configure colors, fonts, and layout from admin
|
||||
- 📱 **Mobile-First** - Optimized for all devices
|
||||
|
||||
## Quick Setup (3 Steps)
|
||||
|
||||
### Step 1: Activate the Plugin
|
||||
|
||||
After installing WooNooW, activate it from **Plugins → Installed Plugins**.
|
||||
|
||||
The plugin will automatically:
|
||||
- Create a "Store" page for the SPA
|
||||
- Configure basic settings
|
||||
|
||||
### Step 2: Access Admin Dashboard
|
||||
|
||||
Go to **WooNooW** in your WordPress admin menu.
|
||||
|
||||
You'll see the admin dashboard with:
|
||||
- Orders management
|
||||
- Settings configuration
|
||||
- Appearance customization
|
||||
|
||||
### Step 3: Configure Your Store
|
||||
|
||||
Navigate to **Appearance** settings to:
|
||||
|
||||
1. **Upload your logo**
|
||||
2. **Set brand colors** (primary, secondary, accent)
|
||||
3. **Choose fonts** for headings and body text
|
||||
4. **Configure SPA mode** (Full or Disabled)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Installation Guide](installation) - Detailed installation instructions
|
||||
- [Appearance Settings](configuration/appearance) - Customize your store's look
|
||||
- [SPA Mode](configuration/spa-mode) - Understand Full vs Disabled mode
|
||||
- [Troubleshooting](troubleshooting) - Common issues and solutions
|
||||
|
||||
## Need Help?
|
||||
|
||||
If you encounter any issues:
|
||||
1. Check the [Troubleshooting](troubleshooting) guide
|
||||
2. Review the [FAQ](faq)
|
||||
3. Contact support with your WordPress and WooCommerce versions
|
||||
92
docs/installation.md
Normal file
92
docs/installation.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Installation Guide
|
||||
|
||||
This guide covers installing WooNooW on your WordPress site.
|
||||
|
||||
## Requirements
|
||||
|
||||
Before installing, ensure your site meets these requirements:
|
||||
|
||||
| Requirement | Minimum | Recommended |
|
||||
|-------------|---------|-------------|
|
||||
| WordPress | 6.0+ | Latest |
|
||||
| WooCommerce | 7.0+ | Latest |
|
||||
| PHP | 7.4+ | 8.1+ |
|
||||
| MySQL | 5.7+ | 8.0+ |
|
||||
|
||||
## Installation Methods
|
||||
|
||||
### Method 1: WordPress Admin (Recommended)
|
||||
|
||||
1. Go to **Plugins → Add New**
|
||||
2. Click **Upload Plugin**
|
||||
3. Select the `woonoow.zip` file
|
||||
4. Click **Install Now**
|
||||
5. Click **Activate**
|
||||
|
||||
### Method 2: FTP Upload
|
||||
|
||||
1. Extract `woonoow.zip` to get the `woonoow` folder
|
||||
2. Upload to `/wp-content/plugins/`
|
||||
3. Go to **Plugins → Installed Plugins**
|
||||
4. Find WooNooW and click **Activate**
|
||||
|
||||
## Post-Installation
|
||||
|
||||
After activation, WooNooW automatically:
|
||||
|
||||
### 1. Creates Store Page
|
||||
A new "Store" page is created with the SPA shortcode. This is your main storefront.
|
||||
|
||||
### 2. Registers Rewrite Rules
|
||||
URL routes like `/store/shop` and `/store/product/...` are registered.
|
||||
|
||||
> **Note**: If you see 404 errors, go to **Settings → Permalinks** and click **Save Changes** to flush rewrite rules.
|
||||
|
||||
### 3. Sets Default Configuration
|
||||
Basic appearance settings are configured with sensible defaults.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After installation, verify everything works:
|
||||
|
||||
- [ ] Plugin activated without errors
|
||||
- [ ] WooNooW menu appears in admin sidebar
|
||||
- [ ] Store page exists (check **Pages**)
|
||||
- [ ] `/store` URL loads the SPA
|
||||
- [ ] Products display on shop page
|
||||
|
||||
## WooCommerce Compatibility
|
||||
|
||||
WooNooW works alongside WooCommerce:
|
||||
|
||||
| WooCommerce Page | WooNooW Behavior (Full Mode) |
|
||||
|------------------|------------------------------|
|
||||
| `/shop` | Redirects to `/store/shop` |
|
||||
| `/product/...` | Redirects to `/store/product/...` |
|
||||
| `/cart` | Redirects to `/store/cart` |
|
||||
| `/checkout` | Redirects to `/store/checkout` |
|
||||
| `/my-account` | Redirects to `/store/my-account` |
|
||||
|
||||
When SPA Mode is **Disabled**, WooCommerce pages work normally.
|
||||
|
||||
## Updating
|
||||
|
||||
To update WooNooW:
|
||||
|
||||
1. Download the latest version
|
||||
2. Go to **Plugins → Installed Plugins**
|
||||
3. Deactivate WooNooW (optional but recommended)
|
||||
4. Delete the old version
|
||||
5. Install and activate the new version
|
||||
|
||||
Your settings are preserved in the database.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
To completely remove WooNooW:
|
||||
|
||||
1. Deactivate the plugin (restores WooCommerce page content)
|
||||
2. Delete the plugin
|
||||
3. (Optional) Delete WooNooW options from database
|
||||
|
||||
> **Note**: Deactivating restores original WooCommerce shortcodes to Cart, Checkout, and My Account pages.
|
||||
173
docs/troubleshooting.md
Normal file
173
docs/troubleshooting.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Troubleshooting
|
||||
|
||||
Common issues and their solutions.
|
||||
|
||||
## Blank Pages
|
||||
|
||||
### Symptom
|
||||
WooCommerce pages (shop, cart, checkout) show blank content.
|
||||
|
||||
### Solutions
|
||||
|
||||
**1. Check SPA Mode Setting**
|
||||
- Go to **WooNooW → Appearance → General**
|
||||
- Ensure **SPA Mode** is set to "Full"
|
||||
- If you want native WooCommerce, set to "Disabled"
|
||||
|
||||
**2. Flush Permalinks**
|
||||
- Go to **Settings → Permalinks**
|
||||
- Click **Save Changes** (no changes needed)
|
||||
- This refreshes rewrite rules
|
||||
|
||||
**3. Clear Cache**
|
||||
If using a caching plugin:
|
||||
- Clear page cache
|
||||
- Clear object cache
|
||||
- Purge CDN cache (if applicable)
|
||||
|
||||
---
|
||||
|
||||
## 404 Errors on SPA Routes
|
||||
|
||||
### Symptom
|
||||
Visiting `/store/shop` or `/store/product/...` shows a 404 error.
|
||||
|
||||
### Solutions
|
||||
|
||||
**1. Flush Permalinks**
|
||||
- Go to **Settings → Permalinks**
|
||||
- Click **Save Changes**
|
||||
|
||||
**2. Check Store Page Exists**
|
||||
- Go to **Pages**
|
||||
- Verify "Store" page exists and is published
|
||||
- The page should contain `[woonoow_spa]` shortcode
|
||||
|
||||
**3. Check SPA Page Setting**
|
||||
- Go to **WooNooW → Appearance → General**
|
||||
- Ensure **SPA Page** is set to the Store page
|
||||
|
||||
---
|
||||
|
||||
## Product Images Not Loading
|
||||
|
||||
### Symptom
|
||||
Products show placeholder images instead of actual images.
|
||||
|
||||
### Solutions
|
||||
|
||||
**1. Regenerate Thumbnails**
|
||||
- Install "Regenerate Thumbnails" plugin
|
||||
- Run regeneration for all images
|
||||
|
||||
**2. Check Image URLs**
|
||||
- Ensure images have valid URLs
|
||||
- Check for mixed content (HTTP vs HTTPS)
|
||||
|
||||
---
|
||||
|
||||
## Slow Performance
|
||||
|
||||
### Symptom
|
||||
SPA feels slow or laggy.
|
||||
|
||||
### Solutions
|
||||
|
||||
**1. Enable Caching**
|
||||
- Install a caching plugin (WP Super Cache, W3 Total Cache)
|
||||
- Enable object caching (Redis/Memcached)
|
||||
|
||||
**2. Optimize Images**
|
||||
- Use WebP format
|
||||
- Compress images before upload
|
||||
- Use lazy loading
|
||||
|
||||
**3. Check Server Resources**
|
||||
- Upgrade hosting if on shared hosting
|
||||
- Consider VPS or managed WordPress hosting
|
||||
|
||||
---
|
||||
|
||||
## Checkout Not Working
|
||||
|
||||
### Symptom
|
||||
Checkout page won't load or payment fails.
|
||||
|
||||
### Solutions
|
||||
|
||||
**1. Check Payment Gateway**
|
||||
- Go to **WooCommerce → Settings → Payments**
|
||||
- Verify payment method is enabled
|
||||
- Check API credentials
|
||||
|
||||
**2. Check SSL Certificate**
|
||||
- Checkout requires HTTPS
|
||||
- Verify SSL is properly installed
|
||||
|
||||
**3. Check for JavaScript Errors**
|
||||
- Open browser Developer Tools (F12)
|
||||
- Check Console for errors
|
||||
- Look for blocked scripts
|
||||
|
||||
---
|
||||
|
||||
## Emails Not Sending
|
||||
|
||||
### Symptom
|
||||
Order confirmation emails not being received.
|
||||
|
||||
### Solutions
|
||||
|
||||
**1. Check Email Settings**
|
||||
- Go to **WooNooW → Settings → Notifications**
|
||||
- Verify email types are enabled
|
||||
|
||||
**2. Check WordPress Email**
|
||||
- Test with a plugin like "Check & Log Email"
|
||||
- Consider using SMTP plugin (WP Mail SMTP)
|
||||
|
||||
**3. Check Spam Folder**
|
||||
- Emails may be in recipient's spam folder
|
||||
- Add sender to whitelist
|
||||
|
||||
---
|
||||
|
||||
## Plugin Conflicts
|
||||
|
||||
### Symptom
|
||||
WooNooW doesn't work after installing another plugin.
|
||||
|
||||
### Steps to Diagnose
|
||||
|
||||
1. **Deactivate other plugins** one by one
|
||||
2. **Switch to default theme** (Twenty Twenty-Three)
|
||||
3. **Check error logs** in `wp-content/debug.log`
|
||||
|
||||
### Common Conflicting Plugins
|
||||
|
||||
- Other WooCommerce template overrides
|
||||
- Page builder plugins (sometimes)
|
||||
- Heavy caching plugins (misconfigured)
|
||||
|
||||
---
|
||||
|
||||
## Getting More Help
|
||||
|
||||
If you can't resolve the issue:
|
||||
|
||||
1. **Collect Information**
|
||||
- WordPress version
|
||||
- WooCommerce version
|
||||
- WooNooW version
|
||||
- PHP version
|
||||
- Error messages (from debug.log)
|
||||
|
||||
2. **Enable Debug Mode**
|
||||
Add to `wp-config.php`:
|
||||
```php
|
||||
define('WP_DEBUG', true);
|
||||
define('WP_DEBUG_LOG', true);
|
||||
```
|
||||
|
||||
3. **Contact Support**
|
||||
Provide the collected information for faster resolution.
|
||||
@@ -17,7 +17,6 @@ class Assets
|
||||
{
|
||||
// Debug logging
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[WooNooW Assets] Hook: ' . $hook);
|
||||
}
|
||||
|
||||
if ($hook !== 'toplevel_page_woonoow') {
|
||||
@@ -32,7 +31,6 @@ class Assets
|
||||
|
||||
// Debug logging
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[WooNooW Assets] Dev mode: ' . ($is_dev ? 'true' : 'false'));
|
||||
}
|
||||
|
||||
if ($is_dev) {
|
||||
@@ -74,6 +72,8 @@ class Assets
|
||||
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
||||
'isAuthenticated' => is_user_logged_in(),
|
||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||
'storeUrl' => self::get_spa_url(),
|
||||
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
|
||||
]);
|
||||
wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after');
|
||||
|
||||
@@ -155,11 +155,6 @@ class Assets
|
||||
|
||||
// Debug logging
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[WooNooW Assets] Dist dir: ' . $dist_dir);
|
||||
error_log('[WooNooW Assets] CSS exists: ' . (file_exists($dist_dir . $css) ? 'yes' : 'no'));
|
||||
error_log('[WooNooW Assets] JS exists: ' . (file_exists($dist_dir . $js) ? 'yes' : 'no'));
|
||||
error_log('[WooNooW Assets] CSS URL: ' . $base_url . $css);
|
||||
error_log('[WooNooW Assets] JS URL: ' . $base_url . $js);
|
||||
}
|
||||
|
||||
if (file_exists($dist_dir . $css)) {
|
||||
@@ -202,6 +197,8 @@ class Assets
|
||||
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
||||
'isAuthenticated' => is_user_logged_in(),
|
||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||
'storeUrl' => self::get_spa_url(),
|
||||
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
|
||||
]);
|
||||
|
||||
// WordPress REST API settings (for media upload compatibility)
|
||||
@@ -286,7 +283,6 @@ class Assets
|
||||
|
||||
// Debug logging (only if WP_DEBUG is enabled)
|
||||
if (defined('WP_DEBUG') && WP_DEBUG && $filtered !== $const_dev) {
|
||||
error_log('[WooNooW Assets] Dev mode changed by filter: ' . ($filtered ? 'true' : 'false'));
|
||||
}
|
||||
|
||||
return (bool) $filtered;
|
||||
@@ -316,4 +312,21 @@ class Assets
|
||||
// Bump when releasing; in dev we don't cache-bust
|
||||
return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0';
|
||||
}
|
||||
|
||||
/** Get the SPA page URL from appearance settings (dynamic slug) */
|
||||
private static function get_spa_url(): string
|
||||
{
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
|
||||
if ($spa_page_id) {
|
||||
$spa_url = get_permalink($spa_page_id);
|
||||
if ($spa_url) {
|
||||
return trailingslashit($spa_url);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to /store/ if no SPA page configured
|
||||
return home_url('/store/');
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,26 @@ class Menu {
|
||||
'title' => __( 'WooNooW Standalone Admin', 'woonoow' ),
|
||||
],
|
||||
] );
|
||||
|
||||
// Add Store link if customer SPA is not disabled
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$spa_page = get_post($spa_page_id);
|
||||
$customer_spa_enabled = get_option( 'woonoow_customer_spa_enabled', true );
|
||||
if ( $customer_spa_enabled && $spa_page) {
|
||||
|
||||
$spa_slug = $spa_page->post_name;
|
||||
$store_url = home_url( '/' . $spa_slug );
|
||||
$wp_admin_bar->add_node( [
|
||||
'id' => 'woonoow-store',
|
||||
'title' => '<span class="ab-icon dashicons-cart"></span><span class="ab-label">' . __( 'Store', 'woonoow' ) . '</span>',
|
||||
'href' => $store_url,
|
||||
'meta' => [
|
||||
'title' => __( 'View Customer Store', 'woonoow' ),
|
||||
'target' => '_blank',
|
||||
],
|
||||
] );
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -53,9 +53,6 @@ class StandaloneAdmin {
|
||||
|
||||
// Debug logging (only in WP_DEBUG mode)
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( '[StandaloneAdmin] is_user_logged_in: ' . ( $is_logged_in ? 'true' : 'false' ) );
|
||||
error_log( '[StandaloneAdmin] has manage_woocommerce: ' . ( $has_permission ? 'true' : 'false' ) );
|
||||
error_log( '[StandaloneAdmin] is_authenticated: ' . ( $is_authenticated ? 'true' : 'false' ) );
|
||||
}
|
||||
|
||||
// Get nonce for REST API
|
||||
@@ -135,7 +132,9 @@ class StandaloneAdmin {
|
||||
currentUser: <?php echo wp_json_encode( $current_user ); ?>,
|
||||
locale: <?php echo wp_json_encode( get_locale() ); ?>,
|
||||
siteUrl: <?php echo wp_json_encode( home_url() ); ?>,
|
||||
siteName: <?php echo wp_json_encode( get_bloginfo( 'name' ) ); ?>
|
||||
siteName: <?php echo wp_json_encode( get_bloginfo( 'name' ) ); ?>,
|
||||
storeUrl: <?php echo wp_json_encode( self::get_spa_url() ); ?>,
|
||||
customerSpaEnabled: <?php echo get_option( 'woonoow_customer_spa_enabled', false ) ? 'true' : 'false'; ?>
|
||||
};
|
||||
|
||||
// Also set WNW_API for API compatibility
|
||||
@@ -197,4 +196,21 @@ class StandaloneAdmin {
|
||||
'currency_pos' => (string) $currency_pos,
|
||||
];
|
||||
}
|
||||
|
||||
/** Get the SPA page URL from appearance settings (dynamic slug) */
|
||||
private static function get_spa_url(): string
|
||||
{
|
||||
$appearance_settings = get_option( 'woonoow_appearance_settings', [] );
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
|
||||
if ( $spa_page_id ) {
|
||||
$spa_url = get_permalink( $spa_page_id );
|
||||
if ( $spa_url ) {
|
||||
return trailingslashit( $spa_url );
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to /store/ if no SPA page configured
|
||||
return home_url( '/store/' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +60,6 @@ class AuthController {
|
||||
|
||||
// Debug logging
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( '[AuthController::login] Login successful for user ID: ' . $user->ID );
|
||||
error_log( '[AuthController::login] Current user ID: ' . get_current_user_id() );
|
||||
error_log( '[AuthController::login] Cookies set: ' . ( headers_sent() ? 'Headers already sent!' : 'OK' ) );
|
||||
}
|
||||
|
||||
// Return user data and new nonce
|
||||
@@ -154,8 +151,6 @@ class AuthController {
|
||||
|
||||
// Debug logging
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( '[AuthController::check] is_user_logged_in: ' . ( $is_logged_in ? 'true' : 'false' ) );
|
||||
error_log( '[AuthController::check] Cookies: ' . print_r( $_COOKIE, true ) );
|
||||
}
|
||||
|
||||
if ( ! $is_logged_in ) {
|
||||
|
||||
@@ -32,6 +32,12 @@ class CheckoutController {
|
||||
'callback' => [ new self(), 'get_fields' ],
|
||||
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
|
||||
]);
|
||||
// Public countries endpoint for customer checkout form
|
||||
register_rest_route($namespace, '/countries', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [ new self(), 'get_countries' ],
|
||||
'permission_callback' => '__return_true', // Public - needed for checkout
|
||||
]);
|
||||
// Public order view endpoint for thank you page
|
||||
register_rest_route($namespace, '/checkout/order/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
@@ -44,6 +50,12 @@ class CheckoutController {
|
||||
],
|
||||
],
|
||||
]);
|
||||
// Get available shipping rates for given address
|
||||
register_rest_route($namespace, '/checkout/shipping-rates', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [ new self(), 'get_shipping_rates' ],
|
||||
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,18 +198,50 @@ class CheckoutController {
|
||||
];
|
||||
}
|
||||
|
||||
// Build shipping lines
|
||||
$shipping_lines = [];
|
||||
foreach ($order->get_shipping_methods() as $shipping_item) {
|
||||
$shipping_lines[] = [
|
||||
'id' => $shipping_item->get_id(),
|
||||
'method_title' => $shipping_item->get_method_title(),
|
||||
'method_id' => $shipping_item->get_method_id(),
|
||||
'total' => wc_price($shipping_item->get_total()),
|
||||
];
|
||||
}
|
||||
|
||||
// Get tracking info from order meta (various plugins use different keys)
|
||||
$tracking_number = $order->get_meta('_tracking_number')
|
||||
?: $order->get_meta('_wc_shipment_tracking_items')
|
||||
?: $order->get_meta('_rajaongkir_awb_number')
|
||||
?: '';
|
||||
$tracking_url = $order->get_meta('_tracking_url')
|
||||
?: $order->get_meta('_rajaongkir_tracking_url')
|
||||
?: '';
|
||||
|
||||
// Check for shipment tracking plugin format (array of tracking items)
|
||||
if (is_array($tracking_number) && !empty($tracking_number)) {
|
||||
$first_tracking = reset($tracking_number);
|
||||
$tracking_number = $first_tracking['tracking_number'] ?? '';
|
||||
$tracking_url = $first_tracking['tracking_url'] ?? $tracking_url;
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'id' => $order->get_id(),
|
||||
'number' => $order->get_order_number(),
|
||||
'status' => $order->get_status(),
|
||||
'subtotal' => (float) $order->get_subtotal(),
|
||||
'discount_total' => (float) $order->get_discount_total(),
|
||||
'shipping_total' => (float) $order->get_shipping_total(),
|
||||
'tax_total' => (float) $order->get_total_tax(),
|
||||
'total' => (float) $order->get_total(),
|
||||
'currency' => $order->get_currency(),
|
||||
'currency_symbol' => get_woocommerce_currency_symbol($order->get_currency()),
|
||||
'payment_method' => $order->get_payment_method_title(),
|
||||
'needs_shipping' => count($shipping_lines) > 0 || $order->needs_shipping_address(),
|
||||
'shipping_lines' => $shipping_lines,
|
||||
'tracking_number' => $tracking_number,
|
||||
'tracking_url' => $tracking_url,
|
||||
'billing' => [
|
||||
'first_name' => $order->get_billing_first_name(),
|
||||
'last_name' => $order->get_billing_last_name(),
|
||||
@@ -382,6 +426,23 @@ class CheckoutController {
|
||||
'taxes' => $rate->get_taxes(),
|
||||
]);
|
||||
$order->add_item($item);
|
||||
} elseif (!empty($payload['shipping_cost']) && $payload['shipping_cost'] > 0) {
|
||||
// Fallback: use shipping_cost directly from frontend
|
||||
// This handles API-based shipping like Rajaongkir where WC zones don't apply
|
||||
$item = new \WC_Order_Item_Shipping();
|
||||
|
||||
// Parse method ID from shipping_method (format: "method_id:instance_id" or "method_id:instance_id:variant")
|
||||
$parts = explode(':', $payload['shipping_method']);
|
||||
$method_id = $parts[0] ?? 'shipping';
|
||||
$instance_id = isset($parts[1]) ? (int)$parts[1] : 0;
|
||||
|
||||
$item->set_props([
|
||||
'method_title' => sanitize_text_field($payload['shipping_title'] ?? 'Shipping'),
|
||||
'method_id' => sanitize_text_field($method_id),
|
||||
'instance_id' => $instance_id,
|
||||
'total' => floatval($payload['shipping_cost']),
|
||||
]);
|
||||
$order->add_item($item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +536,14 @@ class CheckoutController {
|
||||
'custom' => !in_array($key, $this->get_standard_field_keys()), // Flag custom fields
|
||||
'autocomplete'=> $field['autocomplete'] ?? '',
|
||||
'validate' => $field['validate'] ?? [],
|
||||
// New fields for dynamic rendering
|
||||
'input_class' => $field['input_class'] ?? [],
|
||||
'custom_attributes' => $field['custom_attributes'] ?? [],
|
||||
'default' => $field['default'] ?? '',
|
||||
// For searchable_select type
|
||||
'search_endpoint' => $field['search_endpoint'] ?? null,
|
||||
'search_param' => $field['search_param'] ?? 'search',
|
||||
'min_chars' => $field['min_chars'] ?? 2,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -493,9 +562,10 @@ class CheckoutController {
|
||||
|
||||
/**
|
||||
* Get list of standard WooCommerce field keys
|
||||
* Plugins can extend this list via the 'woonoow_standard_checkout_field_keys' filter
|
||||
*/
|
||||
private function get_standard_field_keys(): array {
|
||||
return [
|
||||
$keys = [
|
||||
'billing_first_name',
|
||||
'billing_last_name',
|
||||
'billing_company',
|
||||
@@ -518,6 +588,14 @@ class CheckoutController {
|
||||
'shipping_postcode',
|
||||
'order_comments',
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter the list of standard checkout field keys.
|
||||
* Plugins can add their own field keys to be recognized as "standard" (not custom).
|
||||
*
|
||||
* @param array $keys List of standard field keys
|
||||
*/
|
||||
return apply_filters('woonoow_standard_checkout_field_keys', $keys);
|
||||
}
|
||||
|
||||
/** ----------------- Helpers ----------------- **/
|
||||
@@ -614,6 +692,7 @@ class CheckoutController {
|
||||
$billing = isset($json['billing']) && is_array($json['billing']) ? $json['billing'] : [];
|
||||
$shipping = isset($json['shipping']) && is_array($json['shipping']) ? $json['shipping'] : [];
|
||||
$coupons = isset($json['coupons']) && is_array($json['coupons']) ? array_map('wc_clean', $json['coupons']) : [];
|
||||
$custom_fields = isset($json['custom_fields']) && is_array($json['custom_fields']) ? $json['custom_fields'] : [];
|
||||
|
||||
return [
|
||||
'items' => array_map(function ($i) {
|
||||
@@ -629,6 +708,11 @@ class CheckoutController {
|
||||
'coupons' => $coupons,
|
||||
'shipping_method' => isset($json['shipping_method']) ? wc_clean($json['shipping_method']) : null,
|
||||
'payment_method' => isset($json['payment_method']) ? wc_clean($json['payment_method']) : null,
|
||||
// NEW: Added missing fields that were causing shipping to not be applied
|
||||
'shipping_cost' => isset($json['shipping_cost']) ? (float) $json['shipping_cost'] : null,
|
||||
'shipping_title' => isset($json['shipping_title']) ? sanitize_text_field($json['shipping_title']) : null,
|
||||
'custom_fields' => $custom_fields,
|
||||
'customer_note' => isset($json['customer_note']) ? sanitize_textarea_field($json['customer_note']) : '',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -721,4 +805,161 @@ class CheckoutController {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get countries and states for checkout form
|
||||
* Public endpoint - no authentication required
|
||||
*/
|
||||
public function get_countries(): array {
|
||||
$wc_countries = WC()->countries;
|
||||
|
||||
// Get allowed selling countries
|
||||
$allowed = $wc_countries->get_allowed_countries();
|
||||
|
||||
// Format for frontend
|
||||
$countries = [];
|
||||
foreach ($allowed as $code => $name) {
|
||||
$countries[] = [
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
];
|
||||
}
|
||||
|
||||
// Get states for all allowed countries
|
||||
$states = [];
|
||||
foreach (array_keys($allowed) as $country_code) {
|
||||
$country_states = $wc_countries->get_states($country_code);
|
||||
if (!empty($country_states) && is_array($country_states)) {
|
||||
$states[$country_code] = $country_states;
|
||||
}
|
||||
}
|
||||
|
||||
// Get default country
|
||||
$default_country = $wc_countries->get_base_country();
|
||||
|
||||
return [
|
||||
'countries' => $countries,
|
||||
'states' => $states,
|
||||
'default_country' => $default_country,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available shipping rates for given address
|
||||
* POST /checkout/shipping-rates
|
||||
* Body: { shipping: { country, state, city, postcode, destination_id? }, items: [...] }
|
||||
*/
|
||||
public function get_shipping_rates(WP_REST_Request $r): array {
|
||||
$payload = $r->get_json_params();
|
||||
$shipping = $payload['shipping'] ?? [];
|
||||
$items = $payload['items'] ?? [];
|
||||
|
||||
$country = wc_clean($shipping['country'] ?? '');
|
||||
$state = wc_clean($shipping['state'] ?? '');
|
||||
$city = wc_clean($shipping['city'] ?? '');
|
||||
$postcode = wc_clean($shipping['postcode'] ?? '');
|
||||
|
||||
if (empty($country)) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'rates' => [],
|
||||
'message' => 'Country is required',
|
||||
];
|
||||
}
|
||||
|
||||
// Trigger hook for plugins to set session data (e.g., Rajaongkir destination_id)
|
||||
do_action('woonoow/shipping/before_calculate', $shipping, $items);
|
||||
|
||||
// Set customer location for shipping calculation
|
||||
if (WC()->customer) {
|
||||
WC()->customer->set_shipping_country($country);
|
||||
WC()->customer->set_shipping_state($state);
|
||||
WC()->customer->set_shipping_city($city);
|
||||
WC()->customer->set_shipping_postcode($postcode);
|
||||
}
|
||||
|
||||
// Build package for shipping calculation
|
||||
$contents = [];
|
||||
$contents_cost = 0;
|
||||
foreach ($items as $item) {
|
||||
$product = wc_get_product($item['product_id'] ?? 0);
|
||||
if (!$product) continue;
|
||||
$qty = max(1, (int)($item['quantity'] ?? $item['qty'] ?? 1));
|
||||
$price = (float) wc_get_price_to_display($product);
|
||||
$contents[] = [
|
||||
'data' => $product,
|
||||
'quantity' => $qty,
|
||||
'line_total' => $price * $qty,
|
||||
];
|
||||
$contents_cost += $price * $qty;
|
||||
}
|
||||
|
||||
$package = [
|
||||
'destination' => [
|
||||
'country' => $country,
|
||||
'state' => $state,
|
||||
'city' => $city,
|
||||
'postcode' => $postcode,
|
||||
],
|
||||
'contents' => $contents,
|
||||
'contents_cost' => $contents_cost,
|
||||
'applied_coupons' => [],
|
||||
'user' => ['ID' => get_current_user_id()],
|
||||
];
|
||||
|
||||
// Get matching shipping zone
|
||||
$zone = WC_Shipping_Zones::get_zone_matching_package($package);
|
||||
if (!$zone) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'rates' => [],
|
||||
'message' => 'No shipping zone matches your location',
|
||||
];
|
||||
}
|
||||
|
||||
// Get enabled shipping methods from zone
|
||||
$methods = $zone->get_shipping_methods(true);
|
||||
$rates = [];
|
||||
|
||||
foreach ($methods as $method) {
|
||||
// Check if method has rates (some methods like live rate need to calculate)
|
||||
if (method_exists($method, 'get_rates_for_package')) {
|
||||
$method_rates = $method->get_rates_for_package($package);
|
||||
foreach ($method_rates as $rate) {
|
||||
$rates[] = [
|
||||
'id' => $rate->get_id(),
|
||||
'label' => $rate->get_label(),
|
||||
'cost' => (float) $rate->get_cost(),
|
||||
'method_id' => $rate->get_method_id(),
|
||||
'instance_id' => $rate->get_instance_id(),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Fallback for simple methods
|
||||
$method_id = $method->id . ':' . $method->get_instance_id();
|
||||
$cost = 0;
|
||||
|
||||
// Try to get cost from method
|
||||
if (isset($method->cost)) {
|
||||
$cost = (float) $method->cost;
|
||||
} elseif (method_exists($method, 'get_option')) {
|
||||
$cost = (float) $method->get_option('cost', 0);
|
||||
}
|
||||
|
||||
$rates[] = [
|
||||
'id' => $method_id,
|
||||
'label' => $method->get_title(),
|
||||
'cost' => $cost,
|
||||
'method_id' => $method->id,
|
||||
'instance_id' => $method->get_instance_id(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'rates' => $rates,
|
||||
'zone_name' => $zone->get_zone_name(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,21 @@ class CartController extends WP_REST_Controller {
|
||||
|
||||
$cart = WC()->cart;
|
||||
|
||||
// Calculate totals to ensure discounts are computed
|
||||
$cart->calculate_totals();
|
||||
|
||||
// Format coupons with discount amounts
|
||||
$coupons_with_discounts = [];
|
||||
foreach ($cart->get_applied_coupons() as $coupon_code) {
|
||||
$coupon = new \WC_Coupon($coupon_code);
|
||||
$discount = $cart->get_coupon_discount_amount($coupon_code);
|
||||
$coupons_with_discounts[] = [
|
||||
'code' => $coupon_code,
|
||||
'discount' => (float) $discount,
|
||||
'type' => $coupon->get_discount_type(),
|
||||
];
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'items' => $this->format_cart_items($cart->get_cart()),
|
||||
'totals' => [
|
||||
@@ -145,7 +160,8 @@ class CartController extends WP_REST_Controller {
|
||||
'total' => $cart->get_total(''),
|
||||
'total_tax' => $cart->get_total_tax(),
|
||||
],
|
||||
'coupons' => $cart->get_applied_coupons(),
|
||||
'coupons' => $coupons_with_discounts,
|
||||
'discount_total' => (float) $cart->get_discount_total(), // Root level for frontend
|
||||
'needs_shipping' => $cart->needs_shipping(),
|
||||
'needs_payment' => $cart->needs_payment(),
|
||||
'item_count' => $cart->get_cart_contents_count(),
|
||||
@@ -365,6 +381,8 @@ class CartController extends WP_REST_Controller {
|
||||
'total' => $cart_item['line_total'],
|
||||
'image' => wp_get_attachment_image_url($product->get_image_id(), 'thumbnail'),
|
||||
'permalink' => $product->get_permalink(),
|
||||
'virtual' => $product->is_virtual(),
|
||||
'downloadable' => $product->is_downloadable(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
129
includes/Api/DocsController.php
Normal file
129
includes/Api/DocsController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/**
|
||||
* Documentation API Controller
|
||||
*
|
||||
* Serves documentation content to the Admin SPA.
|
||||
*/
|
||||
|
||||
namespace WooNooW\Api;
|
||||
|
||||
use WP_REST_Controller;
|
||||
use WP_REST_Response;
|
||||
use WP_REST_Request;
|
||||
use WP_Error;
|
||||
|
||||
class DocsController extends WP_REST_Controller {
|
||||
|
||||
/**
|
||||
* Namespace for REST routes
|
||||
*/
|
||||
protected $namespace = 'woonoow/v1';
|
||||
|
||||
/**
|
||||
* Base route
|
||||
*/
|
||||
protected $rest_base = 'docs';
|
||||
|
||||
/**
|
||||
* Register routes
|
||||
*/
|
||||
public function register_routes() {
|
||||
// GET /woonoow/v1/docs - List all documentation
|
||||
register_rest_route($this->namespace, '/' . $this->rest_base, [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_docs_registry'],
|
||||
'permission_callback' => [$this, 'check_permissions'],
|
||||
],
|
||||
]);
|
||||
|
||||
// GET /woonoow/v1/docs/{slug} - Get single document
|
||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<slug>.+)', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_doc'],
|
||||
'permission_callback' => [$this, 'check_permissions'],
|
||||
'args' => [
|
||||
'slug' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check permissions - any logged in admin user
|
||||
*/
|
||||
public function check_permissions($request) {
|
||||
return current_user_can('manage_options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get documentation registry
|
||||
*
|
||||
* @return WP_REST_Response
|
||||
*/
|
||||
public function get_docs_registry($request) {
|
||||
require_once WOONOOW_PATH . 'docs/_registry.php';
|
||||
|
||||
$registry = \WooNooW\Docs\get_docs_registry();
|
||||
|
||||
// Transform to frontend format (without file paths)
|
||||
$result = [];
|
||||
foreach ($registry as $section_key => $section) {
|
||||
$items = [];
|
||||
foreach ($section['items'] as $item) {
|
||||
$items[] = [
|
||||
'slug' => $item['slug'],
|
||||
'title' => $item['title'],
|
||||
];
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'key' => $section_key,
|
||||
'label' => $section['label'],
|
||||
'icon' => $section['icon'] ?? 'file-text',
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'sections' => $result,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single document content
|
||||
*
|
||||
* @param WP_REST_Request $request
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function get_doc($request) {
|
||||
$slug = $request->get_param('slug');
|
||||
|
||||
require_once WOONOOW_PATH . 'docs/_registry.php';
|
||||
|
||||
$doc = \WooNooW\Docs\get_doc_by_slug($slug);
|
||||
|
||||
if (!$doc) {
|
||||
return new WP_Error(
|
||||
'doc_not_found',
|
||||
'Documentation not found',
|
||||
['status' => 404]
|
||||
);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'doc' => [
|
||||
'slug' => $doc['slug'],
|
||||
'title' => $doc['title'],
|
||||
'content' => $doc['content'],
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
291
includes/Api/LicensesController.php
Normal file
291
includes/Api/LicensesController.php
Normal file
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
/**
|
||||
* Licenses API Controller
|
||||
*
|
||||
* REST API endpoints for license management.
|
||||
*
|
||||
* @package WooNooW\Api
|
||||
*/
|
||||
|
||||
namespace WooNooW\Api;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_Error;
|
||||
use WooNooW\Core\ModuleRegistry;
|
||||
use WooNooW\Modules\Licensing\LicenseManager;
|
||||
|
||||
class LicensesController {
|
||||
|
||||
/**
|
||||
* Register REST routes
|
||||
*/
|
||||
public static function register_routes() {
|
||||
// Check if module is enabled
|
||||
if (!ModuleRegistry::is_enabled('licensing')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin routes
|
||||
register_rest_route('woonoow/v1', '/licenses', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_licenses'],
|
||||
'permission_callback' => function() {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_license'],
|
||||
'permission_callback' => function() {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'revoke_license'],
|
||||
'permission_callback' => function() {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)/activations', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_activations'],
|
||||
'permission_callback' => function() {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
// Customer routes
|
||||
register_rest_route('woonoow/v1', '/account/licenses', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_customer_licenses'],
|
||||
'permission_callback' => function() {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/account/licenses/(?P<id>\d+)/deactivate', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'customer_deactivate'],
|
||||
'permission_callback' => function() {
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
|
||||
// Public API routes (for software validation)
|
||||
register_rest_route('woonoow/v1', '/licenses/validate', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'validate_license'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/licenses/activate', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'activate_license'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/licenses/deactivate', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'deactivate_license'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all licenses (admin)
|
||||
*/
|
||||
public static function get_licenses(WP_REST_Request $request) {
|
||||
$args = [
|
||||
'search' => $request->get_param('search'),
|
||||
'status' => $request->get_param('status'),
|
||||
'product_id' => $request->get_param('product_id'),
|
||||
'user_id' => $request->get_param('user_id'),
|
||||
'limit' => $request->get_param('per_page') ?: 50,
|
||||
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 50),
|
||||
];
|
||||
|
||||
$result = LicenseManager::get_all_licenses($args);
|
||||
|
||||
// Enrich with product and user info
|
||||
foreach ($result['licenses'] as &$license) {
|
||||
$license = self::enrich_license($license);
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'licenses' => $result['licenses'],
|
||||
'total' => $result['total'],
|
||||
'page' => $request->get_param('page') ?: 1,
|
||||
'per_page' => $args['limit'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single license (admin)
|
||||
*/
|
||||
public static function get_license(WP_REST_Request $request) {
|
||||
$license = LicenseManager::get_license($request->get_param('id'));
|
||||
|
||||
if (!$license) {
|
||||
return new WP_Error('not_found', __('License not found', 'woonoow'), ['status' => 404]);
|
||||
}
|
||||
|
||||
$license = self::enrich_license($license);
|
||||
$license['activations'] = LicenseManager::get_activations($license['id']);
|
||||
|
||||
return new WP_REST_Response($license);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke license (admin)
|
||||
*/
|
||||
public static function revoke_license(WP_REST_Request $request) {
|
||||
$result = LicenseManager::revoke($request->get_param('id'));
|
||||
|
||||
if (!$result) {
|
||||
return new WP_Error('revoke_failed', __('Failed to revoke license', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get activations for license (admin)
|
||||
*/
|
||||
public static function get_activations(WP_REST_Request $request) {
|
||||
$activations = LicenseManager::get_activations($request->get_param('id'));
|
||||
return new WP_REST_Response($activations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer's licenses
|
||||
*/
|
||||
public static function get_customer_licenses(WP_REST_Request $request) {
|
||||
$user_id = get_current_user_id();
|
||||
$licenses = LicenseManager::get_user_licenses($user_id);
|
||||
|
||||
// Enrich each license
|
||||
foreach ($licenses as &$license) {
|
||||
$license = self::enrich_license($license);
|
||||
$license['activations'] = LicenseManager::get_activations($license['id']);
|
||||
}
|
||||
|
||||
return new WP_REST_Response($licenses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer deactivate their own activation
|
||||
*/
|
||||
public static function customer_deactivate(WP_REST_Request $request) {
|
||||
$user_id = get_current_user_id();
|
||||
$license = LicenseManager::get_license($request->get_param('id'));
|
||||
|
||||
if (!$license || $license['user_id'] != $user_id) {
|
||||
return new WP_Error('not_found', __('License not found', 'woonoow'), ['status' => 404]);
|
||||
}
|
||||
|
||||
$data = $request->get_json_params();
|
||||
$result = LicenseManager::deactivate(
|
||||
$license['license_key'],
|
||||
$data['activation_id'] ?? null,
|
||||
$data['machine_id'] ?? null
|
||||
);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return new WP_REST_Response($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate license (public API)
|
||||
*/
|
||||
public static function validate_license(WP_REST_Request $request) {
|
||||
$data = $request->get_json_params();
|
||||
|
||||
if (empty($data['license_key'])) {
|
||||
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$result = LicenseManager::validate($data['license_key']);
|
||||
return new WP_REST_Response($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate license (public API)
|
||||
*/
|
||||
public static function activate_license(WP_REST_Request $request) {
|
||||
$data = $request->get_json_params();
|
||||
|
||||
if (empty($data['license_key'])) {
|
||||
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$activation_data = [
|
||||
'domain' => $data['domain'] ?? null,
|
||||
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
'machine_id' => $data['machine_id'] ?? null,
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
|
||||
];
|
||||
|
||||
$result = LicenseManager::activate($data['license_key'], $activation_data);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return new WP_REST_Response($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate license (public API)
|
||||
*/
|
||||
public static function deactivate_license(WP_REST_Request $request) {
|
||||
$data = $request->get_json_params();
|
||||
|
||||
if (empty($data['license_key'])) {
|
||||
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$result = LicenseManager::deactivate(
|
||||
$data['license_key'],
|
||||
$data['activation_id'] ?? null,
|
||||
$data['machine_id'] ?? null
|
||||
);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return new WP_REST_Response($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich license with product and user info
|
||||
*/
|
||||
private static function enrich_license($license) {
|
||||
// Add product info
|
||||
$product = wc_get_product($license['product_id']);
|
||||
$license['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'woonoow');
|
||||
|
||||
// Add user info
|
||||
$user = get_userdata($license['user_id']);
|
||||
$license['user_email'] = $user ? $user->user_email : '';
|
||||
$license['user_name'] = $user ? $user->display_name : __('Unknown User', 'woonoow');
|
||||
|
||||
// Add computed fields
|
||||
$license['is_expired'] = $license['expires_at'] && strtotime($license['expires_at']) < time();
|
||||
$license['activations_remaining'] = $license['activation_limit'] > 0
|
||||
? max(0, $license['activation_limit'] - $license['activation_count'])
|
||||
: -1;
|
||||
|
||||
return $license;
|
||||
}
|
||||
}
|
||||
@@ -141,9 +141,9 @@ class ModulesController extends WP_REST_Controller {
|
||||
|
||||
// Toggle module
|
||||
if ($enabled) {
|
||||
ModuleRegistry::enable_module($module_id);
|
||||
ModuleRegistry::enable($module_id);
|
||||
} else {
|
||||
ModuleRegistry::disable_module($module_id);
|
||||
ModuleRegistry::disable($module_id);
|
||||
}
|
||||
|
||||
// Return success response
|
||||
|
||||
@@ -217,6 +217,15 @@ class NotificationsController {
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
],
|
||||
]);
|
||||
|
||||
// GET /woonoow/v1/notifications/logs
|
||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/logs', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_logs'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -872,4 +881,54 @@ class NotificationsController {
|
||||
),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification activity logs
|
||||
*
|
||||
* @param WP_REST_Request $request Request object
|
||||
* @return WP_REST_Response
|
||||
*/
|
||||
public function get_logs(WP_REST_Request $request) {
|
||||
$page = (int) $request->get_param('page') ?: 1;
|
||||
$per_page = (int) $request->get_param('per_page') ?: 20;
|
||||
$channel = $request->get_param('channel');
|
||||
$status = $request->get_param('status');
|
||||
$search = $request->get_param('search');
|
||||
|
||||
// Get logs from option (in a real app, use a custom table)
|
||||
$all_logs = get_option('woonoow_notification_logs', []);
|
||||
|
||||
// Apply filters
|
||||
if ($channel && $channel !== 'all') {
|
||||
$all_logs = array_filter($all_logs, fn($log) => $log['channel'] === $channel);
|
||||
}
|
||||
|
||||
if ($status && $status !== 'all') {
|
||||
$all_logs = array_filter($all_logs, fn($log) => $log['status'] === $status);
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$search_lower = strtolower($search);
|
||||
$all_logs = array_filter($all_logs, function($log) use ($search_lower) {
|
||||
return strpos(strtolower($log['recipient'] ?? ''), $search_lower) !== false ||
|
||||
strpos(strtolower($log['subject'] ?? ''), $search_lower) !== false;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
usort($all_logs, function($a, $b) {
|
||||
return strtotime($b['created_at'] ?? '') - strtotime($a['created_at'] ?? '');
|
||||
});
|
||||
|
||||
$total = count($all_logs);
|
||||
$offset = ($page - 1) * $per_page;
|
||||
$logs = array_slice(array_values($all_logs), $offset, $per_page);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'logs' => $logs,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -770,16 +770,13 @@ class OrdersController {
|
||||
if ( null !== $status && $status !== '' ) {
|
||||
$order_id = $order->get_id();
|
||||
add_action( 'shutdown', function() use ( $order_id, $status ) {
|
||||
error_log('[WooNooW] Shutdown hook firing - scheduling email for order #' . $order_id);
|
||||
self::schedule_order_email( $order_id, $status );
|
||||
error_log('[WooNooW] Email scheduled successfully for order #' . $order_id);
|
||||
}, 999 );
|
||||
}
|
||||
|
||||
return new \WP_REST_Response( [ 'ok' => true, 'id' => $order->get_id() ], 200 );
|
||||
} catch ( \Throwable $e ) {
|
||||
// Log the actual error for debugging
|
||||
error_log('[WooNooW] Order update failed: ' . $e->getMessage());
|
||||
|
||||
// Return user-friendly error message
|
||||
return new \WP_REST_Response( [
|
||||
@@ -797,13 +794,11 @@ class OrdersController {
|
||||
public static function on_order_status_changed( $order_id, $status_from, $status_to, $order ) {
|
||||
// Skip if we're in an API request (we schedule manually there)
|
||||
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
|
||||
error_log('[WooNooW] Skipping auto-schedule during API request for order #' . $order_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule email notification with 15s delay
|
||||
self::schedule_order_email( $order_id, $status_to );
|
||||
error_log('[WooNooW] Order #' . $order_id . ' status changed: ' . $status_from . ' → ' . $status_to . ', email scheduled');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -863,25 +858,49 @@ class OrdersController {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Billing information is required for a healthy order
|
||||
$required_billing_fields = [
|
||||
'first_name' => __( 'Billing first name', 'woonoow' ),
|
||||
'last_name' => __( 'Billing last name', 'woonoow' ),
|
||||
'email' => __( 'Billing email', 'woonoow' ),
|
||||
];
|
||||
// 3. Billing information required based on checkout fields configuration
|
||||
// Get checkout field settings to respect hidden/required status from PHP snippets
|
||||
$checkout_fields = apply_filters( 'woonoow/checkout/fields', [], $items );
|
||||
|
||||
// Address fields only required for physical products
|
||||
if ( $has_physical_product ) {
|
||||
$required_billing_fields['address_1'] = __( 'Billing address', 'woonoow' );
|
||||
$required_billing_fields['city'] = __( 'Billing city', 'woonoow' );
|
||||
$required_billing_fields['postcode'] = __( 'Billing postcode', 'woonoow' );
|
||||
$required_billing_fields['country'] = __( 'Billing country', 'woonoow' );
|
||||
// Helper to check if a billing field is required
|
||||
$is_field_required = function( $field_key ) use ( $checkout_fields ) {
|
||||
foreach ( $checkout_fields as $field ) {
|
||||
if ( isset( $field['key'] ) && $field['key'] === $field_key ) {
|
||||
// Field is not required if hidden or explicitly not required
|
||||
if ( ! empty( $field['hidden'] ) || $field['type'] === 'hidden' ) {
|
||||
return false;
|
||||
}
|
||||
return ! empty( $field['required'] );
|
||||
}
|
||||
}
|
||||
// Default: core fields are required if not found in API
|
||||
return true;
|
||||
};
|
||||
|
||||
// Core billing fields - check against API configuration
|
||||
if ( $is_field_required( 'billing_first_name' ) && empty( $billing['first_name'] ) ) {
|
||||
$validation_errors[] = __( 'Billing first name is required', 'woonoow' );
|
||||
}
|
||||
if ( $is_field_required( 'billing_last_name' ) && empty( $billing['last_name'] ) ) {
|
||||
$validation_errors[] = __( 'Billing last name is required', 'woonoow' );
|
||||
}
|
||||
if ( $is_field_required( 'billing_email' ) && empty( $billing['email'] ) ) {
|
||||
$validation_errors[] = __( 'Billing email is required', 'woonoow' );
|
||||
}
|
||||
|
||||
foreach ( $required_billing_fields as $field => $label ) {
|
||||
if ( empty( $billing[ $field ] ) ) {
|
||||
/* translators: %s: field label */
|
||||
$validation_errors[] = sprintf( __( '%s is required', 'woonoow' ), $label );
|
||||
// Address fields only required for physical products AND if not hidden
|
||||
if ( $has_physical_product ) {
|
||||
if ( $is_field_required( 'billing_address_1' ) && empty( $billing['address_1'] ) ) {
|
||||
$validation_errors[] = __( 'Billing address is required', 'woonoow' );
|
||||
}
|
||||
if ( $is_field_required( 'billing_city' ) && empty( $billing['city'] ) ) {
|
||||
$validation_errors[] = __( 'Billing city is required', 'woonoow' );
|
||||
}
|
||||
if ( $is_field_required( 'billing_postcode' ) && empty( $billing['postcode'] ) ) {
|
||||
$validation_errors[] = __( 'Billing postcode is required', 'woonoow' );
|
||||
}
|
||||
if ( $is_field_required( 'billing_country' ) && empty( $billing['country'] ) ) {
|
||||
$validation_errors[] = __( 'Billing country is required', 'woonoow' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1042,7 +1061,6 @@ class OrdersController {
|
||||
$order->apply_coupon( $coupon );
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
error_log( '[WooNooW] Coupon error: ' . $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1234,7 +1252,6 @@ class OrdersController {
|
||||
|
||||
} catch ( \Throwable $e ) {
|
||||
// Log the actual error for debugging
|
||||
error_log('[WooNooW] Order creation failed: ' . $e->getMessage());
|
||||
|
||||
// Return user-friendly error message
|
||||
return new \WP_REST_Response( [
|
||||
@@ -1251,10 +1268,18 @@ class OrdersController {
|
||||
$s = sanitize_text_field( $req->get_param('search') ?? '' );
|
||||
$limit = max( 1, min( 20, absint( $req->get_param('limit') ?? 10 ) ) );
|
||||
|
||||
$args = [ 'limit' => $limit, 'status' => 'publish' ];
|
||||
if ( $s ) { $args['s'] = $s; }
|
||||
// Use WP_Query for proper search support (wc_get_products doesn't support 's' parameter)
|
||||
$args = [
|
||||
'post_type' => [ 'product' ],
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => $limit,
|
||||
];
|
||||
if ( $s ) {
|
||||
$args['s'] = $s;
|
||||
}
|
||||
|
||||
$prods = wc_get_products( $args );
|
||||
$query = new \WP_Query( $args );
|
||||
$prods = array_filter( array_map( 'wc_get_product', $query->posts ) );
|
||||
$rows = array_map( function( $p ) {
|
||||
$data = [
|
||||
'id' => $p->get_id(),
|
||||
@@ -1302,8 +1327,8 @@ class OrdersController {
|
||||
$value = $term ? $term->name : $value;
|
||||
}
|
||||
} else {
|
||||
// Custom attribute - WooCommerce stores as 'attribute_' + exact attribute name
|
||||
$meta_key = 'attribute_' . $attr_name;
|
||||
// Custom attribute - WooCommerce stores as 'attribute_' + lowercase sanitized name
|
||||
$meta_key = 'attribute_' . sanitize_title( $attr_name );
|
||||
$value = get_post_meta( $variation_id, $meta_key, true );
|
||||
|
||||
// Capitalize the attribute name for display
|
||||
@@ -1492,16 +1517,18 @@ class OrdersController {
|
||||
WC()->customer->set_billing_postcode( $postcode );
|
||||
WC()->customer->set_billing_city( $city );
|
||||
|
||||
// Support for Rajaongkir plugin - set destination in session
|
||||
// Rajaongkir uses session-based destination instead of standard address fields
|
||||
if ( $country === 'ID' && ! empty( $shipping['destination_id'] ) ) {
|
||||
WC()->session->set( 'selected_destination_id', $shipping['destination_id'] );
|
||||
WC()->session->set( 'selected_destination_label', $shipping['destination_label'] ?? $city );
|
||||
} else {
|
||||
// Clear Rajaongkir session data for non-ID countries
|
||||
WC()->session->__unset( 'selected_destination_id' );
|
||||
WC()->session->__unset( 'selected_destination_label' );
|
||||
}
|
||||
/**
|
||||
* Allow shipping addons to prepare session/data before shipping calculation.
|
||||
*
|
||||
* This hook allows third-party shipping plugins (like Rajaongkir, Biteship, etc.)
|
||||
* to set any session variables or prepare data they need before WooCommerce
|
||||
* calculates shipping rates.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @param array $shipping The shipping address data from frontend (country, state, city, postcode, address_1, etc.)
|
||||
* @param array $items The cart items being shipped
|
||||
*/
|
||||
do_action( 'woonoow/shipping/before_calculate', $shipping, $items ?? [] );
|
||||
}
|
||||
|
||||
// Calculate shipping
|
||||
@@ -1510,14 +1537,14 @@ class OrdersController {
|
||||
|
||||
// Get available shipping packages and rates
|
||||
$packages = WC()->shipping()->get_packages();
|
||||
$methods = [];
|
||||
$rates = [];
|
||||
|
||||
foreach ( $packages as $package_key => $package ) {
|
||||
$rates = $package['rates'] ?? [];
|
||||
$package_rates = $package['rates'] ?? [];
|
||||
|
||||
foreach ( $rates as $rate_id => $rate ) {
|
||||
foreach ( $package_rates as $rate_id => $rate ) {
|
||||
/** @var \WC_Shipping_Rate $rate */
|
||||
$methods[] = [
|
||||
$rates[] = [
|
||||
'id' => $rate_id,
|
||||
'method_id' => $rate->get_method_id(),
|
||||
'instance_id' => $rate->get_instance_id(),
|
||||
@@ -1529,12 +1556,65 @@ class OrdersController {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: If no rates from packages, manually calculate from matching zone
|
||||
if ( empty( $rates ) && ! empty( $shipping['country'] ) ) {
|
||||
$package = [
|
||||
'destination' => [
|
||||
'country' => $shipping['country'] ?? '',
|
||||
'state' => $shipping['state'] ?? '',
|
||||
'postcode' => $shipping['postcode'] ?? '',
|
||||
'city' => $shipping['city'] ?? '',
|
||||
],
|
||||
'contents' => WC()->cart->get_cart(),
|
||||
'contents_cost' => WC()->cart->get_subtotal(),
|
||||
'applied_coupons' => WC()->cart->get_applied_coupons(),
|
||||
'user' => [ 'ID' => get_current_user_id() ],
|
||||
];
|
||||
|
||||
$zone = \WC_Shipping_Zones::get_zone_matching_package( $package );
|
||||
if ( $zone ) {
|
||||
foreach ( $zone->get_shipping_methods( true ) as $method ) {
|
||||
if ( method_exists( $method, 'get_rates_for_package' ) ) {
|
||||
$method_rates = $method->get_rates_for_package( $package );
|
||||
foreach ( $method_rates as $rate_id => $rate ) {
|
||||
$rates[] = [
|
||||
'id' => $rate_id,
|
||||
'method_id' => $rate->get_method_id(),
|
||||
'instance_id' => $rate->get_instance_id(),
|
||||
'label' => $rate->get_label(),
|
||||
'cost' => (float) $rate->get_cost(),
|
||||
'taxes' => $rate->get_taxes(),
|
||||
'meta_data' => $rate->get_meta_data(),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Fallback for methods without get_rates_for_package (like Free Shipping)
|
||||
$method_id = $method->id . ':' . $method->instance_id;
|
||||
$rates[] = [
|
||||
'id' => $method_id,
|
||||
'method_id' => $method->id,
|
||||
'instance_id' => $method->instance_id,
|
||||
'label' => $method->get_title(),
|
||||
'cost' => $method->id === 'free_shipping' ? 0 : (float) ($method->cost ?? 0),
|
||||
'taxes' => [],
|
||||
'meta_data' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
WC()->cart->empty_cart();
|
||||
|
||||
return new \WP_REST_Response( [
|
||||
'methods' => $methods,
|
||||
'has_methods' => ! empty( $methods ),
|
||||
'methods' => $rates, // Keep as 'methods' for frontend compatibility
|
||||
'has_methods' => ! empty( $rates ),
|
||||
'debug' => [
|
||||
'packages_count' => count( $packages ),
|
||||
'cart_items_count' => count( WC()->cart->get_cart() ),
|
||||
'address' => $shipping,
|
||||
],
|
||||
], 200 );
|
||||
|
||||
} catch ( \Throwable $e ) {
|
||||
@@ -2025,7 +2105,6 @@ class OrdersController {
|
||||
|
||||
// Check if gateway exists
|
||||
if ( ! isset( $gateways[ $gateway_id ] ) ) {
|
||||
error_log( '[WooNooW] Payment gateway not found: ' . $gateway_id );
|
||||
return new \WP_Error( 'gateway_not_found', sprintf( __( 'Payment gateway not found: %s', 'woonoow' ), $gateway_id ) );
|
||||
}
|
||||
|
||||
@@ -2033,7 +2112,6 @@ class OrdersController {
|
||||
|
||||
// Check if gateway has process_payment method
|
||||
if ( ! method_exists( $gateway, 'process_payment' ) ) {
|
||||
error_log( '[WooNooW] Gateway does not have process_payment method: ' . $gateway_id );
|
||||
return new \WP_Error( 'no_process_method', sprintf( __( 'Gateway does not support payment processing: %s', 'woonoow' ), $gateway_id ) );
|
||||
}
|
||||
|
||||
@@ -2045,7 +2123,6 @@ class OrdersController {
|
||||
// Set flag for gateways to detect admin context
|
||||
add_filter( 'woonoow/is_admin_order', '__return_true' );
|
||||
|
||||
error_log( '[WooNooW] Processing payment for order #' . $order->get_id() . ' with gateway: ' . $gateway_id );
|
||||
|
||||
// Call gateway's process_payment method
|
||||
$result = $gateway->process_payment( $order->get_id() );
|
||||
@@ -2061,11 +2138,9 @@ class OrdersController {
|
||||
|
||||
if ( isset( $result['result'] ) && $result['result'] === 'success' ) {
|
||||
$order->add_order_note( __( 'Payment gateway processing completed via WooNooW', 'woonoow' ) );
|
||||
error_log( '[WooNooW] Payment processing succeeded for order #' . $order->get_id() );
|
||||
} elseif ( isset( $result['result'] ) && $result['result'] === 'failure' ) {
|
||||
$message = isset( $result['message'] ) ? $result['message'] : __( 'Payment processing failed', 'woonoow' );
|
||||
$order->add_order_note( sprintf( __( 'Payment gateway error: %s', 'woonoow' ), $message ) );
|
||||
error_log( '[WooNooW] Payment processing failed for order #' . $order->get_id() . ': ' . $message );
|
||||
}
|
||||
|
||||
$order->save();
|
||||
@@ -2074,7 +2149,6 @@ class OrdersController {
|
||||
return $result;
|
||||
|
||||
} catch ( \Throwable $e ) {
|
||||
error_log( '[WooNooW] Payment processing exception for order #' . $order->get_id() . ': ' . $e->getMessage() );
|
||||
$order->add_order_note( sprintf( __( 'Payment gateway exception: %s', 'woonoow' ), $e->getMessage() ) );
|
||||
$order->save();
|
||||
|
||||
|
||||
@@ -212,12 +212,10 @@ class PaymentsController extends WP_REST_Controller {
|
||||
|
||||
try {
|
||||
// Debug: Log what we're saving
|
||||
error_log(sprintf('[WooNooW] Saving gateway %s settings: %s', $gateway_id, json_encode($settings)));
|
||||
|
||||
$result = PaymentGatewaysProvider::save_gateway_settings($gateway_id, $settings);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
error_log(sprintf('[WooNooW] Save failed: %s', $result->get_error_message()));
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -228,7 +226,6 @@ class PaymentsController extends WP_REST_Controller {
|
||||
$gateway = PaymentGatewaysProvider::get_gateway($gateway_id);
|
||||
|
||||
// Debug: Log success
|
||||
error_log(sprintf('[WooNooW] Gateway %s settings saved successfully', $gateway_id));
|
||||
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
@@ -236,7 +233,6 @@ class PaymentsController extends WP_REST_Controller {
|
||||
'gateway' => $gateway,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
error_log(sprintf('[WooNooW] Save exception: %s', $e->getMessage()));
|
||||
return new WP_Error(
|
||||
'save_gateway_failed',
|
||||
$e->getMessage(),
|
||||
@@ -268,12 +264,10 @@ class PaymentsController extends WP_REST_Controller {
|
||||
|
||||
try {
|
||||
// Debug: Log what we're trying to do
|
||||
error_log(sprintf('[WooNooW] Toggling gateway %s to %s', $gateway_id, $enabled ? 'enabled' : 'disabled'));
|
||||
|
||||
$result = PaymentGatewaysProvider::toggle_gateway($gateway_id, $enabled);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
error_log(sprintf('[WooNooW] Toggle failed: %s', $result->get_error_message()));
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -284,7 +278,6 @@ class PaymentsController extends WP_REST_Controller {
|
||||
$gateway = PaymentGatewaysProvider::get_gateway($gateway_id);
|
||||
|
||||
// Debug: Log what we got back
|
||||
error_log(sprintf('[WooNooW] Gateway %s after toggle: enabled=%s', $gateway_id, $gateway['enabled'] ? 'true' : 'false'));
|
||||
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
@@ -292,7 +285,6 @@ class PaymentsController extends WP_REST_Controller {
|
||||
'gateway' => $gateway,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
error_log(sprintf('[WooNooW] Toggle exception: %s', $e->getMessage()));
|
||||
return new WP_Error(
|
||||
'toggle_gateway_failed',
|
||||
$e->getMessage(),
|
||||
@@ -333,7 +325,6 @@ class PaymentsController extends WP_REST_Controller {
|
||||
$option_key = 'woonoow_payment_gateway_order_' . $category;
|
||||
update_option($option_key, $order, false);
|
||||
|
||||
error_log(sprintf('[WooNooW] Saved %s gateway order: %s', $category, implode(', ', $order)));
|
||||
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
|
||||
@@ -413,6 +413,17 @@ class ProductsController {
|
||||
|
||||
$product->save();
|
||||
|
||||
// Licensing meta
|
||||
if (isset($data['licensing_enabled'])) {
|
||||
update_post_meta($product->get_id(), '_woonoow_licensing_enabled', $data['licensing_enabled'] ? 'yes' : 'no');
|
||||
}
|
||||
if (isset($data['license_activation_limit'])) {
|
||||
update_post_meta($product->get_id(), '_woonoow_license_activation_limit', self::sanitize_number($data['license_activation_limit']));
|
||||
}
|
||||
if (isset($data['license_duration_days'])) {
|
||||
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($data['license_duration_days']));
|
||||
}
|
||||
|
||||
// Handle variations for variable products
|
||||
if ($type === 'variable' && !empty($data['attributes']) && is_array($data['attributes'])) {
|
||||
self::save_product_attributes($product, $data['attributes']);
|
||||
@@ -475,6 +486,29 @@ class ProductsController {
|
||||
$product->set_featured((bool) $data['featured']);
|
||||
}
|
||||
|
||||
// Downloadable files
|
||||
if (isset($data['downloads']) && is_array($data['downloads'])) {
|
||||
$wc_downloads = [];
|
||||
foreach ($data['downloads'] as $download) {
|
||||
if (!empty($download['file'])) {
|
||||
$wc_downloads[] = [
|
||||
'id' => $download['id'] ?? md5($download['file']),
|
||||
'name' => self::sanitize_text($download['name'] ?? ''),
|
||||
'file' => esc_url_raw($download['file']),
|
||||
];
|
||||
}
|
||||
}
|
||||
$product->set_downloads($wc_downloads);
|
||||
}
|
||||
if (isset($data['download_limit'])) {
|
||||
$limit = $data['download_limit'] === '' ? -1 : (int) $data['download_limit'];
|
||||
$product->set_download_limit($limit);
|
||||
}
|
||||
if (isset($data['download_expiry'])) {
|
||||
$expiry = $data['download_expiry'] === '' ? -1 : (int) $data['download_expiry'];
|
||||
$product->set_download_expiry($expiry);
|
||||
}
|
||||
|
||||
// Categories
|
||||
if (isset($data['categories'])) {
|
||||
$product->set_category_ids($data['categories']);
|
||||
@@ -523,6 +557,17 @@ class ProductsController {
|
||||
|
||||
$product->save();
|
||||
|
||||
// Licensing meta
|
||||
if (isset($data['licensing_enabled'])) {
|
||||
update_post_meta($product->get_id(), '_woonoow_licensing_enabled', $data['licensing_enabled'] ? 'yes' : 'no');
|
||||
}
|
||||
if (isset($data['license_activation_limit'])) {
|
||||
update_post_meta($product->get_id(), '_woonoow_license_activation_limit', self::sanitize_number($data['license_activation_limit']));
|
||||
}
|
||||
if (isset($data['license_duration_days'])) {
|
||||
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($data['license_duration_days']));
|
||||
}
|
||||
|
||||
// Allow plugins to perform additional updates (Level 1 compatibility)
|
||||
do_action('woonoow/product_updated', $product, $data, $request);
|
||||
|
||||
@@ -576,6 +621,7 @@ class ProductsController {
|
||||
$categories = [];
|
||||
foreach ($terms as $term) {
|
||||
$categories[] = [
|
||||
'id' => $term->term_id,
|
||||
'term_id' => $term->term_id,
|
||||
'name' => $term->name,
|
||||
'slug' => $term->slug,
|
||||
@@ -604,6 +650,7 @@ class ProductsController {
|
||||
$tags = [];
|
||||
foreach ($terms as $term) {
|
||||
$tags[] = [
|
||||
'id' => $term->term_id,
|
||||
'term_id' => $term->term_id,
|
||||
'name' => $term->name,
|
||||
'slug' => $term->slug,
|
||||
@@ -700,6 +747,26 @@ class ProductsController {
|
||||
$data['downloadable'] = $product->is_downloadable();
|
||||
$data['featured'] = $product->is_featured();
|
||||
|
||||
// Downloadable files
|
||||
if ($product->is_downloadable()) {
|
||||
$downloads = [];
|
||||
foreach ($product->get_downloads() as $download_id => $download) {
|
||||
$downloads[] = [
|
||||
'id' => $download_id,
|
||||
'name' => $download->get_name(),
|
||||
'file' => $download->get_file(),
|
||||
];
|
||||
}
|
||||
$data['downloads'] = $downloads;
|
||||
$data['download_limit'] = $product->get_download_limit() !== -1 ? (string) $product->get_download_limit() : '';
|
||||
$data['download_expiry'] = $product->get_download_expiry() !== -1 ? (string) $product->get_download_expiry() : '';
|
||||
}
|
||||
|
||||
// Licensing fields
|
||||
$data['licensing_enabled'] = get_post_meta($product->get_id(), '_woonoow_licensing_enabled', true) === 'yes';
|
||||
$data['license_activation_limit'] = get_post_meta($product->get_id(), '_license_activation_limit', true) ?: '';
|
||||
$data['license_duration_days'] = get_post_meta($product->get_id(), '_license_duration_days', true) ?: '';
|
||||
|
||||
// Images array (URLs) for frontend - featured + gallery
|
||||
$images = [];
|
||||
$featured_image_id = $product->get_image_id();
|
||||
@@ -833,6 +900,7 @@ class ProductsController {
|
||||
'image_id' => $variation->get_image_id(),
|
||||
'image_url' => $image_url,
|
||||
'image' => $image_url, // For form compatibility
|
||||
'license_duration_days' => get_post_meta($variation->get_id(), '_license_duration_days', true) ?: '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -929,6 +997,11 @@ class ProductsController {
|
||||
$saved_id = $variation->save();
|
||||
$variations_to_keep[] = $saved_id;
|
||||
|
||||
// Save variation-level license duration
|
||||
if (isset($var_data['license_duration_days'])) {
|
||||
update_post_meta($saved_id, '_license_duration_days', self::sanitize_number($var_data['license_duration_days']));
|
||||
}
|
||||
|
||||
// Manually save attributes using direct database insert
|
||||
if (!empty($wc_attributes)) {
|
||||
global $wpdb;
|
||||
|
||||
@@ -24,6 +24,8 @@ use WooNooW\Api\NewsletterController;
|
||||
use WooNooW\Api\ModulesController;
|
||||
use WooNooW\Api\ModuleSettingsController;
|
||||
use WooNooW\Api\CampaignsController;
|
||||
use WooNooW\Api\DocsController;
|
||||
use WooNooW\Api\LicensesController;
|
||||
use WooNooW\Frontend\ShopController;
|
||||
use WooNooW\Frontend\CartController as FrontendCartController;
|
||||
use WooNooW\Frontend\AccountController;
|
||||
@@ -157,6 +159,9 @@ class Routes {
|
||||
// Campaigns controller
|
||||
CampaignsController::register_routes();
|
||||
|
||||
// Licenses controller (licensing module)
|
||||
LicensesController::register_routes();
|
||||
|
||||
// Modules controller
|
||||
$modules_controller = new ModulesController();
|
||||
$modules_controller->register_routes();
|
||||
@@ -165,6 +170,10 @@ class Routes {
|
||||
$module_settings_controller = new ModuleSettingsController();
|
||||
$module_settings_controller->register_routes();
|
||||
|
||||
// Documentation controller
|
||||
$docs_controller = new DocsController();
|
||||
$docs_controller->register_routes();
|
||||
|
||||
// Frontend controllers (customer-facing)
|
||||
ShopController::register_routes();
|
||||
FrontendCartController::register_routes();
|
||||
|
||||
@@ -454,7 +454,6 @@ class ShippingController extends WP_REST_Controller {
|
||||
);
|
||||
|
||||
} catch ( \Exception $e ) {
|
||||
error_log( sprintf( '[WooNooW] Toggle exception: %s', $e->getMessage() ) );
|
||||
return new WP_REST_Response(
|
||||
array(
|
||||
'error' => 'toggle_failed',
|
||||
|
||||
@@ -21,6 +21,7 @@ class CustomerSettingsProvider {
|
||||
// General
|
||||
'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes',
|
||||
'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes',
|
||||
'allow_custom_avatar' => get_option('woonoow_allow_custom_avatar', 'no') === 'yes',
|
||||
|
||||
// VIP Customer Qualification
|
||||
'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
|
||||
@@ -49,8 +50,10 @@ class CustomerSettingsProvider {
|
||||
update_option('woonoow_multiple_addresses_enabled', $value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (array_key_exists('allow_custom_avatar', $settings)) {
|
||||
$value = !empty($settings['allow_custom_avatar']) ? 'yes' : 'no';
|
||||
update_option('woonoow_allow_custom_avatar', $value);
|
||||
}
|
||||
// VIP settings
|
||||
if (isset($settings['vip_min_spent'])) {
|
||||
update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent']));
|
||||
|
||||
@@ -13,7 +13,7 @@ if ( ! defined('ABSPATH') ) exit;
|
||||
*/
|
||||
class NavigationRegistry {
|
||||
const NAV_OPTION = 'wnw_nav_tree';
|
||||
const NAV_VERSION = '1.0.8'; // Added Modules to Settings menu
|
||||
const NAV_VERSION = '1.0.9'; // Added Help menu
|
||||
|
||||
/**
|
||||
* Initialize hooks
|
||||
@@ -186,6 +186,13 @@ class NavigationRegistry {
|
||||
'icon' => 'settings',
|
||||
'children' => self::get_settings_children(),
|
||||
],
|
||||
[
|
||||
'key' => 'help',
|
||||
'label' => __('Help', 'woonoow'),
|
||||
'path' => '/help',
|
||||
'icon' => 'help-circle',
|
||||
'children' => [], // Empty array = no submenu bar
|
||||
],
|
||||
];
|
||||
|
||||
return $tree;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user