Compare commits
48 Commits
0f542ad452
...
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 |
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
|
||||
@@ -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';
|
||||
@@ -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 />} />
|
||||
|
||||
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 {}
|
||||
|
||||
@media print {
|
||||
.print-a4 { }
|
||||
|
||||
/* 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,
|
||||
|
||||
@@ -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 = {
|
||||
@@ -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 })),
|
||||
@@ -424,6 +470,43 @@ export default function OrderForm({
|
||||
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,7 +1090,8 @@ export default function OrderForm({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Billing address - only show full address for physical products */}
|
||||
{/* 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>
|
||||
@@ -1061,45 +1162,116 @@ export default function OrderForm({
|
||||
)}
|
||||
</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)} />
|
||||
{/* 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>{__('Last name')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bLast} onChange={e=>setBLast(e.target.value)} />
|
||||
)}
|
||||
{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>
|
||||
<div>
|
||||
<Label>{__('Email')}</Label>
|
||||
)}
|
||||
{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>
|
||||
<div>
|
||||
<Label>{__('Phone')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bPhone} onChange={e=>setBPhone(e.target.value)} />
|
||||
)}
|
||||
{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>
|
||||
{/* Only show full address fields for physical products */}
|
||||
)}
|
||||
{/* 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>{__('Address')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bAddr1} onChange={e=>setBAddr1(e.target.value)} />
|
||||
<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>
|
||||
<div>
|
||||
<Label>{__('City')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bCity} onChange={e=>setBCity(e.target.value)} />
|
||||
)}
|
||||
{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>
|
||||
<div>
|
||||
<Label>{__('Postcode')}</Label>
|
||||
<Input className="rounded-md border px-3 py-2" value={bPost} onChange={e=>setBPost(e.target.value)} />
|
||||
)}
|
||||
{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>
|
||||
<div>
|
||||
<Label>{__('Country')}</Label>
|
||||
)}
|
||||
{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}
|
||||
@@ -1108,23 +1280,39 @@ export default function OrderForm({
|
||||
disabled={oneCountryOnly}
|
||||
/>
|
||||
</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_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>
|
||||
)}
|
||||
|
||||
{/* Conditional: Only show address fields and shipping for physical products */}
|
||||
{!hasPhysicalProduct && (
|
||||
@@ -1149,19 +1337,35 @@ 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 })}
|
||||
@@ -1183,10 +1387,30 @@ export default function OrderForm({
|
||||
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}
|
||||
/>
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
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">
|
||||
|
||||
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,8 +186,7 @@ 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'
|
||||
}`}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -481,8 +482,7 @@ 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'
|
||||
}`}>
|
||||
@@ -695,8 +695,7 @@ 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'
|
||||
}`}>
|
||||
|
||||
4
admin-spa/src/types/window.d.ts
vendored
4
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 {
|
||||
|
||||
17
customer-spa/package-lock.json
generated
17
customer-spa/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"@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",
|
||||
@@ -3597,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",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -59,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>
|
||||
|
||||
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 };
|
||||
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>
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Cart {
|
||||
tax: number;
|
||||
shipping: number;
|
||||
total: number;
|
||||
needs_shipping?: boolean;
|
||||
coupon?: {
|
||||
code: string;
|
||||
discount: number;
|
||||
|
||||
@@ -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,22 +399,27 @@ export default function Addresses() {
|
||||
{editingAddress ? 'Edit Address' : 'Add New Address'}
|
||||
</h2>
|
||||
|
||||
{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">Label *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.label}
|
||||
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"
|
||||
className="w-full px-3 py-2 border !rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address Type - always shown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Address Type *</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
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"
|
||||
>
|
||||
@@ -268,136 +429,39 @@ export default function Addresses() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">First Name *</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"
|
||||
{/* 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>
|
||||
<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">
|
||||
<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 })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">State/Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.state}
|
||||
onChange={(e) => setFormData({ ...formData, state: 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">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">
|
||||
{/* Set as default checkbox */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_default"
|
||||
checked={formData.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="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>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -97,6 +98,7 @@ export default function Downloads() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SEOHead title="Downloads" description="Your purchased downloads" />
|
||||
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
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,6 +127,7 @@ export default function Wishlist() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SEOHead title="Wishlist" description="Your saved products" />
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ 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, X, Tag } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -155,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">
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||
import { DynamicCheckoutField, type CheckoutField } from '@/components/DynamicCheckoutField';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { ArrowLeft, ShoppingBag, MapPin, Check, Edit2, Loader2, X, Tag } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -41,17 +44,22 @@ export default function Checkout() {
|
||||
const [discountTotal, setDiscountTotal] = useState(0);
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
|
||||
// Check if cart contains only virtual/downloadable products
|
||||
// Check if cart needs shipping (virtual-only carts don't need shipping)
|
||||
// Use cart.needs_shipping from WooCommerce API, fallback to item-level check
|
||||
const isVirtualOnly = React.useMemo(() => {
|
||||
// Prefer the needs_shipping flag from the cart API ( WooCommerce calculates this properly)
|
||||
if (typeof cart.needs_shipping === 'boolean') {
|
||||
return !cart.needs_shipping;
|
||||
}
|
||||
// Fallback: check individual items if needs_shipping not available
|
||||
if (cart.items.length === 0) return false;
|
||||
return cart.items.every(item => item.virtual || item.downloadable);
|
||||
}, [cart.items]);
|
||||
}, [cart.items, cart.needs_shipping]);
|
||||
|
||||
// Calculate totals
|
||||
const subtotal = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||
const shipping = isVirtualOnly ? 0 : 0; // No shipping for virtual products
|
||||
const tax = 0; // TODO: Calculate tax
|
||||
const total = subtotal + shipping + tax;
|
||||
// Note: shipping is calculated from dynamic shippingCost state (defined below)
|
||||
|
||||
// Form state
|
||||
const [billingData, setBillingData] = useState({
|
||||
@@ -90,7 +98,265 @@ export default function Checkout() {
|
||||
const [showBillingForm, setShowBillingForm] = useState(true);
|
||||
const [showShippingForm, setShowShippingForm] = useState(true);
|
||||
|
||||
// Load saved addresses
|
||||
// Countries and states data
|
||||
const [countries, setCountries] = useState<{ code: string; name: string }[]>([]);
|
||||
const [states, setStates] = useState<Record<string, Record<string, string>>>({});
|
||||
const [defaultCountry, setDefaultCountry] = useState('');
|
||||
|
||||
// Load countries and states
|
||||
useEffect(() => {
|
||||
const loadCountries = async () => {
|
||||
try {
|
||||
const data = await api.get<{
|
||||
countries: { code: string; name: string }[];
|
||||
states: Record<string, Record<string, string>>;
|
||||
default_country: string;
|
||||
}>('/countries');
|
||||
setCountries(data.countries || []);
|
||||
setStates(data.states || {});
|
||||
setDefaultCountry(data.default_country || '');
|
||||
|
||||
// Set default country if not already set
|
||||
if (!billingData.country && data.default_country) {
|
||||
setBillingData(prev => ({ ...prev, country: data.default_country }));
|
||||
}
|
||||
if (!shippingData.country && data.default_country) {
|
||||
setShippingData(prev => ({ ...prev, country: data.default_country }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load countries:', error);
|
||||
}
|
||||
};
|
||||
loadCountries();
|
||||
}, []);
|
||||
|
||||
// Country/state options for SearchableSelect
|
||||
const countryOptions = countries.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }));
|
||||
const billingStateOptions = Object.entries(states[billingData.country] || {}).map(([code, name]) => ({ value: code, label: name }));
|
||||
const shippingStateOptions = Object.entries(states[shippingData.country] || {}).map(([code, name]) => ({ value: code, label: name }));
|
||||
|
||||
// Clear state when country changes
|
||||
useEffect(() => {
|
||||
if (billingData.country && billingData.state) {
|
||||
const countryStates = states[billingData.country] || {};
|
||||
if (!countryStates[billingData.state]) {
|
||||
setBillingData(prev => ({ ...prev, state: '' }));
|
||||
}
|
||||
}
|
||||
}, [billingData.country, states]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shippingData.country && shippingData.state) {
|
||||
const countryStates = states[shippingData.country] || {};
|
||||
if (!countryStates[shippingData.state]) {
|
||||
setShippingData(prev => ({ ...prev, state: '' }));
|
||||
}
|
||||
}
|
||||
}, [shippingData.country, states]);
|
||||
|
||||
// Dynamic checkout fields from API
|
||||
const [checkoutFields, setCheckoutFields] = useState<CheckoutField[]>([]);
|
||||
const [customFieldData, setCustomFieldData] = useState<Record<string, string>>({});
|
||||
|
||||
// Dynamic shipping rates
|
||||
interface ShippingRate {
|
||||
id: string;
|
||||
label: string;
|
||||
cost: number;
|
||||
method_id: string;
|
||||
instance_id: number;
|
||||
}
|
||||
const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
|
||||
const [selectedShippingRate, setSelectedShippingRate] = useState<string>('');
|
||||
const [isLoadingRates, setIsLoadingRates] = useState(false);
|
||||
const [shippingCost, setShippingCost] = useState(0);
|
||||
|
||||
// Fetch checkout fields from API
|
||||
useEffect(() => {
|
||||
const loadCheckoutFields = async () => {
|
||||
if (cart.items.length === 0) return;
|
||||
|
||||
try {
|
||||
const items = cart.items.map(item => ({
|
||||
product_id: item.product_id,
|
||||
qty: item.quantity,
|
||||
}));
|
||||
|
||||
const data = await api.post<{
|
||||
ok: boolean;
|
||||
fields: CheckoutField[];
|
||||
is_digital_only: boolean;
|
||||
}>('/checkout/fields', { items, is_digital_only: isVirtualOnly });
|
||||
|
||||
if (data.ok && data.fields) {
|
||||
setCheckoutFields(data.fields);
|
||||
|
||||
// Initialize custom field values with defaults
|
||||
const customDefaults: Record<string, string> = {};
|
||||
data.fields.forEach(field => {
|
||||
if (field.default) {
|
||||
customDefaults[field.key] = field.default;
|
||||
}
|
||||
});
|
||||
if (Object.keys(customDefaults).length > 0) {
|
||||
setCustomFieldData(prev => ({ ...customDefaults, ...prev }));
|
||||
}
|
||||
|
||||
// Set billing default values for hidden fields (e.g., Indonesia-only stores)
|
||||
const billingCountryField = data.fields.find(f => f.key === 'billing_country');
|
||||
if (billingCountryField?.type === 'hidden' && billingCountryField.default) {
|
||||
setBillingData(prev => ({ ...prev, country: billingCountryField.default || prev.country }));
|
||||
}
|
||||
|
||||
// Set shipping default values for hidden fields
|
||||
const shippingCountryField = data.fields.find(f => f.key === 'shipping_country');
|
||||
if (shippingCountryField?.type === 'hidden' && shippingCountryField.default) {
|
||||
setShippingData(prev => ({ ...prev, country: shippingCountryField.default || prev.country }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load checkout fields:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadCheckoutFields();
|
||||
}, [cart.items, isVirtualOnly]);
|
||||
|
||||
// NOTE: Old isFieldHidden function removed - now using getBillingField/getShippingField
|
||||
// which return undefined for hidden fields (type: 'hidden' or hidden: true)
|
||||
|
||||
// Get all billing fields from API (standard + custom), filtered and sorted by priority
|
||||
// This allows ANY field to be hidden via PHP (type: 'hidden' or hidden: true)
|
||||
const billingFields = checkoutFields
|
||||
.filter(f => f.fieldset === 'billing' && f.type !== 'hidden' && !f.hidden)
|
||||
.sort((a, b) => (a.priority || 10) - (b.priority || 10));
|
||||
|
||||
// Get all shipping fields from API (standard + custom), filtered and sorted by priority
|
||||
const shippingFields = checkoutFields
|
||||
.filter(f => f.fieldset === 'shipping' && f.type !== 'hidden' && !f.hidden)
|
||||
.sort((a, b) => (a.priority || 10) - (b.priority || 10));
|
||||
|
||||
// Helper to get a billing field from API by key (for checking if it should be rendered)
|
||||
const getBillingField = (key: string) => billingFields.find(f => f.key === key);
|
||||
const getShippingField = (key: string) => shippingFields.find(f => f.key === key);
|
||||
|
||||
// Helper to determine if a field should be full width based on class array from PHP
|
||||
// Supports: form-row-wide, form-row-first (half), form-row-last (half)
|
||||
const isFullWidthField = (fieldKey: string, fieldset: 'billing' | 'shipping' = 'billing'): boolean => {
|
||||
const field = fieldset === 'billing' ? getBillingField(fieldKey) : getShippingField(fieldKey);
|
||||
if (!field?.class || !Array.isArray(field.class)) return false;
|
||||
return field.class.includes('form-row-wide');
|
||||
};
|
||||
|
||||
// Helper to get wrapper className for a field (handles width based on PHP class)
|
||||
const getFieldWrapperClass = (fieldKey: string, fieldset: 'billing' | 'shipping' = 'billing', defaultFullWidth = false): string => {
|
||||
if (isFullWidthField(fieldKey, fieldset) || defaultFullWidth) {
|
||||
return 'md:col-span-2';
|
||||
}
|
||||
return ''; // Default half width in 2-column grid
|
||||
};
|
||||
|
||||
// Filter custom fields by fieldset (legacy support - for plugins that add non-standard fields)
|
||||
const billingCustomFields = checkoutFields.filter(f => f.fieldset === 'billing' && f.custom && !f.hidden && f.type !== 'hidden');
|
||||
const shippingCustomFields = checkoutFields.filter(f => f.fieldset === 'shipping' && f.custom && !f.hidden && f.type !== 'hidden');
|
||||
|
||||
// Handler for custom field changes
|
||||
const handleCustomFieldChange = (key: string, value: string) => {
|
||||
setCustomFieldData(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Listen for label events from searchable_select
|
||||
useEffect(() => {
|
||||
const handleLabelEvent = (e: Event) => {
|
||||
const { key, value } = (e as CustomEvent).detail;
|
||||
setCustomFieldData(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
document.addEventListener('woonoow:field_label', handleLabelEvent);
|
||||
return () => document.removeEventListener('woonoow:field_label', handleLabelEvent);
|
||||
}, []);
|
||||
|
||||
// Fetch shipping rates when address changes
|
||||
const fetchShippingRates = async () => {
|
||||
if (isVirtualOnly || !cart.items.length) return;
|
||||
|
||||
// Get address data (use shipping if different, otherwise billing)
|
||||
const addressData = shipToDifferentAddress ? shippingData : billingData;
|
||||
|
||||
// Need at least country to calculate shipping
|
||||
if (!addressData.country) {
|
||||
setShippingRates([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingRates(true);
|
||||
try {
|
||||
const items = cart.items.map(item => ({
|
||||
product_id: item.product_id,
|
||||
quantity: item.quantity,
|
||||
}));
|
||||
|
||||
const destinationId = shipToDifferentAddress
|
||||
? customFieldData['shipping_destination_id']
|
||||
: customFieldData['billing_destination_id'];
|
||||
|
||||
const response = await api.post<{ ok: boolean; rates: ShippingRate[]; zone_name?: string }>('/checkout/shipping-rates', {
|
||||
shipping: {
|
||||
country: addressData.country,
|
||||
state: addressData.state,
|
||||
city: addressData.city,
|
||||
postcode: addressData.postcode,
|
||||
destination_id: destinationId || undefined,
|
||||
},
|
||||
items,
|
||||
});
|
||||
|
||||
if (response.ok && response.rates) {
|
||||
setShippingRates(response.rates);
|
||||
// Auto-select first rate if none selected
|
||||
if (response.rates.length > 0 && !selectedShippingRate) {
|
||||
setSelectedShippingRate(response.rates[0].id);
|
||||
setShippingCost(response.rates[0].cost);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch shipping rates:', error);
|
||||
setShippingRates([]);
|
||||
} finally {
|
||||
setIsLoadingRates(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger shipping rate fetch when address or destination changes
|
||||
useEffect(() => {
|
||||
const addressData = shipToDifferentAddress ? shippingData : billingData;
|
||||
const destinationId = shipToDifferentAddress
|
||||
? customFieldData['shipping_destination_id']
|
||||
: customFieldData['billing_destination_id'];
|
||||
|
||||
// Debounce the fetch
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (addressData.country) {
|
||||
fetchShippingRates();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [
|
||||
billingData.country, billingData.state, billingData.city, billingData.postcode,
|
||||
shippingData.country, shippingData.state, shippingData.city, shippingData.postcode,
|
||||
shipToDifferentAddress,
|
||||
customFieldData['billing_destination_id'],
|
||||
customFieldData['shipping_destination_id'],
|
||||
cart.items.length,
|
||||
]);
|
||||
|
||||
// Update shipping cost when rate selected
|
||||
const handleShippingRateChange = (rateId: string) => {
|
||||
setSelectedShippingRate(rateId);
|
||||
const rate = shippingRates.find(r => r.id === rateId);
|
||||
setShippingCost(rate?.cost || 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadAddresses = async () => {
|
||||
if (!user?.isLoggedIn) {
|
||||
@@ -271,6 +537,10 @@ export default function Checkout() {
|
||||
state: billingData.state,
|
||||
postcode: billingData.postcode,
|
||||
country: billingData.country,
|
||||
// Include custom billing fields
|
||||
...Object.fromEntries(
|
||||
billingCustomFields.map(f => [f.key.replace('billing_', ''), customFieldData[f.key] || ''])
|
||||
),
|
||||
},
|
||||
shipping: shipToDifferentAddress ? {
|
||||
first_name: shippingData.firstName,
|
||||
@@ -281,11 +551,22 @@ export default function Checkout() {
|
||||
postcode: shippingData.postcode,
|
||||
country: shippingData.country,
|
||||
ship_to_different: true,
|
||||
// Include custom shipping fields
|
||||
...Object.fromEntries(
|
||||
shippingCustomFields.map(f => [f.key.replace('shipping_', ''), customFieldData[f.key] || ''])
|
||||
),
|
||||
} : {
|
||||
ship_to_different: false,
|
||||
},
|
||||
payment_method: paymentMethod,
|
||||
shipping_method: selectedShippingRate || undefined, // Selected shipping rate ID
|
||||
// Also send shipping cost/title for direct use when WC rate lookup fails (API-based shipping like Rajaongkir)
|
||||
shipping_cost: shippingCost,
|
||||
shipping_title: shippingRates.find(r => r.id === selectedShippingRate)?.label || '',
|
||||
coupons: appliedCoupons.map(c => c.code), // Send applied coupon codes
|
||||
customer_note: orderNotes,
|
||||
// Include all custom field data for backend processing
|
||||
custom_fields: customFieldData,
|
||||
};
|
||||
|
||||
// Submit order
|
||||
@@ -298,11 +579,11 @@ export default function Checkout() {
|
||||
|
||||
toast.success('Order placed successfully!');
|
||||
|
||||
// Use full page reload instead of SPA routing
|
||||
// This ensures auto-registered users get their auth cookies properly set
|
||||
const thankYouUrl = `${window.location.origin}/store/#/order-received/${data.order_id}?key=${data.order_key}`;
|
||||
window.location.href = thankYouUrl;
|
||||
window.location.reload();
|
||||
// Navigate to thank you page via SPA routing
|
||||
// Using window.location.replace to prevent back button issues
|
||||
const thankYouUrl = `/order-received/${data.order_id}?key=${data.order_key}`;
|
||||
navigate(thankYouUrl, { replace: true });
|
||||
return; // Stop execution here
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to create order');
|
||||
}
|
||||
@@ -314,8 +595,8 @@ export default function Checkout() {
|
||||
}
|
||||
};
|
||||
|
||||
// Empty cart redirect
|
||||
if (cart.items.length === 0) {
|
||||
// Empty cart redirect (but only if not processing)
|
||||
if (cart.items.length === 0 && !isProcessing) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="text-center py-16">
|
||||
@@ -333,6 +614,7 @@ export default function Checkout() {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<SEOHead title="Checkout" description="Complete your purchase" />
|
||||
<div className="py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
@@ -419,100 +701,146 @@ export default function Checkout() {
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
||||
{/* All fields conditionally rendered based on API response */}
|
||||
{/* This allows ANY field to be hidden via PHP snippet */}
|
||||
{/* Width controlled via class: ['form-row-wide'] from PHP */}
|
||||
{getBillingField('billing_first_name') && (
|
||||
<div className={getFieldWrapperClass('billing_first_name', 'billing')}>
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_first_name')?.label || 'First Name'} {getBillingField('billing_first_name')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
required={getBillingField('billing_first_name')?.required}
|
||||
value={billingData.firstName}
|
||||
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
||||
)}
|
||||
{getBillingField('billing_last_name') && (
|
||||
<div className={getFieldWrapperClass('billing_last_name', 'billing')}>
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_last_name')?.label || 'Last Name'} {getBillingField('billing_last_name')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
required={getBillingField('billing_last_name')?.required}
|
||||
value={billingData.lastName}
|
||||
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_email') && (
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Email Address *</label>
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_email')?.label || 'Email Address'} {getBillingField('billing_email')?.required && '*'}</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
required={getBillingField('billing_email')?.required}
|
||||
value={billingData.email}
|
||||
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{getBillingField('billing_phone') && (
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Phone *</label>
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_phone')?.label || 'Phone'} {getBillingField('billing_phone')?.required && '*'}</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
required={getBillingField('billing_phone')?.required}
|
||||
value={billingData.phone}
|
||||
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Address fields - only for physical products */}
|
||||
{!isVirtualOnly && (
|
||||
<>
|
||||
{getBillingField('billing_address_1') && (
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_address_1')?.label || 'Street Address'} {getBillingField('billing_address_1')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
required={getBillingField('billing_address_1')?.required}
|
||||
value={billingData.address}
|
||||
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* City field - conditionally rendered based on API */}
|
||||
{getBillingField('billing_city') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">City *</label>
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_city')?.label || 'City'} {getBillingField('billing_city')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
required={getBillingField('billing_city')?.required}
|
||||
value={billingData.city}
|
||||
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Country field - conditionally rendered based on API */}
|
||||
{getBillingField('billing_country') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_country')?.label || 'Country'} {getBillingField('billing_country')?.required && '*'}</label>
|
||||
<SearchableSelect
|
||||
options={countryOptions}
|
||||
value={billingData.country}
|
||||
onChange={(v) => setBillingData({ ...billingData, country: v })}
|
||||
placeholder="Select country"
|
||||
disabled={countries.length === 1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* State field - conditionally rendered based on API */}
|
||||
{getBillingField('billing_state') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_state')?.label || 'State / Province'} {getBillingField('billing_state')?.required && '*'}</label>
|
||||
{billingStateOptions.length > 0 ? (
|
||||
<SearchableSelect
|
||||
options={billingStateOptions}
|
||||
value={billingData.state}
|
||||
onChange={(v) => setBillingData({ ...billingData, state: v })}
|
||||
placeholder="Select state"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.state}
|
||||
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
placeholder="Enter state/province"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Postcode field - conditionally rendered based on API */}
|
||||
{getBillingField('billing_postcode') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
||||
<label className="block text-sm font-medium mb-2">{getBillingField('billing_postcode')?.label || 'Postcode / ZIP'} {getBillingField('billing_postcode')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
required={getBillingField('billing_postcode')?.required}
|
||||
value={billingData.postcode}
|
||||
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.country}
|
||||
onChange={(e) => setBillingData({ ...billingData, country: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
)}
|
||||
|
||||
{/* Custom billing fields from plugins */}
|
||||
{billingCustomFields.map(field => (
|
||||
<DynamicCheckoutField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={customFieldData[field.key] || ''}
|
||||
onChange={(v) => handleCustomFieldChange(field.key, v)}
|
||||
countryOptions={countryOptions}
|
||||
stateOptions={billingStateOptions}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -604,76 +932,116 @@ export default function Checkout() {
|
||||
{/* Shipping Form - Only show if no saved address selected or user wants to enter manually */}
|
||||
{(!selectedShippingAddressId || showShippingForm) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
||||
{/* Dynamic shipping fields using getShippingField like billing */}
|
||||
{getShippingField('shipping_first_name') && (
|
||||
<div className={getFieldWrapperClass('shipping_first_name', 'shipping')}>
|
||||
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_first_name')?.label || 'First Name'} {getShippingField('shipping_first_name')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
required={getShippingField('shipping_first_name')?.required}
|
||||
value={shippingData.firstName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
||||
)}
|
||||
{getShippingField('shipping_last_name') && (
|
||||
<div className={getFieldWrapperClass('shipping_last_name', 'shipping')}>
|
||||
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_last_name')?.label || 'Last Name'} {getShippingField('shipping_last_name')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
required={getShippingField('shipping_last_name')?.required}
|
||||
value={shippingData.lastName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||
)}
|
||||
{getShippingField('shipping_address_1') && (
|
||||
<div className={getFieldWrapperClass('shipping_address_1', 'shipping') || 'md:col-span-2'}>
|
||||
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_address_1')?.label || 'Street Address'} {getShippingField('shipping_address_1')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
required={getShippingField('shipping_address_1')?.required}
|
||||
value={shippingData.address}
|
||||
onChange={(e) => setShippingData({ ...shippingData, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* City field - conditionally rendered based on API */}
|
||||
{getShippingField('shipping_city') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">City *</label>
|
||||
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_city')?.label || 'City'} {getShippingField('shipping_city')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
required={getShippingField('shipping_city')?.required}
|
||||
value={shippingData.city}
|
||||
onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Country field - conditionally rendered based on API */}
|
||||
{getShippingField('shipping_country') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_country')?.label || 'Country'} {getShippingField('shipping_country')?.required && '*'}</label>
|
||||
<SearchableSelect
|
||||
options={countryOptions}
|
||||
value={shippingData.country}
|
||||
onChange={(v) => setShippingData({ ...shippingData, country: v })}
|
||||
placeholder="Select country"
|
||||
disabled={countries.length === 1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* State field - conditionally rendered based on API */}
|
||||
{getShippingField('shipping_state') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_state')?.label || 'State / Province'} {getShippingField('shipping_state')?.required && '*'}</label>
|
||||
{shippingStateOptions.length > 0 ? (
|
||||
<SearchableSelect
|
||||
options={shippingStateOptions}
|
||||
value={shippingData.state}
|
||||
onChange={(v) => setShippingData({ ...shippingData, state: v })}
|
||||
placeholder="Select state"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.state}
|
||||
onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
placeholder="Enter state/province"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Postcode field - conditionally rendered based on API */}
|
||||
{getShippingField('shipping_postcode') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
||||
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_postcode')?.label || 'Postcode / ZIP'} {getShippingField('shipping_postcode')?.required && '*'}</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
required={getShippingField('shipping_postcode')?.required}
|
||||
value={shippingData.postcode}
|
||||
onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
className="w-full border !rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.country}
|
||||
onChange={(e) => setShippingData({ ...shippingData, country: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
)}
|
||||
|
||||
{/* Custom shipping fields from plugins */}
|
||||
{shippingCustomFields.map(field => (
|
||||
<DynamicCheckoutField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={customFieldData[field.key] || ''}
|
||||
onChange={(v) => handleCustomFieldChange(field.key, v)}
|
||||
countryOptions={countryOptions}
|
||||
stateOptions={shippingStateOptions}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -689,7 +1057,7 @@ export default function Checkout() {
|
||||
value={orderNotes}
|
||||
onChange={(e) => setOrderNotes(e.target.value)}
|
||||
placeholder="Notes about your order, e.g. special notes for delivery."
|
||||
className="w-full border rounded-lg px-4 py-2 h-32"
|
||||
className="w-full border !rounded-lg px-4 py-2 h-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -765,24 +1133,45 @@ export default function Checkout() {
|
||||
</div>
|
||||
|
||||
{/* Shipping Options */}
|
||||
{elements.shipping_options && (
|
||||
{!isVirtualOnly && elements.shipping_options && (
|
||||
<div className="mb-4 pb-4 border-b">
|
||||
<h3 className="font-medium mb-3">Shipping Method</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="radio" name="shipping" value="free" defaultChecked className="w-4 h-4" />
|
||||
<span className="text-sm">Free Shipping</span>
|
||||
{isLoadingRates ? (
|
||||
<div className="flex items-center gap-2 p-3 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Calculating shipping rates...</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Free</span>
|
||||
</label>
|
||||
<label className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="radio" name="shipping" value="express" className="w-4 h-4" />
|
||||
<span className="text-sm">Express Shipping</span>
|
||||
) : shippingRates.length === 0 ? (
|
||||
<div className="p-3 text-sm text-gray-500">
|
||||
{billingData.country
|
||||
? 'No shipping methods available for your location'
|
||||
: 'Enter your address to see shipping options'}
|
||||
</div>
|
||||
<span className="text-sm font-medium">$15.00</span>
|
||||
) : (
|
||||
shippingRates.map((rate) => (
|
||||
<label
|
||||
key={rate.id}
|
||||
className={`flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50 ${selectedShippingRate === rate.id ? 'border-primary bg-primary/5' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="shipping_method"
|
||||
value={rate.id}
|
||||
checked={selectedShippingRate === rate.id}
|
||||
onChange={() => handleShippingRateChange(rate.id)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">{rate.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{rate.cost === 0 ? 'Free' : formatPrice(rate.cost)}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -800,10 +1189,12 @@ export default function Checkout() {
|
||||
<span>-{formatPrice(discountTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isVirtualOnly && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Shipping</span>
|
||||
<span>{shipping === 0 ? 'Free' : formatPrice(shipping)}</span>
|
||||
<span>{shippingCost === 0 ? 'Free' : formatPrice(shippingCost)}</span>
|
||||
</div>
|
||||
)}
|
||||
{tax > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Tax</span>
|
||||
@@ -812,7 +1203,7 @@ export default function Checkout() {
|
||||
)}
|
||||
<div className="border-t pt-2 flex justify-between font-bold text-lg">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(total - discountTotal)}</span>
|
||||
<span>{formatPrice(subtotal + (isVirtualOnly ? 0 : shippingCost) + tax - discountTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,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');
|
||||
|
||||
@@ -195,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)
|
||||
@@ -308,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',
|
||||
],
|
||||
] );
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -132,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
|
||||
@@ -194,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/' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,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' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1244,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(),
|
||||
@@ -1295,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
|
||||
@@ -1485,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
|
||||
@@ -1503,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(),
|
||||
@@ -1522,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 ) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -25,6 +25,7 @@ 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;
|
||||
@@ -158,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();
|
||||
|
||||
@@ -82,6 +82,7 @@ class ModuleRegistry {
|
||||
'category' => 'products',
|
||||
'icon' => 'key',
|
||||
'default_enabled' => false,
|
||||
'has_settings' => true,
|
||||
'features' => [
|
||||
__('License key generation', 'woonoow'),
|
||||
__('Activation management', 'woonoow'),
|
||||
|
||||
@@ -87,6 +87,27 @@ class AccountController {
|
||||
'callback' => [__CLASS__, 'get_downloads'],
|
||||
'permission_callback' => [__CLASS__, 'check_customer_permission'],
|
||||
]);
|
||||
|
||||
// Avatar upload
|
||||
register_rest_route($namespace, '/account/avatar', [
|
||||
[
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'upload_avatar'],
|
||||
'permission_callback' => [__CLASS__, 'check_customer_permission'],
|
||||
],
|
||||
[
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_avatar'],
|
||||
'permission_callback' => [__CLASS__, 'check_customer_permission'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Get avatar settings (check if custom avatars are enabled)
|
||||
register_rest_route($namespace, '/account/avatar-settings', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_avatar_settings'],
|
||||
'permission_callback' => [__CLASS__, 'check_customer_permission'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,6 +230,143 @@ class AccountController {
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload customer avatar
|
||||
*/
|
||||
public static function upload_avatar(WP_REST_Request $request) {
|
||||
// Check if custom avatars are enabled (stored as 'yes' or 'no')
|
||||
$allow_custom_avatar = get_option('woonoow_allow_custom_avatar', 'no') === 'yes';
|
||||
|
||||
if (!$allow_custom_avatar) {
|
||||
return new WP_Error('avatar_disabled', 'Custom avatars are not enabled', ['status' => 403]);
|
||||
}
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
// Check for file data (base64 or URL)
|
||||
$avatar_data = $request->get_param('avatar');
|
||||
$avatar_url = $request->get_param('avatar_url');
|
||||
|
||||
if ($avatar_url) {
|
||||
// Avatar URL provided (from media library)
|
||||
update_user_meta($user_id, 'woonoow_custom_avatar', esc_url_raw($avatar_url));
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'Avatar updated successfully',
|
||||
'avatar_url' => $avatar_url,
|
||||
], 200);
|
||||
}
|
||||
|
||||
if (!$avatar_data) {
|
||||
return new WP_Error('no_avatar', 'No avatar data provided', ['status' => 400]);
|
||||
}
|
||||
|
||||
// Handle base64 image upload
|
||||
if (strpos($avatar_data, 'data:image') === 0) {
|
||||
// Extract base64 data
|
||||
$parts = explode(',', $avatar_data);
|
||||
if (count($parts) !== 2) {
|
||||
return new WP_Error('invalid_data', 'Invalid image data format', ['status' => 400]);
|
||||
}
|
||||
|
||||
$image_data = base64_decode($parts[1]);
|
||||
|
||||
// Determine file extension from mime type
|
||||
preg_match('/data:image\/(\w+);/', $parts[0], $matches);
|
||||
$extension = $matches[1] ?? 'png';
|
||||
|
||||
// Validate extension
|
||||
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
if (!in_array(strtolower($extension), $allowed)) {
|
||||
return new WP_Error('invalid_type', 'Invalid image type. Allowed: jpg, png, gif, webp', ['status' => 400]);
|
||||
}
|
||||
|
||||
// Create upload directory
|
||||
$upload_dir = wp_upload_dir();
|
||||
$avatar_dir = $upload_dir['basedir'] . '/woonoow-avatars';
|
||||
|
||||
if (!file_exists($avatar_dir)) {
|
||||
wp_mkdir_p($avatar_dir);
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
$filename = 'avatar-' . $user_id . '-' . time() . '.' . $extension;
|
||||
$filepath = $avatar_dir . '/' . $filename;
|
||||
|
||||
// Delete old avatar if exists
|
||||
$old_avatar = get_user_meta($user_id, 'woonoow_custom_avatar', true);
|
||||
if ($old_avatar) {
|
||||
$old_path = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $old_avatar);
|
||||
if (file_exists($old_path)) {
|
||||
unlink($old_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Save new avatar
|
||||
if (file_put_contents($filepath, $image_data) === false) {
|
||||
return new WP_Error('upload_failed', 'Failed to save avatar', ['status' => 500]);
|
||||
}
|
||||
|
||||
// Get URL
|
||||
$avatar_url = $upload_dir['baseurl'] . '/woonoow-avatars/' . $filename;
|
||||
|
||||
// Save to user meta
|
||||
update_user_meta($user_id, 'woonoow_custom_avatar', $avatar_url);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'Avatar uploaded successfully',
|
||||
'avatar_url' => $avatar_url,
|
||||
], 200);
|
||||
}
|
||||
|
||||
return new WP_Error('invalid_data', 'Invalid avatar data', ['status' => 400]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete customer avatar
|
||||
*/
|
||||
public static function delete_avatar(WP_REST_Request $request) {
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
// Get current avatar
|
||||
$avatar_url = get_user_meta($user_id, 'woonoow_custom_avatar', true);
|
||||
|
||||
if ($avatar_url) {
|
||||
// Try to delete the file
|
||||
$upload_dir = wp_upload_dir();
|
||||
$filepath = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $avatar_url);
|
||||
|
||||
if (file_exists($filepath)) {
|
||||
unlink($filepath);
|
||||
}
|
||||
|
||||
// Remove from user meta
|
||||
delete_user_meta($user_id, 'woonoow_custom_avatar');
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'Avatar removed successfully',
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get avatar settings
|
||||
*/
|
||||
public static function get_avatar_settings(WP_REST_Request $request) {
|
||||
$user_id = get_current_user_id();
|
||||
// Use correct option key (stored as 'yes' or 'no')
|
||||
$allow_custom_avatar = get_option('woonoow_allow_custom_avatar', 'no') === 'yes';
|
||||
|
||||
return new WP_REST_Response([
|
||||
'allow_custom_avatar' => $allow_custom_avatar,
|
||||
'current_avatar' => get_user_meta($user_id, 'woonoow_custom_avatar', true) ?: null,
|
||||
'gravatar_url' => get_avatar_url($user_id),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update password
|
||||
*/
|
||||
@@ -363,6 +521,37 @@ class AccountController {
|
||||
$data['shipping_total'] = html_entity_decode(strip_tags(wc_price($order->get_shipping_total())));
|
||||
$data['tax_total'] = html_entity_decode(strip_tags(wc_price($order->get_total_tax())));
|
||||
$data['discount_total'] = html_entity_decode(strip_tags(wc_price($order->get_discount_total())));
|
||||
|
||||
// Shipping lines with method details
|
||||
$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' => html_entity_decode(strip_tags(wc_price($shipping_item->get_total()))),
|
||||
];
|
||||
}
|
||||
$data['shipping_lines'] = $shipping_lines;
|
||||
|
||||
// Tracking info (from various shipping tracking plugins)
|
||||
$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')
|
||||
?: '';
|
||||
|
||||
// Handle WooCommerce Shipment Tracking plugin format (array)
|
||||
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;
|
||||
}
|
||||
|
||||
$data['tracking_number'] = $tracking_number;
|
||||
$data['tracking_url'] = $tracking_url;
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
||||
@@ -86,25 +86,39 @@ class AddressController {
|
||||
// Generate new ID
|
||||
$new_id = empty($addresses) ? 1 : max(array_column($addresses, 'id')) + 1;
|
||||
|
||||
// Prepare address data
|
||||
// Standard address fields
|
||||
$standard_fields = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
|
||||
$reserved_fields = ['id', 'label', 'type', 'is_default'];
|
||||
|
||||
// Prepare address data with standard fields
|
||||
$address = [
|
||||
'id' => $new_id,
|
||||
'label' => sanitize_text_field($request->get_param('label')),
|
||||
'type' => sanitize_text_field($request->get_param('type')), // 'billing', 'shipping', or 'both'
|
||||
'first_name' => sanitize_text_field($request->get_param('first_name')),
|
||||
'last_name' => sanitize_text_field($request->get_param('last_name')),
|
||||
'company' => sanitize_text_field($request->get_param('company')),
|
||||
'address_1' => sanitize_text_field($request->get_param('address_1')),
|
||||
'address_2' => sanitize_text_field($request->get_param('address_2')),
|
||||
'city' => sanitize_text_field($request->get_param('city')),
|
||||
'state' => sanitize_text_field($request->get_param('state')),
|
||||
'postcode' => sanitize_text_field($request->get_param('postcode')),
|
||||
'country' => sanitize_text_field($request->get_param('country')),
|
||||
'email' => sanitize_email($request->get_param('email')),
|
||||
'phone' => sanitize_text_field($request->get_param('phone')),
|
||||
'is_default' => (bool) $request->get_param('is_default'),
|
||||
];
|
||||
|
||||
// Add standard fields
|
||||
foreach ($standard_fields as $field) {
|
||||
$value = $request->get_param($field);
|
||||
if ($field === 'email') {
|
||||
$address[$field] = sanitize_email($value);
|
||||
} else {
|
||||
$address[$field] = sanitize_text_field($value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any custom fields (like destination_id from Rajaongkir)
|
||||
$all_params = $request->get_json_params();
|
||||
if (is_array($all_params)) {
|
||||
foreach ($all_params as $key => $value) {
|
||||
if (!in_array($key, $standard_fields) && !in_array($key, $reserved_fields)) {
|
||||
// Store custom field
|
||||
$address[$key] = is_string($value) ? sanitize_text_field($value) : $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is set as default, unset other defaults of the same type
|
||||
if ($address['is_default']) {
|
||||
foreach ($addresses as &$addr) {
|
||||
@@ -138,22 +152,36 @@ class AddressController {
|
||||
if ($addr['id'] === $address_id) {
|
||||
$found = true;
|
||||
|
||||
// Update fields
|
||||
// Standard address fields
|
||||
$standard_fields = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
|
||||
$reserved_fields = ['id', 'label', 'type', 'is_default'];
|
||||
|
||||
// Update standard meta fields
|
||||
$addr['label'] = sanitize_text_field($request->get_param('label'));
|
||||
$addr['type'] = sanitize_text_field($request->get_param('type'));
|
||||
$addr['first_name'] = sanitize_text_field($request->get_param('first_name'));
|
||||
$addr['last_name'] = sanitize_text_field($request->get_param('last_name'));
|
||||
$addr['company'] = sanitize_text_field($request->get_param('company'));
|
||||
$addr['address_1'] = sanitize_text_field($request->get_param('address_1'));
|
||||
$addr['address_2'] = sanitize_text_field($request->get_param('address_2'));
|
||||
$addr['city'] = sanitize_text_field($request->get_param('city'));
|
||||
$addr['state'] = sanitize_text_field($request->get_param('state'));
|
||||
$addr['postcode'] = sanitize_text_field($request->get_param('postcode'));
|
||||
$addr['country'] = sanitize_text_field($request->get_param('country'));
|
||||
$addr['email'] = sanitize_email($request->get_param('email'));
|
||||
$addr['phone'] = sanitize_text_field($request->get_param('phone'));
|
||||
$addr['is_default'] = (bool) $request->get_param('is_default');
|
||||
|
||||
// Update standard fields
|
||||
foreach ($standard_fields as $field) {
|
||||
$value = $request->get_param($field);
|
||||
if ($field === 'email') {
|
||||
$addr[$field] = sanitize_email($value);
|
||||
} else {
|
||||
$addr[$field] = sanitize_text_field($value);
|
||||
}
|
||||
}
|
||||
|
||||
// Update any custom fields (like destination_id from Rajaongkir)
|
||||
$all_params = $request->get_json_params();
|
||||
if (is_array($all_params)) {
|
||||
foreach ($all_params as $key => $value) {
|
||||
if (!in_array($key, $standard_fields) && !in_array($key, $reserved_fields)) {
|
||||
// Store/update custom field
|
||||
$addr[$key] = is_string($value) ? sanitize_text_field($value) : $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is set as default, unset other defaults of the same type
|
||||
if ($addr['is_default']) {
|
||||
foreach ($addresses as &$other_addr) {
|
||||
|
||||
@@ -197,7 +197,13 @@ class Assets {
|
||||
// Determine SPA base path for BrowserRouter
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$spa_page = $spa_page_id ? get_post($spa_page_id) : null;
|
||||
$base_path = $spa_page ? '/' . $spa_page->post_name : '/store';
|
||||
|
||||
// Check if SPA page is set as WordPress frontpage
|
||||
$frontpage_id = (int) get_option('page_on_front');
|
||||
$is_spa_frontpage = $frontpage_id && $spa_page_id && $frontpage_id === (int) $spa_page_id;
|
||||
|
||||
// If SPA is frontpage, base path is /, otherwise use page slug
|
||||
$base_path = $is_spa_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
|
||||
|
||||
// Check if BrowserRouter is enabled (default: true for SEO)
|
||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||
@@ -249,6 +255,16 @@ class Assets {
|
||||
private static function should_load_assets() {
|
||||
global $post;
|
||||
|
||||
// Check if we're serving SPA directly (set by serve_spa_for_frontpage_routes)
|
||||
if (defined('WOONOOW_SERVE_SPA') && WOONOOW_SERVE_SPA) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we're on a frontpage SPA route (by URL detection)
|
||||
if (self::is_frontpage_spa_route()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// First check: Is this a designated SPA page?
|
||||
if (self::is_spa_page()) {
|
||||
return true;
|
||||
@@ -366,6 +382,51 @@ class Assets {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current request is a frontpage SPA route
|
||||
* Used to detect SPA routes by URL when SPA page is set as frontpage
|
||||
*/
|
||||
private static function is_frontpage_spa_route() {
|
||||
// Get SPA settings
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||
|
||||
// Only run in full SPA mode
|
||||
if ($spa_mode !== 'full' || !$spa_page_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if SPA page is set as WordPress frontpage
|
||||
$frontpage_id = (int) get_option('page_on_front');
|
||||
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the current request path
|
||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
$path = parse_url($request_uri, PHP_URL_PATH);
|
||||
$path = '/' . trim($path, '/');
|
||||
|
||||
// Define SPA routes
|
||||
$spa_routes = ['/', '/shop', '/cart', '/checkout', '/my-account', '/login', '/register', '/reset-password'];
|
||||
|
||||
// Check exact matches
|
||||
if (in_array($path, $spa_routes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check path prefixes
|
||||
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
|
||||
foreach ($prefix_routes as $prefix) {
|
||||
if (strpos($path, $prefix) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dequeue conflicting scripts when SPA is active
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,9 @@ class TemplateOverride
|
||||
// Redirect WooCommerce pages to SPA routes early (before template loads)
|
||||
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
|
||||
|
||||
// Serve SPA directly for frontpage routes (priority 1 = very early, before WC)
|
||||
add_action('template_redirect', [__CLASS__, 'serve_spa_for_frontpage_routes'], 1);
|
||||
|
||||
// Hook to wp_loaded with priority 10 (BEFORE WooCommerce's priority 20)
|
||||
// This ensures we process add-to-cart before WooCommerce does
|
||||
add_action('wp_loaded', [__CLASS__, 'intercept_add_to_cart'], 10);
|
||||
@@ -68,7 +71,7 @@ class TemplateOverride
|
||||
|
||||
/**
|
||||
* Register rewrite rules for BrowserRouter SEO
|
||||
* Catches all /store/* routes and serves the SPA page
|
||||
* Catches all SPA routes and serves the SPA page
|
||||
*/
|
||||
public static function register_spa_rewrite_rules()
|
||||
{
|
||||
@@ -89,13 +92,82 @@ class TemplateOverride
|
||||
|
||||
$spa_slug = $spa_page->post_name;
|
||||
|
||||
// Rewrite /store/anything to serve the SPA page
|
||||
// Check if SPA page is set as WordPress frontpage
|
||||
$frontpage_id = (int) get_option('page_on_front');
|
||||
$is_spa_frontpage = $frontpage_id && $frontpage_id === (int) $spa_page_id;
|
||||
|
||||
if ($is_spa_frontpage) {
|
||||
// When SPA is frontpage, add root-level routes
|
||||
// /shop, /shop/* → SPA page
|
||||
add_rewrite_rule(
|
||||
'^shop/?$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=shop',
|
||||
'top'
|
||||
);
|
||||
add_rewrite_rule(
|
||||
'^shop/(.*)$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=shop/$matches[1]',
|
||||
'top'
|
||||
);
|
||||
|
||||
// /product/* → SPA page
|
||||
add_rewrite_rule(
|
||||
'^product/(.*)$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=product/$matches[1]',
|
||||
'top'
|
||||
);
|
||||
|
||||
// /cart → SPA page
|
||||
add_rewrite_rule(
|
||||
'^cart/?$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=cart',
|
||||
'top'
|
||||
);
|
||||
|
||||
// /checkout → SPA page
|
||||
add_rewrite_rule(
|
||||
'^checkout/?$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=checkout',
|
||||
'top'
|
||||
);
|
||||
|
||||
// /my-account, /my-account/* → SPA page
|
||||
add_rewrite_rule(
|
||||
'^my-account/?$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=my-account',
|
||||
'top'
|
||||
);
|
||||
add_rewrite_rule(
|
||||
'^my-account/(.*)$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=my-account/$matches[1]',
|
||||
'top'
|
||||
);
|
||||
|
||||
// /login, /register, /reset-password → SPA page
|
||||
add_rewrite_rule(
|
||||
'^login/?$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=login',
|
||||
'top'
|
||||
);
|
||||
add_rewrite_rule(
|
||||
'^register/?$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=register',
|
||||
'top'
|
||||
);
|
||||
add_rewrite_rule(
|
||||
'^reset-password/?$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=reset-password',
|
||||
'top'
|
||||
);
|
||||
} else {
|
||||
// Rewrite /slug/anything to serve the SPA page
|
||||
// React Router handles the path after that
|
||||
add_rewrite_rule(
|
||||
'^' . preg_quote($spa_slug, '/') . '/(.*)$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=$matches[1]',
|
||||
'top'
|
||||
);
|
||||
}
|
||||
|
||||
// Register query var for the SPA path
|
||||
add_filter('query_vars', function($vars) {
|
||||
@@ -174,6 +246,12 @@ class TemplateOverride
|
||||
return; // No SPA page configured
|
||||
}
|
||||
|
||||
// Skip if SPA is set as frontpage (serve_spa_for_frontpage_routes handles it)
|
||||
$frontpage_id = (int) get_option('page_on_front');
|
||||
if ($frontpage_id && $frontpage_id === (int) $spa_page_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Already on SPA page, don't redirect
|
||||
global $post;
|
||||
if ($post && $post->ID == $spa_page_id) {
|
||||
@@ -224,6 +302,88 @@ class TemplateOverride
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve SPA template directly for frontpage SPA routes
|
||||
* When SPA page is set as WordPress frontpage, intercept known routes
|
||||
* and serve the SPA template directly (bypasses WooCommerce templates)
|
||||
*/
|
||||
public static function serve_spa_for_frontpage_routes()
|
||||
{
|
||||
// Get SPA settings
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||
|
||||
// Only run in full SPA mode
|
||||
if ($spa_mode !== 'full' || !$spa_page_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if SPA page is set as WordPress frontpage
|
||||
$frontpage_id = (int) get_option('page_on_front');
|
||||
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
|
||||
return; // SPA is not frontpage, let normal routing handle it
|
||||
}
|
||||
|
||||
// Get the current request path
|
||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
$path = parse_url($request_uri, PHP_URL_PATH);
|
||||
$path = '/' . trim($path, '/');
|
||||
|
||||
// Define SPA routes that should be intercepted when SPA is frontpage
|
||||
$spa_routes = [
|
||||
'/', // Frontpage itself
|
||||
'/shop', // Shop page
|
||||
'/cart', // Cart page
|
||||
'/checkout', // Checkout page
|
||||
'/my-account', // Account page
|
||||
'/login', // Login page
|
||||
'/register', // Register page
|
||||
'/reset-password', // Password reset
|
||||
];
|
||||
|
||||
// Check for exact matches or path prefixes
|
||||
$should_serve_spa = false;
|
||||
|
||||
// Check exact matches
|
||||
if (in_array($path, $spa_routes)) {
|
||||
$should_serve_spa = true;
|
||||
}
|
||||
|
||||
// Check path prefixes (for sub-routes)
|
||||
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
|
||||
foreach ($prefix_routes as $prefix) {
|
||||
if (strpos($path, $prefix) === 0) {
|
||||
$should_serve_spa = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Not a SPA route
|
||||
if (!$should_serve_spa) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent caching for dynamic SPA content
|
||||
nocache_headers();
|
||||
|
||||
// Load the SPA template directly and exit
|
||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||
if (file_exists($spa_template)) {
|
||||
// Set up minimal WordPress environment for the template
|
||||
status_header(200);
|
||||
|
||||
// Define constant to tell Assets to load unconditionally
|
||||
if (!defined('WOONOOW_SERVE_SPA')) {
|
||||
define('WOONOOW_SERVE_SPA', true);
|
||||
}
|
||||
|
||||
// Include the SPA template
|
||||
include $spa_template;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable canonical redirects for SPA routes
|
||||
* This prevents WordPress from redirecting /product/slug URLs
|
||||
@@ -406,17 +566,25 @@ class TemplateOverride
|
||||
private static function is_spa_page()
|
||||
{
|
||||
global $post;
|
||||
if (!$post) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get SPA settings from appearance
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||
|
||||
// Only return true if spa_mode is 'full' AND we're on the SPA page
|
||||
if ($spa_mode === 'full' && $spa_page_id && $post->ID == $spa_page_id) {
|
||||
// Only check if spa_mode is 'full' and SPA page is configured
|
||||
if ($spa_mode !== 'full' || !$spa_page_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if current page is the SPA page
|
||||
if ($post && $post->ID == $spa_page_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if SPA page is set as WordPress frontpage and we're on frontpage
|
||||
$frontpage_id = (int) get_option('page_on_front');
|
||||
if ($frontpage_id && $frontpage_id === (int) $spa_page_id && is_front_page()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
544
includes/Modules/Licensing/LicenseManager.php
Normal file
544
includes/Modules/Licensing/LicenseManager.php
Normal file
@@ -0,0 +1,544 @@
|
||||
<?php
|
||||
/**
|
||||
* License Manager
|
||||
*
|
||||
* Handles license key generation, activation, deactivation, and validation.
|
||||
*
|
||||
* @package WooNooW\Modules\Licensing
|
||||
*/
|
||||
|
||||
namespace WooNooW\Modules\Licensing;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
use WooNooW\Core\ModuleRegistry;
|
||||
|
||||
class LicenseManager {
|
||||
|
||||
private static $table_name = 'woonoow_licenses';
|
||||
private static $activations_table = 'woonoow_license_activations';
|
||||
|
||||
/**
|
||||
* Initialize
|
||||
*/
|
||||
public static function init() {
|
||||
// Only initialize if module is enabled
|
||||
if (!ModuleRegistry::is_enabled('licensing')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hook into order completion - multiple hooks to catch all scenarios
|
||||
add_action('woocommerce_order_status_completed', [__CLASS__, 'generate_licenses_for_order']);
|
||||
add_action('woocommerce_order_status_processing', [__CLASS__, 'generate_licenses_for_order']);
|
||||
add_action('woocommerce_payment_complete', [__CLASS__, 'generate_licenses_for_order']);
|
||||
|
||||
// Also hook into thank you page for COD/pending orders (with lower priority)
|
||||
add_action('woocommerce_thankyou', [__CLASS__, 'maybe_generate_on_thankyou'], 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe generate licenses on thank you page (for COD and pending orders)
|
||||
*/
|
||||
public static function maybe_generate_on_thankyou($order_id) {
|
||||
if (!$order_id) return;
|
||||
|
||||
$order = wc_get_order($order_id);
|
||||
if (!$order) return;
|
||||
|
||||
// Only generate for orders that didn't already get licenses via status hooks
|
||||
// Check if it's a virtual-only order that might skip payment completion
|
||||
$needs_payment = $order->needs_payment();
|
||||
$is_virtual = self::is_virtual_order($order);
|
||||
|
||||
// Generate if: virtual order OR already paid (processing/completed)
|
||||
if ($is_virtual || in_array($order->get_status(), ['processing', 'completed'])) {
|
||||
self::generate_licenses_for_order($order_id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if order contains only virtual items
|
||||
*/
|
||||
private static function is_virtual_order($order) {
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if ($product && !$product->is_virtual()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create database tables
|
||||
*/
|
||||
public static function create_tables() {
|
||||
global $wpdb;
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$licenses_table = $wpdb->prefix . self::$table_name;
|
||||
$activations_table = $wpdb->prefix . self::$activations_table;
|
||||
|
||||
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
|
||||
|
||||
// Create licenses table - dbDelta requires each CREATE TABLE to be called separately
|
||||
$sql_licenses = "CREATE TABLE $licenses_table (
|
||||
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
license_key varchar(255) NOT NULL,
|
||||
product_id bigint(20) UNSIGNED NOT NULL,
|
||||
order_id bigint(20) UNSIGNED NOT NULL,
|
||||
order_item_id bigint(20) UNSIGNED NOT NULL,
|
||||
user_id bigint(20) UNSIGNED NOT NULL,
|
||||
status varchar(20) NOT NULL DEFAULT 'active',
|
||||
activation_limit int(11) NOT NULL DEFAULT 1,
|
||||
activation_count int(11) NOT NULL DEFAULT 0,
|
||||
expires_at datetime DEFAULT NULL,
|
||||
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY license_key (license_key),
|
||||
KEY product_id (product_id),
|
||||
KEY order_id (order_id),
|
||||
KEY user_id (user_id),
|
||||
KEY status (status)
|
||||
) $charset_collate;";
|
||||
|
||||
dbDelta($sql_licenses);
|
||||
|
||||
// Create activations table
|
||||
$sql_activations = "CREATE TABLE $activations_table (
|
||||
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
license_id bigint(20) UNSIGNED NOT NULL,
|
||||
domain varchar(255) DEFAULT NULL,
|
||||
ip_address varchar(45) DEFAULT NULL,
|
||||
machine_id varchar(255) DEFAULT NULL,
|
||||
user_agent text DEFAULT NULL,
|
||||
status varchar(20) NOT NULL DEFAULT 'active',
|
||||
activated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deactivated_at datetime DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY license_id (license_id),
|
||||
KEY status (status)
|
||||
) $charset_collate;";
|
||||
|
||||
dbDelta($sql_activations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate licenses for completed order
|
||||
*/
|
||||
public static function generate_licenses_for_order($order_id) {
|
||||
$order = wc_get_order($order_id);
|
||||
if (!$order) return;
|
||||
|
||||
foreach ($order->get_items() as $item_id => $item) {
|
||||
$product_id = $item->get_product_id();
|
||||
$product = wc_get_product($product_id);
|
||||
|
||||
if (!$product) continue;
|
||||
|
||||
// Check if product has licensing enabled
|
||||
$licensing_enabled = get_post_meta($product_id, '_woonoow_licensing_enabled', true);
|
||||
if ($licensing_enabled !== 'yes') continue;
|
||||
|
||||
// Check if license already exists for this order item
|
||||
if (self::license_exists_for_order_item($item_id)) continue;
|
||||
|
||||
// Get activation limit from product or default
|
||||
$activation_limit = (int) get_post_meta($product_id, '_woonoow_license_activation_limit', true);
|
||||
if ($activation_limit <= 0) {
|
||||
$activation_limit = (int) get_option('woonoow_licensing_default_activation_limit', 1);
|
||||
}
|
||||
|
||||
// Get expiry from product or default
|
||||
$expiry_days = (int) get_post_meta($product_id, '_woonoow_license_expiry_days', true);
|
||||
if ($expiry_days <= 0 && get_option('woonoow_licensing_license_expiry_enabled', false)) {
|
||||
$expiry_days = (int) get_option('woonoow_licensing_default_expiry_days', 365);
|
||||
}
|
||||
|
||||
$expires_at = $expiry_days > 0 ? gmdate('Y-m-d H:i:s', strtotime("+$expiry_days days")) : null;
|
||||
|
||||
// Generate license for each quantity
|
||||
$quantity = $item->get_quantity();
|
||||
for ($i = 0; $i < $quantity; $i++) {
|
||||
self::create_license([
|
||||
'product_id' => $product_id,
|
||||
'order_id' => $order_id,
|
||||
'order_item_id' => $item_id,
|
||||
'user_id' => $order->get_user_id(),
|
||||
'activation_limit' => $activation_limit,
|
||||
'expires_at' => $expires_at,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if license already exists for order item
|
||||
*/
|
||||
public static function license_exists_for_order_item($order_item_id) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
return (bool) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM $table WHERE order_item_id = %d",
|
||||
$order_item_id
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new license
|
||||
*/
|
||||
public static function create_license($data) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$license_key = self::generate_license_key();
|
||||
|
||||
$wpdb->insert($table, [
|
||||
'license_key' => $license_key,
|
||||
'product_id' => $data['product_id'],
|
||||
'order_id' => $data['order_id'],
|
||||
'order_item_id' => $data['order_item_id'],
|
||||
'user_id' => $data['user_id'],
|
||||
'activation_limit' => $data['activation_limit'] ?? 1,
|
||||
'expires_at' => $data['expires_at'] ?? null,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$license_id = $wpdb->insert_id;
|
||||
|
||||
do_action('woonoow/license/created', $license_id, $license_key, $data);
|
||||
|
||||
return [
|
||||
'id' => $license_id,
|
||||
'license_key' => $license_key,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate license key
|
||||
*/
|
||||
public static function generate_license_key() {
|
||||
$format = get_option('woonoow_licensing_license_key_format', 'serial');
|
||||
$prefix = get_option('woonoow_licensing_license_key_prefix', '');
|
||||
|
||||
switch ($format) {
|
||||
case 'uuid':
|
||||
$key = wp_generate_uuid4();
|
||||
break;
|
||||
case 'alphanumeric':
|
||||
$key = strtoupper(wp_generate_password(16, false));
|
||||
break;
|
||||
case 'serial':
|
||||
default:
|
||||
$key = strtoupper(sprintf(
|
||||
'%s-%s-%s-%s',
|
||||
wp_generate_password(4, false),
|
||||
wp_generate_password(4, false),
|
||||
wp_generate_password(4, false),
|
||||
wp_generate_password(4, false)
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
||||
return $prefix . $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license by key
|
||||
*/
|
||||
public static function get_license_by_key($license_key) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
return $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $table WHERE license_key = %s",
|
||||
$license_key
|
||||
), ARRAY_A);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license by ID
|
||||
*/
|
||||
public static function get_license($license_id) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
return $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $table WHERE id = %d",
|
||||
$license_id
|
||||
), ARRAY_A);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get licenses for user
|
||||
*/
|
||||
public static function get_user_licenses($user_id, $args = []) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$defaults = [
|
||||
'status' => null,
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
];
|
||||
$args = wp_parse_args($args, $defaults);
|
||||
|
||||
$where = "user_id = %d";
|
||||
$params = [$user_id];
|
||||
|
||||
if ($args['status']) {
|
||||
$where .= " AND status = %s";
|
||||
$params[] = $args['status'];
|
||||
}
|
||||
|
||||
$sql = "SELECT * FROM $table WHERE $where ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $args['limit'];
|
||||
$params[] = $args['offset'];
|
||||
|
||||
return $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate license
|
||||
*/
|
||||
public static function activate($license_key, $activation_data = []) {
|
||||
global $wpdb;
|
||||
$license = self::get_license_by_key($license_key);
|
||||
|
||||
if (!$license) {
|
||||
return new \WP_Error('invalid_license', __('Invalid license key', 'woonoow'));
|
||||
}
|
||||
|
||||
if ($license['status'] !== 'active') {
|
||||
return new \WP_Error('license_inactive', __('License is not active', 'woonoow'));
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if ($license['expires_at'] && strtotime($license['expires_at']) < time()) {
|
||||
$block_expired = get_option('woonoow_licensing_block_expired_activations', true);
|
||||
if ($block_expired) {
|
||||
return new \WP_Error('license_expired', __('License has expired', 'woonoow'));
|
||||
}
|
||||
}
|
||||
|
||||
// Check activation limit
|
||||
if ($license['activation_limit'] > 0 && $license['activation_count'] >= $license['activation_limit']) {
|
||||
return new \WP_Error('activation_limit_reached', __('Activation limit reached', 'woonoow'));
|
||||
}
|
||||
|
||||
// Create activation record
|
||||
$activations_table = $wpdb->prefix . self::$activations_table;
|
||||
$licenses_table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$wpdb->insert($activations_table, [
|
||||
'license_id' => $license['id'],
|
||||
'domain' => $activation_data['domain'] ?? null,
|
||||
'ip_address' => $activation_data['ip_address'] ?? null,
|
||||
'machine_id' => $activation_data['machine_id'] ?? null,
|
||||
'user_agent' => $activation_data['user_agent'] ?? null,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Increment activation count
|
||||
$wpdb->query($wpdb->prepare(
|
||||
"UPDATE $licenses_table SET activation_count = activation_count + 1 WHERE id = %d",
|
||||
$license['id']
|
||||
));
|
||||
|
||||
do_action('woonoow/license/activated', $license['id'], $activation_data);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'activation_id' => $wpdb->insert_id,
|
||||
'activations_remaining' => $license['activation_limit'] > 0
|
||||
? max(0, $license['activation_limit'] - $license['activation_count'] - 1)
|
||||
: -1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate license
|
||||
*/
|
||||
public static function deactivate($license_key, $activation_id = null, $machine_id = null) {
|
||||
global $wpdb;
|
||||
$license = self::get_license_by_key($license_key);
|
||||
|
||||
if (!$license) {
|
||||
return new \WP_Error('invalid_license', __('Invalid license key', 'woonoow'));
|
||||
}
|
||||
|
||||
// Check if deactivation is allowed
|
||||
$allow_deactivation = get_option('woonoow_licensing_allow_deactivation', true);
|
||||
if (!$allow_deactivation) {
|
||||
return new \WP_Error('deactivation_disabled', __('License deactivation is disabled', 'woonoow'));
|
||||
}
|
||||
|
||||
$activations_table = $wpdb->prefix . self::$activations_table;
|
||||
$licenses_table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
// Find activation to deactivate
|
||||
$where = "license_id = %d AND status = 'active'";
|
||||
$params = [$license['id']];
|
||||
|
||||
if ($activation_id) {
|
||||
$where .= " AND id = %d";
|
||||
$params[] = $activation_id;
|
||||
} elseif ($machine_id) {
|
||||
$where .= " AND machine_id = %s";
|
||||
$params[] = $machine_id;
|
||||
}
|
||||
|
||||
$activation = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $activations_table WHERE $where LIMIT 1",
|
||||
$params
|
||||
), ARRAY_A);
|
||||
|
||||
if (!$activation) {
|
||||
return new \WP_Error('no_activation', __('No active activation found', 'woonoow'));
|
||||
}
|
||||
|
||||
// Deactivate
|
||||
$wpdb->update(
|
||||
$activations_table,
|
||||
['status' => 'deactivated', 'deactivated_at' => current_time('mysql')],
|
||||
['id' => $activation['id']]
|
||||
);
|
||||
|
||||
// Decrement activation count
|
||||
$wpdb->query($wpdb->prepare(
|
||||
"UPDATE $licenses_table SET activation_count = GREATEST(0, activation_count - 1) WHERE id = %d",
|
||||
$license['id']
|
||||
));
|
||||
|
||||
do_action('woonoow/license/deactivated', $license['id'], $activation['id']);
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate license (check if valid without activating)
|
||||
*/
|
||||
public static function validate($license_key) {
|
||||
$license = self::get_license_by_key($license_key);
|
||||
|
||||
if (!$license) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'invalid_license',
|
||||
'message' => __('Invalid license key', 'woonoow'),
|
||||
];
|
||||
}
|
||||
|
||||
$is_expired = $license['expires_at'] && strtotime($license['expires_at']) < time();
|
||||
|
||||
return [
|
||||
'valid' => $license['status'] === 'active' && !$is_expired,
|
||||
'license_key' => $license['license_key'],
|
||||
'status' => $license['status'],
|
||||
'activation_limit' => (int) $license['activation_limit'],
|
||||
'activation_count' => (int) $license['activation_count'],
|
||||
'activations_remaining' => $license['activation_limit'] > 0
|
||||
? max(0, $license['activation_limit'] - $license['activation_count'])
|
||||
: -1,
|
||||
'expires_at' => $license['expires_at'],
|
||||
'is_expired' => $is_expired,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke license
|
||||
*/
|
||||
public static function revoke($license_id) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$result = $wpdb->update(
|
||||
$table,
|
||||
['status' => 'revoked'],
|
||||
['id' => $license_id]
|
||||
);
|
||||
|
||||
if ($result !== false) {
|
||||
do_action('woonoow/license/revoked', $license_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all licenses (admin)
|
||||
*/
|
||||
public static function get_all_licenses($args = []) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$table_name;
|
||||
|
||||
$defaults = [
|
||||
'search' => '',
|
||||
'status' => null,
|
||||
'product_id' => null,
|
||||
'user_id' => null,
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
'orderby' => 'created_at',
|
||||
'order' => 'DESC',
|
||||
];
|
||||
$args = wp_parse_args($args, $defaults);
|
||||
|
||||
$where_clauses = ['1=1'];
|
||||
$params = [];
|
||||
|
||||
if ($args['search']) {
|
||||
$where_clauses[] = "license_key LIKE %s";
|
||||
$params[] = '%' . $wpdb->esc_like($args['search']) . '%';
|
||||
}
|
||||
|
||||
if ($args['status']) {
|
||||
$where_clauses[] = "status = %s";
|
||||
$params[] = $args['status'];
|
||||
}
|
||||
|
||||
if ($args['product_id']) {
|
||||
$where_clauses[] = "product_id = %d";
|
||||
$params[] = $args['product_id'];
|
||||
}
|
||||
|
||||
if ($args['user_id']) {
|
||||
$where_clauses[] = "user_id = %d";
|
||||
$params[] = $args['user_id'];
|
||||
}
|
||||
|
||||
$where = implode(' AND ', $where_clauses);
|
||||
$orderby = sanitize_sql_orderby($args['orderby'] . ' ' . $args['order']) ?: 'created_at DESC';
|
||||
|
||||
$sql = "SELECT * FROM $table WHERE $where ORDER BY $orderby LIMIT %d OFFSET %d";
|
||||
$params[] = $args['limit'];
|
||||
$params[] = $args['offset'];
|
||||
|
||||
$licenses = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
|
||||
// Get total count
|
||||
$count_sql = "SELECT COUNT(*) FROM $table WHERE $where";
|
||||
$total = $wpdb->get_var($wpdb->prepare($count_sql, array_slice($params, 0, -2)));
|
||||
|
||||
return [
|
||||
'licenses' => $licenses,
|
||||
'total' => (int) $total,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get activations for a license
|
||||
*/
|
||||
public static function get_activations($license_id) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::$activations_table;
|
||||
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM $table WHERE license_id = %d ORDER BY activated_at DESC",
|
||||
$license_id
|
||||
), ARRAY_A);
|
||||
}
|
||||
}
|
||||
128
includes/Modules/Licensing/LicensingModule.php
Normal file
128
includes/Modules/Licensing/LicensingModule.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
/**
|
||||
* Licensing Module Bootstrap
|
||||
*
|
||||
* @package WooNooW\Modules\Licensing
|
||||
*/
|
||||
|
||||
namespace WooNooW\Modules\Licensing;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
use WooNooW\Core\ModuleRegistry;
|
||||
use WooNooW\Modules\LicensingSettings;
|
||||
|
||||
class LicensingModule {
|
||||
|
||||
/**
|
||||
* Initialize the licensing module
|
||||
*/
|
||||
public static function init() {
|
||||
// Register settings schema
|
||||
LicensingSettings::init();
|
||||
|
||||
// Initialize license manager immediately since we're already in plugins_loaded
|
||||
// Note: This is called from woonoow.php inside plugins_loaded action,
|
||||
// so we can call maybe_init_manager directly instead of scheduling another hook
|
||||
self::maybe_init_manager();
|
||||
|
||||
// Install tables on module enable
|
||||
add_action('woonoow/module/enabled', [__CLASS__, 'on_module_enabled']);
|
||||
|
||||
// Add product meta fields
|
||||
add_action('woocommerce_product_options_general_product_data', [__CLASS__, 'add_product_licensing_fields']);
|
||||
add_action('woocommerce_process_product_meta', [__CLASS__, 'save_product_licensing_fields']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize manager if module is enabled
|
||||
*/
|
||||
public static function maybe_init_manager() {
|
||||
if (ModuleRegistry::is_enabled('licensing')) {
|
||||
// Ensure tables exist
|
||||
self::ensure_tables();
|
||||
LicenseManager::init();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure database tables exist
|
||||
*/
|
||||
private static function ensure_tables() {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'woonoow_licenses';
|
||||
|
||||
// Check if table exists
|
||||
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
|
||||
LicenseManager::create_tables();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle module enable
|
||||
*/
|
||||
public static function on_module_enabled($module_id) {
|
||||
if ($module_id === 'licensing') {
|
||||
LicenseManager::create_tables();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add licensing fields to product edit page
|
||||
*/
|
||||
public static function add_product_licensing_fields() {
|
||||
global $post;
|
||||
|
||||
if (!ModuleRegistry::is_enabled('licensing')) {
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<div class="options_group show_if_simple show_if_downloadable">';
|
||||
|
||||
woocommerce_wp_checkbox([
|
||||
'id' => '_woonoow_licensing_enabled',
|
||||
'label' => __('Enable Licensing', 'woonoow'),
|
||||
'description' => __('Generate license keys for this product on purchase', 'woonoow'),
|
||||
]);
|
||||
|
||||
woocommerce_wp_text_input([
|
||||
'id' => '_woonoow_license_activation_limit',
|
||||
'label' => __('Activation Limit', 'woonoow'),
|
||||
'description' => __('Max activations per license (0 = use default, leave empty for unlimited)', 'woonoow'),
|
||||
'type' => 'number',
|
||||
'custom_attributes' => [
|
||||
'min' => '0',
|
||||
'step' => '1',
|
||||
],
|
||||
]);
|
||||
|
||||
woocommerce_wp_text_input([
|
||||
'id' => '_woonoow_license_expiry_days',
|
||||
'label' => __('License Expiry (Days)', 'woonoow'),
|
||||
'description' => __('Days until license expires (0 = never expires)', 'woonoow'),
|
||||
'type' => 'number',
|
||||
'custom_attributes' => [
|
||||
'min' => '0',
|
||||
'step' => '1',
|
||||
],
|
||||
]);
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Save licensing fields
|
||||
*/
|
||||
public static function save_product_licensing_fields($post_id) {
|
||||
$licensing_enabled = isset($_POST['_woonoow_licensing_enabled']) ? 'yes' : 'no';
|
||||
update_post_meta($post_id, '_woonoow_licensing_enabled', $licensing_enabled);
|
||||
|
||||
if (isset($_POST['_woonoow_license_activation_limit'])) {
|
||||
update_post_meta($post_id, '_woonoow_license_activation_limit', absint($_POST['_woonoow_license_activation_limit']));
|
||||
}
|
||||
|
||||
if (isset($_POST['_woonoow_license_expiry_days'])) {
|
||||
update_post_meta($post_id, '_woonoow_license_expiry_days', absint($_POST['_woonoow_license_expiry_days']));
|
||||
}
|
||||
}
|
||||
}
|
||||
95
includes/Modules/LicensingSettings.php
Normal file
95
includes/Modules/LicensingSettings.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
/**
|
||||
* Licensing Module Settings
|
||||
*
|
||||
* @package WooNooW\Modules
|
||||
*/
|
||||
|
||||
namespace WooNooW\Modules;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
class LicensingSettings {
|
||||
|
||||
/**
|
||||
* Initialize the settings
|
||||
*/
|
||||
public static function init() {
|
||||
add_filter('woonoow/module_settings_schema', [__CLASS__, 'register_schema']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register licensing settings schema
|
||||
*/
|
||||
public static function register_schema($schemas) {
|
||||
$schemas['licensing'] = [
|
||||
'license_key_format' => [
|
||||
'type' => 'select',
|
||||
'label' => __('License Key Format', 'woonoow'),
|
||||
'description' => __('Format for generated license keys', 'woonoow'),
|
||||
'options' => [
|
||||
'uuid' => 'UUID (e.g., a1b2c3d4-e5f6-7890-abcd-ef1234567890)',
|
||||
'serial' => 'Serial (e.g., XXXX-XXXX-XXXX-XXXX)',
|
||||
'alphanumeric' => 'Alphanumeric (e.g., ABC123DEF456)',
|
||||
],
|
||||
'default' => 'serial',
|
||||
],
|
||||
'license_key_prefix' => [
|
||||
'type' => 'text',
|
||||
'label' => __('License Key Prefix', 'woonoow'),
|
||||
'description' => __('Optional prefix for license keys (e.g., PRO-, ENT-)', 'woonoow'),
|
||||
'placeholder' => 'e.g., PRO-',
|
||||
'default' => '',
|
||||
],
|
||||
'default_activation_limit' => [
|
||||
'type' => 'number',
|
||||
'label' => __('Default Activation Limit', 'woonoow'),
|
||||
'description' => __('Default max activations per license (0 = unlimited). Can be overridden per product.', 'woonoow'),
|
||||
'default' => 1,
|
||||
'min' => 0,
|
||||
'max' => 100,
|
||||
],
|
||||
'allow_deactivation' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Allow Deactivation', 'woonoow'),
|
||||
'description' => __('Allow customers to deactivate their licenses to free up activation slots', 'woonoow'),
|
||||
'default' => true,
|
||||
],
|
||||
'license_expiry_enabled' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Enable License Expiry', 'woonoow'),
|
||||
'description' => __('Licenses expire after a set period (for subscription-based products)', 'woonoow'),
|
||||
'default' => false,
|
||||
],
|
||||
'default_expiry_days' => [
|
||||
'type' => 'number',
|
||||
'label' => __('Default Expiry (Days)', 'woonoow'),
|
||||
'description' => __('Default license validity period in days (0 = never expires)', 'woonoow'),
|
||||
'default' => 365,
|
||||
'min' => 0,
|
||||
],
|
||||
'block_expired_activations' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Block Expired Activations', 'woonoow'),
|
||||
'description' => __('Prevent new activations for expired licenses (deactivations still allowed)', 'woonoow'),
|
||||
'default' => true,
|
||||
],
|
||||
'send_expiry_reminder' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Send Expiry Reminders', 'woonoow'),
|
||||
'description' => __('Send email reminders before license expires', 'woonoow'),
|
||||
'default' => true,
|
||||
],
|
||||
'expiry_reminder_days' => [
|
||||
'type' => 'number',
|
||||
'label' => __('Reminder Days Before Expiry', 'woonoow'),
|
||||
'description' => __('Send reminder this many days before expiry', 'woonoow'),
|
||||
'default' => 7,
|
||||
'min' => 1,
|
||||
'max' => 30,
|
||||
],
|
||||
];
|
||||
|
||||
return $schemas;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ add_action('plugins_loaded', function () {
|
||||
// Initialize module settings
|
||||
WooNooW\Modules\NewsletterSettings::init();
|
||||
WooNooW\Modules\WishlistSettings::init();
|
||||
WooNooW\Modules\Licensing\LicensingModule::init();
|
||||
});
|
||||
|
||||
// Activation/Deactivation hooks
|
||||
|
||||
Reference in New Issue
Block a user