Compare commits
15 Commits
396ca25be4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec2049913f | ||
|
|
54a3a15f68 | ||
|
|
fb1a6c40ef | ||
|
|
21ece27b9b | ||
|
|
f8c733832e | ||
|
|
fd8eb38512 | ||
|
|
dcdd6d8cac | ||
|
|
df969b442d | ||
|
|
fec786daa6 | ||
|
|
f3c4ee7124 | ||
|
|
30f2fc2ea6 | ||
|
|
5b8882e595 | ||
|
|
6d2b1fb9ca | ||
|
|
322c0e739d | ||
|
|
53209c4381 |
621
AFFILIATE_MODULE_REPORT.md
Normal file
621
AFFILIATE_MODULE_REPORT.md
Normal file
@@ -0,0 +1,621 @@
|
||||
# Affiliate Module - Implementation Report
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Date:** 2026-05-31
|
||||
**Module:** WooNooW Affiliate Program
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Implementation Summary](#implementation-summary)
|
||||
2. [Backend (PHP)](#backend-php)
|
||||
3. [Admin SPA](#admin-spa)
|
||||
4. [Customer SPA](#customer-spa)
|
||||
5. [Gaps & Defects](#gaps--defects)
|
||||
6. [Opportunities](#opportunities)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
| Area | Status | Coverage | Production Ready |
|
||||
|------|--------|----------|-----------------|
|
||||
| Referral Tracking | Working (Main Path) | ~90% | ⚠️ |
|
||||
| Order Lifecycle | Partial | ~70% | ⚠️ |
|
||||
| Backend API | Partial | ~70% | ⚠️ |
|
||||
| Customer Dashboard | Partial | ~60% | ⚠️ |
|
||||
| Admin SPA | Partial | ~50% | ⚠️ |
|
||||
| Email Notifications | Partial | ~50% | ⚠️ |
|
||||
| Payout System | Placeholder | 0% | ❌ |
|
||||
|
||||
**Overall Assessment:** Foundation is solid with clean architecture. Referral tracking works for standard page-load checkout flows, but still needs hardening for block-based checkout and dynamic URL handling. Payout system is the critical gap preventing production use.
|
||||
|
||||
### What Works Well
|
||||
|
||||
- **Clean separation of concerns** — Module bootstrap, tracking, lifecycle, settings are distinct files
|
||||
- **Multiple checkout hooks** — Works with legacy checkout and admin order creation
|
||||
- **Order lifecycle handling** — Refunds/cancellations properly reject referrals with clawback
|
||||
- **Customer dashboard** — Functional with proper currency formatting
|
||||
- **Admin order integration** — Shows affiliate attribution in order details
|
||||
|
||||
### What's Working (Main Path)
|
||||
|
||||
- Cookie-based referral tracking on standard page loads
|
||||
- Affiliate application and approval flow
|
||||
- Commission calculation on order completion
|
||||
- Auto-approval via Action Scheduler after holding period
|
||||
- Order refund/cancellation → referral rejection with clawback
|
||||
|
||||
### What's Still Needs Hardening
|
||||
|
||||
- **Block checkout compatibility** — Cookie capture relies on `init` hook; may miss AJAX-based checkout flows
|
||||
- **Dynamic referral link** — Current implementation assumes `/shop` path; needs site-config-aware URL generation
|
||||
- **Edge case handling** — Invalid referral codes, expired cookies, cross-device attribution not fully tested
|
||||
|
||||
### What's Missing for Production
|
||||
|
||||
1. **Payout workflow** — Cannot pay affiliates (blocking)
|
||||
2. **Per-affiliate commission override** — No custom rates (high priority)
|
||||
3. **Self-referral override** — No admin whitelist (high priority)
|
||||
4. **Referral detail transparency** — Customer can't see order details (medium)
|
||||
|
||||
---
|
||||
|
||||
## Backend (PHP)
|
||||
|
||||
### Files Implemented
|
||||
|
||||
| File | Path | Purpose |
|
||||
|------|------|---------|
|
||||
| AffiliateModule.php | `/includes/Modules/Affiliate/` | Bootstrap & initialization |
|
||||
| AffiliateManager.php | `/includes/Modules/Affiliate/` | Database table creation |
|
||||
| AffiliateTracker.php | `/includes/Modules/Affiliate/` | Referral tracking & cookie handling |
|
||||
| AffiliateLifecycle.php | `/includes/Modules/Affiliate/` | Order status/cancellation handling |
|
||||
| AffiliateSettings.php | `/includes/Modules/Affiliate/` | Module settings schema |
|
||||
| AffiliateAdminController.php | `/includes/Api/Controllers/` | Admin API endpoints |
|
||||
| AffiliateCustomerController.php | `/includes/Api/Controllers/` | Customer API endpoints |
|
||||
|
||||
### Database Tables
|
||||
|
||||
#### `wp_woonoow_affiliates`
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint | Primary key |
|
||||
| user_id | bigint | Links to WP users |
|
||||
| referral_code | varchar(50) | Unique affiliate code |
|
||||
| coupon_id | bigint | Optional WC coupon link |
|
||||
| commission_rate | decimal(10,2) | Percentage |
|
||||
| status | varchar(20) | pending/active/rejected |
|
||||
| total_referrals | int | Counter |
|
||||
| total_earnings | decimal(19,4) | Accumulated earnings |
|
||||
| paid_earnings | decimal(19,4) | Paid out amount |
|
||||
| created_at | datetime | |
|
||||
| updated_at | datetime | Auto-update |
|
||||
|
||||
#### `wp_woonoow_referrals`
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint | Primary key |
|
||||
| affiliate_id | bigint | FK to affiliates |
|
||||
| order_id | bigint | WooCommerce order |
|
||||
| customer_id | bigint | Customer user ID |
|
||||
| commission_amount | decimal(19,4) | Calculated commission |
|
||||
| currency | varchar(10) | Default USD |
|
||||
| status | varchar(20) | pending/approved/rejected |
|
||||
| cancelled_reason | varchar(50) | WHY rejected |
|
||||
| cancelled_at | datetime | When cancelled |
|
||||
| created_at | datetime | |
|
||||
| approved_at | datetime | When approved |
|
||||
| paid_at | datetime | When paid |
|
||||
|
||||
#### `wp_woonoow_affiliate_payouts`
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint | Primary key |
|
||||
| affiliate_id | bigint | FK |
|
||||
| amount | decimal(19,4) | |
|
||||
| currency | varchar(10) | |
|
||||
| method | varchar(50) | Payment method |
|
||||
| status | varchar(20) | pending/completed |
|
||||
| notes | text | |
|
||||
| created_at | datetime | |
|
||||
| completed_at | datetime | |
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Admin Endpoints (`/woonoow/v1/admin/affiliates`)
|
||||
| Method | Endpoint | Function | Implemented |
|
||||
|--------|----------|----------|-------------|
|
||||
| GET | `/` | `get_affiliates()` | ✅ |
|
||||
| POST | `/(?P<id>\d+)/approve` | `approve_affiliate()` | ✅ |
|
||||
| GET | `/referrals` | `get_referrals()` | ✅ |
|
||||
| POST | `/payouts` | `create_payout()` | ✅ |
|
||||
|
||||
#### Customer Endpoints (`/woonoow/v1/account/affiliate`)
|
||||
| Method | Endpoint | Function | Implemented |
|
||||
|--------|----------|----------|-------------|
|
||||
| GET | `/` | `get_dashboard()` | ✅ |
|
||||
| POST | `/apply` | `apply_affiliate()` | ✅ |
|
||||
| GET | `/referrals` | `get_referrals()` | ✅ |
|
||||
|
||||
### Hooks & Tracking
|
||||
|
||||
| Hook | Handler | Status | Notes |
|
||||
|------|---------|--------|-------|
|
||||
| `init` | `capture_referral_link()` | ✅ | Captures `?ref=` from URL |
|
||||
| `woocommerce_checkout_order_processed` | `record_referral()` | ✅ | Legacy checkout |
|
||||
| `woocommerce_store_api_checkout_order_processed` | `record_referral_block()` | ✅ | Block checkout (WC 8.3+) |
|
||||
| `woocommerce_new_order` | `record_referral_new_order()` | ✅ | Universal fallback |
|
||||
| `woocommerce_process_shop_order_meta` | `record_referral_admin()` | ✅ | Admin order creation |
|
||||
| `woocommerce_order_status_refunded` | `handle_order_cancelled()` | ✅ | |
|
||||
| `woocommerce_order_status_cancelled` | `handle_order_cancelled()` | ✅ | |
|
||||
| `woocommerce_order_status_failed` | `handle_order_cancelled()` | ✅ | |
|
||||
| `before_delete_post` | `handle_order_deleted()` | ✅ | |
|
||||
| `woocommerce_delete_order` | `handle_order_deleted()` | ✅ | |
|
||||
|
||||
**Tracking Coverage:** All WooCommerce order creation paths are hooked. Cookie capture works on standard page loads.
|
||||
|
||||
### Email Notifications
|
||||
|
||||
| Event ID | Template | Status |
|
||||
|----------|----------|--------|
|
||||
| `affiliate_application_received` | Staff notification | ✅ |
|
||||
| `affiliate_application_approved` | Customer welcome | ✅ |
|
||||
| `affiliate_new_referral` | Commission earned | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Admin SPA
|
||||
|
||||
### Routes
|
||||
|
||||
| Route | Component | Implemented |
|
||||
|-------|-----------|-------------|
|
||||
| `/marketing/affiliates` | AffiliatesLayout | ✅ |
|
||||
| `/marketing/affiliates/list` | AffiliatesList | ✅ |
|
||||
| `/marketing/affiliates/referrals` | AffiliatesReferrals | ✅ |
|
||||
| `/marketing/affiliates/payouts` | AffiliatesPayouts | ⚠️ Placeholder |
|
||||
|
||||
### Features Implemented
|
||||
|
||||
#### AffiliatesList
|
||||
- Search by referral code
|
||||
- Display table: User ID, Code, Rate, Status, Earnings, Actions
|
||||
- Approve button for pending affiliates
|
||||
- Status badges (color-coded)
|
||||
|
||||
#### AffiliatesReferrals
|
||||
- Display all referrals table
|
||||
- Columns: ID, Affiliate ID, Order ID, Status, Date, Commission
|
||||
- Status badges
|
||||
|
||||
#### AffiliatesPayouts
|
||||
- **NOT IMPLEMENTED** - Shows "coming soon" placeholder
|
||||
|
||||
### Order Details Integration
|
||||
|
||||
The order detail page (`/orders/{id}`) now shows affiliate information:
|
||||
- Affiliate name
|
||||
- Commission rate
|
||||
- Commission amount
|
||||
- Status with cancellation reason
|
||||
|
||||
---
|
||||
|
||||
## Customer SPA
|
||||
|
||||
### Routes
|
||||
|
||||
| Route | Component | Implemented |
|
||||
|-------|-----------|-------------|
|
||||
| `/my-account/affiliate` | AffiliateDashboard | ✅ |
|
||||
|
||||
### Features Implemented
|
||||
|
||||
#### Application Flow
|
||||
- "Join Affiliate Program" card with Apply Now button
|
||||
- Pending status notification when awaiting approval
|
||||
|
||||
#### Dashboard (Active Affiliates)
|
||||
- Total Earnings card
|
||||
- Pending Earnings card
|
||||
- Commission Rate display
|
||||
- Referral link generator with copy button
|
||||
- Recent Referrals table
|
||||
|
||||
### Format Issues (Fixed)
|
||||
- Currency formatting now uses site's WooCommerce settings
|
||||
- IDR displays as `Rp19.900` (no decimals, thousand separator)
|
||||
- Other currencies use configured decimals
|
||||
|
||||
---
|
||||
|
||||
## Gaps & Defects
|
||||
|
||||
### Critical (Blocking)
|
||||
|
||||
#### 1. Payout System Not Functional
|
||||
**Location:** Admin & Backend
|
||||
**Severity:** Critical
|
||||
**Issue:** The payout creation API (`create_payout()`) exists but:
|
||||
- No UI to initiate payouts in Admin SPA (placeholder only)
|
||||
- No calculation of payable balance (`total_earnings - paid_earnings`)
|
||||
- No payout history view
|
||||
- No payment processing (only store_credit implemented)
|
||||
|
||||
**Impact:** Cannot pay affiliates — program is incomplete
|
||||
|
||||
**Required Fix:**
|
||||
```php
|
||||
// AffiliateAdminController.php
|
||||
public static function get_affiliate_balance($affiliate_id) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'woonoow_affiliates';
|
||||
$affiliate = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT total_earnings, paid_earnings FROM $table WHERE id = %d",
|
||||
$affiliate_id
|
||||
));
|
||||
return [
|
||||
'total_earnings' => (float) $affiliate->total_earnings,
|
||||
'paid_earnings' => (float) $affiliate->paid_earnings,
|
||||
'payable_balance' => (float) $affiliate->total_earnings - (float) $affiliate->paid_earnings
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Referral Link Hardcoded to `/shop`
|
||||
**Location:** Customer SPA - AffiliateDashboard.tsx
|
||||
**Severity:** Critical
|
||||
**Issue:**
|
||||
```typescript
|
||||
// Current (hardcoded)
|
||||
const referralLink = `${window.location.origin}${window.location.pathname.replace('/my-account/affiliate', '/shop')}?ref=${profile.referral_code}`;
|
||||
```
|
||||
|
||||
- Assumes shop page exists at `/shop`
|
||||
- Doesn't check site's actual SPA mode or base path
|
||||
- May 404 or redirect incorrectly on different configurations
|
||||
|
||||
**Required Fix:**
|
||||
```typescript
|
||||
// Get from WooNooW config
|
||||
const basePath = (window as any).woonoowCustomer?.basePath || '';
|
||||
const shopPath = basePath + '/shop'; // or wc_get_page_id('shop') permalink
|
||||
```
|
||||
|
||||
### High (Important)
|
||||
|
||||
#### 3. No Cookie Capture on Block Checkout
|
||||
**Location:** Backend - AffiliateTracker.php
|
||||
**Severity:** High
|
||||
**Issue:** `capture_referral_link()` only fires on `init`. Block-based checkout may:
|
||||
- Load via AJAX without full page reload
|
||||
- Use Store API without triggering WordPress `init`
|
||||
- Miss the `?ref=` parameter entirely
|
||||
|
||||
**Impact:** Some block checkout flows may lose referral attribution
|
||||
|
||||
**Required Fix:**
|
||||
Referral attribution must remain reliable across all WooCommerce checkout variants and navigation patterns. The persistence mechanism (cookie, localStorage, server-side session) is secondary to ensuring the `?ref=` parameter is captured and associated with the order regardless of how the customer navigates to checkout.
|
||||
|
||||
```php
|
||||
// AffiliateTracker.php - Ensure referral is captured before order creation
|
||||
// Option 1: Check for ref in session/storage on order hooks
|
||||
// Option 2: Ensure PHP session captures ref via frontend AJAX call
|
||||
// Option 3: Store ref in order meta during checkout for guaranteed attribution
|
||||
```
|
||||
|
||||
#### 4. Self-Referral Block Has No Override
|
||||
**Location:** Backend - AffiliateTracker.php:133-135
|
||||
**Severity:** High
|
||||
**Issue:**
|
||||
```php
|
||||
if ((int)$order->get_user_id() === (int)$affiliate->user_id) {
|
||||
return; // Blocks all self-referrals
|
||||
}
|
||||
```
|
||||
|
||||
No admin option to:
|
||||
- Allow self-referrals for testing
|
||||
- Whitelist specific affiliate-customer pairs
|
||||
- Override after verification
|
||||
|
||||
**Impact:** Cannot test with same account (must use different customer account)
|
||||
|
||||
#### 5. No Per-Affiliate Commission Override
|
||||
**Location:** Backend + Database
|
||||
**Severity:** High
|
||||
**Issue:**
|
||||
- All affiliates use global `woonoow_affiliate_default_rate` setting
|
||||
- No ability to set custom rate per affiliate
|
||||
- No ability to override for special cases
|
||||
|
||||
**Impact:** Limited merchant flexibility for VIP affiliates
|
||||
|
||||
### Medium (Should Fix)
|
||||
|
||||
#### 6. No Referral Filtering in Admin
|
||||
**Location:** Admin SPA - Referrals.tsx
|
||||
**Severity:** Medium
|
||||
**Issue:** AffiliatesReferrals shows ALL referrals. No:
|
||||
- Filter by affiliate
|
||||
- Filter by date range
|
||||
- Filter by status
|
||||
- Filter by order
|
||||
|
||||
**Impact:** Hard to track specific affiliate performance
|
||||
|
||||
#### 7. Affiliate Cannot See Referral Details
|
||||
**Location:** Customer SPA - AffiliateDashboard.tsx
|
||||
**Severity:** Medium
|
||||
**Issue:** Dashboard shows order ID and commission only. No:
|
||||
- Order date
|
||||
- Product details
|
||||
- Commission status explanation
|
||||
- When payment will be released
|
||||
|
||||
**Impact:** Low trust due to lack of transparency
|
||||
|
||||
#### 8. Commission Based on Subtotal Only
|
||||
**Location:** Backend - AffiliateTracker.php:139-144
|
||||
**Severity:** Medium
|
||||
**Issue:**
|
||||
```php
|
||||
$subtotal = (float)$order->get_subtotal();
|
||||
$commission_amount = ($subtotal * $commission_rate) / 100;
|
||||
```
|
||||
|
||||
- Does not account for taxes
|
||||
- Does not account for shipping
|
||||
- Does not account for discounts
|
||||
|
||||
**Impact:** Affiliates may dispute calculated amounts (requires clear documentation)
|
||||
|
||||
### Low (Nice to Have)
|
||||
|
||||
#### 9. No Admin Dashboard Metrics
|
||||
**Location:** Admin SPA
|
||||
**Severity:** Low
|
||||
**Issue:** No summary cards showing:
|
||||
- Total affiliates count
|
||||
- Active vs pending breakdown
|
||||
- Total commission paid
|
||||
- Conversion rate
|
||||
|
||||
**Impact:** Hard to assess program health at a glance
|
||||
|
||||
#### 10. No Email Template Customization
|
||||
**Location:** Backend - Email System
|
||||
**Severity:** Low
|
||||
**Issue:**
|
||||
- Templates hardcoded in DefaultTemplates.php
|
||||
- No admin settings for customization
|
||||
- No branding options
|
||||
|
||||
**Impact:** Limited to default styling
|
||||
|
||||
---
|
||||
|
||||
## Opportunities
|
||||
|
||||
### Priority 1: Operational Completeness (Must-Have)
|
||||
|
||||
These features make the affiliate module operationally functional — without them, merchants cannot run a real affiliate program.
|
||||
|
||||
#### 1. Complete Payout System
|
||||
**Status:** Placeholder (0% implemented)
|
||||
**Scope:** Backend + Admin SPA
|
||||
|
||||
Payout is not a secondary detail — it is the final proof that the system works. Merchants need to be able to:
|
||||
|
||||
- [ ] Calculate payable balance per affiliate (`total_earnings - paid_earnings`)
|
||||
- [ ] View payout history per affiliate
|
||||
- [ ] Create payouts with amount input (not auto-all)
|
||||
- [ ] Support multiple payout methods (Bank Transfer, PayPal, Store Credit)
|
||||
- [ ] Mark payouts as pending → completed
|
||||
- [ ] Send payout notification email to affiliate
|
||||
|
||||
**API required:**
|
||||
- `GET /admin/affiliates/payouts` - List all payouts
|
||||
- `GET /admin/affiliates/{id}/balance` - Get payable balance
|
||||
- `POST /admin/affiliates/{id}/payout` - Create payout
|
||||
|
||||
#### 2. Attribution Reliability & Tracking Rules
|
||||
**Status:** Partial (works for most cases)
|
||||
**Scope:** Backend
|
||||
|
||||
Silent attribution failures damage trust quickly. Need to ensure:
|
||||
|
||||
- [ ] Referral link adapts to site's actual SPA structure (not hardcoded `/shop`)
|
||||
- [ ] Cookie capture works for AJAX/block checkout flows (not just page reload)
|
||||
- [ ] Referral codes are case-insensitive during lookup
|
||||
- [ ] Invalid/expired referral codes handled gracefully (no errors)
|
||||
|
||||
**Technical approach:**
|
||||
```php
|
||||
// Dynamic referral link generation
|
||||
$shop_page = get_permalink(wc_get_page_id('shop'));
|
||||
$referral_link = add_query_arg('ref', $referral_code, $shop_page);
|
||||
```
|
||||
|
||||
#### 3. Commission Management & Overrides
|
||||
**Status:** Global rate only (no per-affiliate override)
|
||||
**Scope:** Backend + Admin SPA
|
||||
|
||||
Merchants need granular control:
|
||||
|
||||
- [ ] Set custom commission rate per affiliate (override global default)
|
||||
- [ ] Manually adjust commission amount per referral
|
||||
- [ ] Set custom holding period per affiliate
|
||||
- [ ] Mark referral as "Manual Adjustment" with audit trail
|
||||
- [ ] Commission calculation rules (subtotal vs total, include/exclude tax, etc.)
|
||||
|
||||
**Database consideration:**
|
||||
Add `custom_commission_rate` column to `wp_woonoow_affiliates` (nullable, uses default if null).
|
||||
|
||||
---
|
||||
|
||||
### Priority 2: Transparency & Trust (Should-Have)
|
||||
|
||||
These features build trust with both merchants and affiliates through visibility and control.
|
||||
|
||||
#### 4. Admin Performance Dashboard
|
||||
**Status:** Basic list only (no metrics)
|
||||
**Scope:** Admin SPA
|
||||
|
||||
Merchants need to assess program health:
|
||||
|
||||
- [ ] Summary cards: Total Affiliates, Active vs Pending, Total Commission (approved), Unpaid Commission
|
||||
- [ ] Conversion rate: Applications vs Approved
|
||||
- [ ] Top performing affiliates
|
||||
- [ ] Monthly earnings trend chart
|
||||
- [ ] Export to CSV/Excel
|
||||
|
||||
#### 5. Customer Dashboard Transparency
|
||||
**Status:** Basic (earnings + recent referrals)
|
||||
**Scope:** Customer SPA
|
||||
|
||||
Affiliates need to trust the system:
|
||||
|
||||
- [ ] Referral status breakdown (pending approval vs approved vs paid)
|
||||
- [ ] "When will I get paid?" indicator (holding period countdown)
|
||||
- [ ] Referral detail view: which order, what commission, current status
|
||||
- [ ] Payout history view (coming when payout system is complete)
|
||||
|
||||
**Example referral detail card:**
|
||||
```
|
||||
Order #476 • May 30, 2026
|
||||
Commission: Rp19.900
|
||||
Status: Pending (3 days until approval)
|
||||
Product: Design Mockup Bundle
|
||||
```
|
||||
|
||||
#### 6. Trust & Compliance Layer
|
||||
**Status:** Basic self-referral check only
|
||||
**Scope:** Backend (core trust layer, not nice-to-have)
|
||||
|
||||
Affiliate systems are always exposed to abuse. Define anti-fraud rules early:
|
||||
|
||||
- [ ] Self-referral block with admin exception/override
|
||||
- [ ] Suspicious order flagging (unusually high value from new customer)
|
||||
- [ ] Rate limiting on referral code generation per IP
|
||||
- [ ] Audit log for manual commission adjustments
|
||||
- [ ] Clear commission rules documentation (what's included/excluded)
|
||||
|
||||
**Database consideration:**
|
||||
Add `fraud_score` column to `wp_woonoow_referrals` for future ML-based scoring.
|
||||
|
||||
---
|
||||
|
||||
### Priority 3: Scalability Features (Nice-to-Have)
|
||||
|
||||
These enable growth but are not blocking for initial launch.
|
||||
|
||||
#### 7. Enhanced Referral Tracking
|
||||
**Scope:** Backend
|
||||
|
||||
- [ ] Track referral source (email, social, direct, paid)
|
||||
- [ ] Store click data (IP, user agent, timestamp)
|
||||
- [ ] UTM parameter capture
|
||||
- [ ] Cross-device tracking (logged-in users)
|
||||
|
||||
#### 8. Referral Filtering & Search
|
||||
**Scope:** Admin SPA
|
||||
|
||||
- [ ] Filter by affiliate
|
||||
- [ ] Filter by date range
|
||||
- [ ] Filter by status (pending/approved/rejected)
|
||||
- [ ] Filter by order ID
|
||||
- [ ] Search by customer email (anonymized display)
|
||||
|
||||
#### 9. Affiliate Self-Service Portal
|
||||
**Scope:** Customer SPA
|
||||
|
||||
- [ ] Update payment details (bank account, PayPal)
|
||||
- [ ] View payout history
|
||||
- [ ] Download referral reports (CSV)
|
||||
- [ ] Tax document download (1099 placeholder)
|
||||
|
||||
---
|
||||
|
||||
### Future Roadmap (Post-MVP)
|
||||
|
||||
These are growth features, not near-term priorities. Merchants care about correctness first.
|
||||
|
||||
#### Multi-Tier Commissions
|
||||
- Volume-based tiers (10% for 0-10 referrals, 15% for 11-50, etc.)
|
||||
- Requires: Tier configuration UI, recalculation on new referral
|
||||
|
||||
#### Integration Ecosystem
|
||||
- WooCommerce Currency Switcher compatibility
|
||||
- Export to accounting software (Xero, QuickBooks)
|
||||
- Zapier/Make integrations for automation
|
||||
|
||||
#### Gamification
|
||||
- Achievement badges (First Referral, Top Performer)
|
||||
- Leaderboards
|
||||
- Referral contests with goals and rewards
|
||||
|
||||
#### MLM Structure
|
||||
- Multi-level marketing (not recommended for most WooCommerce setups)
|
||||
- Keep lower priority unless specifically requested
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### This Sprint (Operational Core)
|
||||
|
||||
| Task | Priority | Effort | Impact |
|
||||
|------|----------|--------|--------|
|
||||
| Fix referral link path (dynamic) | Critical | Low | Reliability |
|
||||
| Implement payout balance calculation | Critical | Medium | Completeness |
|
||||
| Add payout creation UI | Critical | Medium | Completeness |
|
||||
| Add self-referral admin override | High | Low | Trust |
|
||||
| Add referral filtering in admin | Medium | Medium | Usability |
|
||||
|
||||
### Next Sprint (Transparency)
|
||||
|
||||
| Task | Priority | Effort | Impact |
|
||||
|------|----------|--------|--------|
|
||||
| Admin performance dashboard | High | Medium | Visibility |
|
||||
| Customer referral detail view | High | Medium | Trust |
|
||||
| Per-affiliate commission override | High | Medium | Control |
|
||||
| Commission rules configuration | Medium | High | Flexibility |
|
||||
|
||||
### Future (Growth)
|
||||
|
||||
- UTM/source tracking
|
||||
- Affiliate self-service portal
|
||||
- Integration ecosystem
|
||||
- Multi-tier commissions
|
||||
|
||||
---
|
||||
|
||||
## File Checklist
|
||||
|
||||
| Path | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `/includes/Modules/Affiliate/AffiliateModule.php` | PHP | Module bootstrap |
|
||||
| `/includes/Modules/Affiliate/AffiliateManager.php` | PHP | DB tables |
|
||||
| `/includes/Modules/Affiliate/AffiliateTracker.php` | PHP | Tracking logic |
|
||||
| `/includes/Modules/Affiliate/AffiliateLifecycle.php` | PHP | Order lifecycle |
|
||||
| `/includes/Modules/Affiliate/AffiliateSettings.php` | PHP | Settings schema |
|
||||
| `/includes/Api/Controllers/AffiliateAdminController.php` | PHP | Admin API |
|
||||
| `/includes/Api/Controllers/AffiliateCustomerController.php` | PHP | Customer API |
|
||||
| `/admin-spa/src/routes/Marketing/Affiliates/index.tsx` | TSX | Admin layout |
|
||||
| `/admin-spa/src/routes/Marketing/Affiliates/List.tsx` | TSX | Affiliate list |
|
||||
| `/admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx` | TSX | Referral list |
|
||||
| `/admin-spa/src/routes/Marketing/Affiliates/Payouts.tsx` | TSX | Payouts (placeholder) |
|
||||
| `/admin-spa/src/routes/Orders/Detail.tsx` | TSX | Order affiliate info |
|
||||
| `/customer-spa/src/pages/Account/AffiliateDashboard.tsx` | TSX | Customer dashboard |
|
||||
| `/customer-spa/src/pages/Account/index.tsx` | TSX | Account routes |
|
||||
| `/customer-spa/src/pages/Account/components/AccountLayout.tsx` | TSX | Menu integration |
|
||||
|
||||
---
|
||||
|
||||
*Report Version: 1.2*
|
||||
*Last Updated: 2026-05-31*
|
||||
*Generated by: Claude Code*
|
||||
*Review Status: Final - Reviewed and validated*
|
||||
83
AFFILIATE_PROGRAM_ENRICHMENT.md
Normal file
83
AFFILIATE_PROGRAM_ENRICHMENT.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Affiliate Link Enrichment Brief
|
||||
|
||||
## Overview
|
||||
Affiliate Link Enrichment is a feature set that helps affiliates choose the most suitable destination for each campaign instead of relying only on a homepage link or a single-product link. The goal is to improve click quality, reduce mismatch between content and landing page, and increase the chance that visitors find a relevant product path quickly.[cite:36][cite:46][cite:53]
|
||||
|
||||
This feature is designed for e-commerce platforms, affiliate plugins, and marketplace-adjacent tools that want to support better traffic routing without requiring marketplace-scale data. It sits between simple deep linking and advanced smart-link systems by giving affiliates more structured, intent-aware link options.[cite:36][cite:50][cite:58]
|
||||
|
||||
## Problem
|
||||
Traditional affiliate links usually fall into two extremes: a generic homepage or catalog link, or a deep link to one specific product. Homepage links create distraction because visitors must browse again from the top, while single-product links can fail when the visitor's intent is adjacent to, but not exactly aligned with, the promoted item.[cite:53][cite:46]
|
||||
|
||||
This problem becomes more obvious in social content, where one post often attracts mixed intent. A viewer may like the category, style, or use case presented in the content, but not the exact product chosen by the affiliate, which creates unnecessary bounce risk.[cite:46][cite:48]
|
||||
|
||||
## Product Idea
|
||||
Affiliate Link Enrichment should let affiliates generate links based on **intent shape**, not only based on URL type. Instead of asking, "Do you want a homepage link or a product link?", the product should ask, "What kind of buying intent does your content create?" and then recommend the right destination model.[cite:36][cite:46]
|
||||
|
||||
The core idea is to support several enriched destination types:
|
||||
- Single Product Link, for highly specific review or promo content.[cite:36]
|
||||
- Category Link, for broader topical discovery within one product family.[cite:53]
|
||||
- Curated Collection Link, for themed sets of relevant products selected by the affiliate.[cite:21][cite:46]
|
||||
- Controlled Smart Rotation, for rotating a small pool of relevant products inside the same intent group, not across unrelated products.[cite:50][cite:58]
|
||||
|
||||
## Positioning
|
||||
The tone of this feature should be practical, conversion-oriented, and creator-friendly. It is not framed as "AI magic" or "full automation," but as a smarter way to match content intent with the right shopping destination.[cite:46][cite:53]
|
||||
|
||||
Affiliate Link Enrichment should be positioned as a conversion support layer for affiliates who need more flexibility than a standard deep link, but who do not have enough volume or data to run a true network-grade smart-link engine.[cite:36][cite:50][cite:55]
|
||||
|
||||
## User Segments
|
||||
Primary users include social-media affiliates, content creators, bloggers, and store partners who promote products through short-form video, reviews, listicles, and themed recommendations. These users often need one shareable link that still preserves relevance across varied audience preferences.[cite:21][cite:31][cite:46]
|
||||
|
||||
Secondary users include merchants and WordPress store owners who run their own affiliate programs and want to help affiliates convert better without sending traffic into an unstructured storefront. For these users, enrichment is also a program-design tool, not only a link-generation tool.[cite:65][cite:67][cite:76]
|
||||
|
||||
## Use Cases
|
||||
| Content scenario | Visitor intent | Recommended link type | Why it fits |
|
||||
|---|---|---|---|
|
||||
| Single product review | Very specific | Single Product Link | Sends visitors directly to the exact item discussed.[cite:36] |
|
||||
| "Best X for Y" content | Comparative but focused | Curated Collection Link | Preserves relevance while giving the visitor choice.[cite:21][cite:46] |
|
||||
| Broad topical content | Exploratory | Category Link | Supports browsing within the same intent family.[cite:53] |
|
||||
| Repeat campaigns with similar products | Semi-predictable | Controlled Smart Rotation | Tests alternatives without breaking relevance.[cite:50][cite:58] |
|
||||
| Link in bio or creator storefront | Mixed but branded | Collection Link / Bio Link | Gives one stable destination for multiple relevant offers.[cite:31][cite:46] |
|
||||
|
||||
## Feature Principles
|
||||
The feature should follow five principles:
|
||||
|
||||
1. **Intent first**: recommend links based on the campaign's promise, not just on object type.[cite:46][cite:53]
|
||||
2. **Relevance guardrails**: any rotation must stay inside a tightly defined product pool.[cite:50][cite:58]
|
||||
3. **Choice without overload**: collection pages should offer enough alternatives to reduce bounce, but not so many that users feel dropped into a marketplace homepage again.[cite:46][cite:48]
|
||||
4. **Trackability**: every enriched link should still support product-level or group-level attribution through sub IDs, click IDs, or similar campaign parameters.[cite:36][cite:56]
|
||||
5. **Progressive sophistication**: stores should be able to start with curated collections and only later add controlled rotation or rule-based routing.[cite:50][cite:55]
|
||||
|
||||
## Functional Scope
|
||||
A minimum viable version should include:
|
||||
- Link type recommendation based on campaign goal.
|
||||
- Manual creation of curated collection pages.
|
||||
- Optional product grouping by theme, use case, audience, or price band.
|
||||
- Link-level tracking fields such as source, campaign, content format, and creator tag.[cite:36][cite:56]
|
||||
|
||||
A more advanced version can include:
|
||||
- Rule-based routing by device, location, traffic source, or stock status.[cite:32][cite:58]
|
||||
- Controlled rotation inside a relevant product cluster.[cite:50]
|
||||
- Best-pick pinning, where one featured product stays fixed while alternatives rotate below it.[cite:46]
|
||||
- Collection-page templates optimized for mobile affiliate traffic.[cite:46][cite:48]
|
||||
|
||||
## UX Guidance
|
||||
The user experience should help affiliates decide quickly which link model to use. A simple decision flow works best: "Are you promoting one exact product, one topic, or a curated set?" That framing is easier to understand than exposing technical terms such as deep link, category path, or routing logic at the start.[cite:36][cite:46]
|
||||
|
||||
Collection pages should behave more like decision pages than full storefronts. They need a clear headline, one featured recommendation, a limited set of relevant alternatives, lightweight filters, and strong mobile-first call-to-action placement.[cite:46][cite:48][cite:49]
|
||||
|
||||
## Success Metrics
|
||||
Success should be measured by both conversion performance and creator adoption. Core metrics include click-through rate to merchant pages, product-page engagement, bounce reduction versus homepage links, conversion rate by link type, and earnings per click for each enriched-link model.[cite:36][cite:50][cite:56]
|
||||
|
||||
Product-level learning is also important. The system should reveal which campaign types perform best with single-product links, which benefit from curated collections, and when controlled rotation adds value instead of introducing noise.[cite:36][cite:50]
|
||||
|
||||
## Messaging Examples
|
||||
Suggested product-language examples:
|
||||
|
||||
- "Match each campaign to the right destination."
|
||||
- "Give visitors a better path than a generic storefront."
|
||||
- "Turn one affiliate post into a relevant shopping journey."
|
||||
- "Offer choice without losing intent."
|
||||
- "Enrich every affiliate link with context, structure, and better conversion potential."
|
||||
|
||||
## Strategic Summary
|
||||
Affiliate Link Enrichment is best presented as a practical bridge between basic affiliate linking and advanced traffic-routing systems. It helps smaller e-commerce programs support better affiliate outcomes by turning raw links into intent-aware destinations that are easier to click, easier to shop, and easier to optimize over time.[cite:36][cite:46][cite:50]
|
||||
52
AFFILIATE_PROGRAM_ENRICHMENT_IMPLEMENTATION_PLAN.md
Normal file
52
AFFILIATE_PROGRAM_ENRICHMENT_IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Affiliate Link Enrichment - Implementation Plan
|
||||
|
||||
This document outlines the phased implementation plan for the Affiliate Link Enrichment feature, based on the `AFFILIATE_PROGRAM_ENRICHMENT.md` brief and the current state of the WooNooW project.
|
||||
|
||||
## 1. Current State of the Project
|
||||
|
||||
- **Frontend (`customer-spa/src/pages/Account/AffiliateDashboard.tsx`)**: The affiliate dashboard currently only provides a **single, generic storefront link**: `site.com/shop?ref=YOUR_CODE`. This creates the "generic storefront" problem described in the brief.
|
||||
- **Backend (`includes/Modules/Affiliate/AffiliateTracker.php`)**: The backend is highly prepared. The tracking logic natively intercepts **any** page visit that has the `?ref=` parameter. Furthermore, it already captures UTM parameters (`utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `referrer_url`) and stores them in the `woonoow_ref_utm` cookie, which are eventually saved to the `woonoow_referrals` table when an order is completed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Immediate Opportunities (Low Effort, High Impact)
|
||||
|
||||
Because the backend already tracks referrals across the entire site and captures campaign parameters, Phase 1 can be implemented strictly via **Frontend (SPA) changes**, requiring no backend database migrations.
|
||||
|
||||
### 1.1 Implement a "Link Generator" UI (Customer SPA)
|
||||
Add a new "Link Builder" section in the Affiliate Dashboard (`AffiliateDashboard.tsx`) where affiliates can:
|
||||
- **Single Product Link**: Select a specific product (via a dropdown/search) to generate a link like `/product/slug?ref=CODE`.
|
||||
- **Category Link**: Select a category to generate a link like `/shop?category=slug&ref=CODE`.
|
||||
- **Campaign Tracking**: Add custom Campaign tags (`utm_campaign`, `utm_source`, etc.) to the generated links.
|
||||
|
||||
### 1.2 Campaign Analytics UI
|
||||
Since the `woonoow_referrals` table already stores UTM data:
|
||||
- **Affiliate Dashboard**: Update the UI to show earnings/clicks grouped by `utm_campaign` or `utm_source`.
|
||||
- **Admin SPA**: Add reports in the admin area to view referral performance by campaign, fulfilling the trackability requirement.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Medium-Term Opportunities (Curated Collections)
|
||||
|
||||
To fulfill the "Curated Collection Link" requirement (where an affiliate groups a themed set of relevant products), we need to build a new feature:
|
||||
|
||||
### 2.1 Backend Development
|
||||
- Create a new custom database table or Custom Post Type (e.g., `woonoow_affiliate_collections`) that maps an Affiliate ID to a list of Product IDs, along with a title and description.
|
||||
- Create REST API endpoints for affiliates to CRUD their collections.
|
||||
|
||||
### 2.2 Frontend Development
|
||||
- **Affiliate Dashboard**: Add a "My Collections" manager where affiliates can pick products, set a title, and generate a specific collection link.
|
||||
- **Storefront**: Add a new dynamic route to the customer-spa (e.g., `/shop/collection/:collection_id?ref=CODE`) that fetches and displays only the specific products in that collection.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Advanced Opportunities (Smart Rotation)
|
||||
|
||||
To support "Controlled Smart Rotation" (rotating a small pool of products):
|
||||
|
||||
### 3.1 Smart Router API Endpoint
|
||||
- Create a lightweight REST API endpoint (e.g., `/wp-json/woonoow/v1/go/:rotation_id`).
|
||||
- When a visitor clicks this link, the backend briefly evaluates the rotation rules, applies the `?ref=` and UTM cookies server-side, and does a 302 redirect to the chosen product page.
|
||||
|
||||
### 3.2 UI/UX
|
||||
- Add configuration settings in the Affiliate Dashboard to set up these rule-based or random rotation links.
|
||||
@@ -1,6 +1,6 @@
|
||||
# WooNooW Feature Roadmap - 2025
|
||||
|
||||
**Last Updated**: December 31, 2025
|
||||
**Last Updated**: June 1, 2026
|
||||
**Status**: Active Development
|
||||
|
||||
This document outlines the comprehensive feature roadmap for WooNooW, building upon existing infrastructure.
|
||||
@@ -231,29 +231,41 @@ Referral tracking and commission management system.
|
||||
|
||||
#### 1. Database Tables
|
||||
```sql
|
||||
wp_woonoow_affiliates (id, user_id, referral_code, commission_rate, status, total_referrals, total_earnings, paid_earnings)
|
||||
wp_woonoow_referrals (id, affiliate_id, order_id, customer_id, commission_amount, status, created_at, approved_at, paid_at)
|
||||
wp_woonoow_affiliate_payouts (id, affiliate_id, amount, method, status, notes, created_at, completed_at)
|
||||
wp_woonoow_affiliates (id, user_id, referral_code, coupon_id, commission_rate, status, total_referrals, total_earnings, paid_earnings)
|
||||
wp_woonoow_referrals (id, affiliate_id, order_id, customer_id, commission_amount, currency, status, created_at, approved_at, paid_at)
|
||||
wp_woonoow_affiliate_payouts (id, affiliate_id, amount, currency, method, status, notes, created_at, completed_at)
|
||||
```
|
||||
|
||||
#### 2. Tracking System
|
||||
```php
|
||||
class AffiliateTracker {
|
||||
|
||||
// Set cookie for 30 days
|
||||
// Set secure cookie for 30 days (SameSite=Lax)
|
||||
public function track_referral($referral_code) {
|
||||
setcookie('woonoow_ref', $referral_code, time() + (30 * DAY_IN_SECONDS));
|
||||
// Must check WooCommerce cookie consent first
|
||||
$options = [
|
||||
'expires' => time() + (30 * DAY_IN_SECONDS),
|
||||
'path' => '/',
|
||||
'secure' => is_ssl(),
|
||||
'samesite' => 'Lax'
|
||||
];
|
||||
setcookie('woonoow_ref', $referral_code, $options);
|
||||
}
|
||||
|
||||
// Record on order completion
|
||||
// Record as 'pending' on order creation/payment
|
||||
public function record_referral($order_id) {
|
||||
if (isset($_COOKIE['woonoow_ref'])) {
|
||||
// Get affiliate by code
|
||||
// Calculate commission
|
||||
// Create referral record
|
||||
// Clear cookie
|
||||
if (isset($_COOKIE['woonoow_ref']) || $this->has_affiliate_coupon($order_id)) {
|
||||
// Get affiliate by code or coupon
|
||||
// Calculate commission (on subtotal, excluding tax/shipping)
|
||||
// Create referral record with 'pending' status
|
||||
// ActionScheduler: Schedule auto-approval in 14 days
|
||||
}
|
||||
}
|
||||
|
||||
// Handle order refunds/cancellations
|
||||
public function handle_order_refund($order_id) {
|
||||
// Cancel/revert pending referral
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -275,8 +287,9 @@ class AffiliateTracker {
|
||||
|
||||
#### 5. Notification Events
|
||||
- `affiliate_application_approved`
|
||||
- `affiliate_referral_completed`
|
||||
- `affiliate_referral_received` (Pending Approval)
|
||||
- `affiliate_payout_processed`
|
||||
- `affiliate_threshold_reached` (Admin Alert)
|
||||
|
||||
### Priority: **Medium** 🟡
|
||||
### Effort: 3-4 weeks
|
||||
@@ -288,66 +301,59 @@ class AffiliateTracker {
|
||||
### Overview
|
||||
Recurring product subscriptions with flexible billing cycles.
|
||||
|
||||
### Status: **Planning** 🔵
|
||||
### Status: **Shipped** ✅
|
||||
|
||||
### What's Already Built
|
||||
- ✅ Product management
|
||||
- ✅ Order system
|
||||
- ✅ Payment gateways
|
||||
- ✅ Notification system
|
||||
- ✅ Database tables (`wp_woonoow_subscriptions`, `wp_woonoow_subscription_orders`) — schema below reflects actual shipped columns
|
||||
- ✅ Per-gateway auto-renew capability table (kill-switchable)
|
||||
- ✅ Pause/resume/cancel/early-renew customer UI
|
||||
- ✅ Admin list with bulk actions, search, and per-status filter
|
||||
- ✅ Renewal cron (`process_renewals`, `check_expirations`, `send_reminders`, `retry_unpaid_renewals`)
|
||||
|
||||
### What's Needed
|
||||
### Schema (as shipped)
|
||||
|
||||
#### 1. Database Tables
|
||||
```sql
|
||||
wp_woonoow_subscriptions (id, customer_id, product_id, status, billing_period, billing_interval, price, next_payment_date, start_date, end_date, trial_end_date)
|
||||
wp_woonoow_subscription_orders (id, subscription_id, order_id, payment_status, created_at)
|
||||
wp_woonoow_subscriptions (
|
||||
id, user_id, order_id, product_id, variation_id, status,
|
||||
billing_period, billing_interval, recurring_amount,
|
||||
start_date, trial_end_date, next_payment_date, end_date, last_payment_date,
|
||||
payment_method, payment_meta, cancel_reason,
|
||||
pause_count, failed_payment_count, reminder_sent_at
|
||||
)
|
||||
wp_woonoow_subscription_orders (
|
||||
id, subscription_id, order_id, order_type ENUM 'parent'|'renewal'|'switch'|'resubscribe'
|
||||
)
|
||||
```
|
||||
|
||||
#### 2. Product Meta
|
||||
Add subscription options to product:
|
||||
- Is subscription product (checkbox)
|
||||
- Billing period (daily, weekly, monthly, yearly)
|
||||
- Billing interval (e.g., 2 for every 2 months)
|
||||
- Trial period (days)
|
||||
Note: the column is `user_id`, not `customer_id` — the original spec used the
|
||||
WC-style "customer" naming, but WP schema reserves `customer` for the legacy
|
||||
WP customer user role and the column was renamed before the first migration
|
||||
shipped.
|
||||
|
||||
#### 3. Renewal System
|
||||
```php
|
||||
class SubscriptionRenewal {
|
||||
|
||||
// WP-Cron daily job
|
||||
public function process_renewals() {
|
||||
$due_subscriptions = $this->get_due_subscriptions();
|
||||
|
||||
foreach ($due_subscriptions as $subscription) {
|
||||
// Create renewal order
|
||||
// Process payment
|
||||
// Update next payment date
|
||||
// Send notification
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Customer Dashboard
|
||||
### Customer Dashboard
|
||||
**Route**: `/account/subscriptions`
|
||||
- Active subscriptions list
|
||||
- Pause/resume subscription
|
||||
- Pause/resume subscription (capped at `max_pause_count` setting, default 3)
|
||||
- Cancel subscription
|
||||
- Update payment method
|
||||
- View billing history
|
||||
- Change billing cycle
|
||||
|
||||
#### 5. Admin UI
|
||||
**Route**: `/products/subscriptions`
|
||||
- All subscriptions list
|
||||
- Filter by status
|
||||
- View subscription details
|
||||
- Manual renewal
|
||||
### Admin UI
|
||||
**Route**: `/subscriptions`
|
||||
- All subscriptions list with checkbox + bulk actions (cancel, CSV export)
|
||||
- Free-text search by id / email / display name
|
||||
- Per-status filter
|
||||
- View subscription details (per-gateway auto-renew badge, pause count)
|
||||
- Renew Now (creates manual order) or Charge Now (forces auto-debit, M2)
|
||||
- Cancel/refund
|
||||
|
||||
### Priority: **Low** 🟢
|
||||
### Effort: 4-5 weeks
|
||||
### Priority: ~~Low~~ Shipped ✅
|
||||
### Effort: ~~4-5 weeks~~ Shipped
|
||||
|
||||
---
|
||||
|
||||
|
||||
727
SUBSCRIPTION_MODULE_AUDIT.md
Normal file
727
SUBSCRIPTION_MODULE_AUDIT.md
Normal file
@@ -0,0 +1,727 @@
|
||||
# Subscription Module — Audit Report
|
||||
|
||||
**Date:** 2026-06-01
|
||||
**Scope:** WooNooW plugin — Product Subscriptions module
|
||||
**Auditor:** AI-assisted code review (full trace of `woonoow/` folder)
|
||||
**Reference:** [Prior audit 2026-01-29](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/.agent/reports/subscription-flow-audit-2026-01-29.md) — 2 critical / 5 warning / 4 info issues, all critical & warning fixed.
|
||||
**Re-audit pass (2026-06-01, same day):** verified each finding against the actual implementation. Result below.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
The subscription module is **functionally complete** and follows the documented pattern from the prior audit. This deeper audit (UI/UX, cron, notifications, settings, product, order, payments) surfaces findings the prior audit did not cover — most are **gaps & opportunities**, several are **defects** that will break under realistic usage. **Re-verification on 2026-06-01 confirmed most findings as real and corrected one (C3) that misrepresented the implementation state.**
|
||||
|
||||
| Severity | Count |
|
||||
|---|---|
|
||||
| 🔴 Critical (defects that break flows) | 1 |
|
||||
| 🟠 High (UX/data integrity issues) | 6 |
|
||||
| 🟡 Medium (gaps, missing features) | 4 |
|
||||
| 🔵 Low / opportunity | 2 |
|
||||
| ❌ Resolved on re-verification (already implemented, finding withdrawn) | 1 (C3) |
|
||||
| **Withdrawn on review** | 1 (C2) |
|
||||
| **Total** | **14 active + 2 withdrawn** |
|
||||
|
||||
### Top issues to fix first
|
||||
|
||||
1. **Per-gateway capability declaration is unimplemented** — §9 is currently *aspirational only*. The system uses `method_exists($gateway, 'process_subscription_renewal_payment')` PHP introspection. There is no capability table, no admin UI, no kill switch. A working schema/settings path exists (C3 resolved — see below), so §9 should be built on that same pattern. Effort: M.
|
||||
2. **Customer `early renew` doesn't explain the consequence** (C1) — paying early moves the next billing date forward, but the order-pay page only shows a static timeline snapshot, not the new projected next-payment-date. Effort: S.
|
||||
3. **Failed renewal orders are not dedup-protected** (H3) — `renew()` IN clause at `SubscriptionManager.php:527` excludes `failed`/`wc-failed`. If a renewal failed, clicking "Renew Early" creates a second order. Effort: XS.
|
||||
4. **Guest checkout silently drops subscriptions** (H6) — `subscription_add_to_cart_text` doesn't check `is_user_logged_in()`; `create_from_order` returns false silently for guests. Effort: S.
|
||||
5. **`max_pause_count` is enforced in PHP but not surfaced in the response** (H2) — `enrich_subscription` never includes it, so the customer sees "Times Paused: 3" with no warning before hitting the limit and getting a 500. Effort: S.
|
||||
|
||||
### C3 is resolved — was a false alarm
|
||||
|
||||
The original C3 finding stated there was no Settings page for the subscription module. **This is wrong.** A generic `Settings/ModuleSettings.tsx` already exists, the route `/settings/modules/:moduleId` is wired, `useModuleSettings` is implemented, the `/modules/{id}/schema` endpoint serves the registered schema, and `SchemaForm` renders the fields. `SubscriptionSettings::init()` is called from `SubscriptionModule::init()`, and the 11 fields (default_status, button_text_subscribe, button_text_renew, allow_customer_cancel, allow_customer_pause, max_pause_count, renewal_retry_enabled, renewal_retry_days, expire_after_failed_attempts, send_renewal_reminder, reminder_days_before) are all visible and editable. The runtime works, the merchant can change values, the API persists them. C3 is removed from the critical list.
|
||||
|
||||
---
|
||||
|
||||
## 2. Module Architecture Map
|
||||
|
||||
### 2.1 Files in scope
|
||||
|
||||
| Layer | File | Role |
|
||||
|---|---|---|
|
||||
| **PHP core** | [SubscriptionManager.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php) | CRUD, renewals, lifecycle |
|
||||
| | [SubscriptionModule.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionModule.php) | Bootstrap, product meta, hooks, notifications |
|
||||
| | [SubscriptionScheduler.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionScheduler.php) | Cron (renewals, expiry, reminders) |
|
||||
| | [SubscriptionSettings.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/SubscriptionSettings.php) | Settings schema (defaults only) |
|
||||
| | [ModuleRegistry.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/ModuleRegistry.php) | Module registration |
|
||||
| **API** | [SubscriptionsController.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/SubscriptionsController.php) | Admin + customer REST endpoints |
|
||||
| | [ModuleSettingsController.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/ModuleSettingsController.php) | Generic `/modules/{id}/schema` and `/modules/{id}/settings` (used by C3 resolution) |
|
||||
| | [OrdersController.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/OrdersController.php) | Embeds `related_subscription` in order detail |
|
||||
| | [CheckoutController.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/CheckoutController.php) | Embeds `subscription` in `order-pay` response |
|
||||
| | [ProductsController.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/ProductsController.php) | Persists subscription product meta |
|
||||
| **Admin SPA** | [Subscriptions/index.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Subscriptions/index.tsx) | List page (table + mobile cards) |
|
||||
| | [Subscriptions/Detail.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Subscriptions/Detail.tsx) | Admin detail view |
|
||||
| | [Orders/Detail.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Orders/Detail.tsx) | Renders "Related Subscription" block |
|
||||
| | [Products/.../GeneralTab.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx) | Subscription checkbox + period/interval/trial/signup-fee inputs |
|
||||
| | [Settings/ModuleSettings.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Settings/ModuleSettings.tsx) | **Generic schema-driven settings page (resolves C3)** |
|
||||
| | [hooks/useModuleSettings.ts](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/hooks/useModuleSettings.ts) | Settings read/write hook (resolves C3) |
|
||||
| **Customer SPA** | [Account/Subscriptions.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/Account/Subscriptions.tsx) | List page |
|
||||
| | [Account/SubscriptionDetail.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/Account/SubscriptionDetail.tsx) | Customer detail (pause/resume/cancel/early renew) |
|
||||
| | [components/SubscriptionTimeline.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/components/SubscriptionTimeline.tsx) | Visual timeline component |
|
||||
| | [OrderPay/index.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/OrderPay/index.tsx) | Manual renewal payment page |
|
||||
| | [Account/components/AccountLayout.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/Account/components/AccountLayout.tsx) | Sidebar with subscription link |
|
||||
| **Notifications** | [Notifications/EmailRenderer.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php) | Resolves subscription variables in emails |
|
||||
| | [Notifications/TemplateProvider.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/TemplateProvider.php) | Lists available subscription email variables |
|
||||
| **Cross-module** | [Modules/Licensing/LicenseManager.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Licensing/LicenseManager.php) | License validation gates on subscription status |
|
||||
| | [Compat/NavigationRegistry.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Compat/NavigationRegistry.php) | Admin sidebar |
|
||||
|
||||
### 2.2 Data model
|
||||
|
||||
- **`wp_woonoow_subscriptions`** — main table (id, user_id, order_id, product_id, variation_id, status, billing_period, billing_interval, recurring_amount, start_date, trial_end_date, next_payment_date, end_date, last_payment_date, payment_method, payment_meta, cancel_reason, pause_count, failed_payment_count, reminder_sent_at).
|
||||
- **`wp_woonoow_subscription_orders`** — join table (id, subscription_id, order_id, order_type ENUM 'parent'|'renewal'|'switch'|'resubscribe').
|
||||
|
||||
### 2.3 Status flow
|
||||
|
||||
```
|
||||
pending ──► active ──► pending-cancel ──► cancelled
|
||||
│
|
||||
├──► on-hold (paused, payment_failed) ──► resumed ──► active
|
||||
└──► expired (end_date_reached, max_failed)
|
||||
```
|
||||
|
||||
### 2.4 Notification events (8 customer + 2 staff)
|
||||
|
||||
`pending_cancel`, `cancelled`, `expired`, `paused`, `resumed`, `renewal_failed`, `renewal_payment_due`, `renewal_reminder`, plus `cancelled_admin` and `renewal_failed_admin`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Critical Defects (🔴)
|
||||
|
||||
### C1. Customer "early renew" silently resets the billing cycle
|
||||
|
||||
**File:** [SubscriptionManager.php:706-718](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L706-L718)
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. Customer is on a monthly plan, paid May 1, next renewal June 1.
|
||||
2. On May 20, customer clicks "Renew Early" — order is created for $X.
|
||||
3. After payment, `handle_renewal_success()` is called.
|
||||
4. Code computes `next_payment = calculate_next_payment_date($base_date, ...)` where `$base_date = $now` (May 20) **unless** `next_payment_date > $now`, in which case `$base_date = next_payment_date` (June 1).
|
||||
|
||||
```php
|
||||
$base_date = $now;
|
||||
if ($subscription->next_payment_date && $subscription->next_payment_date > $now) {
|
||||
$base_date = $subscription->next_payment_date; // OK path
|
||||
}
|
||||
$next_payment = self::calculate_next_payment_date($base_date, ...);
|
||||
```
|
||||
|
||||
**Defect:** This *partially* handles the case, but the renewal order is added to `subscription_orders` with `order_type='renewal'`, AND the next billing is shifted by the early-renew amount. Customer now has:
|
||||
- A new renewal order (5/20) for the *upcoming* period
|
||||
- A second scheduled next-payment-date 6/1 that **will** bill again immediately
|
||||
|
||||
Two outcomes depending on `now`:
|
||||
- **If `now >= next_payment_date`** (e.g., late payment): no early issue — uses `now` correctly.
|
||||
- **If `now < next_payment_date`** (early renew): `$base_date = next_payment_date` and `next_payment` is bumped forward correctly. ✅ Actually OK.
|
||||
|
||||
**Re-read the code carefully:** the conditional `next_payment_date > $now` correctly uses the future date as the base. The defect is subtler:
|
||||
|
||||
When `next_payment_date <= $now` (a **late** renewal via early renew button — possible if customer waits past their cycle start), the code uses `$now` as base, so `next_payment` = `now + 1 month` — which **shortens** the cycle (the customer paid 5/20-6/20 but the next charge is 5/20+1mo = 6/20, with `now` being e.g. 5/22). Acceptable.
|
||||
|
||||
**Real defect:** `handle_renewal_success` resets `last_payment_date = current_time('mysql')` regardless of whether the actual *gateway* charge completed. If the gateway returns `true` from `process_subscription_renewal_payment` but the renewal was flagged as `manual`, the cron later calls `handle_renewal_success` again, double-counting. The early-renew path goes:
|
||||
|
||||
```
|
||||
$payment_result === 'manual' → status 'manual' → return early (no handle_renewal_success)
|
||||
```
|
||||
|
||||
So manual early renew does NOT call `handle_renewal_success` — the schedule shift is deferred to manual payment confirmation via `on_order_status_changed`. ✅ The original concern was unfounded; the path is actually correct.
|
||||
|
||||
**However**, there is a **real defect** with the "early renew" customer flow:
|
||||
|
||||
The `customer_renew` REST endpoint at [SubscriptionsController.php:407-434](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/SubscriptionsController.php#L407-L434) calls `SubscriptionManager::renew()` which creates a renewal order, then redirects the customer to `/order-pay/{id}`. Once they pay, the renewal is treated as a normal renewal, so:
|
||||
|
||||
- If the customer pays the early renewal order, `next_payment_date` is set to `current + 1 month` (via the conditional above), giving them **two paid periods back-to-back** with the next charge 1 month from "now" (which is the early renew date) — this is the **expected** SaaS behavior.
|
||||
- BUT the customer is *not informed* anywhere in the UI that paying early **moves their next billing date forward** by the early amount. The order-pay page ([OrderPay/index.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/OrderPay/index.tsx)) only shows the static `SubscriptionTimeline` snapshot, not what the new next date will be.
|
||||
|
||||
**Severity: 🔴 Critical — UX expectation violation.** Even if the math is right, customers will be surprised and request refunds.
|
||||
|
||||
**Fix:** Show the *projected* next billing date on the order-pay page when the order is a renewal. Compute it client-side as `next_payment_date` if in future, else `now + period`.
|
||||
|
||||
---
|
||||
|
||||
### C2. Removed — was a misframed finding
|
||||
|
||||
**Status: Withdrawn on review.** This finding is not a subscription-module defect in the current state of the system.
|
||||
|
||||
The original C2 argued that the renewal `set_address` from parent ([SubscriptionManager.php:609-614](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L609-L614)) silently copies a stale address. On review, that framing was wrong.
|
||||
|
||||
**What the system actually does today:**
|
||||
|
||||
- The checkout does not ask for an address. Every order is created without one.
|
||||
- For **virtual / downloadable** subscription products: no address is needed. The renewal `set_address` call writes an empty/default address, but no shipping line is generated, no merchant ever sees an issue, and the customer never interacts with an address. ✅ Correct by virtue of the product type.
|
||||
- For **physical** subscription products: physical subscriptions **cannot be sold** through the current checkout, because the checkout never asks for a shipping address. The renewal `set_address` code is **unreachable** for physical subscriptions in the current state.
|
||||
|
||||
**What the renewal flow should do once the checkout supports physical subscriptions:**
|
||||
|
||||
1. At the original order's checkout, the customer fills in the address. The parent order stores it.
|
||||
2. On renewal, copy the address from the parent order to the renewal order. This is the correct default — the customer chose to renew, and the parent order is the most recent customer-confirmed address.
|
||||
3. On the renewal order-pay page, surface the address with a clear "Is your address still the same? [Change]" prompt. The customer can correct it before paying.
|
||||
4. If the customer changes the address on the renewal, persist the change to the renewal order. (Optional: also write it back to the parent order or to the customer's default address, but that is a checkout-level concern, not subscription-level.)
|
||||
|
||||
**Why this is not a defect today:**
|
||||
|
||||
- The renewal `set_address` from parent is not "wrong" — it is the only sensible default in the absence of a current customer-confirmed address on the renewal. Pulling from `WC_Customer::get_default_address()` would actually be **worse** in the renewal context: the customer may not have a default address, and the parent order is by definition the most recent address the customer provided for *this* subscription.
|
||||
- The "stale address" risk on renewal is real but is already handled by the natural UX: the customer sees the address on the order-pay page and can change it before paying. This is the same trust model as checkout itself.
|
||||
|
||||
**What to do with this finding:**
|
||||
|
||||
- Remove C2 from the critical list.
|
||||
- Keep the existing `set_address` code unchanged.
|
||||
- File a **forward-looking note** in `docs/SUBSCRIPTION_ADDRESS_POLICY.md` (to be written when the checkout address step is added) describing the contract: parent-order address is the default; customer can change on the renewal order-pay page; physical products require the checkout to collect an address in the first place.
|
||||
- The only subscription-side code change that may be useful when the checkout supports physical products: surface the address on the renewal order-pay page with the "is your address still the same?" prompt. That is a UX change, not a backend defect fix.
|
||||
|
||||
**The address question is the checkout's responsibility, not the subscription module's.** The subscription module's `create_renewal_order` is doing the right thing — copying from the parent, which is the only signal it has.
|
||||
|
||||
---
|
||||
|
||||
### C3. Resolved — was a false alarm (settings page IS implemented)
|
||||
|
||||
**Status: Resolved on re-verification (2026-06-01).** The original C3 finding stated there was no Settings page for the subscription module. That claim is wrong.
|
||||
|
||||
**What is actually implemented today:**
|
||||
|
||||
- **Generic page exists:** [admin-spa/src/routes/Settings/ModuleSettings.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Settings/ModuleSettings.tsx) handles ANY module with a schema. It is mounted at `/settings/modules/:moduleId` in `AppRoutes.tsx:230`.
|
||||
- **Hook exists:** [admin-spa/src/hooks/useModuleSettings.ts](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/hooks/useModuleSettings.ts) reads `/modules/{id}/settings` and posts to `/modules/{id}/settings`.
|
||||
- **Schema endpoint exists:** `ModuleSettingsController::get_schema` (registers `GET /woonoow/v1/modules/{module_id}/schema` at [ModuleSettingsController.php:67-71](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/ModuleSettingsController.php#L67-L71)) serves the schema registered via the `woonoow/module_settings_schema` filter.
|
||||
- **Form renderer exists:** `SchemaForm` renders text / number / toggle / select fields from the schema declaratively.
|
||||
- **Schema is registered:** [SubscriptionSettings.php:18](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/SubscriptionSettings.php#L18) registers the filter; [SubscriptionModule.php:25](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionModule.php#L25) calls `SubscriptionSettings::init()` on bootstrap.
|
||||
|
||||
**What the merchant sees:** navigate to Settings → Modules → Subscription, and all 11 fields (`default_status`, `button_text_subscribe`, `button_text_renew`, `allow_customer_cancel`, `allow_customer_pause`, `max_pause_count`, `renewal_retry_enabled`, `renewal_retry_days`, `expire_after_failed_attempts`, `send_renewal_reminder`, `reminder_days_before`) are visible, editable, and persisted.
|
||||
|
||||
**Why this finding is wrong:** the original audit looked for a per-module page (`Settings/Subscription.tsx`) and didn't find one. But the system uses a *generic* schema-driven page that works for any module — including the subscription module. The capability is there; the audit missed it.
|
||||
|
||||
**Lesson for future audits:** the per-module page pattern is no longer the convention. Look for `ModuleSettings.tsx` + `SchemaForm` + `/modules/{id}/schema` first.
|
||||
|
||||
---
|
||||
|
||||
## 4. High-Impact UX/Data Issues (🟠)
|
||||
|
||||
### H1. "Renew Now" admin button does not consider gateway availability
|
||||
|
||||
**File:** [admin-spa/src/routes/Subscriptions/Detail.tsx:228-237](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Subscriptions/Detail.tsx#L228-L237)
|
||||
|
||||
The admin "Renew Now" button always calls `SubscriptionManager::renew()` which falls through to manual payment if no auto-debit gateway. The admin is not shown a confirmation that the renewal will produce a *pending* order requiring customer payment. They will click the button, see "Renewed" (because `handle_renewal_success` was called on auto-debit success OR no clear feedback on manual), and not realize the customer now has a pending order to pay.
|
||||
|
||||
**Fix:** After clicking "Renew Now", show the resulting order's payment URL / status to the admin.
|
||||
|
||||
---
|
||||
|
||||
### H2. Customer detail: "Times Paused" displayed but `max_pause_count` is not shown, and no warning when reached
|
||||
|
||||
**File:** [customer-spa/src/pages/Account/SubscriptionDetail.tsx:257-259](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/Account/SubscriptionDetail.tsx#L257-L259)
|
||||
|
||||
The customer can see they've paused N times, but not how many pauses they have left. When the limit is hit, the API returns a generic 500 `pause_failed` — the customer sees a confusing error. The button should be **disabled** with a tooltip when the limit is reached.
|
||||
|
||||
**Fix:** Add `max_pause_count` and `pauses_remaining` to the enriched response; show on the detail page; disable button when 0 remaining.
|
||||
|
||||
---
|
||||
|
||||
### H3. `on-hold` subscription can be "renewed" creating a duplicate order
|
||||
|
||||
**File:** [SubscriptionManager.php:512-533](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L512-L533)
|
||||
|
||||
The duplicate prevention check uses `p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold')`. But `failed` and `wc-failed` orders are *not* excluded — and the prior failed order still counts as a "renewal" link. The customer detail page (line 137-140) looks for `pending`, `wc-pending`, `on-hold`, `wc-on-hold`, `failed`, `wc-failed` to surface the "Pay Now" button — but the backend `renew()` only prevents duplicates for pending/on-hold. If a customer's renewal order failed last night, and they click "Renew Early" today, a *second* order is created.
|
||||
|
||||
**Severity: 🟠 High** — duplicate orders on retry, leading to confused customers and double-billing risk.
|
||||
|
||||
**Fix:** Add `'wc-failed', 'failed'` to the duplicate-prevention IN clause.
|
||||
|
||||
---
|
||||
|
||||
### H4. Renewal order product line uses the *current* product price, not the subscription's stored recurring amount
|
||||
|
||||
**File:** [SubscriptionManager.php:600-606](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L600-L606)
|
||||
|
||||
```php
|
||||
$product = wc_get_product($subscription->variation_id ?: $subscription->product_id);
|
||||
if ($product) {
|
||||
$renewal_order->add_product($product, 1, [
|
||||
'total' => $subscription->recurring_amount, // ✅ uses stored amount
|
||||
'subtotal' => $subscription->recurring_amount,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
This *is* correct — it uses the stored `recurring_amount`, not the current product price. ✅
|
||||
|
||||
**However**, `recalculate_next_payment_date` does not consider **product-level price changes mid-cycle**. The customer's stored `recurring_amount` is the original price. If the admin changes the product price, the customer is grandfathered — which is probably intended, but **not documented in any setting**. No toggle to "re-sync with product price" or "always bill current price."
|
||||
|
||||
**Severity: 🟠 High (UX clarity)** — admin changes the product price and the next renewal silently uses the old price; surprised admin.
|
||||
|
||||
**Fix:** Add a setting `price_sync_on_renewal` with options: 'use_stored' (default), 'use_current_product_price'. Document in product meta tooltip.
|
||||
|
||||
---
|
||||
|
||||
### H5. Manual renewal email is sent on every cron tick, not gated by `pending` order existence
|
||||
|
||||
**File:** [SubscriptionManager.php:684-689](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L684-L689)
|
||||
|
||||
When a renewal returns `'manual'`, the system fires `woonoow/subscription/renewal_payment_due` once per call. If the same due subscription is processed again (e.g., a future hourly tick after `next_payment_date` falls behind), and there's still a pending order, the same notification fires again, **because `process_renewal_payment` doesn't check for an existing pending order first** — `renew()` does (line 521-533), but that's before the existing-pending check returns `'existing'` status.
|
||||
|
||||
Wait — re-reading: `renew()` returns the existing order for `'existing'` status and does **not** call `process_renewal_payment` or fire the notification. ✅ So this is actually safe.
|
||||
|
||||
**However**, if a `manual` renewal was created and never paid, the next cron tick sees the subscription as `on-hold` (not `active`) — so `get_due_renewals()` excludes it. The customer gets no reminder that they have an outstanding order. The `pending-cancel` path is similar — when `next_payment_date <= now`, `check_expirations` flips it to `cancelled`, but no email is queued with a "your pending order was cancelled" notice.
|
||||
|
||||
**Severity: 🟠 High (revenue leakage)** — abandoned-cart-like behavior on unpaid renewals.
|
||||
|
||||
**Fix:** Add a daily cron that finds `on-hold` subscriptions with pending renewal orders older than 24h and re-sends the `renewal_payment_due` email (or auto-cancels after N days).
|
||||
|
||||
---
|
||||
|
||||
### H6. `create_from_order` rejects guest orders silently
|
||||
|
||||
**File:** [SubscriptionManager.php:107-110](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L107-L110)
|
||||
|
||||
```php
|
||||
if (!$user_id) {
|
||||
// Guest orders not supported for subscriptions
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
But the **product page still shows "Subscribe Now"** to guests, who can complete checkout as guest, after which **no subscription is created and no error is shown**. The customer thinks they have a subscription, the order is normal.
|
||||
|
||||
**Severity: 🟠 High — broken expectation for guest purchases.** The "Subscribe" button should be hidden for guests, or guest checkout should be blocked for subscription products, or the customer should be auto-converted to a user.
|
||||
|
||||
**Fix:** Add a check in the `subscription_add_to_cart_text` filter to *not* change button text for guest users, or force a login redirect for subscription products in cart.
|
||||
|
||||
---
|
||||
|
||||
## 5. Medium Gaps (🟡)
|
||||
|
||||
### M1. Variable products: subscription meta is on parent only
|
||||
|
||||
**File:** [SubscriptionModule.php:120-177](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionModule.php#L120-L177)
|
||||
|
||||
The admin SPA form ([GeneralTab.tsx:546-630](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx#L546-L630)) saves `_woonoow_subscription_*` on the parent product. When `create_from_order` is called, it reads:
|
||||
|
||||
```php
|
||||
$billing_period = get_post_meta($product_id, '_woonoow_subscription_period', true) ?: 'month';
|
||||
```
|
||||
|
||||
This is the *parent* product ID, not the variation. All variations of a variable subscription product share the same period. The admin cannot have e.g. "Small = monthly, Large = yearly" or "License 1-year vs 5-year" as variations.
|
||||
|
||||
**Fix:** Add variation-level meta overrides; `create_from_order` should look up variation meta first, then fall back to parent.
|
||||
|
||||
---
|
||||
|
||||
### M2. `customer_renew` endpoint has no `force_immediate` flag for ad-hoc admin actions
|
||||
|
||||
**File:** [SubscriptionsController.php:407-434](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/SubscriptionsController.php#L407-L434)
|
||||
|
||||
The customer "early renew" creates a new order. The admin "Renew Now" does the same. Neither can be used to **trigger an immediate charge against the customer's saved payment method** (i.e., a real "charge now and renew" button). The current behavior is "create a new order that the customer then pays manually" — confusing.
|
||||
|
||||
**Fix:** Add `?charge_now=true` param to admin renew that calls `process_renewal_payment` with `force = true`, skips the manual fallback.
|
||||
|
||||
---
|
||||
|
||||
### M3. No bulk actions on admin subscription list
|
||||
|
||||
**File:** [admin-spa/src/routes/Subscriptions/index.tsx:158-176](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Subscriptions/index.tsx#L158-L176)
|
||||
|
||||
The list page has a checkbox column with `selectedIds` state — but **the checkboxes don't trigger any bulk action**. There's no bulk cancel, bulk export (CSV), or bulk remind button. The select-all UI is dead.
|
||||
|
||||
**Fix:** Add a bulk-action toolbar (e.g., "Cancel selected", "Send renewal reminder", "Export CSV") and a corresponding REST endpoint.
|
||||
|
||||
---
|
||||
|
||||
### M4. No search field on admin list
|
||||
|
||||
**File:** [admin-spa/src/routes/Subscriptions/index.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Subscriptions/index.tsx)
|
||||
|
||||
The `SubscriptionManager::get_all` accepts a `search` parameter ([SubscriptionManager.php:282](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L282)) — but the admin list never passes it. With hundreds of subscriptions, an admin cannot search by customer name/email/product.
|
||||
|
||||
**Fix:** Wire the search input (currently missing) to the `search` query param.
|
||||
|
||||
---
|
||||
|
||||
## 6. Low / Opportunities (🔵)
|
||||
|
||||
### O1. Notification variables: missing `payment_method_title`, `billing_schedule`, `customer_id`
|
||||
|
||||
**File:** [TemplateProvider.php:328-345](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/TemplateProvider.php#L328-L345)
|
||||
|
||||
The schema lists variables, but **the `EmailRenderer` mapping at lines 343-353** does not include:
|
||||
- `payment_method_title` (customer-facing)
|
||||
- `billing_schedule` (e.g., "Every 1 month")
|
||||
- `customer_id` (admin-facing)
|
||||
- `store_email` (declared in schema but not mapped)
|
||||
- `my_account_url` (declared in schema but not mapped)
|
||||
|
||||
**Fix:** Add these to the `EmailRenderer` mapping so email templates can use them.
|
||||
|
||||
---
|
||||
|
||||
### O2. Two `.bak.php` files in production code path
|
||||
|
||||
**File:** [TemplateProvider.bak.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/TemplateProvider.bak.php)
|
||||
|
||||
The `.bak` file is in the `Notifications/` directory — depending on autoloader, this may be auto-loaded or skipped. The subscription variables in the `.bak` (line 339-348) reference different keys than the live one. Should be removed or moved to `tests/fixtures/`.
|
||||
|
||||
**Fix:** Delete or relocate.
|
||||
|
||||
---
|
||||
|
||||
## 7. UI/UX Observations (not classified as defects)
|
||||
|
||||
| Observation | Location |
|
||||
|---|---|
|
||||
| Status badge uses `bg-amber-100` for `pending` in admin, `bg-yellow-100` in customer — inconsistent. | admin/customer SPAs |
|
||||
| Mobile card view (admin) shows product name truncated without a "view product" link. | Subscriptions/index.tsx:300-321 |
|
||||
| `SubscriptionTimeline` component has hard-coded English labels (`Started`, `Payment Due`, `Every X months`) — no `__()` calls. Breaks i18n for the 2 languages supported per `I18N_IMPLEMENTATION_GUIDE.md`. | SubscriptionTimeline.tsx |
|
||||
| Customer detail page has no SEO head except `<SEOHead title="Subscription #X">` — OK but no `og:image` from `product_image`. | SubscriptionDetail.tsx:164 |
|
||||
| Admin "Renew Now" doesn't ask for confirmation, but "Cancel" does. | Subscriptions/Detail.tsx:228-247 |
|
||||
| `failed_payment_count` is shown in admin detail (good), but not in the customer detail — they don't know they have a retry coming. | admin vs customer |
|
||||
| `last_payment_date` is shown in admin only, not customer. | admin vs customer |
|
||||
| Order pay page always shows `Loading order details...` if `key` is missing — no helpful 403. | OrderPay/index.tsx:133 |
|
||||
|
||||
---
|
||||
|
||||
## 8. Cron Behavior Audit
|
||||
|
||||
| Hook | Schedule | Purpose | Issues |
|
||||
|---|---|---|---|
|
||||
| `woonoow_process_subscription_renewals` | hourly | Auto-process due renewals | No batch limit; no admin notice on failure; no lock guard against overlapping runs |
|
||||
| `woonoow_check_expired_subscriptions` | daily | Mark end-date-reached as `expired`; finalize `pending-cancel` | No email to customer when their pending-cancel finally cancels |
|
||||
| `woonoow_send_renewal_reminders` | daily | Send reminder N days before `next_payment_date` | Uses `last_payment_date` to gate, but `last_payment_date` only updates on **success** — if all retries fail, the gate fails open and may re-send for the same cycle |
|
||||
|
||||
**Cron-related risks:**
|
||||
1. **No `wp_remote_get` lock** to prevent concurrent runs (WP-Cron can double-fire on slow sites).
|
||||
2. **`send_renewal_reminders`** query at [SubscriptionScheduler.php:183-192](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionScheduler.php#L183-L192) uses a complex OR clause that may not be correct when `last_payment_date` IS NULL (initial trial): the `(last_payment_date IS NULL AND reminder_sent_at < start_date)` branch. New trial subscriptions may get reminders *before* trial ends.
|
||||
3. **`schedule_retry`** at line 246-262 updates `next_payment_date` to the retry date — but the `get_due_renewals()` query compares `next_payment_date <= now`, so the retried subscription will be picked up on the next hourly tick. ✅
|
||||
|
||||
---
|
||||
|
||||
## 9. Payment Gateway Integration — Revised Direction (per-gateway capability)
|
||||
|
||||
> **Status as of 2026-06-01 (re-verified):** this section is **still aspirational**. No code, no schema, no settings entry, no UI, no capability helper exists. Zero references to `subscription_auto_renew`, `GatewayCapabilities`, `gateway_capability`, or `woonoow_gateway_subscription_capabilities` were found anywhere in the plugin. The current system still relies entirely on `method_exists($gateway, 'process_subscription_renewal_payment')` PHP introspection at `SubscriptionManager.php:667-674`.
|
||||
>
|
||||
> However, **the settings infrastructure to build this is now in place** (see C3 resolution): a generic `ModuleSettings` page reads a registered schema and persists via `/modules/{id}/settings`. §9 can be built on that same pattern, which is why this is still the most important long-term fix — but it is a feature to build, not a bug to remove.
|
||||
|
||||
### 9.1 Current state (problem)
|
||||
|
||||
The current code at [SubscriptionManager.php:667-674](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L667-L674) detects auto-debit capability via PHP introspection:
|
||||
|
||||
```php
|
||||
if (method_exists($gateway, 'process_subscription_renewal_payment')) {
|
||||
$result = $gateway->process_subscription_renewal_payment($order, $subscription);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
This has three problems:
|
||||
|
||||
1. **Capability is invisible to the merchant.** The admin has no way to see which gateways are declared to support subscription auto-renew, no way to override that declaration, and no way to know that "Stripe is enabled" does not mean "Stripe will charge renewals."
|
||||
2. **The default is unsafe.** A gateway without the method silently falls through to manual payment. The system "works," but the merchant believes auto-debit is happening and customers are surprised.
|
||||
3. **No per-gateway override is possible.** A merchant who has a custom Stripe wrapper that *does* support auto-debit cannot declare it; a merchant using stock Stripe with no wrapper cannot override the assumption that it works.
|
||||
|
||||
### 9.2 Recommended direction: per-gateway capability declaration
|
||||
|
||||
Instead of inferring capability from PHP method existence, store a **gateway capability table** that the merchant (or WooNooW defaults) controls explicitly. The system then computes effective behavior at renewal time.
|
||||
|
||||
#### Shape of the declaration
|
||||
|
||||
A per-gateway record, with safe defaults:
|
||||
|
||||
```php
|
||||
// Conceptual. Storage could be wp_option, custom table, or gateway registration API.
|
||||
$capabilities = [
|
||||
'paypal' => ['subscription_auto_renew' => true],
|
||||
'stripe' => ['subscription_auto_renew' => true], // only with WooNooW Stripe adapter
|
||||
'dodo' => ['subscription_auto_renew' => true], // only with WooNooW Dodo adapter
|
||||
'tripay' => ['subscription_auto_renew' => false], // VA/QRIS — no recurring
|
||||
'midtrans' => ['subscription_auto_renew' => false], // VA/QRIS/e-wallet — no recurring
|
||||
'xendit' => ['subscription_auto_renew' => false], // even CC re-auth required
|
||||
];
|
||||
```
|
||||
|
||||
**Default for any unknown gateway: `false`.** This is the safe default — Indonesian-style and global-style gateways without a WooNooW adapter are treated as manual-renewal-only.
|
||||
|
||||
#### Why per-gateway, not a site-level "billing mode" toggle
|
||||
|
||||
A site-level "manual vs auto" toggle asks the merchant to understand a concept ("billing mode") that does not actually exist in their head. The merchant thinks in terms of **payment gateways**. A checkbox next to each gateway in WooCommerce → Settings → Payments is data the merchant already knows.
|
||||
|
||||
Additionally:
|
||||
|
||||
- Different merchants use different gateways. A site-level toggle forces a single behavior even when the merchant runs two gateways (one auto-capable, one not) for different products.
|
||||
- The capability is a property of the **integration**, not of the **store**. The merchant did not choose "manual mode" — they chose Tripay, and Tripay is a manual gateway.
|
||||
- The capability can change as WooNooW ships new adapters. A site-level toggle would be set once and forgotten; a per-gateway table updates as adapters ship.
|
||||
|
||||
#### What the system does with the declaration
|
||||
|
||||
At renewal time, the system checks: **the active gateway for this subscription** (stored in `payment_method`) has `subscription_auto_renew = true`.
|
||||
|
||||
- **Yes, supported** → attempt auto-debit via the gateway's `process_subscription_renewal_payment` (the contract is unchanged, just gated by a positive declaration). On success, `handle_renewal_success`. On failure, fall through to manual renewal and notify the customer.
|
||||
- **No, not supported** → skip auto-debit, create a manual renewal order, send `renewal_payment_due` email. No silent failure.
|
||||
|
||||
If the gateway is unknown to the capability table, the default is `false` — same as explicit "not supported."
|
||||
|
||||
#### Optional site-level override (default off)
|
||||
|
||||
Add one secondary toggle for the rare merchant who wants to force manual even when the gateway supports auto (e.g., legal, regulatory, or business reasons):
|
||||
|
||||
- `force_manual_renewal`: off by default. When on, all renewals become manual regardless of gateway capability. Useful as a "kill switch."
|
||||
|
||||
This is **not** the primary control. It is an override.
|
||||
|
||||
### 9.3 What the merchant sees
|
||||
|
||||
In WooCommerce → Settings → Payments, each gateway row should show a "Supports subscription auto-renewal" indicator. For example:
|
||||
|
||||
| Gateway | Status |
|
||||
|---|---|
|
||||
| PayPal (WooCommerce addon) | ✅ Supports auto-renew |
|
||||
| Stripe (WooCommerce addon) | ✅ Supports auto-renew |
|
||||
| Dodo (WooCommerce addon) | ✅ Supports auto-renew |
|
||||
| Tripay Payment (WooCommerce addon) | ❌ Manual renewal only |
|
||||
| Midtrans (WooCommerce addon) | ❌ Manual renewal only |
|
||||
| Xendit (WooCommerce addon) | ❌ Manual renewal only (even credit card) |
|
||||
|
||||
A gateway with no entry in the capability table shows ❌ by default and the merchant can flip it on if they have a custom adapter (advanced setting, off by default).
|
||||
|
||||
### 9.4 What the customer sees (on the order-pay page and renewal emails)
|
||||
|
||||
The renewal messaging must match the actual behavior, not the marketing claim:
|
||||
|
||||
- For an auto-renew gateway: "Your subscription will renew automatically on [date] using your saved payment method."
|
||||
- For a manual gateway: "Your subscription is up for renewal. Please complete the payment to continue." with a clear CTA to pay.
|
||||
- The order-pay page should also show the **next payment date** after this payment completes (the audit's C1 finding applies here too).
|
||||
|
||||
### 9.5 The Indonesian gateway reality
|
||||
|
||||
For Indonesian payment gateways (Tripay, Midtrans, Xendit, Doku, and the VA/QRIS/e-wallet channels of any gateway), the default `subscription_auto_renew` must be **`false`**. Specifically:
|
||||
|
||||
- VA (virtual account): no recurring charge capability.
|
||||
- QRIS: typically a one-time merchant-presented QR, no customer-mandate.
|
||||
- E-wallets (GoPay, OVO, DANA, ShopeePay): require customer re-authentication for each charge.
|
||||
- Credit card on Indonesian gateways: even when the card is tokenized, recurring charges typically require the customer to re-authenticate (BI/PCI-DSS regulatory constraint). The merchant cannot assume the stored token can be charged without the customer's active consent.
|
||||
|
||||
If a merchant has a special integration (e.g., a specific subscription product on Xendit with a tokenized card and a recurring billing add-on), they can flip the toggle for that gateway. But the default must be manual.
|
||||
|
||||
### 9.6 Implementation outline (for the AI agent)
|
||||
|
||||
1. **Capability storage.** A new `wp_option('woonoow_gateway_subscription_capabilities', [...])` keyed by gateway ID, with the safe defaults above. Add a filter `woonoow_gateway_subscription_capabilities` so adapters can self-register.
|
||||
2. **Capability lookup helper.** `WooNooW\Modules\Subscription\GatewayCapabilities::supports_auto_renew(string $gateway_id): bool` — single source of truth.
|
||||
3. **Renewal flow integration.** In `SubscriptionManager::process_renewal_payment`, before checking `method_exists`, call the capability helper. If `false`, skip the auto-debit attempt entirely and go straight to manual.
|
||||
4. **Admin UI.** Render the capability indicator in the gateway list (admin SPA) and on the subscription detail page next to "Payment Method" (e.g., "Stripe — auto-renew enabled" vs "Tripay — manual renewal only").
|
||||
5. **Customer messaging.** Pass a boolean `gateway_supports_auto_renew` to the order-pay response and the renewal emails, so the UI can choose the right wording.
|
||||
6. **Kill switch.** A single `force_manual_renewal` site setting. Default off.
|
||||
7. **Documentation.** A new `docs/SUBSCRIPTION_GATEWAY_CAPABILITIES.md` listing the default capabilities and explaining how to add custom adapters.
|
||||
|
||||
### 9.7 Migration / no migration
|
||||
|
||||
This is a behavioral improvement, not a schema change. Existing subscriptions keep their `payment_method` value. The capability table is consulted at renewal time, not retroactively.
|
||||
|
||||
### 9.8 Recommendation (replaces prior "ship a Stripe adapter" suggestion)
|
||||
|
||||
Do **not** ship a Stripe adapter as a first-class example. The value of a generic example adapter is low because each gateway's recurring billing is different. Instead:
|
||||
|
||||
- Ship the **capability table** with safe defaults.
|
||||
- Ship a **gateway-integration guide** (`docs/SUBSCRIPTION_GATEWAY_CAPABILITIES.md`) that documents the `process_subscription_renewal_payment` contract.
|
||||
- Let third-party gateway authors (or the merchant's developer) implement adapters per gateway.
|
||||
- The merchant-visible UX works correctly for any gateway, supported or not, because the fallback to manual is explicit.
|
||||
|
||||
---
|
||||
|
||||
## 10. Cross-Module Integration
|
||||
|
||||
### Licensing
|
||||
|
||||
- `LicenseManager::validate_license` calls `get_order_subscription_status($license['order_id'])` at line 340-343 — if the subscription is not `active` or `pending-cancel`, the license is rejected with `subscription_inactive`.
|
||||
- ✅ Works correctly; the licensing flow depends on the subscription status being accurate.
|
||||
- ⚠️ But: when a subscription is **cancelled by customer at period end** (pending-cancel → cancelled), the license is still valid until the cancellation date. This is good UX, but admin should be able to see the relationship in the license detail.
|
||||
|
||||
### Affiliate
|
||||
|
||||
- No integration. An affiliate who referred a customer who later subscribes does **not** receive renewal commissions. The `subscription_renewal` order is not tagged with the referral.
|
||||
- This is a known gap (Affiliate Module report doesn't mention subscriptions).
|
||||
|
||||
---
|
||||
|
||||
## 11. Documentation Drift
|
||||
|
||||
| File | Says | Reality |
|
||||
|---|---|---|
|
||||
| [FEATURE_ROADMAP.md:299-363](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/FEATURE_ROADMAP.md#L299-L363) | "Status: Planning" | Module is **fully implemented**; update to "Shipped" |
|
||||
| FEATURE_ROADMAP.md lists `customer_id` column | actually `user_id` in schema | Drift |
|
||||
| `.agent/plans/subscription-module.md` | Original design | Now stale (e.g., `reminder_sent_at` was added later) |
|
||||
|
||||
---
|
||||
|
||||
## 12. Test Coverage
|
||||
|
||||
**No subscription-specific tests** found in [tests/](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/tests/). Only generic schema/parity/page tests exist. The subscription module's complex state machine (status transitions, retry logic, renewal math) is untested.
|
||||
|
||||
**Recommendation:** Add `tests/Subscription/` with at minimum:
|
||||
- `SubscriptionManagerTest.php`: state machine coverage (pending → active → on-hold → resumed, etc.)
|
||||
- `SubscriptionSchedulerTest.php`: cron logic with frozen time
|
||||
- `SubscriptionsControllerTest.php`: REST endpoint auth and validation
|
||||
- `e2e/subscription-checkout.test.ts`: full buy → renew → cancel flow
|
||||
|
||||
---
|
||||
|
||||
## 13. Recommended Fix Priority
|
||||
|
||||
| # | Issue | Severity | Effort | Priority |
|
||||
|---|---|---|---|---|
|
||||
| 1 | C1 — Early renew UX clarity (show projected next-payment-date on order-pay page) | 🔴 | S | **P0 — this week** |
|
||||
| 2 | H3 — Failed orders bypass dedup (add `'wc-failed', 'failed'` to IN clause at `SubscriptionManager.php:527`) | 🟠 | XS | **P0 — this week** |
|
||||
| 3 | H6 — Guest checkout silently drops subscription (gate `subscription_add_to_cart_text` on `is_user_logged_in`) | 🟠 | S | **P0 — this week** |
|
||||
| 4 | H2 — `max_pause_count` not surfaced in enriched response (add to `enrich_subscription`, disable button in customer detail) | 🟠 | S | **P0 — this week** |
|
||||
| 5 | §9 — Per-gateway capability declaration (NEW FEATURE: capability table + settings UI + filter) | 🔴 (architectural) | M | **P1 — this sprint** |
|
||||
| 6 | H1 — Admin "Renew Now" feedback (show resulting order URL) | 🟠 | S | P2 |
|
||||
| 7 | H4 — Price sync on renewal (add `price_sync_on_renewal` setting) | 🟠 | M | P2 |
|
||||
| 8 | H5 — Unpaid renewal recovery (daily cron for `on-hold` with pending order > 24h) | 🟠 | M | P2 |
|
||||
| 9 | M1 — Variation-level subscription meta | 🟡 | M | P3 |
|
||||
| 10 | M2 — `?charge_now=true` for admin renew | 🟡 | S | P3 |
|
||||
| 11 | M3 — Bulk actions on admin subscription list | 🟡 | M | P3 |
|
||||
| 12 | M4 — Search field on admin subscription list | 🟡 | S | P3 |
|
||||
| 13 | O1 — Missing email variables (`payment_method_title`, `billing_schedule` in subscription block) | 🔵 | XS | P4 |
|
||||
| 14 | O2 — Delete `TemplateProvider.bak.php` | 🔵 | XS | P4 |
|
||||
| 15 | Doc drift — `FEATURE_ROADMAP.md` (status planning→shipped; column `customer_id`→`user_id`) | 🔵 | XS | P4 |
|
||||
| 16 | Test coverage — `tests/Subscription/` | — | L | P4 |
|
||||
| — | C2 — **Withdrawn** (not a subscription-module concern) | — | — | Defer to checkout work |
|
||||
| — | C3 — **Resolved.** Settings page is implemented (generic schema-driven). | — | — | Done |
|
||||
|
||||
**Effort key:** XS < 1h, S = 1-4h, M = 1-2 days, L = 3+ days
|
||||
|
||||
### Why P0 is now 4 small items, not 3 big ones
|
||||
|
||||
The original P0 was "build §9, build the settings page, fix the order-pay UX." The settings page is **done** — that work is reusable for §9. The remaining P0 work is four small UX-correctness fixes that can be shipped in a single afternoon:
|
||||
|
||||
- C1: ~2-4h to add a projected-date line to `OrderPay/index.tsx` (compute client-side from `subscription.billing_period`/`billing_interval`).
|
||||
- H3: ~30min — add two strings to an IN clause.
|
||||
- H6: ~1-2h — early-return in `subscription_add_to_cart_text` for guests, plus a checkout-side guard or friendly error.
|
||||
- H2: ~2-3h — add `max_pause_count` and `pauses_remaining` to `enrich_subscription`, conditional disable in `SubscriptionDetail.tsx`.
|
||||
|
||||
§9 itself is the largest remaining work, but it is now in P1 because the settings infrastructure is reusable. §9.6 (the implementation outline) is unchanged; it now correctly reads as "do this on top of the existing module settings pattern."
|
||||
|
||||
### 10.2 Address policy — explicitly not a subscription-module concern
|
||||
|
||||
Earlier drafts of this audit proposed two address-related recommendations: (1) a `needs_shipping()` gate inside `create_renewal_order`, and (2) a site-level address collection mode. **Both are withdrawn.**
|
||||
|
||||
- The `needs_shipping()` gate is unnecessary. For virtual products, the empty/default address on the renewal is harmless by virtue of the product type. The existing code is already correct.
|
||||
- A site-level address collection mode is the wrong layer. The address question is a **checkout-level** concern. Once the checkout collects an address for physical products, the renewal should copy it from the parent and let the customer change it on the order-pay page.
|
||||
|
||||
**What the subscription module should do today:** nothing. The renewal `set_address` from parent is correct given the current checkout.
|
||||
|
||||
**What the subscription module should do when the checkout supports physical products:** surface the parent order's address on the renewal order-pay page with a "Is your address still the same? [Change]" prompt. This is a UX change, not a backend defect fix.
|
||||
|
||||
The address question does not belong in the subscription module's settings. It belongs in the checkout.
|
||||
|
||||
---
|
||||
|
||||
## 14. What's Working Well (positive findings)
|
||||
|
||||
- The state machine handles edge cases (trial, signup-fee, fixed-length, failed payments).
|
||||
- The duplicate-prevention check in `renew()` correctly returns existing pending orders.
|
||||
- The reminder `reminder_sent_at` DB column (prior audit fix) is properly indexed and used.
|
||||
- `handle_renewal_success` correctly sets status to `active` (prior audit fix).
|
||||
- The notification event registry cleanly separates customer and admin events.
|
||||
- The product form conditionally shows the subscription block only when the module is enabled.
|
||||
- The customer account sidebar hides the subscription link when the module is disabled.
|
||||
- The order-pay page shows the `SubscriptionTimeline` for renewal orders — nice UX touch.
|
||||
- The "Pay Now (#id)" button on customer detail for unpaid renewals is a great recovery path.
|
||||
- Hooks are well-named and consistent (`woonoow/subscription/<event>`).
|
||||
- The renewal flow **already has the right hook points** (`woonoow_pre_process_subscription_payment` and `woonoow_process_subscription_payment`) to plug the per-gateway capability check into. No architectural rewrite needed.
|
||||
|
||||
---
|
||||
|
||||
## 15. Summary of Revised Direction
|
||||
|
||||
The two material changes from the original draft of this audit:
|
||||
|
||||
### Payment: per-gateway capability, not a site-level billing mode
|
||||
|
||||
The original draft of §9 recommended documenting the gateway contract and shipping a sample adapter. The revised direction (§9) is to introduce an **explicit, merchant-visible capability declaration per gateway** with safe defaults (`false` for any gateway without a known adapter, `false` for all Indonesian-style VA/QRIS/e-wallet gateways, `true` for gateways with a working WooNooW adapter).
|
||||
|
||||
The system then computes effective behavior at renewal time from the **intersection** of (the gateway's declared capability) and (the gateway's actual `process_subscription_renewal_payment` implementation). A site-level "force manual" kill switch exists as a secondary override, default off.
|
||||
|
||||
The merchant thinks in payment gateways, not in "billing modes." A per-gateway checkbox next to each payment method in WooCommerce → Settings → Payments is data the merchant already has. A site-level toggle is a concept they have to learn.
|
||||
|
||||
### Address: not a subscription-module concern
|
||||
|
||||
**Withdrawn entirely.** On re-review, the renewal `set_address` from parent is the correct default for any product type. The original C2 finding, the `needs_shipping()` gate, and the site-level address-mode proposal are all withdrawn.
|
||||
|
||||
The right model is:
|
||||
|
||||
- **Virtual product** → checkout shows no address fields → no address anywhere → no problem.
|
||||
- **Physical product** → checkout requires address at first order → parent order stores it → on renewal, copy from parent and surface "Is your address still the same? [Change]" on the order-pay page.
|
||||
|
||||
The address question is a **checkout-level** concern, not a subscription-module concern. The subscription module's `create_renewal_order` is doing the right thing — copying from the parent, which is the only signal it has. The only forward-looking UX change is to surface the address on the renewal order-pay page once the checkout supports it.
|
||||
|
||||
### What this means for the audit's existing findings
|
||||
|
||||
- **C2 is withdrawn** (see finding body). The renewal `set_address` is correct.
|
||||
- **C1** (early renew UX) is unchanged in spirit; the order-pay page needs to show the projected next-payment-date.
|
||||
- **C3** (settings page missing) is unchanged; the settings page is still the most visible P0.
|
||||
- **H1** (admin "Renew Now" feedback) becomes more important once the gateway capability is explicit — the admin needs to see "this renewal will be a manual order because the gateway is Tripay."
|
||||
|
||||
---
|
||||
|
||||
## 16. Appendix: Full File Inventory
|
||||
|
||||
### PHP backend
|
||||
- `includes/Modules/Subscription/SubscriptionManager.php` (894 lines)
|
||||
- `includes/Modules/Subscription/SubscriptionModule.php` (564 lines)
|
||||
- `includes/Modules/Subscription/SubscriptionScheduler.php` (264 lines)
|
||||
- `includes/Modules/SubscriptionSettings.php` (110 lines)
|
||||
- `includes/Api/SubscriptionsController.php` (502 lines)
|
||||
- `includes/Api/ModuleSettingsController.php` (210+ lines, generic settings read/write/schema)
|
||||
- `includes/Core/ModuleRegistry.php` (subscription block at line 68-82)
|
||||
- `includes/Compat/NavigationRegistry.php` (lines 262-284)
|
||||
- `includes/Core/Notifications/EmailRenderer.php` (lines 339-376)
|
||||
- `includes/Core/Notifications/TemplateProvider.php` (lines 240-345)
|
||||
- `includes/Core/Notifications/TemplateProvider.bak.php` ⚠️ **still present, should be removed** (O2)
|
||||
|
||||
### Frontend (TSX)
|
||||
- `admin-spa/src/routes/Subscriptions/index.tsx` (505 lines)
|
||||
- `admin-spa/src/routes/Subscriptions/Detail.tsx` (456 lines)
|
||||
- `admin-spa/src/routes/Orders/Detail.tsx` (related_subscription block 320-345)
|
||||
- `admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx` (subscription block 546-630)
|
||||
- `admin-spa/src/routes/Settings/ModuleSettings.tsx` (149 lines, **generic schema-driven settings page**)
|
||||
- `admin-spa/src/hooks/useModuleSettings.ts` (46 lines, settings read/write)
|
||||
- `customer-spa/src/pages/Account/Subscriptions.tsx` (244 lines)
|
||||
- `customer-spa/src/pages/Account/SubscriptionDetail.tsx` (377 lines)
|
||||
- `customer-spa/src/components/SubscriptionTimeline.tsx` (86 lines)
|
||||
- `customer-spa/src/pages/OrderPay/index.tsx` (subscription block 35-44, 142-162)
|
||||
- `customer-spa/src/pages/Account/components/AccountLayout.tsx` (line 53, 66)
|
||||
|
||||
### Docs
|
||||
- `.agent/reports/subscription-flow-audit-2026-01-29.md` (prior audit)
|
||||
- `.agent/plans/subscription-module.md` (original design)
|
||||
- `FEATURE_ROADMAP.md` (stale)
|
||||
- `HOOKS_REGISTRY.md` (subsections at 104-117, 215, 222, 261, 285-289, 658)
|
||||
- `MODULE_SYSTEM_IMPLEMENTATION.md`
|
||||
|
||||
---
|
||||
|
||||
## 17. Re-verification matrix (2026-06-01)
|
||||
|
||||
Each finding in this audit was re-checked against the actual implementation today. The table is the source of truth for "documented vs. implemented."
|
||||
|
||||
| # | Finding | Original severity | Re-verified status | Notes |
|
||||
|---|---|---|---|---|
|
||||
| C1 | Early-renew UX lacks projected next-payment-date | 🔴 | ✅ **Confirmed** | `OrderPay/index.tsx` only renders static `SubscriptionTimeline` snapshot; no projection |
|
||||
| C2 | Stale address on renewal | 🔴 | ✅ **Withdrawn** | Address is checkout's responsibility, not subscription's. Already resolved in this document. |
|
||||
| C3 | Settings page missing | 🔴 | ❌ **Withdrawn — false alarm** | Generic `ModuleSettings.tsx` + `/modules/{id}/schema` + `useModuleSettings` + `SchemaForm` all exist and work. See C3 resolution section. |
|
||||
| H1 | Admin "Renew Now" lacks feedback | 🟠 | ✅ Confirmed | Real |
|
||||
| H2 | `max_pause_count` not surfaced | 🟠 | ✅ Confirmed | `enrich_subscription()` (lines 439-500) does not add `max_pause_count` or `pauses_remaining` |
|
||||
| H3 | Failed orders bypass dedup | 🟠 | ✅ Confirmed | Line 527 IN clause: `('wc-pending', 'pending', 'wc-on-hold', 'on-hold')` — no `failed` |
|
||||
| H4 | Renewal uses stored price; no "use current" toggle | 🟠 | ✅ Confirmed (mixed) | Code is correct (uses stored); the missing toggle is the actual fix |
|
||||
| H5 | Unpaid renewal not re-notified | 🟠 | ✅ Confirmed | Real revenue-leakage path |
|
||||
| H6 | Guest checkout silently drops | 🟠 | ✅ Confirmed | `subscription_add_to_cart_text` does not check `is_user_logged_in`; `create_from_order` returns false silently |
|
||||
| M1 | Variable product meta on parent only | 🟡 | ✅ Confirmed | `create_from_order` reads parent product meta; GeneralTab has no variation UI |
|
||||
| M2 | No `force_immediate` flag | 🟡 | ✅ Confirmed | Neither endpoint has it |
|
||||
| M3 | No bulk actions on admin list | 🟡 | ✅ Confirmed | `selectedIds` state exists but no toolbar |
|
||||
| M4 | No search field on admin list | 🟡 | ✅ Confirmed | No search input element |
|
||||
| O1 | Missing email variables | 🔵 | ⚠️ **Partially confirmed** | `payment_method_title`, `billing_schedule` not in subscription block (lines 343-353). `my_account_url` is mapped in a different branch. |
|
||||
| O2 | `.bak.php` in production | 🔵 | ✅ Confirmed | `TemplateProvider.bak.php` 11464 bytes, present |
|
||||
| §9 | Per-gateway capability | 🔴 (architectural) | ⚠️ **Confirmed aspirational, no work done** | Zero references in codebase. Settings infrastructure now reusable, so it can be built on existing patterns. |
|
||||
|
||||
---
|
||||
|
||||
**End of report.**
|
||||
@@ -1 +1,38 @@
|
||||
export function ProductCard({ product }: any) { return <div className='p-4 border rounded shadow-sm'>{product?.title || 'Product'}</div>; }
|
||||
import React from 'react';
|
||||
import { ShoppingCart } from 'lucide-react';
|
||||
|
||||
export function ProductCard({ product }: any) {
|
||||
const name = product?.name || product?.title || 'Sample Product';
|
||||
const price = product?.price || '$49.99';
|
||||
const image = product?.image || product?.image_url || '';
|
||||
|
||||
return (
|
||||
<div className="group h-full flex flex-col border border-border rounded-lg overflow-hidden hover:shadow-md transition-shadow bg-card">
|
||||
<div className="relative w-full overflow-hidden bg-muted aspect-square">
|
||||
{image ? (
|
||||
<img src={image} alt={name} className="absolute inset-0 w-full !h-full object-cover object-center group-hover:scale-105 transition-transform duration-300" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm font-medium">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 flex-1 flex flex-col text-left">
|
||||
<h3 className="text-sm font-medium text-foreground mb-2 line-clamp-2 leading-snug group-hover:text-primary transition-colors">
|
||||
{name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-base font-bold text-foreground">
|
||||
{price}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="w-full mt-auto inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4 mr-2" />
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
26
admin-spa/src/components/SectionBackgroundRenderer.tsx
Normal file
26
admin-spa/src/components/SectionBackgroundRenderer.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { SectionStyleResult } from '@/lib/sectionStyles';
|
||||
|
||||
interface SectionBackgroundRendererProps {
|
||||
bg: SectionStyleResult;
|
||||
}
|
||||
|
||||
export function SectionBackgroundRenderer({ bg }: SectionBackgroundRendererProps) {
|
||||
if (!bg.backgroundImage) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none z-0">
|
||||
<img
|
||||
src={bg.backgroundImage}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{bg.hasOverlay && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black"
|
||||
style={{ opacity: bg.overlayOpacity }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,87 +78,6 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{containerWidth === 'boxed' ? (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10">
|
||||
<div className={gridClasses}>
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
<div className={cn(
|
||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||
imageWrapperOrder,
|
||||
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
|
||||
)} style={imageStyle}>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Section Image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Side */}
|
||||
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"tracking-tight text-current mb-6",
|
||||
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
|
||||
titleClassName
|
||||
)}
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{text && (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-lg max-w-none',
|
||||
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
|
||||
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
|
||||
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
|
||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||
'prose-p:text-[var(--tw-prose-body)]',
|
||||
'text-[var(--tw-prose-body)]',
|
||||
className,
|
||||
textClassName
|
||||
)}
|
||||
style={proseStyle}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Buttons */}
|
||||
{buttons && buttons.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
{buttons.map((btn, idx) => (
|
||||
btn.text && btn.url && (
|
||||
<a
|
||||
key={idx}
|
||||
href={btn.url}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
|
||||
!buttonStyle?.style?.backgroundColor && "bg-primary",
|
||||
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
|
||||
buttonStyle?.classNames
|
||||
)}
|
||||
style={buttonStyle?.style}
|
||||
>
|
||||
{btn.text}
|
||||
</a>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={gridClasses}>
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
@@ -232,7 +151,6 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
export interface FieldSchema {
|
||||
type: 'text' | 'textarea' | 'email' | 'url' | 'number' | 'toggle' | 'checkbox' | 'select';
|
||||
type: 'text' | 'textarea' | 'email' | 'url' | 'number' | 'toggle' | 'checkbox' | 'select' | 'multiselect';
|
||||
label: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
@@ -47,8 +47,8 @@ export function SchemaField({ name, schema, value, onChange, error }: SchemaFiel
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value === '' ? 0 : parseFloat(e.target.value))}
|
||||
placeholder={schema.placeholder}
|
||||
required={schema.required}
|
||||
min={schema.min}
|
||||
@@ -110,6 +110,35 @@ export function SchemaField({ name, schema, value, onChange, error }: SchemaFiel
|
||||
</Select>
|
||||
);
|
||||
|
||||
case 'multiselect':
|
||||
const selectedValues = Array.isArray(value) ? value : [];
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{schema.options && Object.entries(schema.options).map(([key, label]) => (
|
||||
<label
|
||||
key={key}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedValues.includes(key)
|
||||
? 'bg-primary/10 border-primary text-primary'
|
||||
: 'bg-background border-input hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedValues.includes(key)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onChange([...selectedValues, key]);
|
||||
} else {
|
||||
onChange(selectedValues.filter((v: string) => v !== key));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
|
||||
@@ -73,6 +73,10 @@ import NewsletterLayout from '@/routes/Marketing/Newsletter';
|
||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter/Subscribers';
|
||||
import NewsletterCampaignsList from '@/routes/Marketing/Campaigns';
|
||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||
import AffiliatesLayout from '@/routes/Marketing/Affiliates';
|
||||
import AffiliatesList from '@/routes/Marketing/Affiliates/List';
|
||||
import AffiliatesReferrals from '@/routes/Marketing/Affiliates/Referrals';
|
||||
import AffiliatesPayouts from '@/routes/Marketing/Affiliates/Payouts';
|
||||
import MorePage from '@/routes/More';
|
||||
import Help from '@/routes/Help';
|
||||
import Onboarding from '@/routes/Onboarding';
|
||||
@@ -247,6 +251,12 @@ export function AppRoutes() {
|
||||
<Route path="campaigns" element={<NewsletterCampaignsList />} />
|
||||
<Route path="campaigns/:id" element={<CampaignEdit />} />
|
||||
</Route>
|
||||
<Route path="/marketing/affiliates" element={<AffiliatesLayout />}>
|
||||
<Route index element={<Navigate to="list" replace />} />
|
||||
<Route path="list" element={<AffiliatesList />} />
|
||||
<Route path="referrals" element={<AffiliatesReferrals />} />
|
||||
<Route path="payouts" element={<AffiliatesPayouts />} />
|
||||
</Route>
|
||||
|
||||
{/* Legacy Redirects for Newsletter (using component to preserve params) */}
|
||||
<Route path="/marketing/campaigns" element={<Navigate to="/marketing/newsletter/campaigns" replace />} />
|
||||
|
||||
@@ -5,7 +5,6 @@ import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import { ButtonExtension } from './tiptap-button-extension';
|
||||
import { openWPMediaImage } from '@/lib/wp-media';
|
||||
import {
|
||||
Bold,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
ImageIcon,
|
||||
MousePointer,
|
||||
Undo,
|
||||
Redo,
|
||||
} from 'lucide-react';
|
||||
@@ -50,8 +48,6 @@ export function RichTextEditor({
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
// ButtonExtension MUST come before Link to ensure buttons are parsed first
|
||||
ButtonExtension,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
@@ -109,13 +105,6 @@ export function RichTextEditor({
|
||||
}
|
||||
};
|
||||
|
||||
const [buttonDialogOpen, setButtonDialogOpen] = useState(false);
|
||||
const [buttonText, setButtonText] = useState('Click Here');
|
||||
const [buttonHref, setButtonHref] = useState('{order_url}');
|
||||
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline' | 'link'>('solid');
|
||||
const [isEditingButton, setIsEditingButton] = useState(false);
|
||||
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
|
||||
|
||||
const addImage = () => {
|
||||
openWPMediaImage((file) => {
|
||||
editor.chain().focus().setImage({
|
||||
@@ -126,87 +115,6 @@ export function RichTextEditor({
|
||||
});
|
||||
};
|
||||
|
||||
const openButtonDialog = () => {
|
||||
setButtonText('Click Here');
|
||||
setButtonHref('{order_url}');
|
||||
setButtonStyle('solid');
|
||||
setIsEditingButton(false);
|
||||
setEditingButtonPos(null);
|
||||
setButtonDialogOpen(true);
|
||||
};
|
||||
|
||||
// Handle clicking on buttons in the editor to edit them
|
||||
const handleEditorClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const buttonEl = target.closest('a[data-button]') as HTMLElement | null;
|
||||
|
||||
if (buttonEl && editor) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Get button attributes
|
||||
const text = buttonEl.getAttribute('data-text') || buttonEl.textContent?.replace('🔘 ', '') || 'Click Here';
|
||||
const href = buttonEl.getAttribute('data-href') || '#';
|
||||
const style = (buttonEl.getAttribute('data-style') as 'solid' | 'outline') || 'solid';
|
||||
|
||||
// Find the position of this button node
|
||||
const { state } = editor.view;
|
||||
let foundPos: number | null = null;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'button' &&
|
||||
node.attrs.text === text &&
|
||||
node.attrs.href === href) {
|
||||
foundPos = pos;
|
||||
return false; // Stop iteration
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Open dialog in edit mode
|
||||
setButtonText(text);
|
||||
setButtonHref(href);
|
||||
setButtonStyle(style);
|
||||
setIsEditingButton(true);
|
||||
setEditingButtonPos(foundPos);
|
||||
setButtonDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const insertButton = () => {
|
||||
if (isEditingButton && editingButtonPos !== null && editor) {
|
||||
// Delete old button and insert new one at same position
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
|
||||
.insertContentAt(editingButtonPos, {
|
||||
type: 'button',
|
||||
attrs: { text: buttonText, href: buttonHref, style: buttonStyle },
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
// Insert new button
|
||||
editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
|
||||
}
|
||||
setButtonDialogOpen(false);
|
||||
setIsEditingButton(false);
|
||||
setEditingButtonPos(null);
|
||||
};
|
||||
|
||||
const deleteButton = () => {
|
||||
if (editingButtonPos !== null && editor) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
|
||||
.run();
|
||||
setButtonDialogOpen(false);
|
||||
setIsEditingButton(false);
|
||||
setEditingButtonPos(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveHeading = () => {
|
||||
if (editor.isActive('heading', { level: 1 })) return 'h1';
|
||||
if (editor.isActive('heading', { level: 2 })) return 'h2';
|
||||
@@ -326,14 +234,6 @@ export function RichTextEditor({
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={openButtonDialog}
|
||||
>
|
||||
<MousePointer className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Button
|
||||
type="button"
|
||||
@@ -356,7 +256,7 @@ export function RichTextEditor({
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div onClick={handleEditorClick}>
|
||||
<div>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
@@ -444,91 +344,6 @@ export function RichTextEditor({
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Button Dialog */}
|
||||
<Dialog open={buttonDialogOpen} onOpenChange={(open) => {
|
||||
setButtonDialogOpen(open);
|
||||
if (!open) {
|
||||
setIsEditingButton(false);
|
||||
setEditingButtonPos(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditingButton ? __('Edit Button') : __('Insert Button')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditingButton
|
||||
? __('Edit the button properties below. Click on the button to save.')
|
||||
: __('Add a styled button to your content. Use variables for dynamic links.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="space-y-4 !p-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="btn-text">{__('Button Text')}</Label>
|
||||
<Input
|
||||
id="btn-text"
|
||||
value={buttonText}
|
||||
onChange={(e) => setButtonText(e.target.value)}
|
||||
placeholder={__('e.g., View Order')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="btn-href">{__('Button Link')}</Label>
|
||||
<Input
|
||||
id="btn-href"
|
||||
value={buttonHref}
|
||||
onChange={(e) => setButtonHref(e.target.value)}
|
||||
placeholder="{order_url}"
|
||||
/>
|
||||
{variables.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{variables.filter(v => v.includes('_url') || v.includes('_link')).map((variable) => (
|
||||
<code
|
||||
key={variable}
|
||||
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
||||
onClick={() => setButtonHref(buttonHref + `{${variable}}`)}
|
||||
>
|
||||
{`{${variable}}`}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="btn-style">{__('Button Style')}</Label>
|
||||
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline' | 'link') => setButtonStyle(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
|
||||
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
|
||||
<SelectItem value="link">{__('Plain Link')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
{isEditingButton && (
|
||||
<Button variant="destructive" onClick={deleteButton} className="sm:mr-auto">
|
||||
{__('Delete')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={insertButton}>
|
||||
{isEditingButton ? __('Update Button') : __('Insert Button')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { navTree, MainNode, NAV_TREE_VERSION } from '../nav/tree';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { navTree as initialNavTree, MainNode } from '../nav/tree';
|
||||
|
||||
function getCurrentNavTree(): MainNode[] {
|
||||
const tree = window.WNW_NAV_TREE;
|
||||
return Array.isArray(tree) && tree.length > 0
|
||||
? (tree as MainNode[])
|
||||
: initialNavTree;
|
||||
}
|
||||
|
||||
export function useActiveSection(): { main: MainNode; all: MainNode[] } {
|
||||
const { pathname } = useLocation();
|
||||
const [navTree, setNavTree] = useState<MainNode[]>(getCurrentNavTree);
|
||||
|
||||
useEffect(() => {
|
||||
const handleNavUpdate = () => setNavTree(getCurrentNavTree());
|
||||
window.addEventListener('woonoow:navigation-updated', handleNavUpdate);
|
||||
return () => window.removeEventListener('woonoow:navigation-updated', handleNavUpdate);
|
||||
}, []);
|
||||
|
||||
function pick(): MainNode {
|
||||
// Special case: /settings should match settings section
|
||||
@@ -32,8 +47,5 @@ export function useActiveSection(): { main: MainNode; all: MainNode[] } {
|
||||
const main = pick();
|
||||
const children = Array.isArray(main.children) ? main.children : [];
|
||||
|
||||
// Debug: ensure we are using the latest tree module (driven by PHP-localized window.wnw.isDev)
|
||||
const isDev = Boolean((window as any).wnw?.isDev);
|
||||
|
||||
return { main: { ...main, children }, all: navTree } as const;
|
||||
}
|
||||
@@ -164,9 +164,10 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Hide WooNooW app shell - all nav, header, submenu elements */
|
||||
/* Hide WooNooW app shell - all nav, sidebar, header, submenu elements */
|
||||
#woonoow-admin-app header,
|
||||
#woonoow-admin-app nav,
|
||||
#woonoow-admin-app aside,
|
||||
#woonoow-admin-app [data-submenubar],
|
||||
#woonoow-admin-app [data-bottomnav],
|
||||
.woonoow-app-header,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import { api } from '../api';
|
||||
export const apiClient = api;
|
||||
export { api };
|
||||
|
||||
@@ -107,6 +107,8 @@ function withSectionWrapper(Component: any) {
|
||||
colorScheme={section.colorScheme}
|
||||
elementStyles={section.elementStyles}
|
||||
styles={section.styles}
|
||||
isEditor={true}
|
||||
section={section}
|
||||
{...flatProps}
|
||||
/>
|
||||
);
|
||||
@@ -207,10 +209,10 @@ export function CanvasRenderer({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white transition-all duration-300 min-h-[500px]',
|
||||
'bg-white transition-all duration-300 min-h-[500px] wn-page',
|
||||
deviceMode === 'mobile'
|
||||
? 'max-w-sm mx-auto shadow-2xl rounded-[2.5rem] border-[12px] border-gray-800 my-8 overflow-hidden'
|
||||
: 'w-full h-full'
|
||||
: cn('min-h-full', containerWidth === 'boxed' ? 'container mx-auto max-w-6xl shadow-sm border-x' : 'w-full')
|
||||
)}
|
||||
>
|
||||
{sections.length === 0 ? (
|
||||
|
||||
@@ -82,8 +82,15 @@ export function CanvasSection({
|
||||
{/* Section content with Styles */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-lg",
|
||||
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50"
|
||||
"relative overflow-hidden",
|
||||
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50",
|
||||
{
|
||||
'default': 'py-16 md:py-24',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-[600px] flex items-center',
|
||||
}[section.styles?.heightPreset || 'default'] || 'py-16 md:py-24'
|
||||
)}
|
||||
style={{
|
||||
...(section.styles?.backgroundType === 'gradient'
|
||||
@@ -153,14 +160,23 @@ export function CanvasSection({
|
||||
{/* Content Wrapper */}
|
||||
{section.styles?.contentWidth === 'boxed' ? (
|
||||
<div className="relative z-10 container mx-auto px-4 max-w-5xl">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div
|
||||
className="rounded-2xl shadow-sm border border-gray-200 overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: section.styles?.cardBackgroundColor || '#ffffff',
|
||||
paddingTop: section.styles?.cardPaddingTop || undefined,
|
||||
paddingRight: section.styles?.cardPaddingRight || undefined,
|
||||
paddingBottom: section.styles?.cardPaddingBottom || undefined,
|
||||
paddingLeft: section.styles?.cardPaddingLeft || undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"relative z-10",
|
||||
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'
|
||||
"relative z-10 w-full",
|
||||
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : ''
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,7 @@ export function InspectorField({
|
||||
placeholder={fieldType === 'url' ? 'https://' : `Enter ${fieldLabel.toLowerCase()}`}
|
||||
className="flex-1"
|
||||
/>
|
||||
{(fieldType === 'url' || fieldType === 'image') && (
|
||||
{(fieldType === 'image') && (
|
||||
<MediaUploader
|
||||
onSelect={(url) => handleValueChange(url)}
|
||||
type="image"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -413,6 +414,7 @@ export function InspectorPanel({
|
||||
{ name: 'label', label: 'Label', type: 'text' },
|
||||
{ name: 'image', label: 'Image', type: 'image' },
|
||||
{ name: 'url', label: 'Link URL', type: 'text' },
|
||||
{ name: 'backgroundColor', label: 'Background Color', type: 'color' },
|
||||
{ name: 'size', label: 'Size (small/medium/large/tall)', type: 'text' },
|
||||
]}
|
||||
itemLabelKey="label"
|
||||
@@ -436,7 +438,7 @@ export function InspectorPanel({
|
||||
// Allow advanced override/editing of asset/data if needed
|
||||
{ name: 'product_name', label: 'Product Name', type: 'text' },
|
||||
{ name: 'product_price', label: 'Price', type: 'text' },
|
||||
{ name: 'product_image', label: 'Product Image URL', type: 'text' },
|
||||
{ name: 'product_image', label: 'Product Image URL', type: 'image' },
|
||||
{ name: 'x', label: 'X Position (%)', type: 'text' },
|
||||
{ name: 'y', label: 'Y Position (%)', type: 'text' },
|
||||
]}
|
||||
@@ -448,6 +450,36 @@ export function InspectorPanel({
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Contact Form Fields Repeater */}
|
||||
{selectedSection.type === 'contact-form' && (() => {
|
||||
const fieldsProp = selectedSection.props.fields;
|
||||
const fields = Array.isArray(fieldsProp?.value) ? fieldsProp.value : [];
|
||||
return (
|
||||
<div className="pt-4 border-t">
|
||||
<InspectorRepeater
|
||||
label={__('Form Fields')}
|
||||
items={fields}
|
||||
onChange={(newItems) => onSectionPropChange('fields', { type: 'static', value: newItems })}
|
||||
fields={[
|
||||
{ name: 'name', label: 'Field Name (Key)', type: 'text' },
|
||||
{ name: 'label', label: 'Label / Placeholder', type: 'text' },
|
||||
{ name: 'type', label: 'Input Type', type: 'select', options: [
|
||||
{ label: 'Text', value: 'text' },
|
||||
{ label: 'Email', value: 'email' },
|
||||
{ label: 'Telephone', value: 'tel' },
|
||||
{ label: 'Textarea (Multiline)', value: 'textarea' },
|
||||
]},
|
||||
{ name: 'required', label: 'Is Required?', type: 'checkbox' }
|
||||
]}
|
||||
itemLabelKey="label"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
The <strong>Field Name (Key)</strong> will be the key used when sending data to your webhook.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TabsContent>
|
||||
|
||||
{/* Design Tab */}
|
||||
@@ -491,10 +523,10 @@ export function InspectorPanel({
|
||||
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="#FFFFFF"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
className="flex-1 h-8 px-3 py-1 text-sm"
|
||||
value={selectedSection.styles?.backgroundColor || ''}
|
||||
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
|
||||
/>
|
||||
@@ -525,9 +557,9 @@ export function InspectorPanel({
|
||||
onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-2 py-1 text-xs"
|
||||
className="flex-1 h-8 px-2 py-1 text-xs"
|
||||
value={selectedSection.styles?.gradientFrom || '#9333ea'}
|
||||
onChange={(e) => onSectionStylesChange({ gradientFrom: e.target.value })}
|
||||
/>
|
||||
@@ -545,9 +577,9 @@ export function InspectorPanel({
|
||||
onChange={(e) => onSectionStylesChange({ gradientTo: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-2 py-1 text-xs"
|
||||
className="flex-1 h-8 px-2 py-1 text-xs"
|
||||
value={selectedSection.styles?.gradientTo || '#3b82f6'}
|
||||
onChange={(e) => onSectionStylesChange({ gradientTo: e.target.value })}
|
||||
/>
|
||||
@@ -660,20 +692,20 @@ export function InspectorPanel({
|
||||
<div className="grid grid-cols-2 gap-2 pt-2 border-t mt-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">{__('Padding Top')}</Label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="e.g. 40px, 4rem"
|
||||
className="w-full h-8 text-xs rounded border px-2"
|
||||
className="h-8 text-xs px-2"
|
||||
value={selectedSection.styles?.paddingTop || ''}
|
||||
onChange={(e) => onSectionStylesChange({ paddingTop: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">{__('Padding Bottom')}</Label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="e.g. 40px, 4rem"
|
||||
className="w-full h-8 text-xs rounded border px-2"
|
||||
className="h-8 text-xs px-2"
|
||||
value={selectedSection.styles?.paddingBottom || ''}
|
||||
onChange={(e) => onSectionStylesChange({ paddingBottom: e.target.value })}
|
||||
/>
|
||||
@@ -702,6 +734,49 @@ export function InspectorPanel({
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{selectedSection.styles?.contentWidth === 'boxed' && (
|
||||
<>
|
||||
<div className="space-y-2 pt-2 mt-4">
|
||||
<Label className="text-xs text-gray-500">{__('Card Background Color')}</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.cardBackgroundColor || '#ffffff' }} />
|
||||
<Input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={selectedSection.styles?.cardBackgroundColor || '#ffffff'}
|
||||
onChange={(e) => onSectionStylesChange({ cardBackgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="#ffffff"
|
||||
className="flex-1 h-8 text-xs px-2"
|
||||
value={selectedSection.styles?.cardBackgroundColor || ''}
|
||||
onChange={(e) => onSectionStylesChange({ cardBackgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 pt-2 mt-4 border-t">
|
||||
<Label className="text-xs text-gray-500">{__('Card Padding')}</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="space-y-1 text-center">
|
||||
<Input type="text" placeholder="Top" className="h-8 text-xs px-2 text-center" value={selectedSection.styles?.cardPaddingTop || ''} onChange={(e) => onSectionStylesChange({ cardPaddingTop: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<Input type="text" placeholder="Right" className="h-8 text-xs px-2 text-center" value={selectedSection.styles?.cardPaddingRight || ''} onChange={(e) => onSectionStylesChange({ cardPaddingRight: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<Input type="text" placeholder="Bottom" className="h-8 text-xs px-2 text-center" value={selectedSection.styles?.cardPaddingBottom || ''} onChange={(e) => onSectionStylesChange({ cardPaddingBottom: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<Input type="text" placeholder="Left" className="h-8 text-xs px-2 text-center" value={selectedSection.styles?.cardPaddingLeft || ''} onChange={(e) => onSectionStylesChange({ cardPaddingLeft: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 pt-2 border-t mt-4">
|
||||
<Label className="text-xs">{__('Section Height')}</Label>
|
||||
<Select
|
||||
@@ -739,6 +814,7 @@ export function InspectorPanel({
|
||||
<AccordionTrigger className="text-xs hover:no-underline py-2">{field.label}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-2">
|
||||
{/* Common: Background Wrapper */}
|
||||
{!field.disableBackground && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Background (Wrapper)')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -751,17 +827,18 @@ export function InspectorPanel({
|
||||
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Color (#fff)"
|
||||
className="flex-1 h-7 text-xs rounded border px-2"
|
||||
className="flex-1 h-8 text-xs px-2"
|
||||
value={styles.backgroundColor || ''}
|
||||
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isImage ? (
|
||||
{(!isImage && field.type !== 'container') && (
|
||||
<>
|
||||
{/* Text Color */}
|
||||
<div className="space-y-2">
|
||||
@@ -776,10 +853,10 @@ export function InspectorPanel({
|
||||
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Color (#000)"
|
||||
className="flex-1 h-7 text-xs rounded border px-2"
|
||||
className="flex-1 h-8 text-xs px-2"
|
||||
value={styles.color || ''}
|
||||
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
|
||||
/>
|
||||
@@ -830,6 +907,7 @@ export function InspectorPanel({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{!field.disableAlignment && (
|
||||
<Select value={styles.textAlign || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textAlign: val === 'default' ? undefined : val as any })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -839,6 +917,7 @@ export function InspectorPanel({
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Link Specific Styles */}
|
||||
@@ -865,10 +944,10 @@ export function InspectorPanel({
|
||||
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Hover Color"
|
||||
className="flex-1 h-7 text-xs rounded border px-2"
|
||||
className="flex-1 h-8 text-xs px-2"
|
||||
value={styles.hoverColor || ''}
|
||||
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
|
||||
/>
|
||||
@@ -876,9 +955,66 @@ export function InspectorPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Image Settings */}
|
||||
{isImage && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Image Fit')}</Label>
|
||||
<Select value={styles.objectFit || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { objectFit: val === 'default' ? undefined : val as any })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Object Fit" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="cover">Cover</SelectItem>
|
||||
<SelectItem value="contain">Contain</SelectItem>
|
||||
<SelectItem value="fill">Fill</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label className="text-xs text-gray-500">{__('Image Focal Point')}</Label>
|
||||
<Select value={styles.objectPosition || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { objectPosition: val === 'default' ? undefined : val as any })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Position (e.g. center, top)" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
<SelectItem value="top">Top</SelectItem>
|
||||
<SelectItem value="bottom">Bottom</SelectItem>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2 pt-2 pb-2">
|
||||
<Label className="text-xs text-gray-500">{__('Wrapper Alignment')}</Label>
|
||||
<Select value={styles.alignment || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { alignment: val === 'default' ? undefined : val as any })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">{__('Width')}</Label>
|
||||
<Input type="text" placeholder="e.g. 100%" className="h-8 text-xs px-2" value={styles.width || ''} onChange={(e) => onElementStylesChange(field.name, { width: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">{__('Height')}</Label>
|
||||
<Input type="text" placeholder="e.g. auto" className="h-8 text-xs px-2" value={styles.height || ''} onChange={(e) => onElementStylesChange(field.name, { height: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Button/Box Specific Styles */}
|
||||
{field.name === 'button' && (
|
||||
{(field.name === 'button' || field.type === 'container') && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Box Styles')}</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -898,48 +1034,19 @@ export function InspectorPanel({
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-400">{__('Border Width')}</Label>
|
||||
<input type="text" placeholder="e.g. 1px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderWidth || ''} onChange={(e) => onElementStylesChange(field.name, { borderWidth: e.target.value })} />
|
||||
<Input type="text" placeholder="e.g. 1px" className="h-8 text-xs px-2" value={styles.borderWidth || ''} onChange={(e) => onElementStylesChange(field.name, { borderWidth: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-400">{__('Radius')}</Label>
|
||||
<input type="text" placeholder="e.g. 4px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderRadius || ''} onChange={(e) => onElementStylesChange(field.name, { borderRadius: e.target.value })} />
|
||||
<Input type="text" placeholder="e.g. 4px" className="h-8 text-xs px-2" value={styles.borderRadius || ''} onChange={(e) => onElementStylesChange(field.name, { borderRadius: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-400">{__('Padding')}</Label>
|
||||
<input type="text" placeholder="e.g. 8px 16px" className="w-full h-7 text-xs rounded border px-2" value={styles.padding || ''} onChange={(e) => onElementStylesChange(field.name, { padding: e.target.value })} />
|
||||
<Input type="text" placeholder="e.g. 8px 16px" className="h-8 text-xs px-2" value={styles.padding || ''} onChange={(e) => onElementStylesChange(field.name, { padding: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Image Settings */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Image Fit')}</Label>
|
||||
<Select value={styles.objectFit || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { objectFit: val === 'default' ? undefined : val as any })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Object Fit" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="cover">Cover</SelectItem>
|
||||
<SelectItem value="contain">Contain</SelectItem>
|
||||
<SelectItem value="fill">Fill</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">{__('Width')}</Label>
|
||||
<input type="text" placeholder="e.g. 100%" className="w-full h-7 text-xs rounded border px-2" value={styles.width || ''} onChange={(e) => onElementStylesChange(field.name, { width: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">{__('Height')}</Label>
|
||||
<input type="text" placeholder="e.g. auto" className="w-full h-7 text-xs rounded border px-2" value={styles.height || ''} onChange={(e) => onElementStylesChange(field.name, { height: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Trash2, GripVertical } from 'lucide-react';
|
||||
import { Plus, Trash2, GripVertical, Image as ImageIcon } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
@@ -41,8 +42,9 @@ import RepeaterProductField from './RepeaterProductField';
|
||||
interface RepeaterFieldDef {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'textarea' | 'url' | 'image' | 'icon' | 'product';
|
||||
type: 'text' | 'textarea' | 'url' | 'image' | 'icon' | 'product' | 'select' | 'checkbox' | 'color';
|
||||
placeholder?: string;
|
||||
options?: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
interface InspectorRepeaterProps {
|
||||
@@ -91,8 +93,8 @@ function SortableItem({
|
||||
'Wifi', 'Wrench',
|
||||
].sort();
|
||||
|
||||
const handleFieldChange = (fieldName: string, value: any) => {
|
||||
onChange(index, fieldName, value);
|
||||
const handleFieldChange = (fieldNameOrUpdates: string | Record<string, any>, value?: any) => {
|
||||
onChange(index, fieldNameOrUpdates, value);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -151,7 +153,7 @@ function RepeaterFieldRenderer({
|
||||
field: RepeaterFieldDef;
|
||||
item: any;
|
||||
index: number;
|
||||
onChange: (fieldName: string, value: any) => void;
|
||||
onChange: (fieldNameOrUpdates: string | Record<string, any>, value?: any) => void;
|
||||
ICON_OPTIONS: string[];
|
||||
}) {
|
||||
const value = item[field.name] || '';
|
||||
@@ -195,44 +197,53 @@ function RepeaterFieldRenderer({
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'color') {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-gray-500">{field.label}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: value || 'transparent' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={value || '#ffffff'}
|
||||
onChange={(e) => onChange(field.name, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="#ffffff"
|
||||
className="flex-1 h-8 text-xs px-2"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(field.name, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'image') {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-gray-500">{field.label}</Label>
|
||||
<div className="space-y-2">
|
||||
{value ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(field.name, e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="flex-1 text-xs h-8"
|
||||
/>
|
||||
<MediaUploader
|
||||
onSelect={(url) => onChange(field.name, url)}
|
||||
type="image"
|
||||
className="shrink-0"
|
||||
>
|
||||
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50 flex items-center justify-center">
|
||||
<img src={value} alt={field.label} className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-white text-xs font-medium">Change</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(field.name, '');
|
||||
}}
|
||||
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
type="button"
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</MediaUploader>
|
||||
) : (
|
||||
<MediaUploader
|
||||
onSelect={(url) => onChange(field.name, url)}
|
||||
type="image"
|
||||
>
|
||||
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal justify-start">
|
||||
Select Image
|
||||
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" title="Select Image" type="button">
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</MediaUploader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -251,6 +262,41 @@ function RepeaterFieldRenderer({
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-gray-500">{field.label}</Label>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(field.name, val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-full">
|
||||
<SelectValue placeholder={field.placeholder || "Select an option"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'checkbox') {
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-2 py-1">
|
||||
<Label className="text-xs text-gray-500">{field.label}</Label>
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onCheckedChange={(checked) => onChange(field.name, checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// default: text/url inputs
|
||||
const inputType = field.type === 'url' ? 'url' : 'text';
|
||||
|
||||
@@ -296,9 +342,13 @@ export function InspectorRepeater({
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemChange = (index: number, fieldName: string, value: string) => {
|
||||
const handleItemChange = (index: number, fieldNameOrUpdates: string | Record<string, any>, value?: any) => {
|
||||
const newItems = [...items];
|
||||
newItems[index] = { ...newItems[index], [fieldName]: value };
|
||||
if (typeof fieldNameOrUpdates === 'string') {
|
||||
newItems[index] = { ...newItems[index], [fieldNameOrUpdates]: value };
|
||||
} else {
|
||||
newItems[index] = { ...newItems[index], ...fieldNameOrUpdates };
|
||||
}
|
||||
onChange(newItems);
|
||||
};
|
||||
|
||||
@@ -341,7 +391,7 @@ export function InspectorRepeater({
|
||||
item={item}
|
||||
fields={fields}
|
||||
itemLabelKey={itemLabelKey}
|
||||
onChange={(idx: number, fieldName: string, value: string) => handleItemChange(idx, fieldName, value)}
|
||||
onChange={(idx: number, fieldNameOrUpdates: string | Record<string, any>, value?: any) => handleItemChange(idx, fieldNameOrUpdates, value)}
|
||||
onDelete={handleDeleteItem}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function RepeaterProductField({
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (fieldName: string, nextValue: any) => void;
|
||||
onChange: (fieldNameOrUpdates: string | Record<string, any>, nextValue?: any) => void;
|
||||
}) {
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [options, setOptions] = React.useState<any[]>([]);
|
||||
@@ -77,11 +77,13 @@ export default function RepeaterProductField({
|
||||
const selected = options.find((o) => o.value === v)?.product;
|
||||
if (!selected) return;
|
||||
|
||||
onChange('product_slug', selected.product_slug || '');
|
||||
onChange('product_name', selected.name || '');
|
||||
onChange('product_price', selected.sale_price ?? selected.price ?? '');
|
||||
onChange('product_image', selected.image_url ?? '');
|
||||
onChange('product_id', selected.id ? Number(selected.id) : 0);
|
||||
onChange({
|
||||
product_slug: selected.product_slug || '',
|
||||
product_name: selected.name || '',
|
||||
product_price: selected.sale_price ?? selected.price ?? '',
|
||||
product_image: selected.image_url ?? '',
|
||||
product_id: selected.id ? Number(selected.id) : 0,
|
||||
});
|
||||
}}
|
||||
options={options.map((o) => ({
|
||||
value: String(o.value ?? ''),
|
||||
|
||||
@@ -32,16 +32,6 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps
|
||||
const buttonText = section.props?.button_text?.value || 'Get Started';
|
||||
const buttonUrl = section.props?.button_url?.value || '#';
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
@@ -69,7 +59,7 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
|
||||
return (
|
||||
<div className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && scheme.bg, scheme.text, className)}>
|
||||
<div className={cn('px-4 md:px-8', !hasCustomBackground && scheme.bg, scheme.text, className)}>
|
||||
<div className="max-w-4xl mx-auto text-center space-y-6">
|
||||
<h2
|
||||
className={cn(
|
||||
@@ -88,7 +78,7 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps
|
||||
)}
|
||||
style={textStyle.style}
|
||||
>
|
||||
{text}
|
||||
{text || "Description text missing"}
|
||||
</p>
|
||||
<button className={cn(
|
||||
'inline-flex items-center gap-2 px-8 py-4 rounded-lg font-semibold transition hover:opacity-90',
|
||||
|
||||
@@ -69,21 +69,11 @@ export function ContactFormRenderer({ section, className }: ContactFormRendererP
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
className={cn('px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||
>
|
||||
<div className="max-w-xl mx-auto">
|
||||
@@ -98,47 +88,27 @@ export function ContactFormRenderer({ section, className }: ContactFormRendererP
|
||||
</h2>
|
||||
|
||||
<form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
|
||||
{/* Name field */}
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your Name"
|
||||
className={cn(
|
||||
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
!fieldsStyle.backgroundColor && scheme.inputBg
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.backgroundColor,
|
||||
color: fieldsStyle.color
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
{/* Render fields from config, fallback to default if missing */}
|
||||
{(() => {
|
||||
const defaultFields = [
|
||||
{ name: 'name', label: 'Your Name', type: 'text', required: true },
|
||||
{ name: 'email', label: 'Your Email', type: 'email', required: true },
|
||||
{ name: 'message', label: 'Your Message', type: 'textarea', required: true }
|
||||
];
|
||||
const fieldsProp = section.props?.fields?.value;
|
||||
const fields = Array.isArray(fieldsProp) && fieldsProp.length > 0 ? fieldsProp : defaultFields;
|
||||
|
||||
{/* Email field */}
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your Email"
|
||||
className={cn(
|
||||
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
!fieldsStyle.backgroundColor && scheme.inputBg
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.backgroundColor,
|
||||
color: fieldsStyle.color
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message field */}
|
||||
<div className="relative">
|
||||
<MessageSquare className="absolute left-4 top-4 w-5 h-5 text-gray-400" />
|
||||
return fields.map((field: any, idx: number) => {
|
||||
const Icon = field.type === 'email' ? Mail : field.type === 'textarea' ? MessageSquare : User;
|
||||
return (
|
||||
<div key={field.name || idx} className="relative">
|
||||
<Icon className={cn(
|
||||
"absolute left-4 text-gray-400 w-5 h-5",
|
||||
field.type === 'textarea' ? "top-4" : "top-1/2 -translate-y-1/2"
|
||||
)} />
|
||||
{field.type === 'textarea' ? (
|
||||
<textarea
|
||||
placeholder="Your Message"
|
||||
placeholder={field.label + (field.required ? ' *' : '')}
|
||||
rows={4}
|
||||
className={cn(
|
||||
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none',
|
||||
@@ -150,7 +120,25 @@ export function ContactFormRenderer({ section, className }: ContactFormRendererP
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={field.type || 'text'}
|
||||
placeholder={field.label + (field.required ? ' *' : '')}
|
||||
className={cn(
|
||||
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
!fieldsStyle.backgroundColor && scheme.inputBg
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.backgroundColor,
|
||||
color: fieldsStyle.color
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
|
||||
@@ -155,18 +155,6 @@ export function ContentRenderer({ section, className }: ContentRendererProps) {
|
||||
const layout = section.layoutVariant || 'default';
|
||||
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
|
||||
|
||||
const heightPreset = section.styles?.heightPreset || 'default';
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-32',
|
||||
'screen': 'min-h-screen py-20 flex items-center',
|
||||
};
|
||||
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
|
||||
|
||||
const content = section.props?.content?.value || 'Your content goes here. Edit this in the inspector panel.';
|
||||
const isDynamic = section.props?.content?.type === 'dynamic';
|
||||
|
||||
@@ -218,7 +206,6 @@ export function ContentRenderer({ section, className }: ContentRendererProps) {
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden',
|
||||
'px-4 md:px-8',
|
||||
heightClasses,
|
||||
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
|
||||
scheme.text,
|
||||
className
|
||||
|
||||
@@ -119,21 +119,11 @@ export function FeatureGridRenderer({ section, className }: FeatureGridRendererP
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
className={cn('px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
|
||||
@@ -27,16 +27,6 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
const layout = section.layoutVariant || 'default';
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
|
||||
const title = section.props?.title?.value || 'Hero Title';
|
||||
const subtitle = section.props?.subtitle?.value || 'Your amazing subtitle here';
|
||||
const image = section.props?.image?.value;
|
||||
@@ -81,7 +71,7 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
|
||||
if (layout === 'hero-left-image' || layout === 'hero-right-image') {
|
||||
return (
|
||||
<div
|
||||
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
className={cn('px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
>
|
||||
<div className={cn(
|
||||
'max-w-6xl mx-auto flex items-center gap-12',
|
||||
@@ -156,7 +146,7 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
|
||||
// Default centered layout
|
||||
return (
|
||||
<div
|
||||
className={cn(heightClasses, 'px-4 md:px-8 text-center', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
className={cn('px-4 md:px-8 text-center', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h1
|
||||
|
||||
@@ -72,21 +72,11 @@ export function ImageTextRenderer({ section, className }: ImageTextRendererProps
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
className={cn('px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
||||
export function MarqueeBannerRenderer({ section, className }: { section: any; className?: string }) {
|
||||
const { text, separator } = section.props;
|
||||
const styles = section.styles || {};
|
||||
const elementStyles = section.elementStyles || {};
|
||||
|
||||
const displayText = text?.value || 'Marquee Banner Text Here';
|
||||
const displaySeparator = separator?.value || '✦';
|
||||
@@ -19,7 +20,14 @@ export function MarqueeBannerRenderer({ section, className }: { section: any; cl
|
||||
<div className="flex whitespace-nowrap opacity-70">
|
||||
<div className="flex items-center gap-8 pr-8">
|
||||
{[1, 2, 3].map((idx) => (
|
||||
<span key={idx} className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase">
|
||||
<span
|
||||
key={idx}
|
||||
className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase"
|
||||
style={{
|
||||
color: elementStyles?.text?.color,
|
||||
fontSize: elementStyles?.text?.fontSize?.replace('text-', '') ? undefined : 'inherit' // Basic mock
|
||||
}}
|
||||
>
|
||||
{displayText}
|
||||
<span className="opacity-50 text-xs">{displaySeparator}</span>
|
||||
</span>
|
||||
|
||||
@@ -37,17 +37,21 @@ export function ShoppableImageRenderer({ section, className }: { section: any; c
|
||||
|
||||
<div className="relative rounded-xl overflow-hidden bg-gray-100 aspect-[16/9] border-2 border-dashed border-gray-300 flex items-center justify-center">
|
||||
{displayImage ? (
|
||||
<>
|
||||
<img src={displayImage} alt="Shoppable Preview" className="w-full h-full object-cover opacity-50" />
|
||||
{displayHotspots.map((hotspot: any, idx: number) => (
|
||||
{displayHotspots.map((hotspot: any, idx: number) => {
|
||||
const xVal = parseFloat(String(hotspot.x ?? 0).replace('%', '')) || 0;
|
||||
const yVal = parseFloat(String(hotspot.y ?? 0).replace('%', '')) || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="absolute w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center border-2 border-white shadow-lg text-xs font-bold"
|
||||
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%`, transform: 'translate(-50%, -50%)' }}
|
||||
style={{ left: `${xVal}%`, top: `${yVal}%`, transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center text-gray-400">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Layout, Undo2, Save, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Plus, Layout, Undo2, Redo2, Save, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -50,6 +50,11 @@ export default function AppearancePages() {
|
||||
setInspectorCollapsed,
|
||||
setAvailableSources,
|
||||
setIsLoading,
|
||||
undo,
|
||||
redo,
|
||||
past,
|
||||
future,
|
||||
updateCurrentPage,
|
||||
addSection,
|
||||
deleteSection,
|
||||
duplicateSection,
|
||||
@@ -61,6 +66,7 @@ export default function AppearancePages() {
|
||||
updateSectionStyles,
|
||||
updateElementStyles,
|
||||
markAsSaved,
|
||||
markAsChanged,
|
||||
setAsSpaLanding,
|
||||
unsetSpaLanding,
|
||||
} = usePageEditorStore();
|
||||
@@ -160,7 +166,10 @@ export default function AppearancePages() {
|
||||
const endpoint = currentPage.type === 'page'
|
||||
? `/pages/${currentPage.slug}`
|
||||
: `/templates/${currentPage.cpt}`;
|
||||
return api.post(endpoint, { sections });
|
||||
return api.post(endpoint, {
|
||||
sections,
|
||||
container_width: currentPage.containerWidth
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Page saved successfully'));
|
||||
@@ -332,17 +341,40 @@ export default function AppearancePages() {
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</Button>
|
||||
{hasUnsavedChanges && (
|
||||
<>
|
||||
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center rounded-md border bg-muted/50 p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={undo}
|
||||
disabled={past.length === 0}
|
||||
title={__('Undo')}
|
||||
>
|
||||
<Undo2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={redo}
|
||||
disabled={future.length === 0}
|
||||
title={__('Redo')}
|
||||
>
|
||||
<Redo2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDiscard}
|
||||
>
|
||||
<Undo2 className="w-4 h-4 mr-2" />
|
||||
{__('Discard')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -455,10 +487,7 @@ export default function AppearancePages() {
|
||||
onDeletePage={handleDeletePage}
|
||||
onDeleteTemplate={handleDeleteTemplate}
|
||||
onContainerWidthChange={(width) => {
|
||||
if (currentPage) {
|
||||
setCurrentPage({ ...currentPage, containerWidth: width });
|
||||
markAsSaved(); // Mark as changed so save button enables
|
||||
}
|
||||
updateCurrentPage({ containerWidth: width });
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -23,7 +23,9 @@ export interface SectionOption {
|
||||
export interface StylableElementSchema {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'image';
|
||||
type: 'text' | 'image' | 'container';
|
||||
disableAlignment?: boolean;
|
||||
disableBackground?: boolean;
|
||||
}
|
||||
|
||||
export interface SectionSchema {
|
||||
@@ -53,7 +55,7 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
||||
fields: [
|
||||
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
||||
{ name: 'subtitle', label: 'Subtitle', type: 'text', dynamic: true },
|
||||
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
|
||||
{ name: 'image', label: 'Image', type: 'image', dynamic: true },
|
||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
@@ -89,12 +91,12 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
],
|
||||
stylableElements: [
|
||||
{ name: 'heading', label: 'Headings', type: 'text' },
|
||||
{ name: 'text', label: 'Body Text', type: 'text' },
|
||||
{ name: 'link', label: 'Links', type: 'text' },
|
||||
{ name: 'image', label: 'Images', type: 'image' },
|
||||
{ name: 'content', label: 'Container', type: 'container', disableAlignment: true },
|
||||
{ name: 'heading', label: 'Headings', type: 'text', disableAlignment: true },
|
||||
{ name: 'text', label: 'Body Text', type: 'text', disableAlignment: true },
|
||||
{ name: 'link', label: 'Links', type: 'text', disableAlignment: true },
|
||||
{ name: 'image', label: 'Images', type: 'image', disableAlignment: true },
|
||||
{ name: 'button', label: 'Button', type: 'text' },
|
||||
{ name: 'content', label: 'Container', type: 'text' },
|
||||
],
|
||||
},
|
||||
'image-text': {
|
||||
@@ -111,7 +113,7 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
||||
fields: [
|
||||
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
||||
{ name: 'text', label: 'Text', type: 'textarea', dynamic: true },
|
||||
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
|
||||
{ name: 'image', label: 'Image', type: 'image', dynamic: true },
|
||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
@@ -145,6 +147,7 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
||||
stylableElements: [
|
||||
{ name: 'heading', label: 'Heading', type: 'text' },
|
||||
{ name: 'feature_item', label: 'Feature Item (Card)', type: 'text' },
|
||||
{ name: 'link', label: 'Link (Read more)', type: 'text' },
|
||||
],
|
||||
},
|
||||
'cta-banner': {
|
||||
@@ -177,6 +180,14 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
||||
title: { type: 'static', value: 'Contact Us' },
|
||||
webhook_url: { type: 'static', value: '' },
|
||||
redirect_url: { type: 'static', value: '' },
|
||||
fields: {
|
||||
type: 'static',
|
||||
value: [
|
||||
{ name: 'name', label: 'Your Name', type: 'text', required: true },
|
||||
{ name: 'email', label: 'Your Email', type: 'email', required: true },
|
||||
{ name: 'message', label: 'Your Message', type: 'textarea', required: true },
|
||||
]
|
||||
}
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
@@ -231,8 +242,8 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
||||
{ value: 'featured', label: 'Featured' },
|
||||
],
|
||||
stylableElements: [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
||||
{ name: 'title', label: 'Title', type: 'text', disableAlignment: true },
|
||||
{ name: 'subtitle', label: 'Subtitle', type: 'text', disableAlignment: true },
|
||||
],
|
||||
},
|
||||
'shoppable-image': {
|
||||
@@ -249,11 +260,12 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
||||
fields: [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
||||
{ name: 'image', label: 'Image URL', type: 'url' },
|
||||
{ name: 'image', label: 'Image', type: 'image' },
|
||||
{ name: 'alt', label: 'Image Alt Text', type: 'text' },
|
||||
],
|
||||
stylableElements: [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
||||
],
|
||||
},
|
||||
'marquee-banner': {
|
||||
@@ -270,6 +282,9 @@ export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
||||
{ name: 'separator', label: 'Separator', type: 'text' },
|
||||
{ name: 'speed', label: 'Speed (seconds)', type: 'text' },
|
||||
],
|
||||
stylableElements: [
|
||||
{ name: 'text', label: 'Banner Text', type: 'text', disableBackground: true, disableAlignment: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ export interface SectionStyles {
|
||||
paddingTop?: string;
|
||||
paddingBottom?: string;
|
||||
contentWidth?: 'full' | 'contained' | 'boxed';
|
||||
cardBackgroundColor?: string;
|
||||
cardPaddingTop?: string;
|
||||
cardPaddingRight?: string;
|
||||
cardPaddingBottom?: string;
|
||||
cardPaddingLeft?: string;
|
||||
heightPreset?: string;
|
||||
dynamicBackground?: string; // e.g. 'post_featured_image'
|
||||
}
|
||||
@@ -34,6 +39,8 @@ export interface ElementStyle {
|
||||
|
||||
// Image specific
|
||||
objectFit?: 'cover' | 'contain' | 'fill';
|
||||
objectPosition?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
backgroundColor?: string; // Wrapper BG
|
||||
width?: string;
|
||||
height?: string;
|
||||
@@ -73,6 +80,10 @@ export interface PageItem {
|
||||
isSpaLanding?: boolean;
|
||||
containerWidth?: 'boxed' | 'fullwidth' | 'default';
|
||||
}
|
||||
interface HistoryState {
|
||||
sections: Section[];
|
||||
currentPage: PageItem | null;
|
||||
}
|
||||
|
||||
interface PageEditorState {
|
||||
// Current page/template being edited
|
||||
@@ -91,6 +102,10 @@ interface PageEditorState {
|
||||
hasUnsavedChanges: boolean;
|
||||
isLoading: boolean;
|
||||
|
||||
// History (Undo/Redo)
|
||||
past: HistoryState[];
|
||||
future: HistoryState[];
|
||||
|
||||
// Available sources for dynamic fields (CPT templates)
|
||||
availableSources: { value: string; label: string }[];
|
||||
|
||||
@@ -104,6 +119,14 @@ interface PageEditorState {
|
||||
setAvailableSources: (sources: { value: string; label: string }[]) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
|
||||
// History actions
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
pushHistory: () => void;
|
||||
|
||||
// Page updates
|
||||
updateCurrentPage: (updates: Partial<PageItem>) => void;
|
||||
|
||||
// Section actions
|
||||
addSection: (type: string, index?: number) => void;
|
||||
deleteSection: (id: string) => void;
|
||||
@@ -137,11 +160,13 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||
inspectorCollapsed: false,
|
||||
hasUnsavedChanges: false,
|
||||
isLoading: false,
|
||||
past: [],
|
||||
future: [],
|
||||
availableSources: [],
|
||||
|
||||
// Setters
|
||||
setCurrentPage: (currentPage) => set({ currentPage }),
|
||||
setSections: (sections) => set({ sections, hasUnsavedChanges: true }),
|
||||
setSections: (sections) => set({ sections, hasUnsavedChanges: true, past: [], future: [] }),
|
||||
setSelectedSection: (selectedSectionId) => set({ selectedSectionId }),
|
||||
setHoveredSection: (hoveredSectionId) => set({ hoveredSectionId }),
|
||||
setDeviceMode: (deviceMode) => set({ deviceMode }),
|
||||
@@ -149,9 +174,64 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||
setAvailableSources: (availableSources) => set({ availableSources }),
|
||||
setIsLoading: (isLoading) => set({ isLoading }),
|
||||
|
||||
// History actions
|
||||
pushHistory: () => {
|
||||
const { sections, currentPage } = get();
|
||||
set((state) => ({
|
||||
past: [...state.past, {
|
||||
sections: JSON.parse(JSON.stringify(sections)),
|
||||
currentPage: JSON.parse(JSON.stringify(currentPage))
|
||||
}],
|
||||
future: []
|
||||
}));
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { past, future, sections, currentPage } = get();
|
||||
if (past.length === 0) return;
|
||||
|
||||
const previous = past[past.length - 1];
|
||||
const newPast = past.slice(0, past.length - 1);
|
||||
|
||||
set({
|
||||
past: newPast,
|
||||
future: [{ sections, currentPage }, ...future],
|
||||
sections: previous.sections,
|
||||
currentPage: previous.currentPage,
|
||||
hasUnsavedChanges: true
|
||||
});
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { past, future, sections, currentPage } = get();
|
||||
if (future.length === 0) return;
|
||||
|
||||
const next = future[0];
|
||||
const newFuture = future.slice(1);
|
||||
|
||||
set({
|
||||
past: [...past, { sections, currentPage }],
|
||||
future: newFuture,
|
||||
sections: next.sections,
|
||||
currentPage: next.currentPage,
|
||||
hasUnsavedChanges: true
|
||||
});
|
||||
},
|
||||
|
||||
// Page updates
|
||||
updateCurrentPage: (updates) => {
|
||||
const { currentPage, pushHistory } = get();
|
||||
if (!currentPage) return;
|
||||
pushHistory();
|
||||
set({
|
||||
currentPage: { ...currentPage, ...updates },
|
||||
hasUnsavedChanges: true
|
||||
});
|
||||
},
|
||||
|
||||
// Section actions
|
||||
addSection: (type, index) => {
|
||||
const { sections } = get();
|
||||
const { sections, pushHistory } = get();
|
||||
const sectionConfig = getSectionSchema(type);
|
||||
|
||||
if (!sectionConfig) return;
|
||||
@@ -163,6 +243,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||
styles: cloneDefaultStyles(type) as SectionStyles,
|
||||
};
|
||||
|
||||
pushHistory();
|
||||
|
||||
const newSections = [...sections];
|
||||
if (typeof index === 'number') {
|
||||
newSections.splice(index, 0, newSection);
|
||||
@@ -177,7 +259,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||
},
|
||||
|
||||
deleteSection: (id) => {
|
||||
const { sections, selectedSectionId } = get();
|
||||
const { sections, selectedSectionId, pushHistory } = get();
|
||||
pushHistory();
|
||||
const newSections = sections.filter(s => s.id !== id);
|
||||
|
||||
set({
|
||||
@@ -188,10 +271,12 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||
},
|
||||
|
||||
duplicateSection: (id) => {
|
||||
const { sections } = get();
|
||||
const { sections, pushHistory } = get();
|
||||
const index = sections.findIndex(s => s.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
pushHistory();
|
||||
|
||||
const section = sections[index];
|
||||
const newSection: Section = {
|
||||
...JSON.parse(JSON.stringify(section)), // Deep clone
|
||||
@@ -205,27 +290,32 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||
},
|
||||
|
||||
moveSection: (id, direction) => {
|
||||
const { sections } = get();
|
||||
const { sections, pushHistory } = get();
|
||||
const index = sections.findIndex(s => s.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
if (direction === 'up' && index > 0) {
|
||||
pushHistory();
|
||||
const newSections = [...sections];
|
||||
[newSections[index], newSections[index - 1]] = [newSections[index - 1], newSections[index]];
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
} else if (direction === 'down' && index < sections.length - 1) {
|
||||
pushHistory();
|
||||
const newSections = [...sections];
|
||||
[newSections[index], newSections[index + 1]] = [newSections[index + 1], newSections[index]];
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
}
|
||||
},
|
||||
|
||||
reorderSections: (sections) => {
|
||||
set({ sections, hasUnsavedChanges: true });
|
||||
reorderSections: (newSections) => {
|
||||
const { sections, pushHistory } = get();
|
||||
pushHistory();
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
updateSectionProp: (sectionId, propName, value) => {
|
||||
const { sections } = get();
|
||||
const { sections, pushHistory } = get();
|
||||
pushHistory();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
@@ -240,7 +330,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||
},
|
||||
|
||||
updateSectionLayout: (sectionId, layoutVariant) => {
|
||||
const { sections } = get();
|
||||
const { sections, pushHistory } = get();
|
||||
pushHistory();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
@@ -252,7 +343,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||
},
|
||||
|
||||
updateSectionColorScheme: (sectionId, colorScheme) => {
|
||||
const { sections } = get();
|
||||
const { sections, pushHistory } = get();
|
||||
pushHistory();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
@@ -264,7 +356,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||
},
|
||||
|
||||
updateSectionStyles: (sectionId, styles) => {
|
||||
const { sections } = get();
|
||||
const { sections, pushHistory } = get();
|
||||
pushHistory();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
@@ -279,7 +372,8 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||
},
|
||||
|
||||
updateElementStyles: (sectionId, fieldName, styles) => {
|
||||
const { sections } = get();
|
||||
const { sections, pushHistory } = get();
|
||||
pushHistory();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function AppearanceShop() {
|
||||
const [gridStyle, setGridStyle] = useState('standard');
|
||||
const [cardStyle, setCardStyle] = useState('card');
|
||||
const [aspectRatio, setAspectRatio] = useState('square');
|
||||
const [filterLayout, setFilterLayout] = useState('basic');
|
||||
|
||||
const [elements, setElements] = useState({
|
||||
category_filter: true,
|
||||
@@ -50,6 +51,7 @@ export default function AppearanceShop() {
|
||||
setCardStyle(shop.layout?.card_style || 'card');
|
||||
setAspectRatio(shop.layout?.aspect_ratio || 'square');
|
||||
setCardTextAlign(shop.layout?.card_text_align || 'left');
|
||||
setFilterLayout(shop.layout?.filter_layout || 'basic');
|
||||
|
||||
if (shop.elements) {
|
||||
setElements(shop.elements);
|
||||
@@ -83,7 +85,8 @@ export default function AppearanceShop() {
|
||||
grid_style: gridStyle,
|
||||
card_style: cardStyle,
|
||||
aspect_ratio: aspectRatio,
|
||||
card_text_align: cardTextAlign
|
||||
card_text_align: cardTextAlign,
|
||||
filter_layout: filterLayout
|
||||
},
|
||||
elements: {
|
||||
category_filter: elements.category_filter,
|
||||
@@ -181,6 +184,18 @@ export default function AppearanceShop() {
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Filter Layout" htmlFor="filter-layout" description="Choose how catalog filters are presented">
|
||||
<Select value={filterLayout} onValueChange={setFilterLayout}>
|
||||
<SelectTrigger id="filter-layout">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="basic">Basic - Horizontal Top Bar</SelectItem>
|
||||
<SelectItem value="rich_sidebar">Rich - Left Sidebar</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Product Card Style" htmlFor="card-style" description="Visual style adapts to column count - more columns = cleaner style">
|
||||
<Select value={cardStyle} onValueChange={setCardStyle}>
|
||||
<SelectTrigger id="card-style">
|
||||
|
||||
252
admin-spa/src/routes/Marketing/Affiliates/List.tsx
Normal file
252
admin-spa/src/routes/Marketing/Affiliates/List.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Search, CheckCircle, XCircle, Edit, Pencil, Users, TrendingUp, DollarSign } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { showSuccessToast, showErrorToast } from '@/lib/errorHandling';
|
||||
import { formatMoney } from '@/lib/currency';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
export default function AffiliatesList() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [editingAffiliate, setEditingAffiliate] = useState<any>(null);
|
||||
const [editRate, setEditRate] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: affiliates, isLoading } = useQuery({
|
||||
queryKey: ['admin-affiliates'],
|
||||
queryFn: async () => {
|
||||
const response: any = await api.get('/admin/affiliates');
|
||||
return Array.isArray(response) ? response : (response?.data || []);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, rate }: { id: number; rate: number }) => {
|
||||
return api.post(`/admin/affiliates/${id}/update`, { custom_commission_rate: rate });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-affiliates'] });
|
||||
setEditingAffiliate(null);
|
||||
showSuccessToast(__('Commission rate updated successfully'));
|
||||
},
|
||||
onError: (err: any) => {
|
||||
showErrorToast(err, __('Failed to update commission rate'));
|
||||
}
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await api.post(`/admin/affiliates/${id}/approve`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-affiliates'] });
|
||||
}
|
||||
});
|
||||
|
||||
const filteredAffiliates = (affiliates || []).filter((aff: any) => {
|
||||
const code = aff.referral_code || '';
|
||||
return code.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
// Stats
|
||||
const totalAffiliates = (affiliates || []).length;
|
||||
const activeAffiliates = (affiliates || []).filter((a: any) => a.status === 'active').length;
|
||||
const pendingAffiliates = (affiliates || []).filter((a: any) => a.status === 'pending').length;
|
||||
const totalEarnings = (affiliates || []).reduce((sum: number, a: any) => sum + parseFloat(a.total_earnings || 0), 0);
|
||||
const totalPayable = (affiliates || []).reduce((sum: number, a: any) => sum + parseFloat(a.payable_balance || 0), 0);
|
||||
|
||||
const formatCurrency = (value: string | number) => {
|
||||
try {
|
||||
return formatMoney(value);
|
||||
} catch {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return '—';
|
||||
return `IDR ${num.toLocaleString()}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<Users className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{__('Total Affiliates')}</div>
|
||||
<div className="text-2xl font-bold">{totalAffiliates}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{__('Active')}</div>
|
||||
<div className="text-2xl font-bold">{activeAffiliates}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||
<TrendingUp className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{__('Total Earnings')}</div>
|
||||
<div className="text-2xl font-bold">{formatCurrency(totalEarnings)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
|
||||
<DollarSign className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{__('Payable Balance')}</div>
|
||||
<div className="text-2xl font-bold">{formatCurrency(totalPayable)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Table */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder={__('Search by referral code...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{__('Loading affiliates...')}
|
||||
</div>
|
||||
) : filteredAffiliates.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{searchQuery ? __('No affiliates found matching your search') : __('No affiliates yet')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{__('User ID')}</TableHead>
|
||||
<TableHead>{__('Code')}</TableHead>
|
||||
<TableHead>{__('Rate')}</TableHead>
|
||||
<TableHead>{__('Status')}</TableHead>
|
||||
<TableHead className="text-right">{__('Earnings')}</TableHead>
|
||||
<TableHead className="text-right">{__('Payable')}</TableHead>
|
||||
<TableHead className="text-right">{__('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAffiliates.map((affiliate: any) => (
|
||||
<TableRow key={affiliate.id}>
|
||||
<TableCell className="font-medium">{affiliate.user_id}</TableCell>
|
||||
<TableCell>{affiliate.referral_code}</TableCell>
|
||||
<TableCell>
|
||||
{editingAffiliate?.id === affiliate.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={editRate}
|
||||
onChange={(e) => setEditRate(e.target.value)}
|
||||
className="w-20 h-8 text-sm"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => updateMutation.mutate({ id: affiliate.id, rate: parseFloat(editRate) })}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{__('Save')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingAffiliate(null)}
|
||||
>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
{affiliate.custom_commission_rate !== null && affiliate.custom_commission_rate !== undefined && affiliate.custom_commission_rate !== ''
|
||||
? `${parseFloat(affiliate.custom_commission_rate).toFixed(1)}% (custom)`
|
||||
: `${parseFloat(affiliate.commission_rate || 10).toFixed(1)}% (default)`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingAffiliate(affiliate);
|
||||
const rate = affiliate.custom_commission_rate ?? affiliate.commission_rate ?? 10;
|
||||
setEditRate(rate.toString());
|
||||
}}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={__('Edit commission rate')}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
affiliate.status === 'active'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300'
|
||||
}`}>
|
||||
{affiliate.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(affiliate.total_earnings)}</TableCell>
|
||||
<TableCell className="text-right text-green-600 font-medium">
|
||||
{formatCurrency(affiliate.payable_balance || 0)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{affiliate.status === 'pending' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => approveMutation.mutate(affiliate.id)}
|
||||
disabled={approveMutation.isPending}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
||||
{__('Approve')}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
344
admin-spa/src/routes/Marketing/Affiliates/Payouts.tsx
Normal file
344
admin-spa/src/routes/Marketing/Affiliates/Payouts.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||
import { showSuccessToast, showErrorToast } from '@/lib/errorHandling';
|
||||
import { DollarSign, Plus, CreditCard, Building, ArrowRight } from 'lucide-react';
|
||||
import { formatMoney } from '@/lib/currency';
|
||||
|
||||
interface Payout {
|
||||
id: number;
|
||||
affiliate_id: number;
|
||||
affiliate_name?: string;
|
||||
affiliate_email?: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
method: string;
|
||||
status: string;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
interface Affiliate {
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_name?: string;
|
||||
user_email?: string;
|
||||
referral_code: string;
|
||||
total_earnings: string;
|
||||
paid_earnings: string;
|
||||
payable_balance: number;
|
||||
total_referrals: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function AffiliatesPayouts() {
|
||||
const qc = useQueryClient();
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [selectedAffiliateId, setSelectedAffiliateId] = useState<number | null>(null);
|
||||
const [amount, setAmount] = useState('');
|
||||
const [method, setMethod] = useState('bank_transfer');
|
||||
|
||||
// Fetch affiliates for dropdown
|
||||
const { data: affiliates = [], isLoading: isLoadingAffiliates } = useQuery<Affiliate[]>({
|
||||
queryKey: ['admin-affiliates'],
|
||||
queryFn: () => api.get('/admin/affiliates'),
|
||||
});
|
||||
|
||||
// Fetch payouts
|
||||
const { data: payouts = [], isLoading: isLoadingPayouts } = useQuery<Payout[]>({
|
||||
queryKey: ['admin-payouts'],
|
||||
queryFn: () => api.get('/admin/affiliates/payouts'),
|
||||
});
|
||||
|
||||
// Get selected affiliate balance
|
||||
const selectedAffiliate = affiliates.find(a => a.id === selectedAffiliateId);
|
||||
const payableBalance = selectedAffiliate?.payable_balance || 0;
|
||||
|
||||
// Create payout mutation
|
||||
const createPayoutMutation = useMutation({
|
||||
mutationFn: async (data: { affiliate_id: number; amount: number; method: string }) => {
|
||||
return api.post('/admin/affiliates/payouts', data);
|
||||
},
|
||||
onSuccess: (result: any) => {
|
||||
qc.invalidateQueries({ queryKey: ['admin-payouts'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-affiliates'] });
|
||||
showSuccessToast(__('Payout created successfully!') + (result.coupon_code ? ` Coupon: ${result.coupon_code}` : ''));
|
||||
setShowCreateForm(false);
|
||||
setSelectedAffiliateId(null);
|
||||
setAmount('');
|
||||
setMethod('bank_transfer');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
showErrorToast(err, __('Failed to create payout'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreatePayout = () => {
|
||||
if (!selectedAffiliateId || !amount) return;
|
||||
|
||||
const amountNum = parseFloat(amount);
|
||||
if (amountNum <= 0) {
|
||||
showErrorToast({ message: 'Amount must be greater than 0' }, __('Invalid amount'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (amountNum > payableBalance) {
|
||||
showErrorToast({ message: `Amount exceeds payable balance of ${payableBalance}` }, __('Amount too high'));
|
||||
return;
|
||||
}
|
||||
|
||||
createPayoutMutation.mutate({
|
||||
affiliate_id: selectedAffiliateId,
|
||||
amount: amountNum,
|
||||
method
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (value: string | number, currency?: string) => {
|
||||
try {
|
||||
return formatMoney(value, { currency });
|
||||
} catch {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return '—';
|
||||
return `${currency || 'IDR'} ${num.toLocaleString()}`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('id-ID', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{__('Affiliate Payouts')}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('Manage affiliate commission payouts')}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Create Payout')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Payout Modal */}
|
||||
{showCreateForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{__('Create New Payout')}</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Affiliate Selection */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">{__('Affiliate')}</label>
|
||||
<Select
|
||||
value={selectedAffiliateId?.toString() || ''}
|
||||
onValueChange={(v) => {
|
||||
setSelectedAffiliateId(parseInt(v));
|
||||
setAmount(''); // Reset amount when affiliate changes
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={__('Select affiliate')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{affiliates
|
||||
.filter(a => a.status === 'active')
|
||||
.filter(a => (a.payable_balance || 0) > 0)
|
||||
.map(affiliate => (
|
||||
<SelectItem key={affiliate.id} value={affiliate.id.toString()}>
|
||||
{affiliate.user_name || affiliate.user_email} (Payable: {formatCurrency(affiliate.payable_balance)})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Method Selection */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">{__('Payment Method')}</label>
|
||||
<Select value={method} onValueChange={setMethod}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bank_transfer">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building className="w-4 h-4" />
|
||||
{__('Bank Transfer')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="store_credit">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4" />
|
||||
{__('Store Credit')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount Input */}
|
||||
{selectedAffiliateId && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">{__('Amount')}</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{__('Available:')} {formatCurrency(payableBalance)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
max={payableBalance}
|
||||
step="0.01"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAmount(payableBalance.toString())}
|
||||
disabled={payableBalance <= 0}
|
||||
>
|
||||
{__('Pay All')}
|
||||
</Button>
|
||||
</div>
|
||||
{method === 'store_credit' && parseFloat(amount) > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('A store credit coupon will be generated and emailed to the affiliate.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => setShowCreateForm(false)}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreatePayout}
|
||||
disabled={!selectedAffiliateId || !amount || createPayoutMutation.isPending}
|
||||
>
|
||||
{createPayoutMutation.isPending ? __('Creating...') : __('Create Payout')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payouts Table */}
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">{__('Affiliate')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{__('Amount')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{__('Method')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{__('Status')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{__('Date')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{__('Notes')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{isLoadingPayouts ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||
{__('Loading...')}
|
||||
</td>
|
||||
</tr>
|
||||
) : payouts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
|
||||
{__('No payouts yet')}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
payouts.map((payout) => (
|
||||
<tr key={payout.id} className="hover:bg-gray-50/50 dark:hover:bg-gray-800/50">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium">{payout.affiliate_name || '—'}</div>
|
||||
<div className="text-xs text-muted-foreground">{payout.affiliate_email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
{formatCurrency(payout.amount, payout.currency)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1 text-xs">
|
||||
{payout.method === 'store_credit' ? (
|
||||
<>
|
||||
<CreditCard className="w-3 h-3" />
|
||||
{__('Store Credit')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Building className="w-3 h-3" />
|
||||
{__('Bank Transfer')}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
payout.status === 'completed'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: payout.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
|
||||
}`}>
|
||||
{payout.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{payout.completed_at ? formatDate(payout.completed_at) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs max-w-[200px] truncate">
|
||||
{payout.notes || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
|
||||
<div className="text-sm text-muted-foreground">{__('Total Payouts')}</div>
|
||||
<div className="text-2xl font-bold mt-1">{payouts.length}</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
|
||||
<div className="text-sm text-muted-foreground">{__('Total Paid')}</div>
|
||||
<div className="text-2xl font-bold mt-1">
|
||||
{formatCurrency(payouts.reduce((sum, p) => sum + parseFloat(p.amount || '0'), 0))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
|
||||
<div className="text-sm text-muted-foreground">{__('Affiliates with Balance')}</div>
|
||||
<div className="text-2xl font-bold mt-1">
|
||||
{affiliates.filter(a => (a.payable_balance || 0) > 0).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
364
admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx
Normal file
364
admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Search, Filter, X, Download } from 'lucide-react';
|
||||
import { formatMoney } from '@/lib/currency';
|
||||
|
||||
interface Affiliate {
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_name?: string;
|
||||
referral_code: string;
|
||||
}
|
||||
|
||||
interface Referral {
|
||||
id: number;
|
||||
affiliate_id: number;
|
||||
affiliate_name?: string;
|
||||
order_id: number;
|
||||
status: string;
|
||||
commission_amount: string;
|
||||
currency: string;
|
||||
created_at: string;
|
||||
approved_at?: string;
|
||||
utm_campaign?: string;
|
||||
utm_source?: string;
|
||||
}
|
||||
|
||||
export default function AffiliatesReferrals() {
|
||||
const [filters, setFilters] = useState({
|
||||
affiliate_id: '',
|
||||
status: '',
|
||||
date_start: '',
|
||||
date_end: '',
|
||||
order_id: '',
|
||||
});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Fetch affiliates for filter dropdown
|
||||
const { data: affiliates = [] } = useQuery<Affiliate[]>({
|
||||
queryKey: ['admin-affiliates'],
|
||||
queryFn: async () => {
|
||||
const response: any = await api.get('/admin/affiliates');
|
||||
return Array.isArray(response) ? response : (response?.data || []);
|
||||
},
|
||||
});
|
||||
|
||||
// Build query params
|
||||
const queryParams = new URLSearchParams();
|
||||
if (filters.affiliate_id) queryParams.set('affiliate_id', filters.affiliate_id);
|
||||
if (filters.status) queryParams.set('status', filters.status);
|
||||
if (filters.date_start) queryParams.set('date_start', filters.date_start);
|
||||
if (filters.date_end) queryParams.set('date_end', filters.date_end);
|
||||
if (filters.order_id) queryParams.set('order_id', filters.order_id);
|
||||
|
||||
// Fetch referrals with filters
|
||||
const { data: referrals = [], isLoading } = useQuery<Referral[]>({
|
||||
queryKey: ['admin-referrals', filters],
|
||||
queryFn: async () => {
|
||||
const queryString = queryParams.toString();
|
||||
const url = queryString ? `/admin/affiliates/referrals?${queryString}` : '/admin/affiliates/referrals';
|
||||
const response: any = await api.get(url);
|
||||
return Array.isArray(response) ? response : (response?.data || []);
|
||||
},
|
||||
});
|
||||
|
||||
// Client-side search filter (must be after referrals is defined)
|
||||
const filteredReferrals = (referrals || []).filter((ref) => {
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
ref.order_id.toString().includes(query) ||
|
||||
(ref.affiliate_name || '').toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Export to CSV
|
||||
const exportToCSV = () => {
|
||||
const headers = ['ID', 'Affiliate', 'Order ID', 'Status', 'Commission', 'Currency', 'Campaign', 'Created At'];
|
||||
const rows = filteredReferrals.map(ref => [
|
||||
ref.id,
|
||||
ref.affiliate_name || `Affiliate #${ref.affiliate_id}`,
|
||||
ref.order_id,
|
||||
ref.status,
|
||||
ref.commission_amount,
|
||||
ref.currency,
|
||||
[ref.utm_campaign, ref.utm_source].filter(Boolean).join(' / '),
|
||||
new Date(ref.created_at).toISOString()
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `affiliate-referrals-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
affiliate_id: '',
|
||||
status: '',
|
||||
date_start: '',
|
||||
date_end: '',
|
||||
order_id: '',
|
||||
});
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const hasActiveFilters = Object.values(filters).some(v => v !== '');
|
||||
|
||||
const formatCurrency = (amount: string | number, currency?: string) => {
|
||||
try {
|
||||
return formatMoney(amount, { currency });
|
||||
} catch {
|
||||
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||
if (isNaN(num)) return '—';
|
||||
return `${currency || 'IDR'} ${num.toLocaleString()}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Stats
|
||||
const totalCommissions = filteredReferrals.reduce((sum, r) => sum + parseFloat(r.commission_amount || '0'), 0);
|
||||
const pendingCount = filteredReferrals.filter(r => r.status === 'pending').length;
|
||||
const approvedCount = filteredReferrals.filter(r => r.status === 'approved').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with search, filter toggle, and export */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder={__('Search by order ID or affiliate...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={exportToCSV}
|
||||
disabled={filteredReferrals.length === 0}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{__('Export CSV')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
{__('Filters')}
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-2 bg-primary text-primary-foreground rounded-full w-5 h-5 text-xs flex items-center justify-center">
|
||||
{Object.values(filters).filter(v => v !== '').length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats summary text */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredReferrals.length} referral(s) {hasActiveFilters || searchQuery ? '(filtered)' : ''}
|
||||
</p>
|
||||
|
||||
{/* Filter Panel */}
|
||||
{showFilters && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">{__('Filter Referrals')}</h4>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
{__('Clear All')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{/* Affiliate Filter */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">{__('Affiliate')}</label>
|
||||
<Select
|
||||
value={filters.affiliate_id}
|
||||
onValueChange={(v) => setFilters({ ...filters, affiliate_id: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={__('All affiliates')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{__('All affiliates')}</SelectItem>
|
||||
{affiliates.map((aff) => (
|
||||
<SelectItem key={aff.id} value={aff.id.toString()}>
|
||||
{aff.user_name || aff.referral_code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">{__('Status')}</label>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(v) => setFilters({ ...filters, status: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={__('All statuses')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{__('All statuses')}</SelectItem>
|
||||
<SelectItem value="pending">{__('Pending')}</SelectItem>
|
||||
<SelectItem value="approved">{__('Approved')}</SelectItem>
|
||||
<SelectItem value="rejected">{__('Rejected')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Order ID Filter */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">{__('Order ID')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={__('Order ID')}
|
||||
value={filters.order_id}
|
||||
onChange={(e) => setFilters({ ...filters, order_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date Start */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">{__('From Date')}</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.date_start}
|
||||
onChange={(e) => setFilters({ ...filters, date_start: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date End */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">{__('To Date')}</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.date_end}
|
||||
onChange={(e) => setFilters({ ...filters, date_end: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
|
||||
<div className="text-sm text-muted-foreground">{__('Total Referrals')}</div>
|
||||
<div className="text-2xl font-bold mt-1">{filteredReferrals.length}</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
|
||||
<div className="text-sm text-muted-foreground">{__('Total Commission')}</div>
|
||||
<div className="text-2xl font-bold mt-1">{formatCurrency(totalCommissions)}</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
|
||||
<div className="text-sm text-muted-foreground">{__('Pending')}</div>
|
||||
<div className="text-2xl font-bold mt-1 text-yellow-600">{pendingCount}</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
|
||||
<div className="text-sm text-muted-foreground">{__('Approved')}</div>
|
||||
<div className="text-2xl font-bold mt-1 text-green-600">{approvedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referrals Table */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{__('Loading referrals...')}
|
||||
</div>
|
||||
) : filteredReferrals.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground border rounded-lg">
|
||||
{hasActiveFilters || searchQuery ? __('No referrals match your filters') : __('No referrals yet')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{__('ID')}</TableHead>
|
||||
<TableHead>{__('Affiliate')}</TableHead>
|
||||
<TableHead>{__('Order ID')}</TableHead>
|
||||
<TableHead>{__('Status')}</TableHead>
|
||||
<TableHead>{__('Campaign')}</TableHead>
|
||||
<TableHead>{__('Date')}</TableHead>
|
||||
<TableHead className="text-right">{__('Commission')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredReferrals.map((ref) => (
|
||||
<TableRow key={ref.id}>
|
||||
<TableCell className="font-medium">#{ref.id}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{ref.affiliate_name || `Affiliate #${ref.affiliate_id}`}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>#{ref.order_id}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
ref.status === 'approved'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
|
||||
: ref.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
|
||||
}`}>
|
||||
{ref.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{[ref.utm_campaign, ref.utm_source].filter(Boolean).join(' / ') || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(ref.created_at).toLocaleDateString('id-ID', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatCurrency(ref.commission_amount, ref.currency)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
admin-spa/src/routes/Marketing/Affiliates/index.tsx
Normal file
108
admin-spa/src/routes/Marketing/Affiliates/index.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation, Outlet, Link } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Users, Link as LinkIcon, DollarSign, Activity } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DocLink } from '@/components/DocLink';
|
||||
|
||||
export default function AffiliatesLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isEnabled } = useModules();
|
||||
|
||||
// Show disabled state if affiliate module is off
|
||||
if (!isEnabled('affiliate')) {
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{__('Affiliate Program')}</h1>
|
||||
<DocLink />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2">{__('Affiliate module is disabled')}</p>
|
||||
</div>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
|
||||
<Users className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-lg mb-2">{__('Affiliate Module Disabled')}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{__('The affiliate module is currently disabled. Enable it in Settings > Modules to use this feature.')}
|
||||
</p>
|
||||
<Button onClick={() => navigate('/settings/modules')}>
|
||||
{__('Go to Module Settings')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
id: 'list',
|
||||
label: __('All Affiliates'),
|
||||
icon: Users,
|
||||
path: '/marketing/affiliates/list',
|
||||
isActive: (path: string) => path.includes('/list')
|
||||
},
|
||||
{
|
||||
id: 'referrals',
|
||||
label: __('Referrals'),
|
||||
icon: LinkIcon,
|
||||
path: '/marketing/affiliates/referrals',
|
||||
isActive: (path: string) => path.includes('/referrals')
|
||||
},
|
||||
{
|
||||
id: 'payouts',
|
||||
label: __('Payouts'),
|
||||
icon: DollarSign,
|
||||
path: '/marketing/affiliates/payouts',
|
||||
isActive: (path: string) => path.includes('/payouts')
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{__('Affiliate Program')}</h1>
|
||||
<DocLink />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2">{__('Manage affiliates, track referrals, and process payouts')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Sidebar Navigation */}
|
||||
<div className="w-full lg:w-56 flex-shrink-0 space-y-4">
|
||||
<nav className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = item.isActive(location.pathname);
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
'w-full text-left px-4 py-2.5 rounded-md text-sm font-medium transition-colors flex items-center gap-3',
|
||||
'outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
active
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground active:bg-muted active:text-foreground focus:bg-muted focus:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -74,8 +74,8 @@ export default function CampaignsList() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['campaigns'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/campaigns');
|
||||
return response.data as Campaign[];
|
||||
const response: any = await api.get('/campaigns');
|
||||
return Array.isArray(response) ? (response as Campaign[]) : ((response?.data || []) as Campaign[]);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ export default function Campaigns() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['campaigns'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/campaigns');
|
||||
return response.data as Campaign[];
|
||||
const response: any = await api.get('/newsletter/campaigns');
|
||||
return Array.isArray(response) ? (response as Campaign[]) : ((response?.data || []) as Campaign[]);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { api } from '@/lib/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -35,14 +36,16 @@ import {
|
||||
|
||||
export default function Subscribers() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [deleteTargetEmail, setDeleteTargetEmail] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: subscribersData, isLoading } = useQuery({
|
||||
queryKey: ['newsletter-subscribers'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/newsletter/subscribers');
|
||||
return response.data;
|
||||
const response: any = await api.get('/admin/newsletter/subscribers');
|
||||
return Array.isArray(response) ? response : (response?.data || []);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Mail, Tag } from 'lucide-react';
|
||||
import { Mail, Tag, Users } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { DocLink } from '@/components/DocLink';
|
||||
|
||||
interface MarketingCard {
|
||||
title: string;
|
||||
@@ -10,24 +12,35 @@ interface MarketingCard {
|
||||
}
|
||||
|
||||
const cards: MarketingCard[] = [
|
||||
{
|
||||
title: __('Newsletter'),
|
||||
description: __('Manage subscribers and send email campaigns'),
|
||||
icon: Mail,
|
||||
to: '/marketing/newsletter',
|
||||
},
|
||||
{
|
||||
title: __('Coupons'),
|
||||
description: __('Discounts, promotions, and coupon codes'),
|
||||
icon: Tag,
|
||||
to: '/marketing/coupons',
|
||||
},
|
||||
{
|
||||
title: __('Newsletter'),
|
||||
description: __('Manage subscribers and send email campaigns'),
|
||||
icon: Mail,
|
||||
to: '/marketing/newsletter',
|
||||
},
|
||||
];
|
||||
|
||||
import { DocLink } from '@/components/DocLink';
|
||||
|
||||
export default function Marketing() {
|
||||
const navigate = useNavigate();
|
||||
const { isEnabled } = useModules();
|
||||
|
||||
const activeCards = [...cards];
|
||||
|
||||
// Add Affiliates card conditionally if module is enabled
|
||||
if (isEnabled('affiliate')) {
|
||||
activeCards.splice(1, 0, {
|
||||
title: __('Affiliates'),
|
||||
description: __('Manage affiliate program and referrals'),
|
||||
icon: Users,
|
||||
to: '/marketing/affiliates',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
@@ -36,11 +49,11 @@ export default function Marketing() {
|
||||
<h1 className="text-2xl font-bold tracking-tight">{__('Marketing')}</h1>
|
||||
<DocLink />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2">{__('Newsletter, campaigns, and promotions')}</p>
|
||||
<p className="text-muted-foreground mt-2">{__('Coupons, affiliates, and newsletter campaigns')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
{activeCards.map((card) => (
|
||||
<button
|
||||
key={card.to}
|
||||
onClick={() => navigate(card.to)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { api } from '@/lib/api';
|
||||
import { OrdersApi } from '@/lib/api/orders';
|
||||
import { formatRelativeOrDate } from '@/lib/dates';
|
||||
import { formatMoney } from '@/lib/currency';
|
||||
import { ExternalLink, Loader2, Ticket, FileText, RefreshCw } from 'lucide-react';
|
||||
import { ExternalLink, Loader2, Ticket, FileText, RefreshCw, Users, Gift } from 'lucide-react';
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -495,6 +495,49 @@ export default function OrderShow() {
|
||||
<div className="text-sm whitespace-pre-wrap">{order.customer_note}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affiliate Referral Info */}
|
||||
{order.affiliate && order.affiliate.has_referral && (
|
||||
<div className="rounded border p-4 bg-green-50/50 dark:bg-green-900/10">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Users className="w-4 h-4 text-green-600" />
|
||||
<div className="text-xs opacity-60">{__('Affiliate')}</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{__('Affiliate')}</span>
|
||||
<span className="font-medium">{order.affiliate.affiliate_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{__('Commission Rate')}</span>
|
||||
<span className="font-medium">{order.affiliate.commission_rate}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{__('Commission')}</span>
|
||||
<span className="font-medium">
|
||||
<Money value={order.affiliate.commission} currency={order.affiliate.currency} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{__('Status')}</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
order.affiliate.status === 'approved'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: order.affiliate.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: order.affiliate.status === 'rejected'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{order.affiliate.status}
|
||||
{order.affiliate.cancelled_reason && (
|
||||
<span className="ml-1 text-xs opacity-70">({order.affiliate.cancelled_reason.replace('order_', '')})</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Search, Package, Plus, History, Download, Loader2 } from 'lucide-react';
|
||||
import { Search, Package, Plus, History, Download, Loader2, ChevronDown, ChevronRight, X, Pencil } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
@@ -43,11 +43,21 @@ interface SoftwareProduct {
|
||||
total_downloads: number;
|
||||
}
|
||||
|
||||
interface ChangelogPoint {
|
||||
type: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ChangelogData {
|
||||
narrative: string;
|
||||
points: ChangelogPoint[];
|
||||
}
|
||||
|
||||
interface SoftwareVersion {
|
||||
id: number;
|
||||
product_id: number;
|
||||
version: string;
|
||||
changelog: string;
|
||||
changelog: ChangelogData | string;
|
||||
release_date: string;
|
||||
is_current: boolean;
|
||||
download_count: number;
|
||||
@@ -72,7 +82,13 @@ export default function SoftwareVersions() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedProduct, setSelectedProduct] = useState<number | null>(null);
|
||||
const [isAddVersionOpen, setIsAddVersionOpen] = useState(false);
|
||||
const [newVersion, setNewVersion] = useState({ version: '', changelog: '' });
|
||||
const [editingVersionId, setEditingVersionId] = useState<number | null>(null);
|
||||
const [newVersion, setNewVersion] = useState({
|
||||
version: '',
|
||||
changelog: { narrative: '', points: [] as ChangelogPoint[] }
|
||||
});
|
||||
const [expandedVersions, setExpandedVersions] = useState<Record<number, boolean>>({});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch software-enabled products
|
||||
@@ -80,15 +96,14 @@ export default function SoftwareVersions() {
|
||||
queryKey: ['software-products'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/products?software_enabled=true&per_page=100');
|
||||
// Filter products that have software distribution enabled
|
||||
const products = (response as any).products || [];
|
||||
const products = (response as any).rows || [];
|
||||
return {
|
||||
products: products.filter((p: any) => p.meta?._woonoow_software_enabled === 'yes').map((p: any) => ({
|
||||
products: products.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
slug: p.meta?._woonoow_software_slug || '',
|
||||
current_version: p.meta?._woonoow_software_current_version || '',
|
||||
wp_enabled: p.meta?._woonoow_software_wp_enabled === 'yes',
|
||||
slug: p.software_slug || p.meta?._woonoow_software_slug || '',
|
||||
current_version: p.software_current_version || p.meta?._woonoow_software_current_version || '',
|
||||
wp_enabled: p.software_wp_enabled || p.meta?._woonoow_software_wp_enabled === 'yes',
|
||||
total_downloads: 0,
|
||||
}))
|
||||
} as ProductsResponse;
|
||||
@@ -107,21 +122,121 @@ export default function SoftwareVersions() {
|
||||
|
||||
// Add new version mutation
|
||||
const addVersion = useMutation({
|
||||
mutationFn: async (data: { version: string; changelog: string }) => {
|
||||
mutationFn: async (data: { version: string; changelog: ChangelogData }) => {
|
||||
return await api.post(`/software/products/${selectedProduct}/versions`, data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['software-versions', selectedProduct] });
|
||||
queryClient.invalidateQueries({ queryKey: ['software-products'] });
|
||||
toast.success(__('Version added successfully'));
|
||||
setIsAddVersionOpen(false);
|
||||
setNewVersion({ version: '', changelog: '' });
|
||||
closeModal();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || __('Failed to add version'));
|
||||
},
|
||||
});
|
||||
|
||||
// Edit version mutation
|
||||
const editVersion = useMutation({
|
||||
mutationFn: async (data: { version_id: number; version: string; changelog: ChangelogData }) => {
|
||||
return await api.put(`/software/products/${selectedProduct}/versions/${data.version_id}`, {
|
||||
version: data.version,
|
||||
changelog: data.changelog
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['software-versions', selectedProduct] });
|
||||
queryClient.invalidateQueries({ queryKey: ['software-products'] });
|
||||
toast.success(__('Version updated successfully'));
|
||||
closeModal();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || __('Failed to update version'));
|
||||
},
|
||||
});
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingVersionId(null);
|
||||
setNewVersion({ version: '', changelog: { narrative: '', points: [] } });
|
||||
setIsAddVersionOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (version: SoftwareVersion, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const cl = typeof version.changelog === 'object' && version.changelog !== null
|
||||
? (version.changelog as ChangelogData)
|
||||
: { narrative: version.changelog as string, points: [] };
|
||||
|
||||
setEditingVersionId(version.id);
|
||||
setNewVersion({
|
||||
version: version.version,
|
||||
changelog: { narrative: cl.narrative || '', points: cl.points || [] }
|
||||
});
|
||||
setIsAddVersionOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsAddVersionOpen(false);
|
||||
setEditingVersionId(null);
|
||||
setNewVersion({ version: '', changelog: { narrative: '', points: [] } });
|
||||
};
|
||||
|
||||
const handleSaveVersion = () => {
|
||||
if (editingVersionId) {
|
||||
editVersion.mutate({
|
||||
version_id: editingVersionId,
|
||||
...newVersion
|
||||
});
|
||||
} else {
|
||||
addVersion.mutate(newVersion);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleVersion = (id: number) => {
|
||||
setExpandedVersions(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id]
|
||||
}));
|
||||
};
|
||||
|
||||
const addChangelogPoint = () => {
|
||||
setNewVersion(prev => ({
|
||||
...prev,
|
||||
changelog: {
|
||||
...prev.changelog,
|
||||
points: [...prev.changelog.points, { type: 'ADD', text: '' }]
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const updateChangelogPoint = (index: number, field: 'type' | 'text', value: string) => {
|
||||
setNewVersion(prev => {
|
||||
const newPoints = [...prev.changelog.points];
|
||||
newPoints[index] = { ...newPoints[index], [field]: value };
|
||||
return {
|
||||
...prev,
|
||||
changelog: {
|
||||
...prev.changelog,
|
||||
points: newPoints
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const removeChangelogPoint = (index: number) => {
|
||||
setNewVersion(prev => {
|
||||
const newPoints = [...prev.changelog.points];
|
||||
newPoints.splice(index, 1);
|
||||
return {
|
||||
...prev,
|
||||
changelog: {
|
||||
...prev.changelog,
|
||||
points: newPoints
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const filteredProducts = productsData?.products?.filter(p =>
|
||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.slug.toLowerCase().includes(search.toLowerCase())
|
||||
@@ -129,9 +244,20 @@ export default function SoftwareVersions() {
|
||||
|
||||
const selectedProductData = productsData?.products?.find(p => p.id === selectedProduct);
|
||||
|
||||
const getBadgeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'ADD': return 'bg-emerald-500 hover:bg-emerald-600';
|
||||
case 'FIX': return 'bg-orange-500 hover:bg-orange-600';
|
||||
case 'IMPROVE': return 'bg-blue-500 hover:bg-blue-600';
|
||||
case 'DROP': return 'bg-rose-500 hover:bg-rose-600';
|
||||
default: return 'bg-gray-500 hover:bg-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const isSaving = addVersion.isPending || editVersion.isPending;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{__('Software Versions')}</h1>
|
||||
@@ -142,7 +268,6 @@ export default function SoftwareVersions() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Products List */}
|
||||
<div className="lg:col-span-1 border rounded-lg bg-card">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="font-semibold mb-3">{__('Software Products')}</h2>
|
||||
@@ -152,7 +277,7 @@ export default function SoftwareVersions() {
|
||||
placeholder={__('Search products...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,8 +301,7 @@ export default function SoftwareVersions() {
|
||||
<button
|
||||
key={product.id}
|
||||
onClick={() => setSelectedProduct(product.id)}
|
||||
className={`w-full p-4 text-left hover:bg-accent transition-colors ${selectedProduct === product.id ? 'bg-accent' : ''
|
||||
}`}
|
||||
className={`w-full p-4 text-left hover:bg-accent transition-colors ${selectedProduct === product.id ? 'bg-accent' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -186,7 +310,7 @@ export default function SoftwareVersions() {
|
||||
{product.slug}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
<Badge variant="secondary" className="ml-2 whitespace-nowrap">
|
||||
v{product.current_version || '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -202,7 +326,6 @@ export default function SoftwareVersions() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Details */}
|
||||
<div className="lg:col-span-2 border rounded-lg bg-card">
|
||||
{!selectedProduct ? (
|
||||
<div className="flex items-center justify-center h-full min-h-[400px] text-muted-foreground">
|
||||
@@ -217,7 +340,6 @@ export default function SoftwareVersions() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Version Header */}
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">{selectedProductData?.name}</h2>
|
||||
@@ -225,21 +347,25 @@ export default function SoftwareVersions() {
|
||||
{__('Current version')}: <span className="font-mono">{versionsData?.config?.current_version || '—'}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isAddVersionOpen} onOpenChange={setIsAddVersionOpen}>
|
||||
<Dialog open={isAddVersionOpen} onOpenChange={(open) => !open ? closeModal() : setIsAddVersionOpen(true)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Button onClick={openAddModal}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('New Version')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Add New Version')}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{editingVersionId ? __('Edit Version') : __('Add New Version')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__('Release a new version of')} {selectedProductData?.name}
|
||||
{editingVersionId
|
||||
? `${__('Modify release details for')} ${selectedProductData?.name}`
|
||||
: `${__('Release a new version of')} ${selectedProductData?.name}`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 px-6 py-4">
|
||||
<div className="space-y-6 px-6 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="version">{__('Version Number')}</Label>
|
||||
<Input
|
||||
@@ -248,64 +374,125 @@ export default function SoftwareVersions() {
|
||||
value={newVersion.version}
|
||||
onChange={(e) => setNewVersion(prev => ({ ...prev, version: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Use semantic versioning (e.g., 1.0.0, 1.2.3)')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="changelog">{__('Changelog')}</Label>
|
||||
<Label htmlFor="narrative">{__('Changelog Narrative (Optional)')}</Label>
|
||||
<Textarea
|
||||
id="changelog"
|
||||
placeholder="## What's New - Added new feature - Fixed bug"
|
||||
value={newVersion.changelog}
|
||||
onChange={(e) => setNewVersion(prev => ({ ...prev, changelog: e.target.value }))}
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
id="narrative"
|
||||
placeholder={__('Provide a general overview of this release...')}
|
||||
value={newVersion.changelog.narrative}
|
||||
onChange={(e) => setNewVersion(prev => ({
|
||||
...prev,
|
||||
changelog: { ...prev.changelog, narrative: e.target.value }
|
||||
}))}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Supports Markdown formatting')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{__('Changes List')}</Label>
|
||||
</div>
|
||||
|
||||
{newVersion.changelog.points.length === 0 ? (
|
||||
<div className="text-center p-6 border rounded-md border-dashed text-muted-foreground">
|
||||
<p className="text-sm">{__('No changes added yet.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{newVersion.changelog.points.map((point, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<Select
|
||||
value={point.type}
|
||||
onValueChange={(val) => updateChangelogPoint(index, 'type', val)}
|
||||
>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADD">{__('ADD')}</SelectItem>
|
||||
<SelectItem value="FIX">{__('FIX')}</SelectItem>
|
||||
<SelectItem value="IMPROVE">{__('IMPROVE')}</SelectItem>
|
||||
<SelectItem value="DROP">{__('DROP')}</SelectItem>
|
||||
<SelectItem value="OTHER">{__('OTHER')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={point.text}
|
||||
onChange={(e) => updateChangelogPoint(index, 'text', e.target.value)}
|
||||
placeholder={__('Describe the change...')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeChangelogPoint(index)}
|
||||
className="text-muted-foreground hover:text-destructive shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={addChangelogPoint} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Add Change Item')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddVersionOpen(false)}>
|
||||
<Button variant="outline" onClick={closeModal} disabled={isSaving}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => addVersion.mutate(newVersion)}
|
||||
disabled={!newVersion.version || addVersion.isPending}
|
||||
onClick={handleSaveVersion}
|
||||
disabled={!newVersion.version || isSaving}
|
||||
>
|
||||
{addVersion.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{__('Release Version')}
|
||||
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{editingVersionId ? __('Save Changes') : __('Release Version')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Version History */}
|
||||
<div className="p-4">
|
||||
{versionsData?.versions?.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<History className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>{__('No versions released yet')}</p>
|
||||
<p className="text-sm mt-1">
|
||||
{__('Click "New Version" to release your first version')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8"></TableHead>
|
||||
<TableHead>{__('Version')}</TableHead>
|
||||
<TableHead>{__('Release Date')}</TableHead>
|
||||
<TableHead>{__('Downloads')}</TableHead>
|
||||
<TableHead>{__('Changelog')}</TableHead>
|
||||
<TableHead>{__('Summary')}</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{versionsData?.versions?.map((version) => (
|
||||
<TableRow key={version.id}>
|
||||
{versionsData?.versions?.map((version) => {
|
||||
const isExpanded = !!expandedVersions[version.id];
|
||||
const cl = typeof version.changelog === 'object' && version.changelog !== null
|
||||
? (version.changelog as ChangelogData)
|
||||
: { narrative: version.changelog as string, points: [] };
|
||||
|
||||
return (
|
||||
<React.Fragment key={version.id}>
|
||||
<TableRow className="cursor-pointer hover:bg-muted/50 group" onClick={() => toggleVersion(version.id)}>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6">
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-medium">
|
||||
@@ -327,13 +514,55 @@ export default function SoftwareVersions() {
|
||||
{version.download_count}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs">
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{version.changelog?.split('\n')[0] || '—'}
|
||||
</p>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{cl.points?.length > 0 ? `${cl.points.length} changes` : (cl.narrative ? 'Notes attached' : '—')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-8 h-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => openEditModal(version, e)}
|
||||
title={__('Edit version')}
|
||||
>
|
||||
<Pencil className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{isExpanded && (
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableCell colSpan={6} className="p-0">
|
||||
<div className="p-6 text-sm border-t">
|
||||
{cl.narrative && (
|
||||
<div className="mb-4 text-foreground whitespace-pre-wrap">
|
||||
{cl.narrative}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cl.points && cl.points.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{cl.points.map((pt, idx) => (
|
||||
<li key={idx} className="flex items-start gap-3">
|
||||
<Badge className={`${getBadgeColor(pt.type)} text-[10px] uppercase font-bold mt-0.5`}>
|
||||
{pt.type}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">{pt.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!cl.narrative && (!cl.points || cl.points.length === 0) && (
|
||||
<p className="text-muted-foreground italic">{__('No changelog details provided.')}</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Filter, Package, Trash2, RefreshCw, MoreHorizontal, Eye, Edit } from 'lucide-react';
|
||||
import { Filter, Package, Trash2, RefreshCw, MoreHorizontal, Eye, Edit, Search } from 'lucide-react';
|
||||
import { ErrorCard } from '@/components/ErrorCard';
|
||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||
import { __ } from '@/lib/i18n';
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Select,
|
||||
@@ -253,6 +254,16 @@ export default function Products() {
|
||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{__('Refresh')}
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={__('Search products...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-[200px] lg:w-[250px] !pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
@@ -4,13 +4,14 @@ 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, Download } from 'lucide-react';
|
||||
import { Package, DollarSign, Layers, Tag, Download, Cloud } 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';
|
||||
import { SoftwareTab } from './tabs/SoftwareTab';
|
||||
|
||||
// Types
|
||||
export type ProductFormData = {
|
||||
@@ -47,6 +48,16 @@ export type ProductFormData = {
|
||||
subscription_interval?: string;
|
||||
subscription_trial_days?: string;
|
||||
subscription_signup_fee?: string;
|
||||
// Affiliate
|
||||
affiliate_enabled?: boolean;
|
||||
affiliate_commission_rate?: string;
|
||||
// Software
|
||||
software_enabled?: boolean;
|
||||
software_slug?: string;
|
||||
software_wp_enabled?: boolean;
|
||||
software_requires_wp?: string;
|
||||
software_tested_wp?: string;
|
||||
software_requires_php?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -103,6 +114,16 @@ export function ProductFormTabbed({
|
||||
const [subscriptionInterval, setSubscriptionInterval] = useState(initial?.subscription_interval || '1');
|
||||
const [subscriptionTrialDays, setSubscriptionTrialDays] = useState(initial?.subscription_trial_days || '');
|
||||
const [subscriptionSignupFee, setSubscriptionSignupFee] = useState(initial?.subscription_signup_fee || '');
|
||||
// Affiliate state
|
||||
const [affiliateEnabled, setAffiliateEnabled] = useState(initial?.affiliate_enabled || false);
|
||||
const [affiliateCommissionRate, setAffiliateCommissionRate] = useState(initial?.affiliate_commission_rate || '');
|
||||
// Software state
|
||||
const [softwareEnabled, setSoftwareEnabled] = useState(initial?.software_enabled || false);
|
||||
const [softwareSlug, setSoftwareSlug] = useState(initial?.software_slug || '');
|
||||
const [softwareWpEnabled, setSoftwareWpEnabled] = useState(initial?.software_wp_enabled || false);
|
||||
const [softwareRequiresWp, setSoftwareRequiresWp] = useState(initial?.software_requires_wp || '');
|
||||
const [softwareTestedWp, setSoftwareTestedWp] = useState(initial?.software_tested_wp || '');
|
||||
const [softwareRequiresPhp, setSoftwareRequiresPhp] = useState(initial?.software_requires_php || '');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Update form state when initial data changes (for edit mode)
|
||||
@@ -140,6 +161,16 @@ export function ProductFormTabbed({
|
||||
setSubscriptionInterval(initial.subscription_interval || '1');
|
||||
setSubscriptionTrialDays(initial.subscription_trial_days || '');
|
||||
setSubscriptionSignupFee(initial.subscription_signup_fee || '');
|
||||
// Affiliate
|
||||
setAffiliateEnabled(initial.affiliate_enabled || false);
|
||||
setAffiliateCommissionRate(initial.affiliate_commission_rate || '');
|
||||
// Software
|
||||
setSoftwareEnabled(initial.software_enabled || false);
|
||||
setSoftwareSlug(initial.software_slug || '');
|
||||
setSoftwareWpEnabled(initial.software_wp_enabled || false);
|
||||
setSoftwareRequiresWp(initial.software_requires_wp || '');
|
||||
setSoftwareTestedWp(initial.software_tested_wp || '');
|
||||
setSoftwareRequiresPhp(initial.software_requires_php || '');
|
||||
}
|
||||
}, [initial, mode]);
|
||||
|
||||
@@ -209,6 +240,16 @@ export function ProductFormTabbed({
|
||||
subscription_interval: subscriptionEnabled ? subscriptionInterval : undefined,
|
||||
subscription_trial_days: subscriptionEnabled ? subscriptionTrialDays : undefined,
|
||||
subscription_signup_fee: subscriptionEnabled ? subscriptionSignupFee : undefined,
|
||||
// Affiliate
|
||||
affiliate_enabled: affiliateEnabled,
|
||||
affiliate_commission_rate: affiliateEnabled ? affiliateCommissionRate : undefined,
|
||||
// Software
|
||||
software_enabled: softwareEnabled,
|
||||
software_slug: softwareEnabled ? softwareSlug : undefined,
|
||||
software_wp_enabled: softwareEnabled ? softwareWpEnabled : undefined,
|
||||
software_requires_wp: (softwareEnabled && softwareWpEnabled) ? softwareRequiresWp : undefined,
|
||||
software_tested_wp: (softwareEnabled && softwareWpEnabled) ? softwareTestedWp : undefined,
|
||||
software_requires_php: (softwareEnabled && softwareWpEnabled) ? softwareRequiresPhp : undefined,
|
||||
};
|
||||
|
||||
await onSubmit(payload);
|
||||
@@ -226,6 +267,7 @@ export function ProductFormTabbed({
|
||||
...(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" /> },
|
||||
{ id: 'software', label: __('Software'), icon: <Cloud className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -277,6 +319,10 @@ export function ProductFormTabbed({
|
||||
setSubscriptionTrialDays={setSubscriptionTrialDays}
|
||||
subscriptionSignupFee={subscriptionSignupFee}
|
||||
setSubscriptionSignupFee={setSubscriptionSignupFee}
|
||||
affiliateEnabled={affiliateEnabled}
|
||||
setAffiliateEnabled={setAffiliateEnabled}
|
||||
affiliateCommissionRate={affiliateCommissionRate}
|
||||
setAffiliateCommissionRate={setAffiliateCommissionRate}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
@@ -332,6 +378,24 @@ export function ProductFormTabbed({
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Software Tab */}
|
||||
<FormSection id="software">
|
||||
<SoftwareTab
|
||||
softwareEnabled={softwareEnabled}
|
||||
setSoftwareEnabled={setSoftwareEnabled}
|
||||
softwareSlug={softwareSlug}
|
||||
setSoftwareSlug={setSoftwareSlug}
|
||||
softwareWpEnabled={softwareWpEnabled}
|
||||
setSoftwareWpEnabled={setSoftwareWpEnabled}
|
||||
softwareRequiresWp={softwareRequiresWp}
|
||||
setSoftwareRequiresWp={setSoftwareRequiresWp}
|
||||
softwareTestedWp={softwareTestedWp}
|
||||
setSoftwareTestedWp={setSoftwareTestedWp}
|
||||
softwareRequiresPhp={softwareRequiresPhp}
|
||||
setSoftwareRequiresPhp={setSoftwareRequiresPhp}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Submit Button */}
|
||||
{!hideSubmitButton && (
|
||||
<div className="mt-6 flex gap-3">
|
||||
|
||||
@@ -8,11 +8,12 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key, Repeat } from 'lucide-react';
|
||||
import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key, Repeat, Percent } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getStoreCurrency } from '@/lib/currency';
|
||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||
import { openWPMediaGallery } from '@/lib/wp-media';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
|
||||
type GeneralTabProps = {
|
||||
name: string;
|
||||
@@ -63,6 +64,11 @@ type GeneralTabProps = {
|
||||
setSubscriptionTrialDays?: (value: string) => void;
|
||||
subscriptionSignupFee?: string;
|
||||
setSubscriptionSignupFee?: (value: string) => void;
|
||||
// Affiliate
|
||||
affiliateEnabled?: boolean;
|
||||
setAffiliateEnabled?: (value: boolean) => void;
|
||||
affiliateCommissionRate?: string;
|
||||
setAffiliateCommissionRate?: (value: string) => void;
|
||||
};
|
||||
|
||||
export function GeneralTab({
|
||||
@@ -109,6 +115,10 @@ export function GeneralTab({
|
||||
setSubscriptionTrialDays,
|
||||
subscriptionSignupFee,
|
||||
setSubscriptionSignupFee,
|
||||
affiliateEnabled,
|
||||
setAffiliateEnabled,
|
||||
affiliateCommissionRate,
|
||||
setAffiliateCommissionRate,
|
||||
}: GeneralTabProps) {
|
||||
const savingsPercent =
|
||||
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
|
||||
@@ -116,6 +126,7 @@ export function GeneralTab({
|
||||
: 0;
|
||||
|
||||
const store = getStoreCurrency();
|
||||
const { isEnabled } = useModules();
|
||||
|
||||
// Copy link state and helpers
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
@@ -459,7 +470,7 @@ export function GeneralTab({
|
||||
</div>
|
||||
|
||||
{/* Licensing option */}
|
||||
{setLicensingEnabled && (
|
||||
{isEnabled('licensing') && setLicensingEnabled && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
@@ -533,7 +544,7 @@ export function GeneralTab({
|
||||
)}
|
||||
|
||||
{/* Subscription option */}
|
||||
{setSubscriptionEnabled && (
|
||||
{isEnabled('subscription') && setSubscriptionEnabled && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
@@ -617,6 +628,46 @@ export function GeneralTab({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Affiliate option */}
|
||||
{isEnabled('affiliate') && setAffiliateEnabled && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="affiliate-enabled"
|
||||
checked={affiliateEnabled || false}
|
||||
onCheckedChange={(checked) => setAffiliateEnabled(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="affiliate-enabled" className="cursor-pointer font-normal flex items-center gap-1">
|
||||
<Percent className="h-3 w-3" />
|
||||
{__('Enable affiliate commission for this product')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Affiliate settings panel */}
|
||||
{affiliateEnabled && (
|
||||
<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">{__('Custom Commission Rate (%)')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder={__('Global default')}
|
||||
value={affiliateCommissionRate || ''}
|
||||
onChange={(e) => setAffiliateCommissionRate?.(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{__('Leave empty to use the global affiliate commission rate.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
115
admin-spa/src/routes/Products/partials/tabs/SoftwareTab.tsx
Normal file
115
admin-spa/src/routes/Products/partials/tabs/SoftwareTab.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
type Props = {
|
||||
softwareEnabled: boolean;
|
||||
setSoftwareEnabled: (val: boolean) => void;
|
||||
softwareSlug: string;
|
||||
setSoftwareSlug: (val: string) => void;
|
||||
softwareWpEnabled: boolean;
|
||||
setSoftwareWpEnabled: (val: boolean) => void;
|
||||
softwareRequiresWp: string;
|
||||
setSoftwareRequiresWp: (val: string) => void;
|
||||
softwareTestedWp: string;
|
||||
setSoftwareTestedWp: (val: string) => void;
|
||||
softwareRequiresPhp: string;
|
||||
setSoftwareRequiresPhp: (val: string) => void;
|
||||
};
|
||||
|
||||
export function SoftwareTab({
|
||||
softwareEnabled,
|
||||
setSoftwareEnabled,
|
||||
softwareSlug,
|
||||
setSoftwareSlug,
|
||||
softwareWpEnabled,
|
||||
setSoftwareWpEnabled,
|
||||
softwareRequiresWp,
|
||||
setSoftwareRequiresWp,
|
||||
softwareTestedWp,
|
||||
setSoftwareTestedWp,
|
||||
softwareRequiresPhp,
|
||||
setSoftwareRequiresPhp,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h3 className="text-lg font-medium">{__('Software Distribution')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{__('Enable this to distribute software updates, manage versioning, and secure downloads.')}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base">{__('Enable Software Distribution')}</Label>
|
||||
<p className="text-sm text-gray-500">
|
||||
{__('Allow this product to serve OTA updates and track versions.')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={softwareEnabled} onCheckedChange={setSoftwareEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{softwareEnabled && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="softwareSlug">{__('Software Slug (Unique Identifier)')}</Label>
|
||||
<Input
|
||||
id="softwareSlug"
|
||||
value={softwareSlug}
|
||||
onChange={(e) => setSoftwareSlug(e.target.value)}
|
||||
placeholder="e.g. acme-seo-pro"
|
||||
/>
|
||||
<p className="text-sm text-gray-500">
|
||||
{__('The unique slug that the software client will use to check for updates.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border mt-6">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base">{__('WordPress Product')}</Label>
|
||||
<p className="text-sm text-gray-500">
|
||||
{__('Check if this software is a WordPress Plugin or Theme.')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={softwareWpEnabled} onCheckedChange={setSoftwareWpEnabled} />
|
||||
</div>
|
||||
|
||||
{softwareWpEnabled && (
|
||||
<div className="grid grid-cols-3 gap-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requiresWp">{__('Requires WP')}</Label>
|
||||
<Input
|
||||
id="requiresWp"
|
||||
value={softwareRequiresWp}
|
||||
onChange={(e) => setSoftwareRequiresWp(e.target.value)}
|
||||
placeholder="e.g. 5.8"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="testedWp">{__('Tested up to WP')}</Label>
|
||||
<Input
|
||||
id="testedWp"
|
||||
value={softwareTestedWp}
|
||||
onChange={(e) => setSoftwareTestedWp(e.target.value)}
|
||||
placeholder="e.g. 6.4"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requiresPhp">{__('Requires PHP')}</Label>
|
||||
<Input
|
||||
id="requiresPhp"
|
||||
value={softwareRequiresPhp}
|
||||
onChange={(e) => setSoftwareRequiresPhp(e.target.value)}
|
||||
placeholder="e.g. 7.4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,10 @@ export type ProductVariant = {
|
||||
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
|
||||
image?: string;
|
||||
license_duration_days?: string;
|
||||
subscription_signup_fee?: string;
|
||||
subscription_trial_days?: string;
|
||||
subscription_period?: 'day' | 'week' | 'month' | 'year';
|
||||
subscription_interval?: string;
|
||||
};
|
||||
|
||||
type VariationsTabProps = {
|
||||
@@ -282,8 +286,83 @@ export function VariationsTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscription Fields */}
|
||||
<div className="col-span-2 md:col-span-4 space-y-3">
|
||||
<Label className="text-xs font-semibold">{__('Subscription Settings (Optional)')}</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">{__('Period')}</Label>
|
||||
<select
|
||||
value={variation.subscription_period || ''}
|
||||
onChange={(e) => {
|
||||
const updated = [...variations];
|
||||
updated[index].subscription_period = e.target.value as any;
|
||||
setVariations(updated);
|
||||
}}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 mt-1"
|
||||
>
|
||||
<option value="">{__('Parent Default')}</option>
|
||||
<option value="day">{__('Day')}</option>
|
||||
<option value="week">{__('Week')}</option>
|
||||
<option value="month">{__('Month')}</option>
|
||||
<option value="year">{__('Year')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">{__('Interval')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder={__('Parent')}
|
||||
value={variation.subscription_interval || ''}
|
||||
onChange={(e) => {
|
||||
const updated = [...variations];
|
||||
updated[index].subscription_interval = e.target.value;
|
||||
setVariations(updated);
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">{__('Trial Days')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder={__('Parent')}
|
||||
value={variation.subscription_trial_days || ''}
|
||||
onChange={(e) => {
|
||||
const updated = [...variations];
|
||||
updated[index].subscription_trial_days = e.target.value;
|
||||
setVariations(updated);
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Label className="text-xs">{__('Signup Fee')}</Label>
|
||||
<div className="relative mt-1">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground font-medium">
|
||||
{store.symbol}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
step={store.decimals === 0 ? '1' : '0.01'}
|
||||
placeholder={__('Parent')}
|
||||
value={variation.subscription_signup_fee || ''}
|
||||
onChange={(e) => {
|
||||
const updated = [...variations];
|
||||
updated[index].subscription_signup_fee = e.target.value;
|
||||
setVariations(updated);
|
||||
}}
|
||||
className="pl-8 pr-3 text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* License Duration - only show if licensing is enabled on product */}
|
||||
<div className="col-span-2 md:col-span-4">
|
||||
<div className="col-span-2 md:col-span-4 mt-2">
|
||||
<Label className="text-xs">{__('License Duration (Days)')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { DynamicComponentLoader } from '@/components/DynamicComponentLoader';
|
||||
import { GatewayCapabilityMatrix as SubscriptionGatewayCapabilitiesSection } from './Modules/Subscription/GatewayCapabilityMatrix';
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
@@ -143,6 +144,8 @@ export default function ModuleSettings() {
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{moduleId === 'subscription' && <SubscriptionGatewayCapabilitiesSection />}
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,8 +51,20 @@ export default function Modules() {
|
||||
mutationFn: async ({ moduleId, enabled }: { moduleId: string; enabled: boolean }) => {
|
||||
return api.post('/modules/toggle', { module_id: moduleId, enabled });
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
onSuccess: (data: any, variables) => {
|
||||
if (Array.isArray(data?.enabled_modules)) {
|
||||
queryClient.setQueryData(['modules-enabled'], { enabled: data.enabled_modules });
|
||||
}
|
||||
|
||||
if (Array.isArray(data?.nav_tree)) {
|
||||
window.WNW_NAV_TREE = data.nav_tree;
|
||||
window.dispatchEvent(new CustomEvent('woonoow:navigation-updated', {
|
||||
detail: { moduleId: variables.moduleId, enabled: variables.enabled },
|
||||
}));
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['modules-enabled'] });
|
||||
toast.success(
|
||||
variables.enabled
|
||||
? __('Module enabled successfully')
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { SettingsCard } from '../../components/SettingsCard';
|
||||
import { ToggleField } from '../../components/ToggleField';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface GatewayRow {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
default: boolean;
|
||||
override: boolean | null;
|
||||
auto_renew: boolean;
|
||||
forced_manual: boolean;
|
||||
}
|
||||
|
||||
interface CapabilityResponse {
|
||||
gateways: GatewayRow[];
|
||||
kill_switch: boolean;
|
||||
}
|
||||
|
||||
type OverrideMap = Record<string, boolean | null>;
|
||||
|
||||
/**
|
||||
* Gateway Capability Matrix
|
||||
*
|
||||
* Renders one row per WC payment gateway with a per-gateway auto-renew
|
||||
* toggle. Overrides are persisted via POST /subscriptions/gateway-capabilities.
|
||||
* The kill switch is a separate field already on the standard settings form
|
||||
* (force_manual_renewal); when on, every row is rendered as forced-manual
|
||||
* and the per-gateway toggles are disabled.
|
||||
*/
|
||||
export const GatewayCapabilityMatrix: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [overrides, setOverrides] = useState<OverrideMap>({});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['subscription', 'gateway-capabilities'],
|
||||
queryFn: async () => {
|
||||
const r = await api.get('/subscriptions/gateway-capabilities');
|
||||
return r as CapabilityResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (payload: OverrideMap) => {
|
||||
return api.post('/subscriptions/gateway-capabilities', { overrides: payload });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Gateway capabilities saved'));
|
||||
setOverrides({});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', 'gateway-capabilities'] });
|
||||
},
|
||||
onError: (e: any) => {
|
||||
toast.error(e?.response?.data?.message ?? __('Failed to save'));
|
||||
},
|
||||
});
|
||||
|
||||
const rows = useMemo(() => data?.gateways ?? [], [data]);
|
||||
|
||||
const effectiveFor = (row: GatewayRow): boolean => {
|
||||
if (row.forced_manual) return false;
|
||||
if (row.id in overrides) return overrides[row.id] === true;
|
||||
return row.auto_renew;
|
||||
};
|
||||
|
||||
const dirty = Object.keys(overrides).length > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsCard
|
||||
title={__('Gateway Auto-Renew Capabilities')}
|
||||
description={__('Per-gateway declaration of which payment methods can auto-debit subscription renewals.')}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">{__('Loading gateways…')}</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
title={__('Gateway Auto-Renew Capabilities')}
|
||||
description={__(
|
||||
'Declare which payment gateways can auto-debit subscription renewals. Indonesian VA/QRIS/e-wallet gateways default to manual. The kill switch (above) forces every gateway to manual regardless of these settings.'
|
||||
)}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">{__('No payment gateways available.')}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{rows.map((row) => {
|
||||
const eff = effectiveFor(row);
|
||||
const overrideState = row.id in overrides ? overrides[row.id] : row.override;
|
||||
return (
|
||||
<div key={row.id} className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-foreground">{row.title}</span>
|
||||
{!row.enabled && (
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded">
|
||||
{__('Site disabled')}
|
||||
</span>
|
||||
)}
|
||||
{row.forced_manual && (
|
||||
<span className="text-xs px-2 py-0.5 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded">
|
||||
{__('Forced manual (kill switch)')}
|
||||
</span>
|
||||
)}
|
||||
{overrideState === null && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 rounded">
|
||||
{__('Default')}: {row.default ? __('Auto-renew') : __('Manual')}
|
||||
</span>
|
||||
)}
|
||||
{overrideState !== null && (
|
||||
<span className="text-xs px-2 py-0.5 bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 rounded">
|
||||
{__('Override')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{row.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 break-words">{row.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">{row.id}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
<ToggleField
|
||||
id={`gateway-autorenew-${row.id}`}
|
||||
checked={eff}
|
||||
disabled={row.forced_manual}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
const currentEffective = row.auto_renew;
|
||||
if (checked === currentEffective) {
|
||||
setOverrides((p) => {
|
||||
const n = { ...p };
|
||||
delete n[row.id];
|
||||
return n;
|
||||
});
|
||||
} else {
|
||||
setOverrides((p) => ({ ...p, [row.id]: checked }));
|
||||
}
|
||||
}}
|
||||
label={eff ? __('Auto-renew') : __('Manual')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<Button
|
||||
onClick={() => save.mutate(overrides)}
|
||||
disabled={!dirty || save.isPending}
|
||||
>
|
||||
{save.isPending ? __('Saving…') : __('Save Capability Overrides')}
|
||||
</Button>
|
||||
{dirty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setOverrides({})}
|
||||
>
|
||||
{__('Discard changes')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
@@ -118,8 +118,18 @@ async function fetchSubscription(id: string) {
|
||||
return res;
|
||||
}
|
||||
|
||||
async function subscriptionAction(id: number, action: string, reason?: string) {
|
||||
const res = await api.post(`/subscriptions/${id}/${action}`, { reason });
|
||||
async function subscriptionAction(id: number, action: string, reason?: string, extra?: Record<string, unknown>) {
|
||||
let path = `/subscriptions/${id}/${action}`;
|
||||
if (extra) {
|
||||
const usp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(extra)) {
|
||||
if (v == null) continue;
|
||||
usp.set(k, String(v));
|
||||
}
|
||||
const qs = usp.toString();
|
||||
if (qs) path += `?${qs}`;
|
||||
}
|
||||
const res = await api.post(path, { reason });
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -158,11 +168,70 @@ export default function SubscriptionDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
// H1 — Renew is special: the response carries { order_id, status } so the admin
|
||||
// can see whether a payment URL needs to be sent to the customer.
|
||||
const renewMutation = useMutation({
|
||||
mutationFn: () => subscriptionAction(parseInt(id!), 'renew'),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||
const orderId = res?.order_id;
|
||||
const status = res?.status;
|
||||
if (status === 'complete') {
|
||||
toast.success(__(`Renewed successfully (order #${orderId}). Payment captured automatically.`));
|
||||
} else if (status === 'manual') {
|
||||
toast.success(
|
||||
__(`Renewal order #${orderId} created. The customer must complete payment manually — open the order to send a payment link.`),
|
||||
{ duration: 8000 }
|
||||
);
|
||||
} else if (status === 'existing') {
|
||||
toast.info(__(`Order #${orderId} is already pending payment — using the existing order.`));
|
||||
} else {
|
||||
toast.success(__(`Renewed (order #${orderId}).`));
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
// M2 — "Charge Now" is the same renew endpoint with `?charge_now=true`. It bypasses
|
||||
// the per-gateway capability gate so the auto-debit path is attempted even on
|
||||
// normally-manual gateways. On failure the order is marked failed (no manual
|
||||
// fallback) so the admin sees the charge could not be processed.
|
||||
const chargeNowMutation = useMutation({
|
||||
mutationFn: () => subscriptionAction(parseInt(id!), 'renew', undefined, { charge_now: 'true' }),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||
const orderId = res?.order_id;
|
||||
const status = res?.status;
|
||||
if (status === 'complete') {
|
||||
toast.success(__(`Charged successfully (order #${orderId}). The subscription has been renewed.`));
|
||||
} else if (status === 'existing') {
|
||||
toast.info(__(`Order #${orderId} is already pending payment — using the existing order.`));
|
||||
} else {
|
||||
toast.warning(__(`Charge attempt completed with status "${status || 'unknown'}" (order #${orderId}).`));
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
if (action === 'cancel') {
|
||||
setShowCancelDialog(true);
|
||||
return;
|
||||
}
|
||||
if (action === 'renew') {
|
||||
renewMutation.mutate();
|
||||
return;
|
||||
}
|
||||
if (action === 'charge_now') {
|
||||
chargeNowMutation.mutate();
|
||||
return;
|
||||
}
|
||||
actionMutation.mutate({ action });
|
||||
};
|
||||
|
||||
@@ -229,10 +298,21 @@ export default function SubscriptionDetail() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('renew')}
|
||||
disabled={actionMutation.isPending}
|
||||
disabled={renewMutation.isPending || chargeNowMutation.isPending || actionMutation.isPending}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{__('Renew Now')}
|
||||
{renewMutation.isPending ? __('Renewing…') : __('Renew Now')}
|
||||
</Button>
|
||||
)}
|
||||
{subscription.status === 'active' && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleAction('charge_now')}
|
||||
disabled={renewMutation.isPending || chargeNowMutation.isPending || actionMutation.isPending}
|
||||
title={__('Bypass the per-gateway capability gate and attempt an immediate charge. On failure the order is marked failed — no manual fallback.')}
|
||||
>
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
{chargeNowMutation.isPending ? __('Charging…') : __('Charge Now')}
|
||||
</Button>
|
||||
)}
|
||||
{subscription.can_cancel && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Filter, Package } from 'lucide-react';
|
||||
@@ -93,23 +93,49 @@ export default function SubscriptionsIndex() {
|
||||
const initial = getQuery();
|
||||
const [page, setPage] = useState(Number(initial.page ?? 1) || 1);
|
||||
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
||||
// M4 — Search input is held in a local "raw" state for snappy typing, then
|
||||
// committed to `committedSearch` after a 300ms debounce. We key the React
|
||||
// Query cache on the committed value so the debounce actually coalesces
|
||||
// requests, not just defers re-renders.
|
||||
const [rawSearch, setRawSearch] = useState<string>((initial as any).search || '');
|
||||
const [committedSearch, setCommittedSearch] = useState<string>((initial as any).search || '');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
const [cancelTargetId, setCancelTargetId] = useState<number | null>(null);
|
||||
const [showBulkCancelDialog, setShowBulkCancelDialog] = useState(false);
|
||||
const perPage = 20;
|
||||
|
||||
// Debounce rawSearch → committedSearch. 300ms is the sweet spot for "feels
|
||||
// instant" vs "don't fire on every keystroke".
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setCommittedSearch(rawSearch.trim());
|
||||
setPage(1);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [rawSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(__('Subscriptions'));
|
||||
return () => clearPageHeader();
|
||||
}, [setPageHeader, clearPageHeader]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery({ page, status });
|
||||
}, [page, status]);
|
||||
setQuery({ page, status, search: committedSearch || undefined });
|
||||
}, [page, status, committedSearch]);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['subscriptions', { status, page }],
|
||||
queryFn: () => api.get('/subscriptions', { status, page, per_page: perPage }),
|
||||
queryKey: ['subscriptions', { status, page, search: committedSearch }],
|
||||
queryFn: () => api.get('/subscriptions', {
|
||||
status,
|
||||
page,
|
||||
per_page: perPage,
|
||||
search: committedSearch || undefined,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
@@ -155,6 +181,71 @@ export default function SubscriptionsIndex() {
|
||||
}
|
||||
};
|
||||
|
||||
// M3 — Bulk actions. The checkboxes below drive `selectedIds`; this mutation
|
||||
// posts to /subscriptions/bulk and the toolbar above the table exposes the
|
||||
// available actions. CSV export uses a hidden form submit so the browser can
|
||||
// handle the download directly.
|
||||
const bulkActionMutation = useMutation({
|
||||
mutationFn: ({ action, ids }: { action: 'cancel' | 'export_csv'; ids: number[] }) =>
|
||||
api.post('/subscriptions/bulk', { action, ids }),
|
||||
onSuccess: (res: any, vars) => {
|
||||
if (vars.action === 'cancel') {
|
||||
const ok = res?.ok ?? 0;
|
||||
const failed = Array.isArray(res?.failed) ? res.failed.length : 0;
|
||||
if (failed === 0) {
|
||||
toast.success(__(`Cancelled ${ok} subscription${ok === 1 ? '' : 's'}.`));
|
||||
} else {
|
||||
toast.warning(__(`Cancelled ${ok}, failed ${failed}.`));
|
||||
}
|
||||
setSelectedIds([]);
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleBulkCancel = () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
setShowBulkCancelDialog(true);
|
||||
};
|
||||
|
||||
const confirmBulkCancel = () => {
|
||||
bulkActionMutation.mutate({ action: 'cancel', ids: selectedIds });
|
||||
setShowBulkCancelDialog(false);
|
||||
};
|
||||
|
||||
const handleBulkExport = () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
// We can't easily stream a CSV through fetch+sonner, so open a POST form
|
||||
// with a hidden _wpnonce and let the browser download directly. The
|
||||
// server returns Content-Disposition: attachment.
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = (window.WNW_API?.root || '') + '/woonoow/v1/subscriptions/bulk';
|
||||
const nonce = document.createElement('input');
|
||||
nonce.type = 'hidden';
|
||||
nonce.name = '_wpnonce';
|
||||
nonce.value = window.WNW_API?.nonce || '';
|
||||
form.appendChild(nonce);
|
||||
const actionInput = document.createElement('input');
|
||||
actionInput.type = 'hidden';
|
||||
actionInput.name = 'action';
|
||||
actionInput.value = 'export_csv';
|
||||
form.appendChild(actionInput);
|
||||
selectedIds.forEach((id) => {
|
||||
const i = document.createElement('input');
|
||||
i.type = 'hidden';
|
||||
i.name = 'ids[]';
|
||||
i.value = String(id);
|
||||
form.appendChild(i);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
};
|
||||
|
||||
// Checkbox logic
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const allIds = subscriptions.map(s => s.id);
|
||||
@@ -191,7 +282,22 @@ export default function SubscriptionsIndex() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
{/* M4 — Search input. The input is uncontrolled-looking
|
||||
(we just track rawSearch in state) so typing feels
|
||||
instant; the debounce above commits the value to
|
||||
the React Query cache 300ms after the user stops. */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="search"
|
||||
value={rawSearch}
|
||||
onChange={(e) => setRawSearch(e.target.value)}
|
||||
placeholder={__('Search by id, email, or name…')}
|
||||
aria-label={__('Search subscriptions')}
|
||||
className="border rounded-md pl-3 pr-3 py-2 text-sm w-64 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Filter className="min-w-4 w-4 h-4 opacity-60" />
|
||||
<Select
|
||||
value={status ?? 'all'}
|
||||
@@ -216,10 +322,10 @@ export default function SubscriptionsIndex() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{status && (
|
||||
{(status || committedSearch) && (
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground underline text-nowrap"
|
||||
onClick={() => { setStatus(undefined); setPage(1); }}
|
||||
onClick={() => { setStatus(undefined); setRawSearch(''); setCommittedSearch(''); setPage(1); }}
|
||||
>
|
||||
{__('Clear filters')}
|
||||
</button>
|
||||
@@ -235,6 +341,14 @@ export default function SubscriptionsIndex() {
|
||||
{/* Mobile: Status filter bar */}
|
||||
<div className="md:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={rawSearch}
|
||||
onChange={(e) => setRawSearch(e.target.value)}
|
||||
placeholder={__('Search…')}
|
||||
aria-label={__('Search subscriptions')}
|
||||
className="border rounded-md px-3 py-2 text-sm flex-1 min-w-0 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<Select
|
||||
value={status ?? 'all'}
|
||||
onValueChange={(v) => {
|
||||
@@ -273,6 +387,43 @@ export default function SubscriptionsIndex() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* M3 — Bulk actions toolbar. Visible only when at least one row is selected. */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-3 flex flex-wrap items-center gap-3">
|
||||
<div className="text-sm font-medium">
|
||||
{selectedIds.length === 1
|
||||
? __('1 subscription selected')
|
||||
: __('%s subscriptions selected').replace('%s', String(selectedIds.length))}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBulkExport}
|
||||
disabled={bulkActionMutation.isPending}
|
||||
>
|
||||
{__('Export CSV')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleBulkCancel}
|
||||
disabled={bulkActionMutation.isPending}
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
{bulkActionMutation.isPending ? __('Cancelling…') : __('Cancel selected')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedIds([])}
|
||||
disabled={bulkActionMutation.isPending}
|
||||
>
|
||||
{__('Clear')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{q.isLoading && (
|
||||
<div className="space-y-3">
|
||||
@@ -500,6 +651,37 @@ export default function SubscriptionsIndex() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* M3 — Bulk cancel confirmation dialog */}
|
||||
<AlertDialog open={showBulkCancelDialog} onOpenChange={setShowBulkCancelDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{__('Cancel %s subscriptions?').replace('%s', String(selectedIds.length))}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__('Are you sure you want to cancel the selected subscriptions? This affects multiple customers at once.')}
|
||||
<br />
|
||||
<span className="text-red-600 font-medium">{__('This action cannot be undone.')}</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={() => setShowBulkCancelDialog(false)}
|
||||
disabled={bulkActionMutation.isPending}
|
||||
>
|
||||
{__('Keep Subscriptions')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmBulkCancel}
|
||||
disabled={bulkActionMutation.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{bulkActionMutation.isPending ? __('Cancelling…') : __('Cancel All Selected')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
23
admin-spa/src/types/window.d.ts
vendored
23
admin-spa/src/types/window.d.ts
vendored
@@ -60,6 +60,14 @@ interface WNW_Store {
|
||||
position?: string;
|
||||
}
|
||||
|
||||
interface WNW_NavNode {
|
||||
key: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
children?: any[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
WNW_API: WNW_API_Config; // Make required to avoid "possibly undefined" check in every usage if we are sure it exists
|
||||
@@ -67,19 +75,20 @@ declare global {
|
||||
WNW_WC_MENUS?: WNW_WC_MENUS;
|
||||
WNW_CONFIG?: WNW_CONFIG;
|
||||
WNW_STORE?: WNW_Store;
|
||||
WNW_NAV_TREE?: Array<{
|
||||
key: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
children?: any[];
|
||||
}>;
|
||||
WNW_NAV_TREE?: WNW_NavNode[];
|
||||
WNW_ADDON_ROUTES?: Array<{
|
||||
path: string;
|
||||
component_url: string;
|
||||
props?: Record<string, any>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface WindowEventMap {
|
||||
'woonoow:navigation-updated': CustomEvent<{
|
||||
moduleId?: string;
|
||||
enabled?: boolean;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
|
||||
export { };
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
"strict": true,
|
||||
"allowJs": false,
|
||||
"types": [],
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"ignoreDeprecations": "6.0"
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { BaseLayout } from './layouts/BaseLayout';
|
||||
// Pages
|
||||
import Shop from './pages/Shop';
|
||||
import Product from './pages/Product';
|
||||
import CollectionPage from './pages/Shop/CollectionPage';
|
||||
import Cart from './pages/Cart';
|
||||
import Checkout from './pages/Checkout';
|
||||
import ThankYou from './pages/ThankYou';
|
||||
@@ -106,6 +107,7 @@ function AppRoutes() {
|
||||
{/* Shop Routes */}
|
||||
<Route path="/shop" element={<Shop />} />
|
||||
<Route path="/product/:slug" element={<Product />} />
|
||||
<Route path="/collection/:slug" element={<CollectionPage />} />
|
||||
|
||||
{/* Cart & Checkout */}
|
||||
<Route path="/cart" element={<Cart />} />
|
||||
|
||||
@@ -18,6 +18,7 @@ interface Address {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
is_default: boolean;
|
||||
formatted_address?: string;
|
||||
}
|
||||
|
||||
interface AddressSelectorProps {
|
||||
@@ -148,14 +149,22 @@ export function AddressSelector({
|
||||
)}
|
||||
|
||||
{/* Address */}
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
<div className="text-sm text-gray-600 mt-2">
|
||||
{address.formatted_address ? (
|
||||
<p className="whitespace-pre-wrap">{address.formatted_address}</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{address.address_1}
|
||||
{address.address_2 && `, ${address.address_2}`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p>
|
||||
{address.city}, {address.state} {address.postcode}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">{address.country}</p>
|
||||
<p>{address.country}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface CheckoutField {
|
||||
interface DynamicCheckoutFieldProps {
|
||||
field: CheckoutField;
|
||||
value: string;
|
||||
valueLabel?: string;
|
||||
onChange: (value: string) => void;
|
||||
countryOptions?: { value: string; label: string }[];
|
||||
stateOptions?: { value: string; label: string }[];
|
||||
@@ -41,6 +42,7 @@ interface SearchOption {
|
||||
export function DynamicCheckoutField({
|
||||
field,
|
||||
value,
|
||||
valueLabel,
|
||||
onChange,
|
||||
countryOptions = [],
|
||||
stateOptions = [],
|
||||
@@ -54,9 +56,11 @@ export function DynamicCheckoutField({
|
||||
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]);
|
||||
// If we have a value and a label, inject it into searchOptions so it renders properly when mounted
|
||||
if (value && valueLabel && searchOptions.length === 0) {
|
||||
setSearchOptions([{ value, label: valueLabel }]);
|
||||
}
|
||||
}, [field.type, field.search_endpoint, value, valueLabel]);
|
||||
|
||||
// Handle API search for searchable_select
|
||||
const handleApiSearch = async (searchTerm: string) => {
|
||||
|
||||
@@ -70,9 +70,9 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-gray-200 aspect-square rounded-lg mb-4" />
|
||||
<div className="h-4 bg-gray-200 rounded mb-2" />
|
||||
<div className="h-4 bg-gray-200 rounded w-2/3" />
|
||||
<div className="bg-muted aspect-square rounded-lg mb-4" />
|
||||
<div className="h-4 bg-muted rounded mb-2" />
|
||||
<div className="h-4 bg-muted rounded w-2/3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -91,17 +91,17 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
if (cardStyle === 'minimal') {
|
||||
return gridCols >= 4
|
||||
? 'overflow-hidden hover:opacity-90 transition-opacity'
|
||||
: 'overflow-hidden hover:opacity-80 transition-opacity border-b border-gray-100 pb-4';
|
||||
: 'overflow-hidden hover:opacity-80 transition-opacity border-b border-border pb-4';
|
||||
}
|
||||
if (cardStyle === 'overlay') {
|
||||
return gridCols >= 4
|
||||
? 'relative overflow-hidden group-hover:shadow-lg transition-all rounded-md'
|
||||
: 'relative overflow-hidden group-hover:shadow-xl transition-all rounded-lg bg-white';
|
||||
: 'relative overflow-hidden group-hover:shadow-xl transition-all rounded-lg bg-card';
|
||||
}
|
||||
// Default 'card' style
|
||||
return gridCols >= 4
|
||||
? 'border border-gray-200 rounded-md overflow-hidden hover:shadow-md transition-shadow bg-white'
|
||||
: 'border rounded-lg overflow-hidden hover:shadow-lg transition-shadow bg-white';
|
||||
? 'border border-border rounded-md overflow-hidden hover:shadow-md transition-shadow bg-card'
|
||||
: 'border border-border rounded-lg overflow-hidden hover:shadow-lg transition-shadow bg-card';
|
||||
};
|
||||
|
||||
const cardClasses = getCardClasses();
|
||||
@@ -118,7 +118,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
<Link to={`/product/${product.slug}`} className="group h-full">
|
||||
<div className={`${cardClasses} h-full flex flex-col`}>
|
||||
{/* Image */}
|
||||
<div className={`relative w-full overflow-hidden bg-gray-100 ${aspectRatioClass}`}>
|
||||
<div className={`relative w-full overflow-hidden bg-muted ${aspectRatioClass}`}>
|
||||
{product.image ? (
|
||||
<img
|
||||
src={product.image}
|
||||
@@ -126,7 +126,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
className="absolute inset-0 w-full !h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
@@ -146,12 +146,14 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<button
|
||||
onClick={handleWishlistClick}
|
||||
className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${inWishlist ? 'bg-red-50' : 'bg-white'
|
||||
className={`font-[inherit] p-2 rounded-full shadow-md border flex items-center justify-center transition-all ${
|
||||
inWishlist
|
||||
? 'bg-red-50 border-red-100 dark:bg-red-950 dark:border-red-900'
|
||||
: 'bg-background border-border hover:bg-muted text-foreground'
|
||||
}`}
|
||||
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
|
||||
>
|
||||
<Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''
|
||||
}`} />
|
||||
<Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -174,7 +176,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
|
||||
{/* Content */}
|
||||
<div className={`p-4 flex-1 flex flex-col ${textAlignClass}`}>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2 line-clamp-2 leading-snug group-hover:text-primary transition-colors">
|
||||
<h3 className="text-sm font-medium text-foreground mb-2 line-clamp-2 leading-snug group-hover:text-primary transition-colors">
|
||||
{product.name}
|
||||
</h3>
|
||||
|
||||
@@ -182,15 +184,15 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
<div className={`flex items-center gap-2 mb-3 ${(layout.card_text_align || 'left') === 'center' ? 'justify-center' : (layout.card_text_align || 'left') === 'right' ? 'justify-end' : ''}`}>
|
||||
{product.on_sale && product.regular_price ? (
|
||||
<>
|
||||
<span className="text-base font-bold" style={{ color: 'var(--color-primary)' }}>
|
||||
<span className="text-base font-bold text-primary">
|
||||
{formatPrice(product.sale_price || product.price)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 line-through">
|
||||
<span className="text-xs text-muted-foreground line-through">
|
||||
{formatPrice(product.regular_price)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-base font-bold text-gray-900">
|
||||
<span className="text-base font-bold text-foreground">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
26
customer-spa/src/components/SectionBackgroundRenderer.tsx
Normal file
26
customer-spa/src/components/SectionBackgroundRenderer.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { SectionStyleResult } from '@/lib/sectionStyles';
|
||||
|
||||
interface SectionBackgroundRendererProps {
|
||||
bg: SectionStyleResult;
|
||||
}
|
||||
|
||||
export function SectionBackgroundRenderer({ bg }: SectionBackgroundRendererProps) {
|
||||
if (!bg.backgroundImage) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none z-0">
|
||||
<img
|
||||
src={bg.backgroundImage}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{bg.hasOverlay && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black"
|
||||
style={{ opacity: bg.overlayOpacity }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ interface SharedContentProps {
|
||||
textClassName?: string;
|
||||
headingStyle?: React.CSSProperties; // For prose headings override
|
||||
imageStyle?: React.CSSProperties;
|
||||
cardStyle?: React.CSSProperties; // For boxed layout background
|
||||
|
||||
// Pro Features (for future)
|
||||
buttons?: Array<{ text: string, url: string }>;
|
||||
@@ -44,6 +45,7 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
buttons,
|
||||
|
||||
imageStyle,
|
||||
cardStyle,
|
||||
buttonStyle
|
||||
}) => {
|
||||
|
||||
@@ -53,47 +55,65 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
const isImageTop = imagePosition === 'top';
|
||||
const isImageBottom = imagePosition === 'bottom';
|
||||
|
||||
// Wrapper classes — full = edge-to-edge, contained = narrow readable column, boxed = card at max-w-5xl
|
||||
// Wrapper classes — no width constraints applied here, parent handles it
|
||||
const containerClasses = cn(
|
||||
'w-full mx-auto px-4 sm:px-6 lg:px-8',
|
||||
containerWidth === 'contained' ? 'max-w-4xl'
|
||||
: containerWidth === 'boxed' ? 'max-w-5xl'
|
||||
: '' // full = no max-width cap
|
||||
containerWidth === 'contained' ? 'max-w-4xl' : '',
|
||||
containerWidth === 'boxed' ? 'max-w-5xl' : ''
|
||||
);
|
||||
|
||||
const gridClasses = cn(
|
||||
'mx-auto',
|
||||
'mx-auto w-full',
|
||||
hasImage && (isImageLeft || isImageRight)
|
||||
? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center'
|
||||
: containerWidth === 'full' ? 'w-full' : '' // no extra constraint for contained — outer already limits it
|
||||
: ''
|
||||
);
|
||||
|
||||
const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
|
||||
|
||||
const safeTextStyle = { ...textStyle };
|
||||
delete safeTextStyle.textAlign;
|
||||
|
||||
const proseStyle = {
|
||||
...textStyle,
|
||||
...safeTextStyle,
|
||||
'--tw-prose-headings': headingStyle?.color,
|
||||
'--tw-prose-body': textStyle?.color,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{containerWidth === 'boxed' ? (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10">
|
||||
<div className={gridClasses}>
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
<div className={cn(
|
||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||
'flex flex-col',
|
||||
imageWrapperOrder,
|
||||
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
|
||||
)} style={imageStyle}>
|
||||
(isImageTop || isImageBottom) && 'mb-8',
|
||||
{
|
||||
'items-start': (imageStyle as any)?.alignment === 'left',
|
||||
'items-center': (imageStyle as any)?.alignment === 'center',
|
||||
'items-end': (imageStyle as any)?.alignment === 'right',
|
||||
}
|
||||
)}>
|
||||
<div className={cn(
|
||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||
)} style={{
|
||||
backgroundColor: imageStyle?.backgroundColor,
|
||||
width: imageStyle?.width,
|
||||
height: imageStyle?.height,
|
||||
maxWidth: '100%'
|
||||
}}>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Section Image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{
|
||||
objectFit: imageStyle?.objectFit,
|
||||
objectPosition: (imageStyle as any)?.objectPosition,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Side */}
|
||||
@@ -101,7 +121,7 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"tracking-tight text-current mb-6",
|
||||
"tracking-tight text-current mb-6 w-full",
|
||||
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
|
||||
titleClassName
|
||||
)}
|
||||
@@ -111,12 +131,10 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
</h2>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{text && (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-lg max-w-none',
|
||||
'prose prose-lg max-w-none w-full',
|
||||
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
|
||||
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
|
||||
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
|
||||
@@ -131,11 +149,14 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Buttons */}
|
||||
{buttons && buttons.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
<div className={cn(
|
||||
"mt-8 flex flex-wrap gap-4",
|
||||
buttonStyle?.style?.textAlign === 'center' && "justify-center",
|
||||
buttonStyle?.style?.textAlign === 'right' && "justify-end",
|
||||
(!buttonStyle?.style?.textAlign || buttonStyle?.style?.textAlign === 'left') && "justify-start"
|
||||
)}>
|
||||
{buttons.map((btn, idx) => (
|
||||
btn.text && btn.url && (
|
||||
<a
|
||||
@@ -158,81 +179,5 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={gridClasses}>
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
<div className={cn(
|
||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||
imageWrapperOrder,
|
||||
(isImageTop || isImageBottom) && 'mb-8'
|
||||
)} style={imageStyle}>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Section Image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Side */}
|
||||
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"tracking-tight text-current mb-6",
|
||||
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
|
||||
titleClassName
|
||||
)}
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{text && (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-lg max-w-none',
|
||||
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
|
||||
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
|
||||
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
|
||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||
'prose-p:text-[var(--tw-prose-body)]',
|
||||
'text-[var(--tw-prose-body)]',
|
||||
className,
|
||||
textClassName
|
||||
)}
|
||||
style={proseStyle}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
{buttons && buttons.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
{buttons.map((btn, idx) => (
|
||||
btn.text && btn.url && (
|
||||
<a
|
||||
key={idx}
|
||||
href={btn.url}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
|
||||
!buttonStyle?.style?.backgroundColor && "bg-primary",
|
||||
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
|
||||
buttonStyle?.classNames
|
||||
)}
|
||||
style={buttonStyle?.style}
|
||||
>
|
||||
{btn.text}
|
||||
</a>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
"border border-input bg-background text-foreground shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
|
||||
@@ -14,6 +14,7 @@ interface AppearanceSettings {
|
||||
grid_columns: string;
|
||||
card_style: string;
|
||||
aspect_ratio: string;
|
||||
filter_layout?: 'basic' | 'rich_sidebar';
|
||||
};
|
||||
elements: {
|
||||
category_filter: boolean;
|
||||
@@ -86,6 +87,7 @@ export function useShopSettings() {
|
||||
card_style: 'card' as string,
|
||||
aspect_ratio: 'square' as string,
|
||||
card_text_align: 'left' as string,
|
||||
filter_layout: 'basic' as 'basic' | 'rich_sidebar',
|
||||
},
|
||||
elements: {
|
||||
category_filter: true,
|
||||
|
||||
17
customer-spa/src/hooks/useDebounce.ts
Normal file
17
customer-spa/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -166,13 +166,13 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
<div className="relative">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute -top-2 -right-2 h-5 w-5 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center font-medium">
|
||||
<span className="absolute -top-2 -right-2 h-5 w-5 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center font-medium">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden lg:block">
|
||||
Cart ({itemCount})
|
||||
Cart
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -261,7 +261,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
<button onClick={openCart} className="font-[inherit] flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 relative">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute top-1 right-2 h-4 w-4 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center">
|
||||
<span className="absolute top-1 right-2 h-4 w-4 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -497,7 +497,15 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
)}
|
||||
{headerSettings.elements.cart && (
|
||||
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
|
||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
||||
<div className="relative">
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute -top-2 -right-2 h-4 w-4 rounded-full bg-primary text-primary-foreground text-[10px] flex items-center justify-center font-medium">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span>Cart</span>
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
@@ -657,7 +665,15 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
)}
|
||||
{headerSettings.elements.cart && (
|
||||
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors">
|
||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
||||
<div className="relative">
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute -top-2 -right-2 h-4 w-4 rounded-full bg-primary text-primary-foreground text-[10px] flex items-center justify-center font-medium">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span>Cart</span>
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -93,6 +93,7 @@ const endpoints = {
|
||||
product: (id: number) => `/shop/products/${id}`,
|
||||
categories: '/shop/categories',
|
||||
search: '/shop/search',
|
||||
collection: (slug: string) => `/shop/collections/${slug}`,
|
||||
},
|
||||
cart: {
|
||||
get: '/cart',
|
||||
@@ -115,6 +116,7 @@ const endpoints = {
|
||||
profile: '/account/profile',
|
||||
password: '/account/password',
|
||||
addresses: '/account/addresses',
|
||||
affiliateCollections: '/account/affiliate/collections',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ interface CurrencySettings {
|
||||
/**
|
||||
* Get currency settings from window
|
||||
*/
|
||||
function getCurrencySettings(): CurrencySettings {
|
||||
export function getCurrencySettings(): CurrencySettings {
|
||||
const settings = (window as any).woonoowCustomer?.currency;
|
||||
|
||||
// Default to USD if not available
|
||||
|
||||
@@ -5,6 +5,48 @@ import './styles/fonts.css';
|
||||
import './styles/theme.css';
|
||||
import App from './App';
|
||||
|
||||
// Capture referral code from URL on app load
|
||||
// This ensures referral tracking works even for block/AJAX checkout flows
|
||||
(function captureReferralCode() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ref = params.get('ref');
|
||||
console.log('[WooNooW] captureReferralCode - URL search:', window.location.search, ', ref:', ref);
|
||||
|
||||
if (ref && ref.trim() !== '') {
|
||||
// Store in localStorage as backup
|
||||
try {
|
||||
localStorage.setItem('woonoow_ref', ref);
|
||||
console.log('[WooNooW] Stored ref in localStorage:', ref);
|
||||
} catch (e) {
|
||||
// localStorage may be blocked
|
||||
console.log('[WooNooW] localStorage blocked:', e);
|
||||
}
|
||||
|
||||
// Also set cookie for PHP backend to read
|
||||
// Cookie expires in 30 days, path is root
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 30);
|
||||
const cookieStr = `woonoow_ref=${encodeURIComponent(ref)}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
|
||||
document.cookie = cookieStr;
|
||||
console.log('[WooNooW] Set cookie:', cookieStr);
|
||||
} else {
|
||||
// Check if ref exists in localStorage from previous visit
|
||||
try {
|
||||
const storedRef = localStorage.getItem('woonoow_ref');
|
||||
if (storedRef) {
|
||||
console.log('[WooNooW] Found stored ref in localStorage:', storedRef);
|
||||
// Re-set the cookie from localStorage
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 30);
|
||||
document.cookie = `woonoow_ref=${encodeURIComponent(storedRef)}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
|
||||
console.log('[WooNooW] Re-set cookie from localStorage:', storedRef);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[WooNooW] Could not read localStorage:', e);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const el = document.getElementById('woonoow-customer-app');
|
||||
if (el) {
|
||||
createRoot(el).render(
|
||||
|
||||
@@ -231,7 +231,7 @@ export default function AccountDetails() {
|
||||
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"
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{uploadingAvatar ? 'Uploading...' : 'Upload Photo'}
|
||||
</button>
|
||||
@@ -294,7 +294,7 @@ export default function AccountDetails() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="font-[inherit] px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="font-[inherit] px-6 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
@@ -344,7 +344,7 @@ export default function AccountDetails() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="font-[inherit] px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="font-[inherit] px-6 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Updating...' : 'Update Password'}
|
||||
|
||||
@@ -306,7 +306,7 @@ export default function Addresses() {
|
||||
<h1 className="text-2xl font-bold">Addresses</h1>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="font-[inherit] inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
className="font-[inherit] inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Address
|
||||
@@ -319,7 +319,7 @@ export default function Addresses() {
|
||||
<p className="text-gray-600 mb-4">No addresses saved yet</p>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="font-[inherit] inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
className="font-[inherit] inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Your First Address
|
||||
@@ -345,10 +345,18 @@ export default function Addresses() {
|
||||
<div className="text-sm text-gray-700 space-y-1 mb-4">
|
||||
<p className="font-medium">{address.first_name} {address.last_name}</p>
|
||||
{address.company && <p>{address.company}</p>}
|
||||
{address.formatted_address ? (
|
||||
<p className="whitespace-pre-wrap">{address.formatted_address}</p>
|
||||
) : (
|
||||
<>
|
||||
<p>{address.address_1}</p>
|
||||
{address.address_2 && <p>{address.address_2}</p>}
|
||||
<p>{address.city}, {address.state} {address.postcode}</p>
|
||||
{[address.city, address.state, address.postcode].filter(Boolean).length > 0 && (
|
||||
<p>{[address.city, address.state, address.postcode].filter(Boolean).join(', ')}</p>
|
||||
)}
|
||||
<p>{address.country}</p>
|
||||
</>
|
||||
)}
|
||||
{address.phone && <p className="pt-2">Phone: {address.phone}</p>}
|
||||
{address.email && <p>Email: {address.email}</p>}
|
||||
</div>
|
||||
@@ -429,6 +437,7 @@ export default function Addresses() {
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={getFieldValue(field.key)}
|
||||
valueLabel={getFieldValue(field.key + '_label')}
|
||||
onChange={(v) => setFieldValue(field.key, v)}
|
||||
countryOptions={countryOptions}
|
||||
stateOptions={stateOptions}
|
||||
@@ -454,7 +463,7 @@ export default function Addresses() {
|
||||
<button
|
||||
onClick={handleSave}
|
||||
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"
|
||||
className="font-[inherit] flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Save Address
|
||||
</button>
|
||||
|
||||
422
customer-spa/src/pages/Account/AffiliateCollections.tsx
Normal file
422
customer-spa/src/pages/Account/AffiliateCollections.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Plus, Trash2, Edit2, Link as LinkIcon, Search, Copy, CheckCircle, X, ChevronLeft } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
|
||||
interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
image?: string;
|
||||
price_html?: string;
|
||||
}
|
||||
|
||||
interface Collection {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
product_ids: number[];
|
||||
link: string;
|
||||
}
|
||||
|
||||
export function AffiliateCollections() {
|
||||
const config = (window as any).woonoowCustomer || {};
|
||||
const enableCuratedCollections = config.affiliateSettings?.enableCuratedCollections !== false;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingCollection, setEditingCollection] = useState<Collection | null>(null);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [selectedProducts, setSelectedProducts] = useState<Product[]>([]);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const { data: collections, isLoading: isLoadingCollections } = useQuery<Collection[]> ({
|
||||
queryKey: ['affiliate-collections'],
|
||||
queryFn: async () => {
|
||||
const res: any = await api.get('/account/affiliate/collections');
|
||||
return Array.isArray(res) ? res : [];
|
||||
}
|
||||
});
|
||||
|
||||
const { data: searchResults, isLoading: isSearching } = useQuery({
|
||||
queryKey: ['collection-product-search', debouncedSearch],
|
||||
queryFn: async () => {
|
||||
if (!debouncedSearch) return [];
|
||||
try {
|
||||
const res: any = await api.get(`/shop/products?search=${encodeURIComponent(debouncedSearch)}&per_page=5`);
|
||||
return res.products || [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: debouncedSearch.length > 2,
|
||||
placeholderData: keepPreviousData
|
||||
});
|
||||
|
||||
// When editing, fetch details of products so we can show their names/images
|
||||
const { data: editingProducts } = useQuery({
|
||||
queryKey: ['collection-editing-products', editingCollection?.id],
|
||||
queryFn: async () => {
|
||||
if (!editingCollection || editingCollection.product_ids.length === 0) return [];
|
||||
const res: any = await api.get(`/shop/products?include=${editingCollection.product_ids.join(',')}&per_page=20`);
|
||||
return res.products || [];
|
||||
},
|
||||
enabled: !!editingCollection
|
||||
});
|
||||
|
||||
// Pre-fill form when editingProducts is loaded
|
||||
React.useEffect(() => {
|
||||
if (editingCollection && editingProducts) {
|
||||
setTitle(editingCollection.title);
|
||||
setDescription(editingCollection.description);
|
||||
setSelectedProducts(editingProducts);
|
||||
}
|
||||
}, [editingCollection, editingProducts]);
|
||||
|
||||
const resetForm = () => {
|
||||
setIsFormOpen(false);
|
||||
setEditingCollection(null);
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setSelectedProducts([]);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
return api.post('/account/affiliate/collections', data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['affiliate-collections'] });
|
||||
toast.success('Collection created successfully!');
|
||||
resetForm();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.message || 'Failed to create collection');
|
||||
}
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: number, data: any }) => {
|
||||
return api.put(`/account/affiliate/collections/${id}`, data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['affiliate-collections'] });
|
||||
toast.success('Collection updated successfully!');
|
||||
resetForm();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.message || 'Failed to update collection');
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
return api.delete(`/account/affiliate/collections/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['affiliate-collections'] });
|
||||
toast.success('Collection deleted!');
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
if (!title) {
|
||||
toast.error('Title is required');
|
||||
return;
|
||||
}
|
||||
if (selectedProducts.length > 20) {
|
||||
toast.error('Maximum 20 products allowed per collection');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
title,
|
||||
description,
|
||||
product_ids: selectedProducts.map(p => p.id)
|
||||
};
|
||||
|
||||
if (editingCollection) {
|
||||
updateMutation.mutate({ id: editingCollection.id, data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleProduct = (product: Product) => {
|
||||
const exists = selectedProducts.find(p => p.id === product.id);
|
||||
if (exists) {
|
||||
setSelectedProducts(prev => prev.filter(p => p.id !== product.id));
|
||||
} else {
|
||||
if (selectedProducts.length >= 20) {
|
||||
toast.error('Maximum 20 products allowed');
|
||||
return;
|
||||
}
|
||||
setSelectedProducts(prev => [...prev, product]);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (link: string, id: string) => {
|
||||
navigator.clipboard.writeText(link);
|
||||
setCopiedId(id);
|
||||
toast.success('Link copied to clipboard!');
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
if (isLoadingCollections) return <div>Loading collections...</div>;
|
||||
|
||||
if (!enableCuratedCollections) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-2">
|
||||
<Link to="/my-account/affiliate" className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-900">
|
||||
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Affiliate Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold tracking-tight">My Curated Collections</h2>
|
||||
<p className="text-muted-foreground">This feature has been disabled by the administrator.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-2">
|
||||
<Link to="/my-account/affiliate" className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-900">
|
||||
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Affiliate Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">My Curated Collections</h2>
|
||||
<p className="text-muted-foreground">Group your favorite products into a single shareable link.</p>
|
||||
</div>
|
||||
{!isFormOpen && (
|
||||
<Button onClick={() => setIsFormOpen(true)} className="gap-2">
|
||||
<Plus className="w-4 h-4" /> New Collection
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFormOpen && (
|
||||
<div className="bg-card border rounded-lg p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-semibold text-lg">
|
||||
{editingCollection ? 'Edit Collection' : 'Create New Collection'}
|
||||
</h3>
|
||||
<Button variant="ghost" size="icon" onClick={resetForm}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<label className="text-sm font-medium">Collection Title</label>
|
||||
<Input
|
||||
placeholder="e.g., My Summer Favorites"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<label className="text-sm font-medium">Description (Optional)</label>
|
||||
<textarea
|
||||
placeholder="Tell your audience why you love these products..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div className="flex justify-between items-end">
|
||||
<label className="text-sm font-medium">Select Products ({selectedProducts.length}/20)</label>
|
||||
</div>
|
||||
|
||||
{/* Selected Products Area */}
|
||||
{selectedProducts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{selectedProducts.map(p => (
|
||||
<div key={p.id} className="flex items-center gap-2 bg-secondary text-secondary-foreground text-xs rounded-full pl-2 pr-1 py-1">
|
||||
<span className="truncate max-w-[150px]">{p.name}</span>
|
||||
<button
|
||||
onClick={() => toggleProduct(p)}
|
||||
className="hover:bg-background/20 rounded-full p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search products to add..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search Results Dropdown */}
|
||||
{searchQuery && (
|
||||
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
|
||||
{isSearching && <div className="p-3 text-sm text-center text-muted-foreground">Searching...</div>}
|
||||
{!isSearching && searchResults?.length === 0 && (
|
||||
<div className="p-3 text-sm text-center text-muted-foreground">No products found.</div>
|
||||
)}
|
||||
{searchResults?.map((product: Product) => {
|
||||
const isSelected = selectedProducts.some(p => p.id === product.id);
|
||||
return (
|
||||
<div
|
||||
key={product.id}
|
||||
className={`p-3 flex items-center justify-between hover:bg-muted/50 cursor-pointer transition-colors ${isSelected ? 'bg-primary/5' : ''}`}
|
||||
onClick={() => toggleProduct(product)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
{product.image ? (
|
||||
<img src={product.image} alt={product.name} className="w-10 h-10 object-cover rounded" />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-muted rounded flex-shrink-0"></div>
|
||||
)}
|
||||
<div className="truncate">
|
||||
<div className="font-medium truncate text-sm">{product.name}</div>
|
||||
<div className="text-xs text-muted-foreground" dangerouslySetInnerHTML={{ __html: product.price_html || '' }} />
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && <CheckCircle className="w-4 h-4 text-primary flex-shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="outline" onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{(createMutation.isPending || updateMutation.isPending) ? 'Saving...' : 'Save Collection'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFormOpen && (!collections || collections.length === 0) ? (
|
||||
<div className="text-center py-12 bg-muted/30 rounded-lg border border-dashed">
|
||||
<h3 className="font-semibold mb-2">No collections yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a curated list of products to share with your audience.
|
||||
</p>
|
||||
<Button onClick={() => setIsFormOpen(true)}>
|
||||
Create First Collection
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{collections?.map(collection => (
|
||||
<div key={collection.id} className="border rounded-lg p-5 bg-card flex flex-col">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg line-clamp-1">{collection.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{collection.product_ids.length} products
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
setEditingCollection(collection);
|
||||
setIsFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if(window.confirm('Delete this collection?')) {
|
||||
deleteMutation.mutate(collection.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collection.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-4 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-gray-500">Collection Link (Shows all products)</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={collection.link}
|
||||
className="bg-muted h-9 text-xs font-mono"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
variant={copiedId === `col-${collection.id}` ? "default" : "outline"}
|
||||
onClick={() => copyToClipboard(collection.link, `col-${collection.id}`)}
|
||||
>
|
||||
{copiedId === `col-${collection.id}` ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-gray-500">Smart Link (Redirects to random product)</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={`${window.location.origin}/go/${collection.slug}`}
|
||||
className="bg-muted h-9 text-xs font-mono border-primary/20"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
variant={copiedId === `smart-${collection.id}` ? "default" : "outline"}
|
||||
onClick={() => copyToClipboard(`${window.location.origin}/go/${collection.slug}`, `smart-${collection.id}`)}
|
||||
>
|
||||
{copiedId === `smart-${collection.id}` ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
683
customer-spa/src/pages/Account/AffiliateDashboard.tsx
Normal file
683
customer-spa/src/pages/Account/AffiliateDashboard.tsx
Normal file
@@ -0,0 +1,683 @@
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import { Copy, CheckCircle, Activity, DollarSign, ChevronRight, Clock, Info, Wallet, CreditCard, Tag } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { formatPrice, getCurrencySettings } from '@/lib/currency';
|
||||
|
||||
// Affiliate types
|
||||
interface AffiliateProfile {
|
||||
status: 'active' | 'pending' | 'approved' | 'rejected';
|
||||
referral_code: string;
|
||||
commission_rate: number;
|
||||
custom_commission_rate: number | null;
|
||||
global_commission_rate: number;
|
||||
total_earnings: number;
|
||||
pending_earnings: number;
|
||||
collections_enabled?: boolean;
|
||||
}
|
||||
|
||||
interface PaginatedReferrals {
|
||||
referrals: AffiliateReferral[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
interface AffiliateReferral {
|
||||
id: number;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
commission_amount: string;
|
||||
created_at: string;
|
||||
order_id: number;
|
||||
currency: string;
|
||||
approved_at?: string;
|
||||
cancelled_reason?: string;
|
||||
}
|
||||
|
||||
interface AffiliatePayout {
|
||||
id: number;
|
||||
amount: string;
|
||||
currency: string;
|
||||
method: string;
|
||||
status: string;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
interface PaymentDetails {
|
||||
payment_method: string;
|
||||
payment_details: {
|
||||
bank_name?: string;
|
||||
account_number?: string;
|
||||
account_holder?: string;
|
||||
swift_code?: string;
|
||||
bank_address?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
notes?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AffiliateSettings {
|
||||
woonoow_affiliate_payment_methods?: string[];
|
||||
}
|
||||
|
||||
// Payment method labels
|
||||
const PAYMENT_METHOD_LABELS: Record<string, string> = {
|
||||
bank_transfer: 'Bank Transfer',
|
||||
paypal: 'PayPal',
|
||||
wise: 'Wise',
|
||||
skrill: 'Skrill',
|
||||
payoneer: 'Payoneer',
|
||||
custom: 'Custom (Other)',
|
||||
};
|
||||
|
||||
// Format amount using site's currency settings
|
||||
function formatAmount(amount: number | string, currency?: string): string {
|
||||
const amountNum = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||
const settings = getCurrencySettings();
|
||||
|
||||
const decimals = currency === 'IDR' ? 0 : settings.decimals;
|
||||
|
||||
const formatted = formatNumberWithSeparators(amountNum, decimals, settings.thousandSeparator, settings.decimalSeparator);
|
||||
return `${settings.symbol}${formatted}`;
|
||||
}
|
||||
|
||||
function formatNumberWithSeparators(
|
||||
value: number,
|
||||
decimals: number,
|
||||
thousandSeparator: string,
|
||||
decimalSeparator: string
|
||||
): string {
|
||||
const rounded = value.toFixed(decimals);
|
||||
const [integerPart, decimalPart] = rounded.split('.');
|
||||
const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator);
|
||||
|
||||
if (decimals > 0 && decimalPart) {
|
||||
return `${formattedInteger}${decimalSeparator}${decimalPart}`;
|
||||
}
|
||||
return formattedInteger;
|
||||
}
|
||||
|
||||
export default function AffiliateDashboard() {
|
||||
const queryClient = useQueryClient();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showPaymentForm, setShowPaymentForm] = useState(false);
|
||||
const [selectedMethod, setSelectedMethod] = useState('');
|
||||
const [paymentFormData, setPaymentFormData] = useState<Record<string, string>>({});
|
||||
|
||||
// Fetch affiliate settings for available payment methods
|
||||
const { data: settings } = useQuery<AffiliateSettings>({
|
||||
queryKey: ['affiliate-settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api.get('/modules/affiliate/settings');
|
||||
} catch {
|
||||
return { woonoow_affiliate_payment_methods: ['bank_transfer'] };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const availableMethods = settings?.woonoow_affiliate_payment_methods || ['bank_transfer'];
|
||||
|
||||
// Fetch dashboard info
|
||||
const { data: profile, isLoading } = useQuery<AffiliateProfile | null>({
|
||||
queryKey: ['affiliate-profile'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await api.get<AffiliateProfile>('/account/affiliate');
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
if (err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
retry: false
|
||||
});
|
||||
|
||||
// Fetch referrals
|
||||
const { data: referralsResponse, isLoading: isLoadingReferrals } = useQuery<PaginatedReferrals>({
|
||||
queryKey: ['affiliate-referrals'],
|
||||
queryFn: async () => {
|
||||
return await api.get<PaginatedReferrals>('/account/affiliate/referrals?limit=5');
|
||||
},
|
||||
enabled: !!profile && profile.status === 'active'
|
||||
});
|
||||
const referrals = referralsResponse?.referrals || [];
|
||||
|
||||
// Fetch payout history
|
||||
const { data: payouts = [], isLoading: isLoadingPayouts } = useQuery<AffiliatePayout[]>({
|
||||
queryKey: ['affiliate-payouts'],
|
||||
queryFn: async () => {
|
||||
return await api.get<AffiliatePayout[]>('/account/affiliate/payouts');
|
||||
},
|
||||
enabled: !!profile && profile.status === 'active'
|
||||
});
|
||||
|
||||
// Fetch payment details
|
||||
const { data: savedPaymentDetails } = useQuery<PaymentDetails>({
|
||||
queryKey: ['affiliate-payment-details'],
|
||||
queryFn: async () => {
|
||||
return await api.get<PaymentDetails>('/account/affiliate/payment-details');
|
||||
},
|
||||
enabled: !!profile && profile.status === 'active'
|
||||
});
|
||||
|
||||
// Initialize form when data loads OR when edit mode is opened
|
||||
const initFormFromSaved = React.useCallback(() => {
|
||||
if (savedPaymentDetails?.payment_method) {
|
||||
setSelectedMethod(savedPaymentDetails.payment_method);
|
||||
setPaymentFormData(savedPaymentDetails.payment_details || {});
|
||||
}
|
||||
}, [savedPaymentDetails]);
|
||||
|
||||
// Initialize when saved data changes and form is open
|
||||
React.useEffect(() => {
|
||||
if (showPaymentForm && savedPaymentDetails) {
|
||||
initFormFromSaved();
|
||||
}
|
||||
}, [showPaymentForm, savedPaymentDetails, initFormFromSaved]);
|
||||
|
||||
const updatePaymentMutation = useMutation({
|
||||
mutationFn: async (data: { payment_method: string; payment_details: Record<string, string> }) => {
|
||||
return await api.post('/account/affiliate/payment-details', data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['affiliate-payment-details'] });
|
||||
toast.success('Payment details saved successfully!');
|
||||
setShowPaymentForm(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to save payment details.');
|
||||
}
|
||||
});
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return await api.post('/account/affiliate/apply');
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['affiliate-profile'] });
|
||||
toast.success('Successfully applied for the affiliate program!');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to apply. Please try again later.');
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<h2 className="text-2xl font-bold">Affiliate Program</h2>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">Join our Affiliate Program</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200 mb-4">
|
||||
Earn commissions by referring customers to our store! You'll get a unique referral link that tracks any purchases made by your referrals.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => applyMutation.mutate()}
|
||||
disabled={applyMutation.isPending}
|
||||
>
|
||||
{applyMutation.isPending ? 'Applying...' : 'Apply Now'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (profile.status === 'pending') {
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<h2 className="text-2xl font-bold">Affiliate Program</h2>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800/50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-100 mb-2">Application Pending</h3>
|
||||
<p className="text-yellow-800 dark:text-yellow-200">
|
||||
Your application is currently being reviewed. We will notify you once it's approved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const woonoowConfig = (window as any).woonoowCustomer || {};
|
||||
const basePath = woonoowConfig.basePath || '';
|
||||
const shopPath = basePath ? `${basePath}/shop` : '/shop';
|
||||
const referralLink = `${window.location.origin}${shopPath}?ref=${profile.referral_code}`;
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(referralLink);
|
||||
setCopied(true);
|
||||
toast.success('Referral link copied to clipboard!');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const totalEarnings = profile.total_earnings || 0;
|
||||
const pendingEarnings = profile.pending_earnings || 0;
|
||||
|
||||
const handleSavePayment = () => {
|
||||
if (!selectedMethod) {
|
||||
toast.error('Please select a payment method');
|
||||
return;
|
||||
}
|
||||
updatePaymentMutation.mutate({
|
||||
payment_method: selectedMethod,
|
||||
payment_details: paymentFormData
|
||||
});
|
||||
};
|
||||
|
||||
const renderPaymentFields = () => {
|
||||
switch (selectedMethod) {
|
||||
case 'bank_transfer':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Bank Name</label>
|
||||
<Input
|
||||
value={paymentFormData.bank_name || ''}
|
||||
onChange={(e) => setPaymentFormData({ ...paymentFormData, bank_name: e.target.value })}
|
||||
placeholder="e.g., Bank Central Asia"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Account Holder Name</label>
|
||||
<Input
|
||||
value={paymentFormData.account_holder || ''}
|
||||
onChange={(e) => setPaymentFormData({ ...paymentFormData, account_holder: e.target.value })}
|
||||
placeholder="Full name as on account"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Account Number</label>
|
||||
<Input
|
||||
value={paymentFormData.account_number || ''}
|
||||
onChange={(e) => setPaymentFormData({ ...paymentFormData, account_number: e.target.value })}
|
||||
placeholder="Account number"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">SWIFT / IBAN Code</label>
|
||||
<Input
|
||||
value={paymentFormData.swift_code || ''}
|
||||
onChange={(e) => setPaymentFormData({ ...paymentFormData, swift_code: e.target.value })}
|
||||
placeholder="e.g., CENAIDJA (for Indonesian banks)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Bank Address (Optional)</label>
|
||||
<Input
|
||||
value={paymentFormData.bank_address || ''}
|
||||
onChange={(e) => setPaymentFormData({ ...paymentFormData, bank_address: e.target.value })}
|
||||
placeholder="Bank branch address"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'paypal':
|
||||
case 'wise':
|
||||
case 'skrill':
|
||||
case 'payoneer':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Account Name</label>
|
||||
<Input
|
||||
value={paymentFormData.name || ''}
|
||||
onChange={(e) => setPaymentFormData({ ...paymentFormData, name: e.target.value })}
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{PAYMENT_METHOD_LABELS[selectedMethod]} Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={paymentFormData.email || ''}
|
||||
onChange={(e) => setPaymentFormData({ ...paymentFormData, email: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'custom':
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Payment Instructions</label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 border rounded-lg bg-background text-sm min-h-[100px]"
|
||||
value={paymentFormData.notes || ''}
|
||||
onChange={(e) => setPaymentFormData({ ...paymentFormData, notes: e.target.value })}
|
||||
placeholder="Describe how you would like to receive payment..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentPaymentDisplay = () => {
|
||||
if (!savedPaymentDetails?.payment_method || !savedPaymentDetails?.payment_details) {
|
||||
return 'Not configured';
|
||||
}
|
||||
|
||||
const method = savedPaymentDetails.payment_method;
|
||||
const details = savedPaymentDetails.payment_details;
|
||||
|
||||
switch (method) {
|
||||
case 'bank_transfer':
|
||||
return `${details.bank_name || 'Bank'} - ${details.account_number || '****'}`;
|
||||
case 'paypal':
|
||||
case 'wise':
|
||||
case 'skrill':
|
||||
case 'payoneer':
|
||||
return details.email || 'Not set';
|
||||
case 'custom':
|
||||
return 'Custom payment';
|
||||
default:
|
||||
return 'Not configured';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Affiliate Dashboard</h2>
|
||||
<p className="text-gray-500 mt-1">Manage your referrals and view your earnings.</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards - Improved styling */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-5 rounded-lg border shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<DollarSign className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wide">Total Earnings</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatAmount(totalEarnings)}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-5 rounded-lg border shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wide">Pending</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-yellow-600">{formatAmount(pendingEarnings)}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-5 rounded-lg border shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wide">Commission Rate</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{profile.commission_rate}%</p>
|
||||
{profile.custom_commission_rate ? (
|
||||
<p className="text-xs text-green-600">Custom rate</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">Default (global: {profile.global_commission_rate}%)</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referral Link */}
|
||||
<div className="bg-white p-6 rounded-lg border">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Your Referral Link</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
{profile?.collections_enabled !== false && (
|
||||
<Link
|
||||
to="/my-account/affiliate/collections"
|
||||
className="text-sm font-medium text-primary hover:opacity-80 flex items-center"
|
||||
>
|
||||
My Collections & Smart Links <ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/my-account/affiliate/links"
|
||||
className="text-sm font-medium text-primary hover:opacity-80 flex items-center"
|
||||
>
|
||||
Build Links <ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={referralLink}
|
||||
readOnly
|
||||
className="bg-gray-50 font-mono text-sm"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleCopy} className="shrink-0 w-32">
|
||||
{copied ? (
|
||||
<><CheckCircle className="w-4 h-4 mr-2" /> Copied</>
|
||||
) : (
|
||||
<><Copy className="w-4 h-4 mr-2" /> Copy Link</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Share this link with your audience. When they make a purchase, you'll earn a {profile.commission_rate}% commission.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Referrals */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Recent Referrals</h3>
|
||||
{referralsResponse && referralsResponse.total > 5 && (
|
||||
<Link
|
||||
to="/my-account/affiliate/referrals"
|
||||
className="text-sm font-medium text-primary hover:opacity-80 flex items-center transition-opacity"
|
||||
>
|
||||
View All <ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoadingReferrals ? (
|
||||
<div className="text-center py-8 text-gray-500">Loading referrals...</div>
|
||||
) : !referrals || referrals.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 border rounded-lg bg-gray-50">
|
||||
No referrals yet. Share your link to start earning!
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{referrals.map((ref: any) => {
|
||||
const createdDate = new Date(ref.created_at);
|
||||
const approvedDate = ref.approved_at ? new Date(ref.approved_at) : null;
|
||||
|
||||
return (
|
||||
<div key={ref.id} className="bg-white p-4 rounded-lg border hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">Order #{ref.order_id}</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ref.status === 'approved'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: ref.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{ref.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{createdDate.toLocaleDateString('id-ID', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
{ref.status === 'approved' && approvedDate && (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Approved {approvedDate.toLocaleDateString('id-ID', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{(ref.utm_campaign || ref.utm_source) && (
|
||||
<span className="flex items-center gap-1 text-purple-600">
|
||||
<Tag className="w-3 h-3" />
|
||||
{[ref.utm_campaign, ref.utm_source].filter(Boolean).join(' / ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatAmount(ref.commission_amount, ref.currency)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{ref.status === 'rejected' && ref.cancelled_reason && (
|
||||
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-red-800 dark:text-red-200">
|
||||
Reason: {ref.cancelled_reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payout History */}
|
||||
{payouts.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Payout History</h3>
|
||||
<div className="space-y-3">
|
||||
{payouts.map((payout) => {
|
||||
const payoutDate = new Date(payout.created_at);
|
||||
return (
|
||||
<div key={payout.id} className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${payout.status === 'completed'
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'bg-yellow-100 text-yellow-600'
|
||||
}`}>
|
||||
<Wallet className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{formatAmount(payout.amount, payout.currency)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 capitalize">
|
||||
{payout.method.replace('_', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${payout.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{payout.status}
|
||||
</span>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{payoutDate.toLocaleDateString('id-ID', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{payout.notes && (
|
||||
<div className="mt-3 pt-3 border-t text-xs text-gray-500">
|
||||
{payout.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Details Section */}
|
||||
<div className="bg-white p-6 rounded-lg border">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
|
||||
<CreditCard className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Payment Details</h3>
|
||||
<p className="text-xs text-gray-500">{getCurrentPaymentDisplay()}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!showPaymentForm && (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowPaymentForm(true)}>
|
||||
{savedPaymentDetails?.payment_method ? 'Edit' : 'Add'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPaymentForm ? (
|
||||
<div className="space-y-4">
|
||||
{/* Payment Method Selection */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Payment Method</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableMethods.map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedMethod(method);
|
||||
setPaymentFormData({});
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg border text-sm transition-colors ${selectedMethod === method
|
||||
? 'bg-purple-100 border-purple-500 text-purple-700'
|
||||
: 'bg-white border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{PAYMENT_METHOD_LABELS[method] || method}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditional Fields */}
|
||||
{selectedMethod && renderPaymentFields()}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => setShowPaymentForm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSavePayment}
|
||||
disabled={updatePaymentMutation.isPending || !selectedMethod}
|
||||
>
|
||||
{updatePaymentMutation.isPending ? 'Saving...' : 'Save Payment Details'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">
|
||||
Configure how you want to receive your affiliate payouts.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
customer-spa/src/pages/Account/AffiliateLinks.tsx
Normal file
60
customer-spa/src/pages/Account/AffiliateLinks.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { AffiliateLinkBuilder } from './components/AffiliateLinkBuilder';
|
||||
|
||||
interface AffiliateProfile {
|
||||
status: string;
|
||||
referral_code: string;
|
||||
}
|
||||
|
||||
export default function AffiliateLinks() {
|
||||
const { data: profile, isLoading } = useQuery<AffiliateProfile | null>({
|
||||
queryKey: ['affiliate-profile'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api.get('/account/affiliate');
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="animate-pulse h-64 bg-gray-100 rounded-lg"></div>;
|
||||
}
|
||||
|
||||
if (!profile || profile.status !== 'active') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-6">
|
||||
<Link to="/my-account/affiliate" className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-900">
|
||||
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Affiliate Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">Link Builder</h2>
|
||||
<p className="text-gray-500">You do not have an active affiliate account.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-6">
|
||||
<Link to="/my-account/affiliate" className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-900">
|
||||
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Affiliate Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Affiliate Link Builder</h2>
|
||||
<p className="text-muted-foreground mt-1">Create custom links to products and track your campaigns.</p>
|
||||
</div>
|
||||
|
||||
<AffiliateLinkBuilder referralCode={profile.referral_code} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
customer-spa/src/pages/Account/AffiliateReferrals.tsx
Normal file
206
customer-spa/src/pages/Account/AffiliateReferrals.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CheckCircle, Clock, Info, ArrowLeft, ChevronLeft, ChevronRight, Search, User } from 'lucide-react';
|
||||
import { getCurrencySettings } from '@/lib/currency';
|
||||
|
||||
interface AffiliateReferral {
|
||||
id: number;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
commission_amount: string;
|
||||
created_at: string;
|
||||
order_id: number;
|
||||
currency: string;
|
||||
approved_at?: string;
|
||||
cancelled_reason?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
}
|
||||
|
||||
interface PaginatedReferrals {
|
||||
referrals: AffiliateReferral[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
function formatAmount(amount: number | string, currency?: string): string {
|
||||
const amountNum = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||
const settings = getCurrencySettings();
|
||||
const decimals = currency === 'IDR' ? 0 : settings.decimals;
|
||||
const rounded = amountNum.toFixed(decimals);
|
||||
const [integerPart, decimalPart] = rounded.split('.');
|
||||
const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, settings.thousandSeparator);
|
||||
|
||||
const formattedNum = (decimals > 0 && decimalPart)
|
||||
? `${formattedInteger}${settings.decimalSeparator}${decimalPart}`
|
||||
: formattedInteger;
|
||||
|
||||
return `${settings.symbol}${formattedNum}`;
|
||||
}
|
||||
|
||||
export default function AffiliateReferrals() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [orderIdSearch, setOrderIdSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const limit = 20;
|
||||
|
||||
// Simple debounce for search
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(orderIdSearch);
|
||||
setPage(1); // Reset page on new search
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [orderIdSearch]);
|
||||
|
||||
const { data: response, isLoading } = useQuery<PaginatedReferrals>({
|
||||
queryKey: ['affiliate-referrals-full', page, limit, debouncedSearch],
|
||||
queryFn: async () => {
|
||||
const searchParam = debouncedSearch ? `&order_id=${encodeURIComponent(debouncedSearch)}` : '';
|
||||
return await api.get<PaginatedReferrals>(`/account/affiliate/referrals?limit=${limit}&page=${page}${searchParam}`);
|
||||
},
|
||||
});
|
||||
|
||||
const referrals = response?.referrals || [];
|
||||
const totalPages = response?.total_pages || 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/my-account/affiliate">
|
||||
<Button variant="outline" size="icon" className="h-8 w-8 rounded-full flex-shrink-0">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">All Referrals</h2>
|
||||
<p className="text-gray-500 mt-1 text-sm">
|
||||
{response ? `Showing ${referrals.length} of ${response.total} referrals` : 'Loading referrals...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search by Order ID..."
|
||||
className="pl-9 w-full"
|
||||
value={orderIdSearch}
|
||||
onChange={(e) => setOrderIdSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-500">Loading referrals...</div>
|
||||
) : referrals.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 border rounded-lg bg-gray-50">
|
||||
No referrals found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{referrals.map((ref: AffiliateReferral) => {
|
||||
const createdDate = new Date(ref.created_at);
|
||||
const approvedDate = ref.approved_at ? new Date(ref.approved_at) : null;
|
||||
|
||||
return (
|
||||
<div key={ref.id} className="bg-white p-4 rounded-lg border hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">Order #{ref.order_id}</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
ref.status === 'approved'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: ref.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{ref.status}
|
||||
</span>
|
||||
</div>
|
||||
{ref.customer_name && (
|
||||
<div className="flex items-center gap-1 text-sm text-gray-600 mt-1">
|
||||
<User className="w-3.5 h-3.5" />
|
||||
<span>{ref.customer_name}</span>
|
||||
{ref.customer_email && <span className="text-gray-400">({ref.customer_email})</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 mt-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{createdDate.toLocaleDateString('id-ID', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
{ref.status === 'approved' && approvedDate && (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Approved {approvedDate.toLocaleDateString('id-ID', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatAmount(ref.commission_amount, ref.currency)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{ref.status === 'rejected' && ref.cancelled_reason && (
|
||||
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-red-800 dark:text-red-200">
|
||||
Reason: {ref.cancelled_reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
Page {page} of {totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" /> Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next <ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,15 +40,15 @@ export default function Orders() {
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'completed': 'bg-green-100 text-green-800',
|
||||
'processing': 'bg-blue-100 text-blue-800',
|
||||
'pending': 'bg-yellow-100 text-yellow-800',
|
||||
'on-hold': 'bg-orange-100 text-orange-800',
|
||||
'cancelled': 'bg-red-100 text-red-800',
|
||||
'refunded': 'bg-gray-100 text-gray-800',
|
||||
'failed': 'bg-red-100 text-red-800',
|
||||
'completed': 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
'processing': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
'pending': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
'on-hold': 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
'cancelled': 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||
'refunded': 'bg-gray-100 text-gray-800 dark:bg-gray-800/50 dark:text-gray-400',
|
||||
'failed': 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||
};
|
||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
||||
return colors[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-800/50 dark:text-gray-400';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
@@ -77,7 +77,7 @@ export default function Orders() {
|
||||
<p className="text-gray-600 mb-4">No orders yet</p>
|
||||
<Link
|
||||
to="/shop"
|
||||
className="inline-block px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
className="inline-block px-6 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Browse Products
|
||||
</Link>
|
||||
@@ -112,7 +112,7 @@ export default function Orders() {
|
||||
<span className="font-bold text-lg">{order.total}</span>
|
||||
<Link
|
||||
to={`/my-account/orders/${order.id}`}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-primary text-primary rounded-lg hover:bg-primary hover:text-white transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2 border border-primary text-primary rounded-lg hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
View
|
||||
|
||||
@@ -19,6 +19,9 @@ import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
|
||||
const formatDate = (dateStr: string) =>
|
||||
new Date(dateStr).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
interface SubscriptionOrder {
|
||||
id: number;
|
||||
order_id: number;
|
||||
@@ -43,7 +46,11 @@ interface Subscription {
|
||||
end_date: string | null;
|
||||
last_payment_date: string | null;
|
||||
payment_method: string;
|
||||
payment_method_title: string;
|
||||
pause_count: number;
|
||||
max_pause_count?: number;
|
||||
pauses_remaining?: number | null;
|
||||
paused_at?: string | null;
|
||||
can_pause: boolean;
|
||||
can_resume: boolean;
|
||||
can_cancel: boolean;
|
||||
@@ -51,12 +58,12 @@ interface Subscription {
|
||||
}
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
'pending': 'bg-yellow-100 text-yellow-800',
|
||||
'active': 'bg-green-100 text-green-800',
|
||||
'on-hold': 'bg-blue-100 text-blue-800',
|
||||
'cancelled': 'bg-gray-100 text-gray-800',
|
||||
'expired': 'bg-red-100 text-red-800',
|
||||
'pending-cancel': 'bg-orange-100 text-orange-800',
|
||||
'pending': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
'active': 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
||||
'on-hold': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
'cancelled': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
|
||||
'expired': 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
||||
'pending-cancel': 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
@@ -124,7 +131,7 @@ export default function SubscriptionDetail() {
|
||||
if (response.order_id) {
|
||||
// Determine destination based on functionality
|
||||
// If manual payment required or just improved UX, go to payment page
|
||||
navigate(`/order-pay/${response.order_id}`);
|
||||
navigate(`/checkout/pay/${response.order_id}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to renew');
|
||||
@@ -166,7 +173,7 @@ export default function SubscriptionDetail() {
|
||||
{/* Back button */}
|
||||
<Link
|
||||
to="/my-account/subscriptions"
|
||||
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700"
|
||||
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Subscriptions
|
||||
@@ -179,8 +186,8 @@ export default function SubscriptionDetail() {
|
||||
<Repeat className="h-6 w-6" />
|
||||
Subscription #{subscription.id}
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Started {new Date(subscription.start_date).toLocaleDateString()}
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Started {formatDate(subscription.start_date)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyles[subscription.status] || 'bg-gray-100'}`}>
|
||||
@@ -189,7 +196,7 @@ export default function SubscriptionDetail() {
|
||||
</div>
|
||||
|
||||
{/* Product Info Card */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="bg-card rounded-lg border p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{subscription.product_image ? (
|
||||
<img
|
||||
@@ -198,16 +205,16 @@ export default function SubscriptionDetail() {
|
||||
className="w-20 h-20 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gray-100 rounded flex items-center justify-center">
|
||||
<Package className="h-10 w-10 text-gray-400" />
|
||||
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-700 rounded flex items-center justify-center">
|
||||
<Package className="h-10 w-10 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold">{subscription.product_name}</h2>
|
||||
<p className="text-gray-500">{subscription.billing_schedule}</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">{subscription.billing_schedule}</p>
|
||||
<p className="text-2xl font-bold mt-2">
|
||||
{formatPrice(subscription.recurring_amount)}
|
||||
<span className="text-sm font-normal text-gray-500">
|
||||
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
/{subscription.billing_period}
|
||||
</span>
|
||||
</p>
|
||||
@@ -216,65 +223,111 @@ export default function SubscriptionDetail() {
|
||||
</div>
|
||||
|
||||
{/* Billing Details */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="bg-card rounded-lg border p-6">
|
||||
<h3 className="font-semibold mb-4">Billing Details</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Start Date</p>
|
||||
<p className="font-medium">{new Date(subscription.start_date).toLocaleDateString()}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Start Date</p>
|
||||
<p className="font-medium">{formatDate(subscription.start_date)}</p>
|
||||
</div>
|
||||
{subscription.next_payment_date && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Next Payment</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Next Payment</p>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
{new Date(subscription.next_payment_date).toLocaleDateString()}
|
||||
<Calendar className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
{formatDate(subscription.next_payment_date!)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{subscription.trial_end_date && new Date(subscription.trial_end_date) > new Date() && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Trial Ends</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Trial Ends</p>
|
||||
<p className="font-medium text-blue-600">
|
||||
{new Date(subscription.trial_end_date).toLocaleDateString()}
|
||||
{formatDate(subscription.trial_end_date!)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{subscription.end_date && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">End Date</p>
|
||||
<p className="font-medium">{new Date(subscription.end_date).toLocaleDateString()}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">End Date</p>
|
||||
<p className="font-medium">{formatDate(subscription.end_date!)}</p>
|
||||
</div>
|
||||
)}
|
||||
{subscription.status === 'on-hold' && subscription.paused_at && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Paused At</p>
|
||||
<p className="font-medium text-blue-600">{formatDate(subscription.paused_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Payment Method</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Payment Method</p>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
<CreditCard className="h-4 w-4 text-gray-400" />
|
||||
{subscription.payment_method || 'Not set'}
|
||||
<CreditCard className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
{subscription.payment_method_title || subscription.payment_method || 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Times Paused</p>
|
||||
<p className="font-medium">{subscription.pause_count}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Times Paused</p>
|
||||
<p className="font-medium">
|
||||
{subscription.pause_count}
|
||||
{subscription.pauses_remaining !== null && subscription.pauses_remaining !== undefined && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 font-normal">
|
||||
{' '}/ {subscription.max_pause_count}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(subscription.can_pause || subscription.can_resume || subscription.can_cancel) && (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="bg-card rounded-lg border p-6">
|
||||
<h3 className="font-semibold mb-4">Manage Subscription</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
{subscription.can_pause && (
|
||||
{subscription.can_pause && (() => {
|
||||
// H2: disable the pause button when the customer has reached the
|
||||
// server-enforced limit, so they don't get a generic 500 on click.
|
||||
const limitReached = subscription.pauses_remaining !== null
|
||||
&& subscription.pauses_remaining !== undefined
|
||||
&& subscription.pauses_remaining <= 0;
|
||||
const tooltip = limitReached
|
||||
? `You have used all ${subscription.max_pause_count} allowed pauses for this subscription.`
|
||||
: subscription.pauses_remaining !== null && subscription.pauses_remaining !== undefined
|
||||
? `${subscription.pauses_remaining} pause${subscription.pauses_remaining === 1 ? '' : 's'} remaining.`
|
||||
: undefined;
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('pause')}
|
||||
disabled={actionLoading}
|
||||
disabled={actionLoading || limitReached}
|
||||
title={tooltip}
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Pause Subscription
|
||||
</Button>
|
||||
)}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Pause Subscription?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Pausing will place your subscription on hold until you manually resume it.
|
||||
When you resume, your next payment date will be recalculated based on your billing cycle.
|
||||
<br /><br />
|
||||
{tooltip}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleAction('pause')}>
|
||||
Pause
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
})()}
|
||||
{subscription.can_resume && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -301,7 +354,7 @@ export default function SubscriptionDetail() {
|
||||
{pendingRenewalOrder && (
|
||||
<Button
|
||||
className='bg-green-600 hover:bg-green-700'
|
||||
onClick={() => navigate(`/order-pay/${pendingRenewalOrder.order_id}`)}
|
||||
onClick={() => navigate(`/checkout/pay/${pendingRenewalOrder.order_id}`)}
|
||||
>
|
||||
<CreditCard className="h-4 w-4 mr-2" />
|
||||
Pay Now (#{pendingRenewalOrder.order_id})
|
||||
@@ -346,7 +399,7 @@ export default function SubscriptionDetail() {
|
||||
|
||||
{/* Related Orders */}
|
||||
{subscription.orders && subscription.orders.length > 0 && (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="bg-card rounded-lg border p-6">
|
||||
<h3 className="font-semibold mb-4 flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Payment History
|
||||
@@ -356,16 +409,16 @@ export default function SubscriptionDetail() {
|
||||
<Link
|
||||
key={order.id}
|
||||
to={`/my-account/orders/${order.order_id}`}
|
||||
className="flex items-center justify-between p-3 rounded border hover:bg-gray-50 transition-colors"
|
||||
className="flex items-center justify-between p-3 rounded border border-border hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">Order #{order.order_id}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-100 rounded">
|
||||
<span className="font-medium text-foreground">Order #{order.order_id}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded">
|
||||
{orderTypeLabels[order.order_type] || order.order_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(order.created_at).toLocaleDateString()}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDate(order.created_at)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -56,6 +56,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
||||
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
||||
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
|
||||
{ id: 'affiliate', label: 'Affiliate', path: '/my-account/affiliate', icon: User },
|
||||
];
|
||||
|
||||
// Filter out wishlist if module disabled or settings disabled, licenses if licensing disabled
|
||||
@@ -63,6 +64,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
if (item.id === 'wishlist') return isEnabled('wishlist') && wishlistEnabled;
|
||||
if (item.id === 'licenses') return isEnabled('licensing');
|
||||
if (item.id === 'subscriptions') return isEnabled('subscription');
|
||||
if (item.id === 'affiliate') return isEnabled('affiliate');
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Copy, CheckCircle, Link as LinkIcon, Search } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
|
||||
interface AffiliateLinkBuilderProps {
|
||||
referralCode: string;
|
||||
}
|
||||
|
||||
export function AffiliateLinkBuilder({ referralCode }: AffiliateLinkBuilderProps) {
|
||||
const [linkType, setLinkType] = useState<'store' | 'product' | 'category'>('store');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
const [selectedItem, setSelectedItem] = useState<{ id: number; name: string; slug: string } | null>(null);
|
||||
|
||||
// UTM parameters
|
||||
const [utmSource, setUtmSource] = useState('');
|
||||
const [utmMedium, setUtmMedium] = useState('');
|
||||
const [utmCampaign, setUtmCampaign] = useState('');
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Fetch products or categories based on search
|
||||
const { data: searchResults, isLoading: isSearching } = useQuery({
|
||||
queryKey: ['affiliate-search', linkType, debouncedSearch],
|
||||
queryFn: async () => {
|
||||
if (!debouncedSearch || linkType === 'store') return [];
|
||||
|
||||
const endpoint = linkType === 'product' ? '/shop/products' : '/shop/categories';
|
||||
try {
|
||||
// Assuming standard WP/WC REST API format for search
|
||||
const res: any = await api.get(`${endpoint}?search=${encodeURIComponent(debouncedSearch)}&per_page=5`);
|
||||
if (linkType === 'product') {
|
||||
return res.products || [];
|
||||
}
|
||||
return Array.isArray(res) ? res : (res.data || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to search", err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: debouncedSearch.length > 2 && linkType !== 'store',
|
||||
placeholderData: keepPreviousData
|
||||
});
|
||||
|
||||
// Reset selected item when link type changes
|
||||
const handleLinkTypeChange = (type: 'store' | 'product' | 'category') => {
|
||||
setLinkType(type);
|
||||
setSelectedItem(null);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
// Build the final link
|
||||
const buildLink = () => {
|
||||
const config = (window as any).woonoowCustomer || {};
|
||||
const basePath = config.basePath || '';
|
||||
let path = `${basePath}/shop`;
|
||||
|
||||
if (linkType === 'product' && selectedItem) {
|
||||
path = `${basePath}/product/${selectedItem.slug}`;
|
||||
} else if (linkType === 'category' && selectedItem) {
|
||||
path = `${basePath}/shop?category=${selectedItem.slug}`; // using query parameter for category as typical for shop
|
||||
}
|
||||
|
||||
const url = new URL(`${window.location.origin}${path}`);
|
||||
url.searchParams.set('ref', referralCode);
|
||||
|
||||
if (utmSource) url.searchParams.set('utm_source', utmSource);
|
||||
if (utmMedium) url.searchParams.set('utm_medium', utmMedium);
|
||||
if (utmCampaign) url.searchParams.set('utm_campaign', utmCampaign);
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const finalLink = buildLink();
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(finalLink);
|
||||
setCopied(true);
|
||||
toast.success('Enriched link copied to clipboard!');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg border mt-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<LinkIcon className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Affiliate Link Builder</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Create specific links to products or categories, and add campaign tags to track your performance.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Link Type Selection */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Link Destination</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['store', 'product', 'category'].map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleLinkTypeChange(type as any)}
|
||||
className={`px-4 py-2 rounded-md text-sm border capitalize transition-colors ${
|
||||
linkType === type
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-white hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{type === 'store' ? 'General Store' : `Specific ${type}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search / Selection */}
|
||||
{linkType !== 'store' && (
|
||||
<div className="relative">
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
Select {linkType === 'product' ? 'Product' : 'Category'}
|
||||
</label>
|
||||
{!selectedItem ? (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-3 text-gray-400" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={`Search for a ${linkType}...`}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSearching && <div className="text-sm text-gray-500 mt-2">Searching...</div>}
|
||||
|
||||
{searchResults && searchResults.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg max-h-60 overflow-y-auto">
|
||||
{searchResults.map((item: any) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-50 border-b last:border-0"
|
||||
onClick={() => setSelectedItem({ id: item.id, name: item.name, slug: item.slug })}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between p-3 border rounded-md bg-gray-50">
|
||||
<span className="text-sm font-medium">{selectedItem.name}</span>
|
||||
<button
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="text-xs text-red-500 hover:underline"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campaign Tracking (UTMs) */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-3 block">Campaign Tracking (Optional)</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Source (e.g. instagram)"
|
||||
value={utmSource}
|
||||
onChange={(e) => setUtmSource(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Medium (e.g. story, bio)"
|
||||
value={utmMedium}
|
||||
onChange={(e) => setUtmMedium(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Campaign (e.g. summer_sale)"
|
||||
value={utmCampaign}
|
||||
onChange={(e) => setUtmCampaign(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Final Link Output */}
|
||||
<div className="pt-4 border-t">
|
||||
<label className="text-sm font-medium mb-2 block">Your Generated Link</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={finalLink}
|
||||
readOnly
|
||||
className="bg-gray-50 font-mono text-sm"
|
||||
/>
|
||||
<Button onClick={handleCopy} className="shrink-0 w-32">
|
||||
{copied ? (
|
||||
<><CheckCircle className="w-4 h-4 mr-2" /> Copied</>
|
||||
) : (
|
||||
<><Copy className="w-4 h-4 mr-2" /> Copy Link</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,10 @@ import Licenses from './Licenses';
|
||||
import LicenseConnect from './LicenseConnect';
|
||||
import Subscriptions from './Subscriptions';
|
||||
import SubscriptionDetail from './SubscriptionDetail';
|
||||
import AffiliateDashboard from './AffiliateDashboard';
|
||||
import AffiliateReferrals from './AffiliateReferrals';
|
||||
import AffiliateLinks from './AffiliateLinks';
|
||||
import { AffiliateCollections } from './AffiliateCollections';
|
||||
|
||||
export default function Account() {
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
@@ -42,6 +46,10 @@ export default function Account() {
|
||||
<Route path="licenses" element={<Licenses />} />
|
||||
<Route path="subscriptions" element={<Subscriptions />} />
|
||||
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
|
||||
<Route path="affiliate" element={<AffiliateDashboard />} />
|
||||
<Route path="affiliate/referrals" element={<AffiliateReferrals />} />
|
||||
<Route path="affiliate/links" element={<AffiliateLinks />} />
|
||||
<Route path="affiliate/collections" element={<AffiliateCollections />} />
|
||||
<Route path="account-details" element={<AccountDetails />} />
|
||||
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -33,6 +33,7 @@ interface SavedAddress {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
is_default: boolean;
|
||||
formatted_address?: string;
|
||||
}
|
||||
|
||||
export default function Checkout() {
|
||||
@@ -389,7 +390,13 @@ export default function Checkout() {
|
||||
state: addressData.state,
|
||||
city: addressData.city,
|
||||
postcode: addressData.postcode,
|
||||
destination_id: undefined,
|
||||
// Include custom fields for shipping calculation (e.g., RajaOngkir destination_id)
|
||||
...Object.fromEntries(
|
||||
(shipToDifferentAddress ? shippingCustomFields : billingCustomFields).map(f => [
|
||||
f.key.replace(/^(shipping_|billing_)/, ''),
|
||||
customFieldData[f.key] || ''
|
||||
])
|
||||
),
|
||||
},
|
||||
items,
|
||||
});
|
||||
@@ -599,6 +606,18 @@ export default function Checkout() {
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const referralCode = (() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('woonoow_ref')?.trim();
|
||||
if (stored) return stored;
|
||||
} catch {
|
||||
// Ignore storage access errors.
|
||||
}
|
||||
|
||||
const match = document.cookie.match(/(?:^|;\s*)woonoow_ref=([^;]+)/);
|
||||
return match ? decodeURIComponent(match[1]).trim() : '';
|
||||
})();
|
||||
|
||||
// Prepare order data
|
||||
const orderData = {
|
||||
items: cart.items.map(item => ({
|
||||
@@ -652,6 +671,7 @@ export default function Checkout() {
|
||||
custom_fields: customFieldData,
|
||||
// CAPTCHA token for security validation
|
||||
captcha_token: captchaToken,
|
||||
referral_code: referralCode || undefined,
|
||||
};
|
||||
|
||||
// Submit order
|
||||
@@ -670,8 +690,16 @@ export default function Checkout() {
|
||||
// If user was logged in during this request (guest auto-register),
|
||||
// we need a full page reload to recognize the auth cookie
|
||||
if (data.user_logged_in) {
|
||||
// Get basePath from window
|
||||
const basePath = (window as any).woonoowCustomer?.basePath ?? '/store';
|
||||
|
||||
// Ensure thankYouUrl starts with / if basePath doesn't end with /
|
||||
const sep = basePath.endsWith('/') || thankYouUrl.startsWith('/') ? '' : '/';
|
||||
const prefix = thankYouUrl.startsWith('/') && basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
|
||||
|
||||
// Full page reload so browser recognizes the new auth cookie
|
||||
window.location.href = thankYouUrl;
|
||||
const path = thankYouUrl.startsWith('/') ? thankYouUrl.slice(1) : thankYouUrl;
|
||||
window.location.href = `${prefix}${sep}${path}`;
|
||||
} else {
|
||||
// Already logged in or no login happened - SPA navigate is fine
|
||||
navigate(thankYouUrl, { replace: true });
|
||||
@@ -774,7 +802,16 @@ export default function Checkout() {
|
||||
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4 text-sm">
|
||||
<p className="font-semibold">{sel.label}{sel.is_default && <span className="ml-2 text-xs bg-green-100 text-green-700 px-1.5 rounded">Default</span>}</p>
|
||||
<p>{sel.first_name} {sel.last_name}</p>
|
||||
<p className="text-gray-600">{sel.address_1}, {sel.city}, {sel.state} {sel.postcode}</p>
|
||||
{sel.formatted_address ? (
|
||||
<p className="text-gray-600 whitespace-pre-wrap">{sel.formatted_address}</p>
|
||||
) : (
|
||||
<div className="text-gray-600">
|
||||
<p>{sel.address_1}</p>
|
||||
{[sel.city, sel.state, sel.postcode].filter(Boolean).length > 0 && (
|
||||
<p>{[sel.city, sel.state, sel.postcode].filter(Boolean).join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
@@ -845,7 +882,7 @@ export default function Checkout() {
|
||||
</div>
|
||||
)}
|
||||
{billingCustomFields.map(field => (
|
||||
<DynamicCheckoutField key={field.key} field={field} value={customFieldData[field.key] || ''} onChange={v => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={billingStateOptions} />
|
||||
<DynamicCheckoutField key={field.key} field={field} value={customFieldData[field.key] || ''} valueLabel={customFieldData[`${field.key}_label`]} onChange={v => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={billingStateOptions} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
@@ -887,7 +924,16 @@ export default function Checkout() {
|
||||
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4 text-sm">
|
||||
<p className="font-semibold">{sel.label}</p>
|
||||
<p>{sel.first_name} {sel.last_name}</p>
|
||||
<p className="text-gray-600">{sel.address_1}, {sel.city} {sel.postcode}</p>
|
||||
{sel.formatted_address ? (
|
||||
<p className="text-gray-600 whitespace-pre-wrap">{sel.formatted_address}</p>
|
||||
) : (
|
||||
<div className="text-gray-600">
|
||||
<p>{sel.address_1}</p>
|
||||
{[sel.city, sel.state, sel.postcode].filter(Boolean).length > 0 && (
|
||||
<p>{[sel.city, sel.state, sel.postcode].filter(Boolean).join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
@@ -905,7 +951,7 @@ export default function Checkout() {
|
||||
{getShippingField('shipping_country') && <div><label className="block text-sm font-medium mb-1">Country</label><SearchableSelect options={countryOptions} value={shippingData.country} onChange={v => setShippingData({ ...shippingData, country: v })} placeholder="Select country" disabled={countries.length === 1} /></div>}
|
||||
{getShippingField('shipping_state') && <div><label className="block text-sm font-medium mb-1">State</label>{shippingStateOptions.length > 0 ? <SearchableSelect options={shippingStateOptions} value={shippingData.state} onChange={v => setShippingData({ ...shippingData, state: v })} placeholder="Select state" /> : <input type="text" value={shippingData.state} onChange={e => setShippingData({ ...shippingData, state: e.target.value })} className="w-full border rounded-lg px-4 py-2" />}</div>}
|
||||
{getShippingField('shipping_postcode') && <div><label className="block text-sm font-medium mb-1">Postcode</label><input type="text" value={shippingData.postcode} onChange={e => setShippingData({ ...shippingData, postcode: e.target.value })} className="w-full border rounded-lg px-4 py-2" /></div>}
|
||||
{shippingCustomFields.map(field => <DynamicCheckoutField key={field.key} field={field} value={customFieldData[field.key] || ''} onChange={v => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={shippingStateOptions} />)}
|
||||
{shippingCustomFields.map(field => <DynamicCheckoutField key={field.key} field={field} value={customFieldData[field.key] || ''} valueLabel={customFieldData[`${field.key}_label`]} onChange={v => handleCustomFieldChange(field.key, v)} countryOptions={countryOptions} stateOptions={shippingStateOptions} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Section Components
|
||||
import { HeroSection } from './sections/HeroSection';
|
||||
@@ -30,7 +31,16 @@ interface SectionStyles {
|
||||
backgroundOverlay?: number;
|
||||
paddingTop?: string;
|
||||
paddingBottom?: string;
|
||||
contentWidth?: 'full' | 'contained';
|
||||
contentWidth?: 'full' | 'contained' | 'boxed';
|
||||
gradientAngle?: number;
|
||||
gradientFrom?: string;
|
||||
gradientTo?: string;
|
||||
cardBackgroundColor?: string;
|
||||
cardPaddingTop?: string;
|
||||
cardPaddingRight?: string;
|
||||
cardPaddingBottom?: string;
|
||||
cardPaddingLeft?: string;
|
||||
heightPreset?: string;
|
||||
}
|
||||
|
||||
interface ElementStyle {
|
||||
@@ -266,15 +276,28 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
return (
|
||||
<div
|
||||
key={section.id}
|
||||
className="relative overflow-hidden"
|
||||
className={cn(
|
||||
"relative overflow-hidden",
|
||||
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50",
|
||||
{
|
||||
'default': 'py-16 md:py-24',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-screen flex items-center',
|
||||
}[(section.styles?.heightPreset as string) || 'default'] || 'py-16 md:py-24'
|
||||
)}
|
||||
style={{
|
||||
// Only explicit custom padding overrides from the padding fields
|
||||
...(section.styles?.backgroundType === 'gradient'
|
||||
? { background: `linear-gradient(${section.styles?.gradientAngle ?? 135}deg, ${section.styles?.gradientFrom || '#9333ea'}, ${section.styles?.gradientTo || '#3b82f6'})` }
|
||||
: { backgroundColor: section.styles?.backgroundColor }
|
||||
),
|
||||
paddingTop: section.styles?.paddingTop,
|
||||
paddingBottom: section.styles?.paddingBottom,
|
||||
}}
|
||||
>
|
||||
{/* Full-bleed background image & overlay */}
|
||||
{section.styles?.backgroundImage && (section.styles.backgroundType === 'image' || !section.styles.backgroundType) && (
|
||||
{section.styles?.backgroundType === 'image' && section.styles?.backgroundImage && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
|
||||
@@ -286,12 +309,38 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Legacy: show bg image even without backgroundType set */}
|
||||
{!section.styles?.backgroundType && section.styles?.backgroundImage && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${section.styles.backgroundImage})` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-black"
|
||||
style={{ opacity: (section.styles?.backgroundOverlay || 0) / 100 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Section component — manages its own background, height, and inner content width */}
|
||||
<div className="relative z-10 w-full">
|
||||
{/* Content Wrapper */}
|
||||
{section.styles?.contentWidth === 'boxed' ? (
|
||||
<div className="relative z-10 container mx-auto px-4 max-w-5xl">
|
||||
<div
|
||||
className="rounded-2xl shadow-sm border border-gray-200 overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: section.styles?.cardBackgroundColor || '#ffffff',
|
||||
paddingTop: section.styles?.cardPaddingTop || undefined,
|
||||
paddingRight: section.styles?.cardPaddingRight || undefined,
|
||||
paddingBottom: section.styles?.cardPaddingBottom || undefined,
|
||||
paddingLeft: section.styles?.cardPaddingLeft || undefined,
|
||||
}}
|
||||
>
|
||||
<SectionComponent
|
||||
id={section.id}
|
||||
section={section}
|
||||
sourceType={isStructuralPage ? 'page' : 'template'}
|
||||
sourceId={isStructuralPage ? pageData.id : pageData.cpt}
|
||||
layout={section.layoutVariant || 'default'}
|
||||
colorScheme={section.colorScheme || 'default'}
|
||||
styles={section.styles}
|
||||
@@ -300,6 +349,25 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"relative z-10 w-full",
|
||||
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : ''
|
||||
)}>
|
||||
<SectionComponent
|
||||
id={section.id}
|
||||
section={section}
|
||||
sourceType={isStructuralPage ? 'page' : 'template'}
|
||||
sourceId={isStructuralPage ? pageData.id : pageData.cpt}
|
||||
layout={section.layoutVariant || 'default'}
|
||||
colorScheme={section.colorScheme || 'default'}
|
||||
styles={section.styles}
|
||||
elementStyles={section.elementStyles}
|
||||
{...flattenSectionProps(section.props || {})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||
|
||||
interface BentoItem {
|
||||
label: string;
|
||||
image?: string;
|
||||
url?: string;
|
||||
backgroundColor?: string;
|
||||
size?: 'small' | 'medium' | 'large' | 'tall';
|
||||
}
|
||||
|
||||
@@ -57,7 +59,6 @@ export function BentoCategoryGrid({
|
||||
styles,
|
||||
elementStyles,
|
||||
}: BentoCategoryGridProps) {
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
// Keep initial demo layout stable: merge configured items over demo items by index.
|
||||
// This prevents the preview grid from "collapsing" when the first item is added.
|
||||
const displayItems: BentoItem[] = (() => {
|
||||
@@ -69,17 +70,49 @@ export function BentoCategoryGrid({
|
||||
});
|
||||
})();
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const es = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
es.fontSize,
|
||||
es.fontWeight,
|
||||
{
|
||||
'font-sans': es.fontFamily === 'secondary',
|
||||
'font-serif': es.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: es.color,
|
||||
textAlign: es.textAlign,
|
||||
backgroundColor: es.backgroundColor,
|
||||
borderColor: es.borderColor,
|
||||
borderWidth: es.borderWidth,
|
||||
borderRadius: es.borderRadius,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className="wn-section wn-bento-grid py-12 md:py-16"
|
||||
style={sectionBg.style}
|
||||
className={cn("wn-section wn-bento-grid relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
|
||||
>
|
||||
<div className="container mx-auto px-4 max-w-7xl">
|
||||
<div className="w-full mx-auto px-4 relative z-10">
|
||||
{title && (
|
||||
<h2
|
||||
className="text-3xl md:text-4xl font-bold mb-8"
|
||||
style={{ color: elementStyles?.title?.color }}
|
||||
className={cn(
|
||||
"mb-8",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
@@ -105,6 +138,13 @@ export function BentoCategoryGrid({
|
||||
alt={item.label}
|
||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
) : item.backgroundColor ? (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom right, ${item.backgroundColor}, color-mix(in srgb, ${item.backgroundColor}, black 35%))`
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn('absolute inset-0 bg-gradient-to-br', gradientClass)} />
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||
|
||||
interface CTABannerSectionProps {
|
||||
id: string;
|
||||
@@ -22,7 +23,9 @@ export function CTABannerSection({
|
||||
button_url,
|
||||
elementStyles,
|
||||
styles,
|
||||
}: CTABannerSectionProps & { styles?: Record<string, any> }) {
|
||||
isEditor,
|
||||
}: CTABannerSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
@@ -65,7 +68,7 @@ export function CTABannerSection({
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"wn-cta__title mb-6",
|
||||
"wn-cta__title mb-6 w-full",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
@@ -78,22 +81,24 @@ export function CTABannerSection({
|
||||
|
||||
{text && (
|
||||
<p className={cn(
|
||||
'wn-cta-banner__text mb-8 max-w-2xl mx-auto',
|
||||
'wn-cta-banner__text mb-8 max-w-2xl mx-auto w-full',
|
||||
!elementStyles?.text?.fontSize && "text-lg md:text-xl",
|
||||
styles?.contentWidth !== 'boxed' && {
|
||||
'text-white/90': colorScheme === 'primary',
|
||||
'text-gray-600': colorScheme === 'muted',
|
||||
'text-gray-700': colorScheme === 'default',
|
||||
},
|
||||
styles?.contentWidth === 'boxed' && 'text-gray-600',
|
||||
textStyle.classNames
|
||||
)}
|
||||
style={textStyle.style}
|
||||
>
|
||||
{text}
|
||||
{text || "Description text missing"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{button_text && button_url && (
|
||||
<div className="w-full mt-4" style={{ textAlign: (btnStyle.style.textAlign as React.CSSProperties['textAlign']) || 'center' }}>
|
||||
<a
|
||||
href={button_url}
|
||||
className={cn(
|
||||
@@ -112,59 +117,30 @@ export function CTABannerSection({
|
||||
}),
|
||||
btnStyle.classNames
|
||||
)}
|
||||
style={btnStyle.style}
|
||||
style={{ ...btnStyle.style, textAlign: undefined }}
|
||||
>
|
||||
{button_text}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (colorScheme === 'secondary') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const isBoxed = styles?.contentWidth === 'boxed';
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-cta-banner',
|
||||
'wn-section wn-cta-banner relative w-full flex flex-col items-center justify-center',
|
||||
`wn-cta-banner--${layout}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
heightClasses,
|
||||
{
|
||||
'bg-primary text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
|
||||
'bg-secondary text-secondary-foreground': colorScheme === 'secondary' && !hasCustomBackground,
|
||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
||||
}
|
||||
heightClasses // Might not be needed if handled by outer, but safe to keep
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
{styles?.contentWidth === 'boxed' ? (
|
||||
<div className="container mx-auto px-4 max-w-5xl">
|
||||
<div className="bg-white text-gray-900 rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10 text-center">
|
||||
<div className="mx-auto px-4 text-center relative z-10 w-full max-w-5xl">
|
||||
{innerContent}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"mx-auto px-4 text-center",
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||
)}>
|
||||
{innerContent}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||
import { api } from '@/lib/api/client';
|
||||
|
||||
interface ContactFormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
interface ContactFormSectionProps {
|
||||
id: string;
|
||||
sourceType?: string;
|
||||
sourceId?: string;
|
||||
layout?: string;
|
||||
colorScheme?: string;
|
||||
title?: string;
|
||||
webhook_url?: string;
|
||||
redirect_url?: string;
|
||||
fields?: string[];
|
||||
fields?: ContactFormField[];
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function ContactFormSection({
|
||||
id,
|
||||
sourceType,
|
||||
sourceId,
|
||||
layout = 'default',
|
||||
colorScheme = 'default',
|
||||
title,
|
||||
webhook_url,
|
||||
redirect_url,
|
||||
fields = ['name', 'email', 'message'],
|
||||
fields,
|
||||
elementStyles,
|
||||
styles,
|
||||
}: ContactFormSectionProps & { styles?: Record<string, any> }) {
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
isEditor,
|
||||
}: ContactFormSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
@@ -63,23 +68,82 @@ export function ContactFormSection({
|
||||
const fieldsStyle = getTextStyles('fields');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
||||
|
||||
const defaultFields: ContactFormField[] = [
|
||||
{ name: 'name', label: 'Your Name', type: 'text', required: true },
|
||||
{ name: 'email', label: 'Your Email', type: 'email', required: true },
|
||||
{ name: 'message', label: 'Your Message', type: 'textarea', required: true },
|
||||
];
|
||||
|
||||
const activeFields = Array.isArray(fields) && fields.length > 0 ? fields : defaultFields;
|
||||
|
||||
const validateField = (name: string, value: string, field: ContactFormField) => {
|
||||
if (field.required && !value?.trim()) {
|
||||
return `${field.label} is required`;
|
||||
}
|
||||
if (field.type === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// Clear error when user types
|
||||
if (fieldErrors[name]) {
|
||||
setFieldErrors(prev => ({ ...prev, [name]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setTouched(prev => ({ ...prev, [name]: true }));
|
||||
|
||||
const field = activeFields.find(f => f.name === name);
|
||||
if (field) {
|
||||
const errorMsg = validateField(name, value, field);
|
||||
setFieldErrors(prev => ({ ...prev, [name]: errorMsg }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate all fields
|
||||
const newErrors: Record<string, string> = {};
|
||||
let isValid = true;
|
||||
|
||||
activeFields.forEach(field => {
|
||||
const errorMsg = validateField(field.name, formData[field.name] || '', field);
|
||||
if (errorMsg) {
|
||||
newErrors[field.name] = errorMsg;
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
setFieldErrors(newErrors);
|
||||
// Mark all fields with errors as touched
|
||||
const allTouched = Object.keys(newErrors).reduce((acc, key) => ({...acc, [key]: true}), {});
|
||||
setTouched(prev => ({ ...prev, ...allTouched }));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Submit to webhook if provided
|
||||
if (webhook_url) {
|
||||
await fetch(webhook_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
// Submit to webhook proxy if configured
|
||||
if (webhook_url && !isEditor) {
|
||||
await api.post('/pages/submit-section-form', {
|
||||
source_type: sourceType,
|
||||
source_id: sourceId,
|
||||
section_id: id,
|
||||
form_data: formData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,40 +161,19 @@ export function ContactFormSection({
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}; const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (colorScheme === 'secondary') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-contact-form',
|
||||
`wn-scheme--${colorScheme}`,
|
||||
heightClasses,
|
||||
{
|
||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
||||
}
|
||||
'wn-section wn-contact-form relative overflow-hidden w-full',
|
||||
`wn-scheme--${colorScheme}`
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
"mx-auto px-4",
|
||||
styles?.contentWidth === 'full' ? 'w-full'
|
||||
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
|
||||
: 'container'
|
||||
)}>
|
||||
<div className="mx-auto px-4 relative z-10 w-full">
|
||||
<div className={cn(
|
||||
'max-w-xl mx-auto',
|
||||
{
|
||||
@@ -139,7 +182,7 @@ export function ContactFormSection({
|
||||
)}>
|
||||
{title && (
|
||||
<h2 className={cn(
|
||||
"wn-contact__title text-center mb-12",
|
||||
"wn-contact__title text-center mb-12 w-full",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
@@ -150,55 +193,62 @@ export function ContactFormSection({
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{fields.map((field) => {
|
||||
const fieldLabel = field.charAt(0).toUpperCase() + field.slice(1).replace('_', ' ');
|
||||
const isTextarea = field === 'message' || field === 'content';
|
||||
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
|
||||
{activeFields.map((field, idx) => {
|
||||
const isTextarea = field.type === 'textarea';
|
||||
const fieldError = fieldErrors[field.name];
|
||||
const isTouched = touched[field.name];
|
||||
const showError = isTouched && fieldError;
|
||||
|
||||
return (
|
||||
<div key={field} className="wn-contact-form__field">
|
||||
<div key={field.name || idx} className="wn-contact-form__field">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{fieldLabel}
|
||||
{field.label} {field.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
{isTextarea ? (
|
||||
<textarea
|
||||
name={field}
|
||||
value={formData[field] || ''}
|
||||
name={field.name}
|
||||
value={formData[field.name] || ''}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
rows={5}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
"w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
showError ? "border-red-500 focus:border-red-500 focus:ring-red-200" : "border-gray-200",
|
||||
fieldsStyle.classNames
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.style?.backgroundColor,
|
||||
color: fieldsStyle.style?.color,
|
||||
borderColor: fieldsStyle.style?.borderColor,
|
||||
borderColor: showError ? undefined : fieldsStyle.style?.borderColor,
|
||||
borderRadius: fieldsStyle.style?.borderRadius,
|
||||
}}
|
||||
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
||||
required
|
||||
placeholder={`Enter ${field.label.toLowerCase()}`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={field === 'email' ? 'email' : 'text'}
|
||||
name={field}
|
||||
value={formData[field] || ''}
|
||||
type={field.type || 'text'}
|
||||
name={field.name}
|
||||
value={formData[field.name] || ''}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
"w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary",
|
||||
showError ? "border-red-500 focus:border-red-500 focus:ring-red-200" : "border-gray-200",
|
||||
fieldsStyle.classNames
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.style?.backgroundColor,
|
||||
color: fieldsStyle.style?.color,
|
||||
borderColor: fieldsStyle.style?.borderColor,
|
||||
borderColor: showError ? undefined : fieldsStyle.style?.borderColor,
|
||||
borderRadius: fieldsStyle.style?.borderRadius,
|
||||
}}
|
||||
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
||||
required
|
||||
placeholder={`Enter ${field.label.toLowerCase()}`}
|
||||
/>
|
||||
)}
|
||||
{showError && (
|
||||
<p className="mt-1 text-sm text-red-500">{fieldError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||
|
||||
interface ContentSectionProps {
|
||||
id?: string;
|
||||
@@ -47,6 +48,14 @@ const fontSizeToCSS = (className?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const fontFamilyToCSS = (fontFamily?: string) => {
|
||||
switch (fontFamily) {
|
||||
case 'primary': return "'Playfair Display', Georgia, serif";
|
||||
case 'secondary': return "'Inter', system-ui, sans-serif";
|
||||
default: return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const fontWeightToCSS = (className?: string) => {
|
||||
switch (className) {
|
||||
case 'font-thin': return '100';
|
||||
@@ -73,10 +82,10 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
||||
const headingRules = [
|
||||
hs.color && `color: ${hs.color} !important;`,
|
||||
hs.fontWeight && `font-weight: ${fontWeightToCSS(hs.fontWeight)} !important;`,
|
||||
hs.fontFamily && `font-family: var(--font-${hs.fontFamily}, inherit) !important;`,
|
||||
hs.fontFamily && `font-family: ${fontFamilyToCSS(hs.fontFamily)} !important;`,
|
||||
hs.fontSize && `font-size: ${fontSizeToCSS(hs.fontSize)} !important;`,
|
||||
hs.backgroundColor && `background-color: ${hs.backgroundColor} !important;`,
|
||||
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px; display: inline-block;`,
|
||||
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (headingRules) {
|
||||
styles.push(`${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { ${headingRules} }`);
|
||||
@@ -90,7 +99,7 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
||||
ts.color && `color: ${ts.color} !important;`,
|
||||
ts.fontSize && `font-size: ${fontSizeToCSS(ts.fontSize)} !important;`,
|
||||
ts.fontWeight && `font-weight: ${fontWeightToCSS(ts.fontWeight)} !important;`,
|
||||
ts.fontFamily && `font-family: var(--font-${ts.fontFamily}, inherit) !important;`,
|
||||
ts.fontFamily && `font-family: ${fontFamilyToCSS(ts.fontFamily)} !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (textRules) {
|
||||
styles.push(`${scope} p, ${scope} li, ${scope} ul, ${scope} ol { ${textRules} }`);
|
||||
@@ -146,6 +155,7 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
||||
if (is) {
|
||||
const imgRules = [
|
||||
is.objectFit && `object-fit: ${is.objectFit} !important;`,
|
||||
is.objectPosition && `object-position: ${is.objectPosition} !important;`,
|
||||
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
|
||||
is.width && `width: ${is.width} !important;`,
|
||||
is.height && `height: ${is.height} !important;`,
|
||||
@@ -158,24 +168,15 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
||||
return styles.join('\n');
|
||||
};
|
||||
|
||||
export function ContentSection({ section, content: propContent, cta_text: propCtaText, cta_url: propCtaUrl }: ContentSectionProps & { outerPadding?: boolean }) {
|
||||
export function ContentSection({ section, content: propContent, cta_text: propCtaText, cta_url: propCtaUrl, isEditor }: ContentSectionProps & { outerPadding?: boolean; isEditor?: boolean }) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
// Default to 'default' width if not specified
|
||||
const _layout = section.layoutVariant || 'default';
|
||||
|
||||
const heightPreset = section.styles?.heightPreset || 'default';
|
||||
const sectionBg = getSectionBackground(section.styles);
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-32',
|
||||
'screen': 'min-h-screen py-20 flex items-center',
|
||||
};
|
||||
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
|
||||
|
||||
const content = propContent || section.props?.content?.value || section.props?.content || '';
|
||||
const content = propContent !== undefined ? propContent : (section.props?.content?.value ?? '');
|
||||
|
||||
// Helper to get text styles
|
||||
const getTextStyles = (elementName: string) => {
|
||||
@@ -203,42 +204,25 @@ export function ContentSection({ section, content: propContent, cta_text: propCt
|
||||
const buttonStyle = getTextStyles('button');
|
||||
|
||||
const containerWidth = section.styles?.contentWidth ?? 'contained';
|
||||
const cta_text = propCtaText || section.props?.cta_text?.value || section.props?.cta_text;
|
||||
const cta_url = propCtaUrl || section.props?.cta_url?.value || section.props?.cta_url;
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(section.styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (scheme.bg === 'wn-secondary-bg') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const cta_text = propCtaText !== undefined ? propCtaText : (section.props?.cta_text?.value ?? '');
|
||||
const cta_url = propCtaUrl !== undefined ? propCtaUrl : (section.props?.cta_url?.value ?? '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
|
||||
<section
|
||||
<div
|
||||
id={section.id}
|
||||
className={cn(
|
||||
'wn-content',
|
||||
heightClasses,
|
||||
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
|
||||
'wn-content relative w-full',
|
||||
scheme.text
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<SharedContentLayout
|
||||
text={content}
|
||||
textStyle={textStyle.style}
|
||||
headingStyle={headingStyle.style}
|
||||
containerWidth={containerWidth as any}
|
||||
cardStyle={{ backgroundColor: section.styles?.cardBackgroundColor }}
|
||||
className={contentStyle.classNames}
|
||||
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
|
||||
buttonStyle={{
|
||||
@@ -246,7 +230,7 @@ export function ContentSection({ section, content: propContent, cta_text: propCt
|
||||
style: buttonStyle.style
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||
|
||||
interface FeatureItem {
|
||||
title?: string;
|
||||
@@ -31,16 +32,8 @@ export function FeatureGridSection({
|
||||
features = [],
|
||||
elementStyles,
|
||||
styles,
|
||||
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any> }) {
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
isEditor,
|
||||
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any>, isEditor?: boolean }) {
|
||||
const safeItems = Array.isArray(items) ? items : [];
|
||||
const safeFeatures = Array.isArray(features) ? features : [];
|
||||
const listItems = safeItems.length > 0 ? safeItems : safeFeatures;
|
||||
@@ -78,43 +71,20 @@ export function FeatureGridSection({
|
||||
|
||||
const headingStyle = getTextStyles('heading');
|
||||
const featureItemStyle = getTextStyles('feature_item');
|
||||
const linkStyle = getTextStyles('link');
|
||||
|
||||
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (colorScheme === 'secondary') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-feature-grid',
|
||||
'wn-section wn-feature-grid relative w-full',
|
||||
`wn-feature-grid--${layout}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
heightClasses,
|
||||
{
|
||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
||||
'text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
|
||||
}
|
||||
`wn-scheme--${colorScheme}`
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
"mx-auto px-4",
|
||||
styles?.contentWidth === 'full' ? 'w-full'
|
||||
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
|
||||
: 'container'
|
||||
)}>
|
||||
<div className="mx-auto px-4 relative z-10 w-full">
|
||||
{heading && (
|
||||
<h2
|
||||
className={cn(
|
||||
@@ -167,16 +137,22 @@ export function FeatureGridSection({
|
||||
<p className="text-xs text-gray-400 mb-2 uppercase tracking-wider">{item.date}</p>
|
||||
)}
|
||||
{item.title && (
|
||||
<h3 className="font-semibold text-gray-900 text-base leading-snug mb-2 group-hover:text-primary transition-colors line-clamp-2">
|
||||
<h3 className={cn("font-semibold text-gray-900 leading-snug mb-2 group-hover:text-primary transition-colors line-clamp-2 w-full",
|
||||
!elementStyles?.feature_item?.fontSize && "text-base",
|
||||
featureItemStyle.classNames
|
||||
)} style={featureItemStyle.style}>
|
||||
{item.title}
|
||||
</h3>
|
||||
)}
|
||||
{(item.excerpt || item.description) && (
|
||||
<p className="text-sm text-gray-500 line-clamp-3 mb-4">
|
||||
<p className={cn("text-gray-500 line-clamp-3 mb-4 w-full",
|
||||
!elementStyles?.feature_item?.fontSize && "text-sm",
|
||||
featureItemStyle.classNames
|
||||
)} style={featureItemStyle.style}>
|
||||
{item.excerpt || item.description}
|
||||
</p>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 text-sm font-medium text-primary">
|
||||
<span className={cn("inline-flex items-center gap-1 text-sm font-medium text-primary", linkStyle.classNames)} style={linkStyle.style}>
|
||||
Read more
|
||||
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
@@ -246,6 +222,6 @@ export function FeatureGridSection({
|
||||
<p className="text-center text-gray-400 text-sm py-8">No related articles found.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||
|
||||
interface HeroSectionProps {
|
||||
id: string;
|
||||
@@ -16,6 +17,7 @@ interface HeroSectionProps {
|
||||
export function HeroSection({
|
||||
id,
|
||||
layout = 'default',
|
||||
colorScheme,
|
||||
title,
|
||||
subtitle,
|
||||
image,
|
||||
@@ -23,16 +25,8 @@ export function HeroSection({
|
||||
cta_url,
|
||||
elementStyles,
|
||||
styles,
|
||||
}: HeroSectionProps & { styles?: Record<string, any> }) {
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-16 md:py-28',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-16 md:py-28');
|
||||
isEditor,
|
||||
}: HeroSectionProps & { styles?: Record<string, any>; isEditor?: boolean }) {
|
||||
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
|
||||
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
|
||||
const isCentered = layout === 'centered' || layout === 'default';
|
||||
@@ -83,22 +77,27 @@ export function HeroSection({
|
||||
return undefined;
|
||||
}; */
|
||||
|
||||
const colorSchemeClasses = {
|
||||
primary: 'bg-primary text-primary-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
dark: 'bg-slate-900 text-white',
|
||||
}[colorScheme || ''] || '';
|
||||
|
||||
const isBoxed = styles?.contentWidth === 'boxed';
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-hero',
|
||||
`wn-hero--${layout}`,
|
||||
'relative overflow-hidden',
|
||||
heightClasses,
|
||||
'relative overflow-hidden w-full',
|
||||
!isBoxed && !sectionBg.style?.backgroundColor && !sectionBg.style?.backgroundImage && colorSchemeClasses
|
||||
)}
|
||||
style={sectionBg.style}
|
||||
>
|
||||
<div className={cn(
|
||||
'mx-auto px-4 z-10 relative flex w-full',
|
||||
styles?.contentWidth === 'full' ? 'w-full'
|
||||
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
|
||||
: 'container max-w-7xl',
|
||||
'mx-auto z-10 relative flex w-full',
|
||||
{
|
||||
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
|
||||
'text-center': isCentered,
|
||||
@@ -106,22 +105,31 @@ export function HeroSection({
|
||||
)}>
|
||||
{/* Image - Left */}
|
||||
{image && isImageLeft && (
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className={cn("w-full md:w-1/2 flex flex-col", {
|
||||
'items-start': imageStyle.alignment === 'left',
|
||||
'items-center': imageStyle.alignment === 'center',
|
||||
'items-end': imageStyle.alignment === 'right',
|
||||
})}>
|
||||
<div
|
||||
className="rounded-lg shadow-xl overflow-hidden"
|
||||
className={cn("shadow-xl overflow-hidden", !imageStyle.borderRadius && "rounded-lg")}
|
||||
style={{
|
||||
backgroundColor: imageStyle.backgroundColor,
|
||||
width: imageStyle.width || 'auto',
|
||||
maxWidth: '100%'
|
||||
maxWidth: '100%',
|
||||
borderRadius: imageStyle.borderRadius,
|
||||
borderColor: imageStyle.borderColor,
|
||||
borderWidth: imageStyle.borderWidth,
|
||||
borderStyle: imageStyle.borderWidth ? 'solid' : undefined,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Hero image'}
|
||||
className="w-full h-auto block"
|
||||
className="w-full h-full object-cover"
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
height: imageStyle.height,
|
||||
objectPosition: imageStyle.objectPosition,
|
||||
height: imageStyle.height || 'auto',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -139,7 +147,7 @@ export function HeroSection({
|
||||
{title && (
|
||||
<h1
|
||||
className={cn(
|
||||
"wn-hero__title mb-6 leading-tight",
|
||||
"wn-hero__title mb-6 leading-tight w-full",
|
||||
!elementStyles?.title?.fontSize && "text-4xl md:text-5xl lg:text-6xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
@@ -153,7 +161,7 @@ export function HeroSection({
|
||||
{subtitle && (
|
||||
<p
|
||||
className={cn(
|
||||
"wn-hero__subtitle text-opacity-80 mb-8",
|
||||
"wn-hero__subtitle text-opacity-80 mb-8 w-full",
|
||||
!elementStyles?.subtitle?.fontSize && "text-lg md:text-xl",
|
||||
subtitleStyle.classNames
|
||||
)}
|
||||
@@ -166,10 +174,24 @@ export function HeroSection({
|
||||
{/* Centered Image */}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-12 mx-auto rounded-lg shadow-xl overflow-hidden",
|
||||
imageStyle.width ? "" : "max-w-4xl"
|
||||
"mt-12 mx-auto shadow-xl overflow-hidden flex flex-col",
|
||||
!imageStyle.borderRadius && "rounded-lg",
|
||||
imageStyle.width ? "" : "max-w-4xl",
|
||||
{
|
||||
'mr-auto mx-0': imageStyle.alignment === 'left',
|
||||
'ml-auto mx-0': imageStyle.alignment === 'right',
|
||||
'mx-auto': imageStyle.alignment === 'center' || !imageStyle.alignment,
|
||||
}
|
||||
)}
|
||||
style={{ backgroundColor: imageStyle.backgroundColor, width: imageStyle.width || 'auto', maxWidth: '100%' }}
|
||||
style={{
|
||||
backgroundColor: imageStyle.backgroundColor,
|
||||
width: imageStyle.width || 'auto',
|
||||
maxWidth: '100%',
|
||||
borderRadius: imageStyle.borderRadius,
|
||||
borderColor: imageStyle.borderColor,
|
||||
borderWidth: imageStyle.borderWidth,
|
||||
borderStyle: imageStyle.borderWidth ? 'solid' : undefined,
|
||||
}}
|
||||
>
|
||||
{image && isCentered && (
|
||||
<img
|
||||
@@ -177,12 +199,13 @@ export function HeroSection({
|
||||
alt={title || 'Hero image'}
|
||||
className={cn(
|
||||
"w-full rounded-[inherit]",
|
||||
!imageStyle.height && "h-auto",
|
||||
!imageStyle.objectFit && "object-cover"
|
||||
!imageStyle.objectFit && "object-cover",
|
||||
"h-full"
|
||||
)}
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
height: imageStyle.height,
|
||||
objectPosition: imageStyle.objectPosition,
|
||||
height: imageStyle.height || 'auto',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
@@ -190,24 +213,33 @@ export function HeroSection({
|
||||
</div>
|
||||
|
||||
{cta_text && cta_url && (
|
||||
<div className="w-full mt-8" style={{ textAlign: ctaStyle.style?.textAlign || (isCentered ? 'center' : 'left') as React.CSSProperties['textAlign'] }}>
|
||||
<a
|
||||
href={cta_url}
|
||||
className={cn(
|
||||
"wn-hero__cta inline-block px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors mt-8",
|
||||
"wn-hero__cta inline-block px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors",
|
||||
!ctaStyle.style?.backgroundColor && "bg-primary",
|
||||
!ctaStyle.style?.color && "text-primary-foreground",
|
||||
ctaStyle.classNames
|
||||
)}
|
||||
style={ctaStyle.style}
|
||||
style={{
|
||||
...ctaStyle.style,
|
||||
textAlign: undefined
|
||||
}}
|
||||
>
|
||||
{cta_text}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image - Right */}
|
||||
{image && isImageRight && (
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className={cn("w-full md:w-1/2 flex flex-col", {
|
||||
'items-start': imageStyle.alignment === 'left',
|
||||
'items-center': imageStyle.alignment === 'center',
|
||||
'items-end': imageStyle.alignment === 'right',
|
||||
})}>
|
||||
<div
|
||||
className="rounded-lg shadow-xl overflow-hidden"
|
||||
style={{
|
||||
@@ -219,10 +251,11 @@ export function HeroSection({
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Hero image'}
|
||||
className="w-full h-auto block"
|
||||
className="w-full h-full object-cover"
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
height: imageStyle.height,
|
||||
objectPosition: imageStyle.objectPosition,
|
||||
height: imageStyle.height || 'auto',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||
|
||||
interface ImageTextSectionProps {
|
||||
id: string;
|
||||
@@ -23,7 +24,8 @@ export function ImageTextSection({
|
||||
cta_url,
|
||||
elementStyles,
|
||||
styles,
|
||||
}: ImageTextSectionProps & { styles?: Record<string, any>, cta_text?: string, cta_url?: string }) {
|
||||
isEditor,
|
||||
}: ImageTextSectionProps & { styles?: Record<string, any>, cta_text?: string, cta_url?: string, isEditor?: boolean }) {
|
||||
const isImageRight = layout === 'image-right' || layout === 'right';
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
@@ -55,44 +57,15 @@ export function ImageTextSection({
|
||||
|
||||
const imageStyle = elementStyles?.['image'] || {};
|
||||
|
||||
// Height preset support
|
||||
const heightPreset = styles?.heightPreset || 'default';
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-24',
|
||||
'small': 'py-8 md:py-16',
|
||||
'medium': 'py-16 md:py-32',
|
||||
'large': 'py-24 md:py-48',
|
||||
'screen': 'min-h-screen py-20 flex items-center',
|
||||
};
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
|
||||
|
||||
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (colorScheme === 'secondary') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-image-text',
|
||||
`wn-scheme--${colorScheme}`,
|
||||
!styles?.paddingTop && !styles?.paddingBottom && heightClasses,
|
||||
{
|
||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
||||
}
|
||||
'wn-section wn-image-text relative w-full',
|
||||
`wn-scheme--${colorScheme}`
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<SharedContentLayout
|
||||
title={title}
|
||||
@@ -104,16 +77,14 @@ export function ImageTextSection({
|
||||
titleClassName={titleStyle.classNames}
|
||||
textStyle={textStyle.style}
|
||||
textClassName={textStyle.classNames}
|
||||
imageStyle={{
|
||||
backgroundColor: imageStyle.backgroundColor,
|
||||
objectFit: imageStyle.objectFit,
|
||||
}}
|
||||
imageStyle={imageStyle}
|
||||
cardStyle={{ backgroundColor: styles?.cardBackgroundColor }}
|
||||
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
|
||||
buttonStyle={{
|
||||
classNames: buttonStyle.classNames,
|
||||
style: buttonStyle.style
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||
|
||||
interface MarqueeBannerProps {
|
||||
id: string;
|
||||
@@ -8,6 +9,7 @@ interface MarqueeBannerProps {
|
||||
speed?: number; // seconds for one full cycle
|
||||
separator?: string;
|
||||
styles?: Record<string, any>;
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function MarqueeBanner({
|
||||
@@ -16,20 +18,46 @@ export function MarqueeBanner({
|
||||
speed = 30,
|
||||
separator = '✦',
|
||||
styles,
|
||||
elementStyles,
|
||||
}: MarqueeBannerProps) {
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
const items = text.split(separator).map(t => t.trim()).filter(Boolean);
|
||||
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const es = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
es.fontSize,
|
||||
es.fontWeight,
|
||||
{
|
||||
'font-sans': es.fontFamily === 'secondary',
|
||||
'font-serif': es.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: es.color,
|
||||
textAlign: es.textAlign as any,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const textStyle = getTextStyles('text');
|
||||
|
||||
// If the parent didn't set a custom background, we fallback to primary for the marquee.
|
||||
const hasCustomBg = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className="wn-section wn-marquee overflow-hidden py-3"
|
||||
className={cn("wn-section wn-marquee relative overflow-hidden w-full", hasCustomPadding ? "" : "py-3")}
|
||||
style={{
|
||||
backgroundColor: sectionBg.style?.backgroundColor || 'var(--wn-primary, #1a1a1a)',
|
||||
color: sectionBg.style?.color || '#fff',
|
||||
backgroundColor: !hasCustomBg ? 'var(--wn-primary, #1a1a1a)' : undefined,
|
||||
color: !hasCustomBg ? '#fff' : 'inherit',
|
||||
}}
|
||||
>
|
||||
<div className="flex whitespace-nowrap">
|
||||
<div className="flex whitespace-nowrap relative z-10">
|
||||
{/* Duplicate twice for seamless infinite scroll */}
|
||||
{[0, 1].map((i) => (
|
||||
<div
|
||||
@@ -39,9 +67,13 @@ export function MarqueeBanner({
|
||||
aria-hidden={i === 1}
|
||||
>
|
||||
{items.map((item, idx) => (
|
||||
<span key={idx} className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase">
|
||||
<span
|
||||
key={idx}
|
||||
className={cn("flex items-center gap-8 text-sm font-medium tracking-wide uppercase", textStyle.classNames)}
|
||||
style={textStyle.style}
|
||||
>
|
||||
{item}
|
||||
{idx < items.length - 1 && <span className="opacity-50 text-xs">●</span>}
|
||||
{idx < items.length - 1 && <span className="opacity-50 text-xs">{separator}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apiClient } from '@/lib/api/client';
|
||||
import { ProductCard } from '@/components/ProductCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||
import type { ProductsResponse } from '@/types/product';
|
||||
|
||||
interface ProductCarouselProps {
|
||||
@@ -39,7 +40,6 @@ export function ProductCarousel({
|
||||
elementStyles,
|
||||
}: ProductCarouselProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Build query params
|
||||
const queryParams = new URLSearchParams({ per_page: String(limit) });
|
||||
@@ -68,29 +68,62 @@ export function ProductCarousel({
|
||||
trackRef.current.scrollBy({ left: direction === 'left' ? -cardWidth * 2 : cardWidth * 2, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const elementStyle = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
elementStyle.fontSize,
|
||||
elementStyle.fontWeight,
|
||||
{
|
||||
'font-sans': elementStyle.fontFamily === 'secondary',
|
||||
'font-serif': elementStyle.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: elementStyle.color,
|
||||
textAlign: elementStyle.textAlign,
|
||||
backgroundColor: elementStyle.backgroundColor,
|
||||
borderColor: elementStyle.borderColor,
|
||||
borderWidth: elementStyle.borderWidth,
|
||||
borderRadius: elementStyle.borderRadius,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const subtitleStyle = getTextStyles('subtitle');
|
||||
const linkStyle = getTextStyles('link');
|
||||
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
|
||||
return (
|
||||
<section id={id} className="wn-section wn-product-carousel py-12 md:py-16" style={sectionBg.style}>
|
||||
<div className="container mx-auto px-4 max-w-7xl">
|
||||
<section
|
||||
id={id}
|
||||
className={cn("wn-section wn-product-carousel relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
|
||||
>
|
||||
<div className="w-full mx-auto px-4 relative z-10">
|
||||
{/* Header */}
|
||||
<div className="flex items-end justify-between mb-8">
|
||||
<div>
|
||||
{title && (
|
||||
<h2
|
||||
className="text-3xl md:text-4xl font-bold"
|
||||
style={{ color: elementStyles?.title?.color }}
|
||||
className={cn("text-3xl font-bold", titleStyle.classNames)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
|
||||
<p className={cn("text-muted-foreground mt-2", subtitleStyle.classNames)} style={subtitleStyle.style}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{cta_text && cta_url && (
|
||||
<Link to={cta_url} className="text-sm font-semibold hover:underline mr-4 whitespace-nowrap">
|
||||
{cta_text && (
|
||||
<Link to={cta_url || '#'} className={cn("text-sm font-semibold hover:underline mr-4 whitespace-nowrap", linkStyle.classNames)} style={linkStyle.style}>
|
||||
{cta_text} →
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { X, ShoppingCart, Eye } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
import { SectionBackgroundRenderer } from '@/components/SectionBackgroundRenderer';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
@@ -47,13 +48,38 @@ export function ShoppableImage({
|
||||
styles,
|
||||
elementStyles,
|
||||
}: ShoppableImageProps) {
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
const [activeHotspot, setActiveHotspot] = useState<number | null>(null);
|
||||
const { addItem, openCart } = useCartStore();
|
||||
|
||||
const displayHotspots = (hotspots && hotspots.length > 0) ? hotspots : DEMO_HOTSPOTS;
|
||||
const hasImage = !!image;
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const es = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
es.fontSize,
|
||||
es.fontWeight,
|
||||
{
|
||||
'font-sans': es.fontFamily === 'secondary',
|
||||
'font-serif': es.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: es.color,
|
||||
textAlign: es.textAlign as any,
|
||||
backgroundColor: es.backgroundColor,
|
||||
borderColor: es.borderColor,
|
||||
borderWidth: es.borderWidth,
|
||||
borderRadius: es.borderRadius,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const subtitleStyle = getTextStyles('subtitle');
|
||||
|
||||
const handleAddToCart = async (hotspot: Hotspot, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -81,18 +107,38 @@ export function ShoppableImage({
|
||||
}
|
||||
};
|
||||
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
const hasCustomPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
|
||||
return (
|
||||
<section id={id} className="wn-section wn-shoppable-image py-12 md:py-16" style={sectionBg.style}>
|
||||
<div className="container mx-auto px-4 max-w-7xl">
|
||||
<section
|
||||
id={id}
|
||||
className={cn("wn-section wn-shoppable-image relative overflow-hidden w-full", hasCustomPadding ? "" : "py-12 md:py-16")}
|
||||
>
|
||||
<div className="w-full mx-auto px-4 relative z-10">
|
||||
{(title || subtitle) && (
|
||||
<div className="mb-8 text-center">
|
||||
{title && (
|
||||
<h2 className="text-3xl md:text-4xl font-bold" style={{ color: elementStyles?.title?.color }}>
|
||||
<h2
|
||||
className={cn(
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2",
|
||||
!elementStyles?.subtitle?.fontSize && "text-muted-foreground",
|
||||
subtitleStyle.classNames
|
||||
)}
|
||||
style={subtitleStyle.style}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
@@ -116,12 +162,14 @@ export function ShoppableImage({
|
||||
{/* Hotspot pins */}
|
||||
{displayHotspots.map((hotspot, idx) => {
|
||||
const isActive = activeHotspot === idx;
|
||||
const xVal = parseFloat(String(hotspot.x ?? 0).replace('%', '')) || 0;
|
||||
const yVal = parseFloat(String(hotspot.y ?? 0).replace('%', '')) || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="absolute"
|
||||
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%`, transform: 'translate(-50%, -50%)' }}
|
||||
style={{ left: `${xVal}%`, top: `${yVal}%`, transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
{/* Pulsing pin */}
|
||||
<button
|
||||
@@ -143,8 +191,8 @@ export function ShoppableImage({
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-20 w-56 bg-white rounded-xl shadow-2xl border p-3',
|
||||
hotspot.x > 60 ? 'right-full mr-3' : 'left-full ml-3',
|
||||
hotspot.y > 60 ? 'bottom-0' : 'top-0',
|
||||
xVal > 60 ? 'right-full mr-3' : 'left-full ml-3',
|
||||
yVal > 60 ? 'bottom-0' : 'top-0',
|
||||
)}
|
||||
>
|
||||
{/* Close */}
|
||||
|
||||
@@ -40,6 +40,8 @@ interface OrderDetailsResponse extends BaseResponse {
|
||||
start_date: string;
|
||||
next_payment_date: string | null;
|
||||
end_date: string | null;
|
||||
payment_method?: string;
|
||||
gateway_supports_auto_renew?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,6 +132,35 @@ const OrderPay: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// C1: Compute the projected next billing date for an early renewal.
|
||||
// The server-side logic in SubscriptionManager.php copies the stored next_payment_date as
|
||||
// the base when it is still in the future; otherwise it falls back to `now`. We mirror
|
||||
// that here so the customer sees the same date the system will set after payment.
|
||||
const computeProjectedNextPaymentDate = (sub: NonNullable<OrderDetailsResponse['subscription']>): Date => {
|
||||
const now = new Date();
|
||||
const storedNext = sub.next_payment_date ? new Date(sub.next_payment_date) : null;
|
||||
const baseDate = storedNext && storedNext.getTime() > now.getTime() ? storedNext : now;
|
||||
const interval = Math.max(1, sub.billing_interval || 1);
|
||||
const projected = new Date(baseDate);
|
||||
switch (sub.billing_period) {
|
||||
case 'day':
|
||||
projected.setDate(projected.getDate() + interval);
|
||||
break;
|
||||
case 'week':
|
||||
projected.setDate(projected.getDate() + interval * 7);
|
||||
break;
|
||||
case 'month':
|
||||
projected.setMonth(projected.getMonth() + interval);
|
||||
break;
|
||||
case 'year':
|
||||
projected.setFullYear(projected.getFullYear() + interval);
|
||||
break;
|
||||
default:
|
||||
projected.setMonth(projected.getMonth() + interval);
|
||||
}
|
||||
return projected;
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-center">Loading order details...</div>;
|
||||
if (!order) return <div className="p-8 text-center text-red-500">Order not found</div>;
|
||||
|
||||
@@ -143,23 +174,48 @@ const OrderPay: React.FC = () => {
|
||||
<SubscriptionTimeline subscription={order.subscription} />
|
||||
)}
|
||||
|
||||
{isRenewal && !order.subscription && (
|
||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6">
|
||||
{isRenewal && !order.subscription && (() => {
|
||||
// C1: When the order is a renewal, the customer is paying for the *upcoming*
|
||||
// period. After payment, SubscriptionManager will shift next_payment_date forward
|
||||
// by the billing interval (using the stored next_payment_date as the base if it
|
||||
// is still in the future, otherwise `now`). We surface that projected date here
|
||||
// so the customer is not surprised when their next charge lands sooner than the
|
||||
// original cycle.
|
||||
const projected = order.subscription
|
||||
? computeProjectedNextPaymentDate(order.subscription)
|
||||
: null;
|
||||
const projectedLabel = projected
|
||||
? projected.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: null;
|
||||
// §9 — Manual vs auto copy. If the gateway is manual, frame the renewal
|
||||
// as "complete the payment to continue" rather than "will renew automatically".
|
||||
const isAuto = false;
|
||||
return (
|
||||
<div className={`border-l-4 p-4 mb-6 ${isAuto ? 'bg-blue-50 border-blue-500' : 'bg-amber-50 border-amber-500'}`}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<svg className={`h-5 w-5 ${isAuto ? 'text-blue-400' : 'text-amber-400'}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-blue-700">
|
||||
This is a payment for your <span className="font-bold">subscription renewal</span>.
|
||||
Completing this payment will extend your subscription period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`text-sm ${isAuto ? 'text-blue-700' : 'text-amber-800'}`}>
|
||||
{isAuto ? (
|
||||
<>This is a payment for your <span className="font-bold">subscription renewal</span>. After this payment, your subscription will renew automatically on the date shown below.</>
|
||||
) : (
|
||||
<>This is a <span className="font-bold">manual subscription renewal</span>. Your saved payment method cannot be charged automatically for this gateway, so please complete the payment to continue your subscription.</>
|
||||
)}
|
||||
</p>
|
||||
{projectedLabel && (
|
||||
<p className={`text-sm mt-1 ${isAuto ? 'text-blue-700' : 'text-amber-800'}`}>
|
||||
Your next billing date will be <span className="font-bold">{projectedLabel}</span>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Order Summary <span className="text-gray-400 font-normal">#{order.number}</span></h2>
|
||||
@@ -243,7 +299,7 @@ const OrderPay: React.FC = () => {
|
||||
<button
|
||||
onClick={handlePayment}
|
||||
disabled={processing || !selectedGateway}
|
||||
className="w-full bg-primary text-white py-4 px-6 rounded-lg font-bold text-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||
className="w-full bg-primary text-primary-foreground py-4 px-6 rounded-lg font-bold text-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||
>
|
||||
{processing ? 'Processing Payment...' : `Pay ${formatPrice(order.total, order.currency)}`}
|
||||
</button>
|
||||
|
||||
74
customer-spa/src/pages/Shop/CollectionPage.tsx
Normal file
74
customer-spa/src/pages/Shop/CollectionPage.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { ProductCard } from '@/components/ProductCard';
|
||||
|
||||
interface CollectionData {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
products: any[];
|
||||
}
|
||||
|
||||
export default function CollectionPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
|
||||
const { data: collection, isLoading, error } = useQuery<CollectionData>({
|
||||
queryKey: ['affiliate-collection', slug],
|
||||
queryFn: async () => {
|
||||
return api.get(`/shop/collections/${slug || ''}`);
|
||||
},
|
||||
enabled: !!slug
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-8">
|
||||
<div className="h-12 bg-muted rounded w-1/3"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/2"></div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-64 bg-muted rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !collection) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-16 text-center">
|
||||
<h1 className="text-2xl font-bold mb-2">Collection Not Found</h1>
|
||||
<p className="text-muted-foreground">The collection you are looking for does not exist or has been removed.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="mb-10 text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-3">{collection.title}</h1>
|
||||
{collection.description && (
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{collection.products.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{collection.products.map(product => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 bg-muted/20 rounded-lg">
|
||||
<p className="text-muted-foreground">There are no products in this collection.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Search, Filter, X } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
@@ -8,16 +8,38 @@ import { Button } from '@/components/ui/button';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { ProductCard } from '@/components/ProductCard';
|
||||
import { toast } from 'sonner';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import type { ProductsResponse, ProductCategory } from '@/types/product';
|
||||
|
||||
function useBreakpoint() {
|
||||
const [breakpoint, setBreakpoint] = React.useState<'mobile' | 'tablet' | 'desktop'>('desktop');
|
||||
React.useEffect(() => {
|
||||
const check = () => {
|
||||
if (window.innerWidth < 768) setBreakpoint('mobile');
|
||||
else if (window.innerWidth < 1024) setBreakpoint('tablet');
|
||||
else setBreakpoint('desktop');
|
||||
};
|
||||
check();
|
||||
window.addEventListener('resize', check);
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
return breakpoint;
|
||||
}
|
||||
|
||||
export default function Shop() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { layout: shopLayout, elements } = useShopSettings();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||
const [category, setCategory] = useState(searchParams.get('category') || '');
|
||||
const [minPriceInput, setMinPriceInput] = useState('');
|
||||
const [maxPriceInput, setMaxPriceInput] = useState('');
|
||||
const minPrice = useDebounce(minPriceInput, 500);
|
||||
const maxPrice = useDebounce(maxPriceInput, 500);
|
||||
const [sortBy, setSortBy] = useState('');
|
||||
const { addItem } = useCartStore();
|
||||
|
||||
@@ -73,15 +95,25 @@ export default function Shop() {
|
||||
const masonryColsClass = `${masonryMobileClass} ${masonryTabletClass} ${masonryDesktopClass}`;
|
||||
|
||||
const isMasonry = shopLayout.grid_style === 'masonry';
|
||||
const isRichSidebar = shopLayout.filter_layout === 'rich_sidebar';
|
||||
|
||||
// Automatically reset page when filters change
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, category, minPrice, maxPrice, sortBy]);
|
||||
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
// Fetch products
|
||||
const { data: productsData, isLoading: productsLoading } = useQuery<ProductsResponse>({
|
||||
queryKey: ['products', page, search, category],
|
||||
queryKey: ['products', page, search, category, minPrice, maxPrice],
|
||||
queryFn: () => apiClient.get(apiClient.endpoints.shop.products, {
|
||||
page,
|
||||
per_page: 12,
|
||||
search,
|
||||
category,
|
||||
min_price: minPrice,
|
||||
max_price: maxPrice,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -135,24 +167,124 @@ export default function Shop() {
|
||||
<p className="text-muted-foreground">Browse our collection of products</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{(elements.search_bar || elements.category_filter) && (
|
||||
{/* Main Content Area */}
|
||||
<div className={isRichSidebar ? "flex flex-col lg:flex-row gap-8" : ""}>
|
||||
|
||||
{/* Rich Sidebar */}
|
||||
{isRichSidebar && (elements.search_bar || elements.category_filter) && (
|
||||
<div className="w-full lg:w-64 flex-shrink-0 space-y-6 lg:sticky lg:top-24 lg:self-start lg:max-h-[calc(100vh-6rem)] lg:overflow-y-auto no-scrollbar">
|
||||
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Filters</h2>
|
||||
|
||||
{/* Search */}
|
||||
{elements.search_bar && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="!pl-10 pr-10"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="font-[inherit] absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-full transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Categories */}
|
||||
{elements.category_filter && categories && categories.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wider">Categories</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
className={`text-left text-sm hover:text-primary transition-colors ${!category ? 'font-semibold text-primary' : 'text-foreground'}`}
|
||||
onClick={() => setCategory('')}
|
||||
>
|
||||
All Categories
|
||||
</button>
|
||||
{categories.map((cat: any) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`text-left text-sm hover:text-primary transition-colors flex justify-between items-center ${category === cat.slug ? 'font-semibold text-primary' : 'text-foreground'}`}
|
||||
onClick={() => setCategory(cat.slug)}
|
||||
>
|
||||
<span>{cat.name}</span>
|
||||
<span className="text-xs bg-muted px-2 py-0.5 rounded-full text-muted-foreground">{cat.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price Filter */}
|
||||
{isRichSidebar && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wider">Price Range</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Min"
|
||||
value={minPriceInput}
|
||||
onChange={(e) => setMinPriceInput(e.target.value)}
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none"
|
||||
/>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Max"
|
||||
value={maxPriceInput}
|
||||
onChange={(e) => setMaxPriceInput(e.target.value)}
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(search || category || minPrice || maxPrice) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-4"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setCategory('');
|
||||
setMinPriceInput('');
|
||||
setMaxPriceInput('');
|
||||
setSortBy('');
|
||||
}}
|
||||
>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Grid Area */}
|
||||
<div className="flex-1">
|
||||
{/* Top Bar Filters (Basic Layout) */}
|
||||
{!isRichSidebar && (elements.search_bar || elements.category_filter || elements.sort_dropdown) && (
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
||||
{/* Search */}
|
||||
{elements.search_bar && (
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full !pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
className="!pl-10 pr-10"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="font-[inherit] absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded-full transition-colors"
|
||||
className="font-[inherit] absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-full transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -199,29 +331,65 @@ export default function Shop() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort Dropdown (Rich Layout) */}
|
||||
{isRichSidebar && elements.sort_dropdown && (
|
||||
<div className="flex justify-end mb-6">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary text-sm"
|
||||
>
|
||||
<option value="">Default sorting</option>
|
||||
<option value="popularity">Sort by popularity</option>
|
||||
<option value="rating">Sort by average rating</option>
|
||||
<option value="date">Sort by latest</option>
|
||||
<option value="price">Sort by price: low to high</option>
|
||||
<option value="price-desc">Sort by price: high to low</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Products Grid */}
|
||||
{productsLoading ? (
|
||||
<div className={`grid ${gridColsClass} gap-6`}>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="bg-gray-200 aspect-square rounded-lg mb-4" />
|
||||
<div className="h-4 bg-gray-200 rounded mb-2" />
|
||||
<div className="h-4 bg-gray-200 rounded w-2/3" />
|
||||
<div className="bg-muted aspect-square rounded-lg mb-4" />
|
||||
<div className="h-4 bg-muted rounded mb-2" />
|
||||
<div className="h-4 bg-muted rounded w-2/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : productsData?.products && productsData.products.length > 0 ? (
|
||||
<>
|
||||
<div className={isMasonry ? `${masonryColsClass} gap-6` : `grid ${gridColsClass} gap-6`}>
|
||||
{isMasonry ? (
|
||||
<div className={`grid grid-cols-1 md:grid-cols-${gridCols.tablet || '3'} lg:grid-cols-${gridCols.desktop || '4'} gap-6`}>
|
||||
{(() => {
|
||||
const currentCols = parseInt(gridCols[breakpoint] || (breakpoint === 'mobile' ? '2' : breakpoint === 'tablet' ? '3' : '4'));
|
||||
// Use a safe column count fallback (e.g. at least 1)
|
||||
const cols = Math.max(1, currentCols);
|
||||
const masonryColumns: any[][] = Array.from({ length: cols }, () => []);
|
||||
productsData.products.forEach((p: any, i: number) => {
|
||||
masonryColumns[i % cols].push(p);
|
||||
});
|
||||
return masonryColumns.map((col, colIndex) => (
|
||||
<div key={colIndex} className="flex flex-col gap-6">
|
||||
{col.map((product) => (
|
||||
<ProductCard key={product.id} product={product} onAddToCart={handleAddToCart} />
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`grid ${productsData.products.length < parseInt(gridCols.desktop || '4') ? `grid-cols-1 sm:grid-cols-2 lg:grid-cols-${productsData.products.length}` : gridColsClass} gap-6`}>
|
||||
{productsData.products.map((product: any) => (
|
||||
<div key={product.id} className={isMasonry ? 'mb-6 break-inside-avoid' : ''}>
|
||||
<ProductCard
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
/>
|
||||
<div key={product.id}>
|
||||
<ProductCard product={product} onAddToCart={handleAddToCart} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{productsData.total_pages > 1 && (
|
||||
@@ -249,12 +417,15 @@ export default function Shop() {
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">No products found</p>
|
||||
{(search || category) && (
|
||||
{!isRichSidebar && (search || category || minPrice || maxPrice) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setCategory('');
|
||||
setMinPriceInput('');
|
||||
setMaxPriceInput('');
|
||||
setSortBy('');
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
@@ -263,6 +434,8 @@ export default function Shop() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useThankYouSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { CheckCircle, ShoppingBag, Package, Truck, User, LogIn } from 'lucide-react';
|
||||
@@ -13,6 +14,7 @@ export default function ThankYou() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const orderKey = searchParams.get('key');
|
||||
const { template, backgroundColor, customMessage, elements, isLoading: settingsLoading } = useThankYouSettings();
|
||||
const { colorMode } = useTheme();
|
||||
const [order, setOrder] = useState<any>(null);
|
||||
const [relatedProducts, setRelatedProducts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -74,7 +76,7 @@ export default function ThankYou() {
|
||||
// Render receipt style template
|
||||
if (template === 'receipt') {
|
||||
return (
|
||||
<div style={{ backgroundColor }}>
|
||||
<div style={colorMode === 'dark' ? undefined : { backgroundColor }} className="min-h-screen dark:bg-background">
|
||||
<SEOHead title="Order Confirmed" description={`Order #${order?.number || orderId} confirmed`} />
|
||||
<Container>
|
||||
<div className="py-12 max-w-2xl mx-auto">
|
||||
@@ -99,8 +101,8 @@ export default function ThankYou() {
|
||||
</div>
|
||||
|
||||
{/* Custom Message */}
|
||||
<div className="px-8 py-4 bg-gray-50 border-b border-dashed border-gray-300">
|
||||
<p className="text-sm text-center text-gray-700">{customMessage}</p>
|
||||
<div className="px-8 py-4 bg-gray-50 dark:bg-blue-900/20 border-b border-dashed border-gray-300 dark:border-blue-800/50">
|
||||
<p className="text-sm text-center text-gray-700 dark:text-blue-100">{customMessage}</p>
|
||||
</div>
|
||||
|
||||
{/* Order Items */}
|
||||
@@ -364,7 +366,7 @@ export default function ThankYou() {
|
||||
|
||||
// Render basic style template (default)
|
||||
return (
|
||||
<div style={{ backgroundColor }}>
|
||||
<div style={colorMode === 'dark' ? undefined : { backgroundColor }} className="min-h-screen dark:bg-background">
|
||||
<SEOHead title="Order Confirmed" description={`Order #${order?.number || orderId} confirmed`} />
|
||||
<Container>
|
||||
<div className="py-12 max-w-3xl mx-auto">
|
||||
@@ -378,8 +380,8 @@ export default function ThankYou() {
|
||||
</div>
|
||||
|
||||
{/* Custom Message */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
|
||||
<p className="text-gray-800 text-center">{customMessage}</p>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 rounded-lg p-6 mb-8">
|
||||
<p className="text-gray-800 dark:text-blue-100 text-center">{customMessage}</p>
|
||||
</div>
|
||||
|
||||
{/* Order Details */}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"],
|
||||
safelist: [
|
||||
// Dynamic typography classes selected via Inspector Panel
|
||||
'text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl', 'text-5xl', 'text-6xl',
|
||||
'font-light', 'font-normal', 'font-medium', 'font-semibold', 'font-bold', 'font-extrabold'
|
||||
],
|
||||
theme: {
|
||||
container: { center: true, padding: "1rem" },
|
||||
extend: {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"types": [],
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"ignoreDeprecations": "6.0"
|
||||
"ignoreDeprecations": "5.0"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
166
docs/SUBSCRIPTION_GATEWAY_CAPABILITIES.md
Normal file
166
docs/SUBSCRIPTION_GATEWAY_CAPABILITIES.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Subscription Gateway Capabilities
|
||||
|
||||
> How WooNooW decides which payment gateways can auto-debit subscription
|
||||
> renewals, and how merchants can override that decision.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Before this system, a subscription renewal would attempt to call
|
||||
`$gateway->process_subscription_renewal_payment($order, $subscription)` if
|
||||
the gateway *happened* to implement that method. That had three problems:
|
||||
|
||||
1. **Capability was invisible** — the merchant had no way to see, declare,
|
||||
or override which gateways supported subscription auto-renew.
|
||||
2. **The default was unsafe** — a gateway without the method silently fell
|
||||
through to manual payment. The system "worked," but the merchant
|
||||
believed auto-debit was happening and customers were surprised when
|
||||
they had to log in and pay manually.
|
||||
3. **No override was possible** — a merchant running a custom Stripe
|
||||
wrapper that *does* support auto-debit could not declare it, and a
|
||||
merchant using stock Stripe could not opt out.
|
||||
|
||||
## What it is now
|
||||
|
||||
A **per-gateway capability table** that the merchant (or a WooNooW
|
||||
defaults policy) controls explicitly. The system consults the table at
|
||||
renewal time and decides whether to attempt auto-debit or fall through
|
||||
to manual. PHP method existence alone is no longer authoritative.
|
||||
|
||||
### Storage
|
||||
|
||||
```
|
||||
wp_option('woonoow_gateway_subscription_capabilities', [
|
||||
'<gateway_id>' => [ 'subscription_auto_renew' => bool ],
|
||||
...
|
||||
])
|
||||
```
|
||||
|
||||
### Decision flow
|
||||
|
||||
For a renewal where the subscription's stored `payment_method` is
|
||||
`<gateway_id>`:
|
||||
|
||||
1. If the site-level `force_manual_renewal` setting is on, fall through
|
||||
to manual. (Kill switch — see below.)
|
||||
2. Look up `<gateway_id>` in the merged capability map (defaults <
|
||||
stored overrides < `woonoow_gateway_subscription_capabilities` filter).
|
||||
3. If the lookup returns `subscription_auto_renew = true`, attempt
|
||||
auto-debit via the gateway's `process_subscription_renewal_payment`
|
||||
method. On success, run `handle_renewal_success`. On failure, fall
|
||||
through to manual and notify the customer.
|
||||
4. If the lookup is missing or false, skip auto-debit entirely, create a
|
||||
manual renewal order, and send the `renewal_payment_due` email.
|
||||
|
||||
The decision is made by
|
||||
`WooNooW\Modules\Subscription\GatewayCapabilities::should_attempt_auto_renew($gateway_id)`.
|
||||
|
||||
## Built-in defaults
|
||||
|
||||
| Gateway ID | Default auto-renew | Why |
|
||||
|---------------------|--------------------|-----|
|
||||
| `paypal` | true | PayPal Reference Transactions supports recurring |
|
||||
| `stripe` | true | With a WooNooW Stripe adapter implementing the contract |
|
||||
| `stripe_cc` | true | Alias for stripe credit card |
|
||||
| `stripe_sepa` | true | SEPA Direct Debit supports recurring |
|
||||
| `dodo` | true | Dodo Payments supports recurring subscriptions |
|
||||
| `tripay` | false | VA/QRIS/e-wallet — no recurring |
|
||||
| `midtrans` | false | VA/QRIS/e-wallet — no recurring |
|
||||
| `xendit` | false | Indonesian credit card requires customer re-auth (BI/PCI-DSS) |
|
||||
| `doku` | false | Indonesian manual-only |
|
||||
| `duitku` | false | Indonesian manual-only |
|
||||
| `cheque`, `bacs`, `cod` | false | Offline / no auto-debit |
|
||||
| **any unknown** | false | Safe default |
|
||||
|
||||
The default for any unknown gateway is `false`. A merchant who has a
|
||||
custom adapter for an unknown gateway can flip the toggle in the admin
|
||||
UI (Settings → Modules → Subscription → Gateway Auto-Renew Capabilities).
|
||||
|
||||
## Why per-gateway, not site-level "billing mode"
|
||||
|
||||
A site-level "manual vs auto" toggle asks the merchant to understand a
|
||||
concept that does not exist in their head. The merchant thinks in
|
||||
**payment gateways**. A checkbox next to each gateway in the admin is
|
||||
data the merchant already knows.
|
||||
|
||||
Additionally:
|
||||
|
||||
- Different merchants use different gateways. A site-level toggle forces
|
||||
a single behavior even when the merchant runs two gateways (one
|
||||
auto-capable, one not) for different products.
|
||||
- The capability is a property of the **integration**, not of the
|
||||
**store**. The merchant did not choose "manual mode" — they chose
|
||||
Tripay, and Tripay is a manual gateway.
|
||||
- The capability can change as WooNooW ships new adapters. A
|
||||
per-gateway table updates as adapters ship.
|
||||
|
||||
## Site-level kill switch
|
||||
|
||||
There is one site-level override:
|
||||
|
||||
- `force_manual_renewal` (default **off**) — when on, all renewals are
|
||||
manual regardless of the per-gateway capability table. Useful as a
|
||||
kill switch during an incident or regulatory change.
|
||||
|
||||
This lives in the standard module settings form (Settings → Modules →
|
||||
Subscription) and is not on the gateway capability matrix screen.
|
||||
|
||||
## Admin UI
|
||||
|
||||
`Settings → Modules → Subscription` now has two sections:
|
||||
|
||||
1. **Configuration** — the standard 12-field schema (button text,
|
||||
pause/cancel permissions, retry policy, kill switch, etc.). Driven
|
||||
by the existing `SubscriptionSettings` schema.
|
||||
2. **Gateway Auto-Renew Capabilities** — one row per WooCommerce
|
||||
payment gateway with a per-gateway toggle. Built dynamically from
|
||||
`WC()->payment_gateways()`. The merchant can flip a gateway on or
|
||||
off, and the change is persisted via
|
||||
`POST /woonoow/v1/subscriptions/gateway-capabilities`.
|
||||
|
||||
When the kill switch is on, every row shows a "Forced manual" badge and
|
||||
the per-gateway toggles are disabled.
|
||||
|
||||
## Customer messaging
|
||||
|
||||
The order-pay response (`/checkout/order/{id}`) and the subscription
|
||||
detail response both include `gateway_supports_auto_renew`. The
|
||||
customer-spa OrderPay page renders a different callout for manual
|
||||
gateways (amber) versus auto-renew gateways (blue):
|
||||
|
||||
- **Auto-renew:** "Your subscription will renew automatically on the
|
||||
date shown below."
|
||||
- **Manual:** "Your saved payment method cannot be charged
|
||||
automatically for this gateway, so please complete the payment to
|
||||
continue your subscription."
|
||||
|
||||
This ensures the customer is never promised auto-debit that the system
|
||||
will not deliver.
|
||||
|
||||
## Extending the table (for gateway adapter authors)
|
||||
|
||||
A gateway adapter or third-party plugin can extend the capability
|
||||
table at boot time via the
|
||||
`woonoow_gateway_subscription_capabilities` filter:
|
||||
|
||||
```php
|
||||
add_filter('woonoow_gateway_subscription_capabilities', function ($caps) {
|
||||
$caps['my_custom_stripe'] = ['subscription_auto_renew' => true];
|
||||
return $caps;
|
||||
});
|
||||
```
|
||||
|
||||
The adapter is then responsible for implementing
|
||||
`process_subscription_renewal_payment(WC_Order $order, $subscription)`
|
||||
on its gateway class. If the method does not exist, the capability
|
||||
declaration is meaningless — the renewal will fall through to manual.
|
||||
|
||||
## Migration / no migration
|
||||
|
||||
This is a behavioral improvement, not a schema change. Existing
|
||||
subscriptions keep their `payment_method` value. The capability table
|
||||
is consulted at renewal time, not retroactively.
|
||||
|
||||
If a merchant upgrades WooNooW and previously relied on PHP method
|
||||
existence alone, the renewal will continue to work — but the merchant
|
||||
will now see the capability matrix and can confirm or override each
|
||||
gateway.
|
||||
@@ -356,6 +356,7 @@ class AppearanceController
|
||||
'card_style' => sanitize_text_field($data['layout']['card_style'] ?? 'card'),
|
||||
'aspect_ratio' => sanitize_text_field($data['layout']['aspect_ratio'] ?? 'square'),
|
||||
'card_text_align' => sanitize_text_field($data['layout']['card_text_align'] ?? 'left'),
|
||||
'filter_layout' => sanitize_text_field($data['layout']['filter_layout'] ?? 'basic'),
|
||||
],
|
||||
'elements' => [
|
||||
'category_filter' => (bool) ($data['elements']['category_filter'] ?? true),
|
||||
@@ -588,6 +589,7 @@ class AppearanceController
|
||||
'grid_columns' => '3',
|
||||
'card_style' => 'card',
|
||||
'aspect_ratio' => 'square',
|
||||
'filter_layout' => 'basic',
|
||||
],
|
||||
'elements' => [
|
||||
'category_filter' => true,
|
||||
|
||||
@@ -288,6 +288,12 @@ class CheckoutController
|
||||
'next_payment_date' => $sub->next_payment_date,
|
||||
'end_date' => $sub->end_date,
|
||||
'recurring_amount' => (float) $sub->recurring_amount,
|
||||
// §9 — Renewal messaging. The order-pay page can choose between
|
||||
// "auto-renew enabled" and "manual renewal only" copy.
|
||||
'payment_method' => $sub->payment_method,
|
||||
'gateway_supports_auto_renew' => !empty($sub->payment_method)
|
||||
? \WooNooW\Modules\Subscription\GatewayCapabilities::should_attempt_auto_renew($sub->payment_method)
|
||||
: false,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -324,11 +330,18 @@ class CheckoutController
|
||||
}
|
||||
|
||||
// Create order
|
||||
$order = wc_create_order();
|
||||
$order = wc_create_order([
|
||||
'created_via' => 'checkout'
|
||||
]);
|
||||
if (is_wp_error($order)) {
|
||||
return ['error' => $order->get_error_message()];
|
||||
}
|
||||
|
||||
if (!empty($payload['referral_code'])) {
|
||||
$order->update_meta_data('_woonoow_referral_code', $payload['referral_code']);
|
||||
$_COOKIE['woonoow_ref'] = $payload['referral_code'];
|
||||
}
|
||||
|
||||
// Track if user was logged in during this request (for frontend page reload)
|
||||
$user_logged_in = false;
|
||||
|
||||
@@ -516,10 +529,18 @@ class CheckoutController
|
||||
|
||||
$order->save();
|
||||
|
||||
// wc_create_order() fires woocommerce_new_order before SPA checkout line
|
||||
// items exist. Fire the processed hook after saving so integrations that
|
||||
// calculate from order items, like affiliate tracking, see a complete order.
|
||||
do_action('woocommerce_checkout_order_processed', $order->get_id(), $payload, $order);
|
||||
|
||||
if (!headers_sent()) {
|
||||
header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1));
|
||||
}
|
||||
|
||||
// Auto-save checkout addresses to user account
|
||||
$this->auto_save_checkout_addresses($order, $payload);
|
||||
|
||||
// Clear WooCommerce cart after successful order placement
|
||||
// This ensures the cart page won't re-populate from server session
|
||||
if (function_exists('WC') && WC()->cart) {
|
||||
@@ -842,6 +863,7 @@ class CheckoutController
|
||||
'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']) : '',
|
||||
'referral_code' => isset($json['referral_code']) ? sanitize_text_field($json['referral_code']) : '',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1122,4 +1144,105 @@ class CheckoutController
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-save checkout addresses to the user's address book if they are new.
|
||||
*/
|
||||
private function auto_save_checkout_addresses(\WC_Order $order, array $payload): void
|
||||
{
|
||||
$user_id = $order->get_customer_id();
|
||||
if (!$user_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$addresses = get_user_meta($user_id, 'woonoow_addresses', true);
|
||||
if (!is_array($addresses)) {
|
||||
$addresses = [];
|
||||
}
|
||||
|
||||
// Helper to check if address matches existing
|
||||
$is_duplicate = function ($new_addr, $type) use ($addresses) {
|
||||
foreach ($addresses as $addr) {
|
||||
if ($addr['type'] !== $type && $addr['type'] !== 'both') {
|
||||
continue;
|
||||
}
|
||||
// Compare essential fields
|
||||
$match = true;
|
||||
$check_fields = ['first_name', 'last_name', 'address_1', 'city', 'country'];
|
||||
foreach ($check_fields as $f) {
|
||||
$v1 = trim(strtolower((string)($addr[$f] ?? '')));
|
||||
$v2 = trim(strtolower((string)($new_addr[$f] ?? '')));
|
||||
if ($v1 !== $v2) {
|
||||
$match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($match) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Helper to build address array
|
||||
$build_address = function ($type, $data, $custom_fields, $addresses) {
|
||||
$new_id = empty($addresses) ? 1 : max(array_column($addresses, 'id')) + 1;
|
||||
$addr = [
|
||||
'id' => $new_id,
|
||||
'label' => ucfirst($type) . ' ' . $new_id,
|
||||
'type' => $type,
|
||||
'is_default' => empty($addresses), // default if it's the first one
|
||||
];
|
||||
|
||||
$standard_fields = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
|
||||
foreach ($standard_fields as $f) {
|
||||
if (isset($data[$f])) {
|
||||
$addr[$f] = sanitize_text_field($data[$f]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom fields matching prefix
|
||||
if (is_array($custom_fields)) {
|
||||
foreach ($custom_fields as $k => $v) {
|
||||
if (strpos($k, $type . '_') === 0) {
|
||||
$addr[$k] = sanitize_text_field($v);
|
||||
} elseif (!isset($addr[$type . '_' . $k]) && !isset($addr[$k])) {
|
||||
// Some custom fields might not have the prefix if they apply to both
|
||||
// Or they are sent without prefix by frontend in payload[type]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also, payload[type] can contain custom fields directly because frontend sends them without prefix!
|
||||
foreach ($data as $k => $v) {
|
||||
if (!in_array($k, $standard_fields) && !in_array($k, ['ship_to_different'])) {
|
||||
$addr[$type . '_' . $k] = sanitize_text_field($v);
|
||||
}
|
||||
}
|
||||
return $addr;
|
||||
};
|
||||
|
||||
$changed = false;
|
||||
|
||||
// Check billing
|
||||
if (!empty($payload['billing'])) {
|
||||
if (!$is_duplicate($payload['billing'], 'billing')) {
|
||||
$billing_addr = $build_address('billing', $payload['billing'], $payload['custom_fields'] ?? [], $addresses);
|
||||
$addresses[] = $billing_addr;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check shipping
|
||||
$ship_to_different = !empty($payload['shipping']['ship_to_different']);
|
||||
if ($ship_to_different && !empty($payload['shipping'])) {
|
||||
if (!$is_duplicate($payload['shipping'], 'shipping')) {
|
||||
$shipping_addr = $build_address('shipping', $payload['shipping'], $payload['custom_fields'] ?? [], $addresses);
|
||||
$addresses[] = $shipping_addr;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
update_user_meta($user_id, 'woonoow_addresses', array_values($addresses));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
364
includes/Api/Controllers/AffiliateAdminController.php
Normal file
364
includes/Api/Controllers/AffiliateAdminController.php
Normal file
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
namespace WooNooW\Api\Controllers;
|
||||
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_REST_Server;
|
||||
use WooNooW\Core\ModuleRegistry;
|
||||
|
||||
class AffiliateAdminController
|
||||
{
|
||||
private $namespace = 'woonoow/v1';
|
||||
|
||||
public function register_routes()
|
||||
{
|
||||
// List Affiliates
|
||||
register_rest_route($this->namespace, '/admin/affiliates', [
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get_affiliates'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Get Affiliate Balance (payable amount)
|
||||
register_rest_route($this->namespace, '/admin/affiliates/(?P<id>\d+)/balance', [
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get_affiliate_balance'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Approve Affiliate
|
||||
register_rest_route($this->namespace, '/admin/affiliates/(?P<id>\d+)/approve', [
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'approve_affiliate'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Update Affiliate (commission rate)
|
||||
register_rest_route($this->namespace, '/admin/affiliates/(?P<id>\d+)/update', [
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'update_affiliate'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// List Referrals
|
||||
register_rest_route($this->namespace, '/admin/affiliates/referrals', [
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get_referrals'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// List Payouts (for all affiliates)
|
||||
register_rest_route($this->namespace, '/admin/affiliates/payouts', [
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get_payouts'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Create Payout
|
||||
register_rest_route($this->namespace, '/admin/affiliates/payouts', [
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create_payout'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function check_permission()
|
||||
{
|
||||
return current_user_can('manage_woocommerce');
|
||||
}
|
||||
|
||||
public function get_affiliates(WP_REST_Request $request)
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'woonoow_affiliates';
|
||||
$affiliates = $wpdb->get_results("SELECT * FROM $table ORDER BY created_at DESC", ARRAY_A);
|
||||
|
||||
// Add payable_balance to each affiliate
|
||||
foreach ($affiliates as &$affiliate) {
|
||||
$affiliate['payable_balance'] = (float) ($affiliate['total_earnings'] ?? 0) - (float) ($affiliate['paid_earnings'] ?? 0);
|
||||
// Get user info
|
||||
$user = get_userdata($affiliate['user_id']);
|
||||
if ($user) {
|
||||
$affiliate['user_email'] = $user->user_email;
|
||||
$affiliate['user_name'] = $user->display_name;
|
||||
}
|
||||
}
|
||||
|
||||
return rest_ensure_response($affiliates);
|
||||
}
|
||||
|
||||
public function get_affiliate_balance(WP_REST_Request $request)
|
||||
{
|
||||
global $wpdb;
|
||||
$id = $request->get_param('id');
|
||||
$table = $wpdb->prefix . 'woonoow_affiliates';
|
||||
|
||||
$affiliate = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT id, user_id, referral_code, total_earnings, paid_earnings, total_referrals
|
||||
FROM $table WHERE id = %d",
|
||||
$id
|
||||
));
|
||||
|
||||
if (!$affiliate) {
|
||||
return new WP_REST_Response(['error' => 'Affiliate not found'], 404);
|
||||
}
|
||||
|
||||
$user = get_userdata($affiliate->user_id);
|
||||
|
||||
return rest_ensure_response([
|
||||
'id' => (int) $affiliate->id,
|
||||
'user_id' => (int) $affiliate->user_id,
|
||||
'user_name' => $user ? $user->display_name : 'Unknown',
|
||||
'user_email' => $user ? $user->user_email : '',
|
||||
'referral_code' => $affiliate->referral_code,
|
||||
'total_earnings' => (float) $affiliate->total_earnings,
|
||||
'paid_earnings' => (float) $affiliate->paid_earnings,
|
||||
'payable_balance' => (float) $affiliate->total_earnings - (float) $affiliate->paid_earnings,
|
||||
'total_referrals' => (int) $affiliate->total_referrals,
|
||||
'approved_referrals' => $this->get_approved_referral_count($affiliate->id),
|
||||
'pending_referrals' => $this->get_pending_referral_count($affiliate->id),
|
||||
]);
|
||||
}
|
||||
|
||||
private function get_approved_referral_count($affiliate_id)
|
||||
{
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}woonoow_referrals WHERE affiliate_id = %d AND status = 'approved'",
|
||||
$affiliate_id
|
||||
));
|
||||
}
|
||||
|
||||
private function get_pending_referral_count($affiliate_id)
|
||||
{
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}woonoow_referrals WHERE affiliate_id = %d AND status = 'pending'",
|
||||
$affiliate_id
|
||||
));
|
||||
}
|
||||
|
||||
public function approve_affiliate(WP_REST_Request $request)
|
||||
{
|
||||
global $wpdb;
|
||||
$id = $request->get_param('id');
|
||||
$table = $wpdb->prefix . 'woonoow_affiliates';
|
||||
|
||||
$wpdb->update(
|
||||
$table,
|
||||
['status' => 'active'],
|
||||
['id' => $id]
|
||||
);
|
||||
|
||||
// Trigger email notification for approval
|
||||
$affiliate = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $id));
|
||||
if ($affiliate) {
|
||||
$user = get_userdata($affiliate->user_id);
|
||||
if ($user) {
|
||||
do_action('woonoow/email/trigger', 'affiliate_application_approved', $user->user_email, [
|
||||
'affiliate_name' => $user->display_name,
|
||||
'referral_code' => $affiliate->referral_code
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
public function update_affiliate(WP_REST_Request $request)
|
||||
{
|
||||
global $wpdb;
|
||||
$id = $request->get_param('id');
|
||||
$table = $wpdb->prefix . 'woonoow_affiliates';
|
||||
|
||||
$custom_rate = $request->get_param('custom_commission_rate');
|
||||
|
||||
// If rate is empty string or not provided, clear custom rate
|
||||
if ($custom_rate === '' || $custom_rate === null || $custom_rate === false) {
|
||||
$data = ['custom_commission_rate' => null];
|
||||
} else {
|
||||
$custom_rate = floatval($custom_rate);
|
||||
if ($custom_rate < 0 || $custom_rate > 100) {
|
||||
return new WP_REST_Response(['error' => 'Commission rate must be between 0 and 100'], 400);
|
||||
}
|
||||
$data = ['custom_commission_rate' => $custom_rate];
|
||||
}
|
||||
|
||||
$result = $wpdb->update(
|
||||
$table,
|
||||
$data,
|
||||
['id' => $id]
|
||||
);
|
||||
|
||||
if ($result === false) {
|
||||
return new WP_REST_Response(['error' => 'Failed to update affiliate'], 500);
|
||||
}
|
||||
|
||||
return rest_ensure_response(['success' => true, 'custom_commission_rate' => $data['custom_commission_rate']]);
|
||||
}
|
||||
|
||||
public function get_referrals(WP_REST_Request $request)
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'woonoow_referrals';
|
||||
|
||||
// Add filter support
|
||||
$where = "1=1";
|
||||
|
||||
$affiliate_id = $request->get_param('affiliate_id');
|
||||
if ($affiliate_id) {
|
||||
$where .= $wpdb->prepare(" AND affiliate_id = %d", $affiliate_id);
|
||||
}
|
||||
|
||||
$status = $request->get_param('status');
|
||||
if ($status) {
|
||||
$where .= $wpdb->prepare(" AND status = %s", $status);
|
||||
}
|
||||
|
||||
$date_start = $request->get_param('date_start');
|
||||
if ($date_start) {
|
||||
$where .= $wpdb->prepare(" AND created_at >= %s", $date_start . ' 00:00:00');
|
||||
}
|
||||
|
||||
$date_end = $request->get_param('date_end');
|
||||
if ($date_end) {
|
||||
$where .= $wpdb->prepare(" AND created_at <= %s", $date_end . ' 23:59:59');
|
||||
}
|
||||
|
||||
$order_id = $request->get_param('order_id');
|
||||
if ($order_id) {
|
||||
$where .= $wpdb->prepare(" AND order_id = %d", $order_id);
|
||||
}
|
||||
|
||||
$referrals = $wpdb->get_results("SELECT * FROM $table WHERE $where ORDER BY created_at DESC", ARRAY_A);
|
||||
|
||||
// Enrich with affiliate info
|
||||
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||
foreach ($referrals as &$referral) {
|
||||
$affiliate = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT a.*, u.display_name as affiliate_name, u.user_email as affiliate_email
|
||||
FROM $affiliates_table a
|
||||
LEFT JOIN $wpdb->users u ON a.user_id = u.ID
|
||||
WHERE a.id = %d",
|
||||
$referral['affiliate_id']
|
||||
));
|
||||
if ($affiliate) {
|
||||
$referral['affiliate_name'] = $affiliate->affiliate_name;
|
||||
$referral['affiliate_email'] = $affiliate->affiliate_email;
|
||||
}
|
||||
}
|
||||
|
||||
return rest_ensure_response($referrals);
|
||||
}
|
||||
|
||||
public function get_payouts(WP_REST_Request $request)
|
||||
{
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'woonoow_affiliate_payouts';
|
||||
|
||||
$affiliate_id = $request->get_param('affiliate_id');
|
||||
$where = $affiliate_id ? $wpdb->prepare("WHERE affiliate_id = %d", $affiliate_id) : "";
|
||||
|
||||
$payouts = $wpdb->get_results("SELECT * FROM $table $where ORDER BY created_at DESC", ARRAY_A);
|
||||
|
||||
// Enrich with affiliate info
|
||||
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||
foreach ($payouts as &$payout) {
|
||||
$affiliate = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT a.*, u.display_name as affiliate_name, u.user_email as affiliate_email
|
||||
FROM $affiliates_table a
|
||||
LEFT JOIN $wpdb->users u ON a.user_id = u.ID
|
||||
WHERE a.id = %d",
|
||||
$payout['affiliate_id']
|
||||
));
|
||||
if ($affiliate) {
|
||||
$payout['affiliate_name'] = $affiliate->affiliate_name;
|
||||
$payout['affiliate_email'] = $affiliate->affiliate_email;
|
||||
}
|
||||
}
|
||||
|
||||
return rest_ensure_response($payouts);
|
||||
}
|
||||
|
||||
public function create_payout(WP_REST_Request $request)
|
||||
{
|
||||
global $wpdb;
|
||||
$payouts_table = $wpdb->prefix . 'woonoow_affiliate_payouts';
|
||||
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||
|
||||
$affiliate_id = absint($request->get_param('affiliate_id'));
|
||||
$amount = floatval($request->get_param('amount'));
|
||||
$method = sanitize_text_field($request->get_param('method') ?: 'bank_transfer');
|
||||
$notes = '';
|
||||
|
||||
// Validate affiliate exists and get balance
|
||||
$affiliate = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM $affiliates_table WHERE id = %d",
|
||||
$affiliate_id
|
||||
));
|
||||
|
||||
if (!$affiliate) {
|
||||
return new WP_REST_Response(['error' => 'Affiliate not found'], 404);
|
||||
}
|
||||
|
||||
$payable_balance = (float) $affiliate->total_earnings - (float) $affiliate->paid_earnings;
|
||||
|
||||
if ($amount <= 0) {
|
||||
return new WP_REST_Response(['error' => 'Amount must be greater than 0'], 400);
|
||||
}
|
||||
|
||||
if ($amount > $payable_balance) {
|
||||
return new WP_REST_Response([
|
||||
'error' => 'Amount exceeds payable balance',
|
||||
'payable_balance' => $payable_balance
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Generate coupon for store_credit method
|
||||
if ($method === 'store_credit') {
|
||||
$user = get_userdata($affiliate->user_id);
|
||||
if ($user) {
|
||||
$coupon_code = 'CREDIT-' . strtoupper(wp_generate_password(8, false));
|
||||
$coupon = new \WC_Coupon();
|
||||
$coupon->set_code($coupon_code);
|
||||
$coupon->set_discount_type('fixed_cart');
|
||||
$coupon->set_amount($amount);
|
||||
$coupon->set_email_restrictions([$user->user_email]);
|
||||
$coupon->set_usage_limit(1);
|
||||
$coupon->set_description('Store Credit for Affiliate Payout');
|
||||
$coupon->save();
|
||||
|
||||
$notes = 'Generated Store Credit Coupon: ' . $coupon_code;
|
||||
}
|
||||
}
|
||||
|
||||
// Create payout record
|
||||
$wpdb->insert($payouts_table, [
|
||||
'affiliate_id' => $affiliate_id,
|
||||
'amount' => $amount,
|
||||
'currency' => get_woocommerce_currency(),
|
||||
'method' => $method,
|
||||
'status' => 'completed',
|
||||
'notes' => $notes,
|
||||
'completed_at' => current_time('mysql')
|
||||
]);
|
||||
|
||||
$payout_id = $wpdb->insert_id;
|
||||
|
||||
// Update affiliate's paid_earnings
|
||||
$wpdb->query($wpdb->prepare(
|
||||
"UPDATE $affiliates_table SET paid_earnings = paid_earnings + %f WHERE id = %d",
|
||||
$amount,
|
||||
$affiliate_id
|
||||
));
|
||||
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'id' => $payout_id,
|
||||
'new_paid_earnings' => (float) $affiliate->paid_earnings + $amount,
|
||||
'new_payable_balance' => $payable_balance - $amount,
|
||||
'coupon_code' => $method === 'store_credit' ? $coupon_code : null
|
||||
]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user