Compare commits
6 Commits
396ca25be4
...
f3c4ee7124
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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*
|
||||||
@@ -231,29 +231,41 @@ Referral tracking and commission management system.
|
|||||||
|
|
||||||
#### 1. Database Tables
|
#### 1. Database Tables
|
||||||
```sql
|
```sql
|
||||||
wp_woonoow_affiliates (id, user_id, referral_code, commission_rate, status, total_referrals, total_earnings, paid_earnings)
|
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, status, created_at, approved_at, paid_at)
|
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, method, status, notes, created_at, completed_at)
|
wp_woonoow_affiliate_payouts (id, affiliate_id, amount, currency, method, status, notes, created_at, completed_at)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. Tracking System
|
#### 2. Tracking System
|
||||||
```php
|
```php
|
||||||
class AffiliateTracker {
|
class AffiliateTracker {
|
||||||
|
|
||||||
// Set cookie for 30 days
|
// Set secure cookie for 30 days (SameSite=Lax)
|
||||||
public function track_referral($referral_code) {
|
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) {
|
public function record_referral($order_id) {
|
||||||
if (isset($_COOKIE['woonoow_ref'])) {
|
if (isset($_COOKIE['woonoow_ref']) || $this->has_affiliate_coupon($order_id)) {
|
||||||
// Get affiliate by code
|
// Get affiliate by code or coupon
|
||||||
// Calculate commission
|
// Calculate commission (on subtotal, excluding tax/shipping)
|
||||||
// Create referral record
|
// Create referral record with 'pending' status
|
||||||
// Clear cookie
|
// 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
|
#### 5. Notification Events
|
||||||
- `affiliate_application_approved`
|
- `affiliate_application_approved`
|
||||||
- `affiliate_referral_completed`
|
- `affiliate_referral_received` (Pending Approval)
|
||||||
- `affiliate_payout_processed`
|
- `affiliate_payout_processed`
|
||||||
|
- `affiliate_threshold_reached` (Admin Alert)
|
||||||
|
|
||||||
### Priority: **Medium** 🟡
|
### Priority: **Medium** 🟡
|
||||||
### Effort: 3-4 weeks
|
### Effort: 3-4 weeks
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
|
||||||
export interface FieldSchema {
|
export interface FieldSchema {
|
||||||
type: 'text' | 'textarea' | 'email' | 'url' | 'number' | 'toggle' | 'checkbox' | 'select';
|
type: 'text' | 'textarea' | 'email' | 'url' | 'number' | 'toggle' | 'checkbox' | 'select' | 'multiselect';
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -47,8 +47,8 @@ export function SchemaField({ name, schema, value, onChange, error }: SchemaFiel
|
|||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={value || ''}
|
value={value ?? ''}
|
||||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
onChange={(e) => onChange(e.target.value === '' ? 0 : parseFloat(e.target.value))}
|
||||||
placeholder={schema.placeholder}
|
placeholder={schema.placeholder}
|
||||||
required={schema.required}
|
required={schema.required}
|
||||||
min={schema.min}
|
min={schema.min}
|
||||||
@@ -110,6 +110,35 @@ export function SchemaField({ name, schema, value, onChange, error }: SchemaFiel
|
|||||||
</Select>
|
</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:
|
default:
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ import NewsletterLayout from '@/routes/Marketing/Newsletter';
|
|||||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter/Subscribers';
|
import NewsletterSubscribers from '@/routes/Marketing/Newsletter/Subscribers';
|
||||||
import NewsletterCampaignsList from '@/routes/Marketing/Campaigns';
|
import NewsletterCampaignsList from '@/routes/Marketing/Campaigns';
|
||||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
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 MorePage from '@/routes/More';
|
||||||
import Help from '@/routes/Help';
|
import Help from '@/routes/Help';
|
||||||
import Onboarding from '@/routes/Onboarding';
|
import Onboarding from '@/routes/Onboarding';
|
||||||
@@ -247,6 +251,12 @@ export function AppRoutes() {
|
|||||||
<Route path="campaigns" element={<NewsletterCampaignsList />} />
|
<Route path="campaigns" element={<NewsletterCampaignsList />} />
|
||||||
<Route path="campaigns/:id" element={<CampaignEdit />} />
|
<Route path="campaigns/:id" element={<CampaignEdit />} />
|
||||||
</Route>
|
</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) */}
|
{/* Legacy Redirects for Newsletter (using component to preserve params) */}
|
||||||
<Route path="/marketing/campaigns" element={<Navigate to="/marketing/newsletter/campaigns" replace />} />
|
<Route path="/marketing/campaigns" element={<Navigate to="/marketing/newsletter/campaigns" replace />} />
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
import { useLocation } from 'react-router-dom';
|
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[] } {
|
export function useActiveSection(): { main: MainNode; all: MainNode[] } {
|
||||||
const { pathname } = useLocation();
|
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 {
|
function pick(): MainNode {
|
||||||
// Special case: /settings should match settings section
|
// Special case: /settings should match settings section
|
||||||
@@ -32,8 +47,5 @@ export function useActiveSection(): { main: MainNode; all: MainNode[] } {
|
|||||||
const main = pick();
|
const main = pick();
|
||||||
const children = Array.isArray(main.children) ? main.children : [];
|
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;
|
return { main: { ...main, children }, all: navTree } as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,9 +164,10 @@
|
|||||||
display: none !important;
|
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 header,
|
||||||
#woonoow-admin-app nav,
|
#woonoow-admin-app nav,
|
||||||
|
#woonoow-admin-app aside,
|
||||||
#woonoow-admin-app [data-submenubar],
|
#woonoow-admin-app [data-submenubar],
|
||||||
#woonoow-admin-app [data-bottomnav],
|
#woonoow-admin-app [data-bottomnav],
|
||||||
.woonoow-app-header,
|
.woonoow-app-header,
|
||||||
@@ -372,4 +373,4 @@ html #wpadminbar {
|
|||||||
.media-modal .attachments,
|
.media-modal .attachments,
|
||||||
.media-modal .attachment {
|
.media-modal .attachment {
|
||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
357
admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx
Normal file
357
admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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', '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,
|
||||||
|
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>{__('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">
|
||||||
|
{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({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['campaigns'],
|
queryKey: ['campaigns'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/campaigns');
|
const response: any = await api.get('/campaigns');
|
||||||
return response.data as Campaign[];
|
return Array.isArray(response) ? (response as Campaign[]) : ((response?.data || []) as Campaign[]);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export default function Campaigns() {
|
|||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['campaigns'],
|
queryKey: ['campaigns'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/campaigns');
|
const response: any = await api.get('/newsletter/campaigns');
|
||||||
return response.data as Campaign[];
|
return Array.isArray(response) ? (response as Campaign[]) : ((response?.data || []) as Campaign[]);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,14 +35,16 @@ import {
|
|||||||
|
|
||||||
export default function Subscribers() {
|
export default function Subscribers() {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [deleteTargetEmail, setDeleteTargetEmail] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { data: subscribersData, isLoading } = useQuery({
|
const { data: subscribersData, isLoading } = useQuery({
|
||||||
queryKey: ['newsletter-subscribers'],
|
queryKey: ['newsletter-subscribers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/newsletter/subscribers');
|
const response: any = await api.get('/admin/newsletter/subscribers');
|
||||||
return response.data;
|
return Array.isArray(response) ? response : (response?.data || []);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Mail, Tag } from 'lucide-react';
|
import { Mail, Tag, Users } from 'lucide-react';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { useModules } from '@/hooks/useModules';
|
||||||
|
import { DocLink } from '@/components/DocLink';
|
||||||
|
|
||||||
interface MarketingCard {
|
interface MarketingCard {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -10,24 +12,35 @@ interface MarketingCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cards: MarketingCard[] = [
|
const cards: MarketingCard[] = [
|
||||||
{
|
|
||||||
title: __('Newsletter'),
|
|
||||||
description: __('Manage subscribers and send email campaigns'),
|
|
||||||
icon: Mail,
|
|
||||||
to: '/marketing/newsletter',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: __('Coupons'),
|
title: __('Coupons'),
|
||||||
description: __('Discounts, promotions, and coupon codes'),
|
description: __('Discounts, promotions, and coupon codes'),
|
||||||
icon: Tag,
|
icon: Tag,
|
||||||
to: '/marketing/coupons',
|
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() {
|
export default function Marketing() {
|
||||||
const navigate = useNavigate();
|
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 (
|
return (
|
||||||
<div className="w-full space-y-6">
|
<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>
|
<h1 className="text-2xl font-bold tracking-tight">{__('Marketing')}</h1>
|
||||||
<DocLink />
|
<DocLink />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{cards.map((card) => (
|
{activeCards.map((card) => (
|
||||||
<button
|
<button
|
||||||
key={card.to}
|
key={card.to}
|
||||||
onClick={() => navigate(card.to)}
|
onClick={() => navigate(card.to)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { api } from '@/lib/api';
|
|||||||
import { OrdersApi } from '@/lib/api/orders';
|
import { OrdersApi } from '@/lib/api/orders';
|
||||||
import { formatRelativeOrDate } from '@/lib/dates';
|
import { formatRelativeOrDate } from '@/lib/dates';
|
||||||
import { formatMoney } from '@/lib/currency';
|
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 { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -495,6 +495,49 @@ export default function OrderShow() {
|
|||||||
<div className="text-sm whitespace-pre-wrap">{order.customer_note}</div>
|
<div className="text-sm whitespace-pre-wrap">{order.customer_note}</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export type ProductFormData = {
|
|||||||
subscription_interval?: string;
|
subscription_interval?: string;
|
||||||
subscription_trial_days?: string;
|
subscription_trial_days?: string;
|
||||||
subscription_signup_fee?: string;
|
subscription_signup_fee?: string;
|
||||||
|
// Affiliate
|
||||||
|
affiliate_enabled?: boolean;
|
||||||
|
affiliate_commission_rate?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -103,6 +106,9 @@ export function ProductFormTabbed({
|
|||||||
const [subscriptionInterval, setSubscriptionInterval] = useState(initial?.subscription_interval || '1');
|
const [subscriptionInterval, setSubscriptionInterval] = useState(initial?.subscription_interval || '1');
|
||||||
const [subscriptionTrialDays, setSubscriptionTrialDays] = useState(initial?.subscription_trial_days || '');
|
const [subscriptionTrialDays, setSubscriptionTrialDays] = useState(initial?.subscription_trial_days || '');
|
||||||
const [subscriptionSignupFee, setSubscriptionSignupFee] = useState(initial?.subscription_signup_fee || '');
|
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 || '');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Update form state when initial data changes (for edit mode)
|
// Update form state when initial data changes (for edit mode)
|
||||||
@@ -140,6 +146,9 @@ export function ProductFormTabbed({
|
|||||||
setSubscriptionInterval(initial.subscription_interval || '1');
|
setSubscriptionInterval(initial.subscription_interval || '1');
|
||||||
setSubscriptionTrialDays(initial.subscription_trial_days || '');
|
setSubscriptionTrialDays(initial.subscription_trial_days || '');
|
||||||
setSubscriptionSignupFee(initial.subscription_signup_fee || '');
|
setSubscriptionSignupFee(initial.subscription_signup_fee || '');
|
||||||
|
// Affiliate
|
||||||
|
setAffiliateEnabled(initial.affiliate_enabled || false);
|
||||||
|
setAffiliateCommissionRate(initial.affiliate_commission_rate || '');
|
||||||
}
|
}
|
||||||
}, [initial, mode]);
|
}, [initial, mode]);
|
||||||
|
|
||||||
@@ -209,6 +218,9 @@ export function ProductFormTabbed({
|
|||||||
subscription_interval: subscriptionEnabled ? subscriptionInterval : undefined,
|
subscription_interval: subscriptionEnabled ? subscriptionInterval : undefined,
|
||||||
subscription_trial_days: subscriptionEnabled ? subscriptionTrialDays : undefined,
|
subscription_trial_days: subscriptionEnabled ? subscriptionTrialDays : undefined,
|
||||||
subscription_signup_fee: subscriptionEnabled ? subscriptionSignupFee : undefined,
|
subscription_signup_fee: subscriptionEnabled ? subscriptionSignupFee : undefined,
|
||||||
|
// Affiliate
|
||||||
|
affiliate_enabled: affiliateEnabled,
|
||||||
|
affiliate_commission_rate: affiliateEnabled ? affiliateCommissionRate : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await onSubmit(payload);
|
await onSubmit(payload);
|
||||||
@@ -277,6 +289,10 @@ export function ProductFormTabbed({
|
|||||||
setSubscriptionTrialDays={setSubscriptionTrialDays}
|
setSubscriptionTrialDays={setSubscriptionTrialDays}
|
||||||
subscriptionSignupFee={subscriptionSignupFee}
|
subscriptionSignupFee={subscriptionSignupFee}
|
||||||
setSubscriptionSignupFee={setSubscriptionSignupFee}
|
setSubscriptionSignupFee={setSubscriptionSignupFee}
|
||||||
|
affiliateEnabled={affiliateEnabled}
|
||||||
|
setAffiliateEnabled={setAffiliateEnabled}
|
||||||
|
affiliateCommissionRate={affiliateCommissionRate}
|
||||||
|
setAffiliateCommissionRate={setAffiliateCommissionRate}
|
||||||
/>
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { DollarSign, Upload, X, Image as ImageIcon, 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 { toast } from 'sonner';
|
||||||
import { getStoreCurrency } from '@/lib/currency';
|
import { getStoreCurrency } from '@/lib/currency';
|
||||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||||
import { openWPMediaGallery } from '@/lib/wp-media';
|
import { openWPMediaGallery } from '@/lib/wp-media';
|
||||||
|
import { useModules } from '@/hooks/useModules';
|
||||||
|
|
||||||
type GeneralTabProps = {
|
type GeneralTabProps = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -63,6 +64,11 @@ type GeneralTabProps = {
|
|||||||
setSubscriptionTrialDays?: (value: string) => void;
|
setSubscriptionTrialDays?: (value: string) => void;
|
||||||
subscriptionSignupFee?: string;
|
subscriptionSignupFee?: string;
|
||||||
setSubscriptionSignupFee?: (value: string) => void;
|
setSubscriptionSignupFee?: (value: string) => void;
|
||||||
|
// Affiliate
|
||||||
|
affiliateEnabled?: boolean;
|
||||||
|
setAffiliateEnabled?: (value: boolean) => void;
|
||||||
|
affiliateCommissionRate?: string;
|
||||||
|
setAffiliateCommissionRate?: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GeneralTab({
|
export function GeneralTab({
|
||||||
@@ -109,6 +115,10 @@ export function GeneralTab({
|
|||||||
setSubscriptionTrialDays,
|
setSubscriptionTrialDays,
|
||||||
subscriptionSignupFee,
|
subscriptionSignupFee,
|
||||||
setSubscriptionSignupFee,
|
setSubscriptionSignupFee,
|
||||||
|
affiliateEnabled,
|
||||||
|
setAffiliateEnabled,
|
||||||
|
affiliateCommissionRate,
|
||||||
|
setAffiliateCommissionRate,
|
||||||
}: GeneralTabProps) {
|
}: GeneralTabProps) {
|
||||||
const savingsPercent =
|
const savingsPercent =
|
||||||
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
|
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
|
||||||
@@ -116,6 +126,7 @@ export function GeneralTab({
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const store = getStoreCurrency();
|
const store = getStoreCurrency();
|
||||||
|
const { isEnabled } = useModules();
|
||||||
|
|
||||||
// Copy link state and helpers
|
// Copy link state and helpers
|
||||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||||
@@ -459,7 +470,7 @@ export function GeneralTab({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Licensing option */}
|
{/* Licensing option */}
|
||||||
{setLicensingEnabled && (
|
{isEnabled('licensing') && setLicensingEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -533,7 +544,7 @@ export function GeneralTab({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Subscription option */}
|
{/* Subscription option */}
|
||||||
{setSubscriptionEnabled && (
|
{isEnabled('subscription') && setSubscriptionEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,20 @@ export default function Modules() {
|
|||||||
mutationFn: async ({ moduleId, enabled }: { moduleId: string; enabled: boolean }) => {
|
mutationFn: async ({ moduleId, enabled }: { moduleId: string; enabled: boolean }) => {
|
||||||
return api.post('/modules/toggle', { module_id: moduleId, enabled });
|
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'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['modules-enabled'] });
|
||||||
toast.success(
|
toast.success(
|
||||||
variables.enabled
|
variables.enabled
|
||||||
? __('Module enabled successfully')
|
? __('Module enabled successfully')
|
||||||
|
|||||||
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;
|
position?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WNW_NavNode {
|
||||||
|
key: string;
|
||||||
|
path: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
children?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
WNW_API: WNW_API_Config; // Make required to avoid "possibly undefined" check in every usage if we are sure it exists
|
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_WC_MENUS?: WNW_WC_MENUS;
|
||||||
WNW_CONFIG?: WNW_CONFIG;
|
WNW_CONFIG?: WNW_CONFIG;
|
||||||
WNW_STORE?: WNW_Store;
|
WNW_STORE?: WNW_Store;
|
||||||
WNW_NAV_TREE?: Array<{
|
WNW_NAV_TREE?: WNW_NavNode[];
|
||||||
key: string;
|
|
||||||
path: string;
|
|
||||||
icon: string;
|
|
||||||
label: string;
|
|
||||||
children?: any[];
|
|
||||||
}>;
|
|
||||||
WNW_ADDON_ROUTES?: Array<{
|
WNW_ADDON_ROUTES?: Array<{
|
||||||
path: string;
|
path: string;
|
||||||
component_url: string;
|
component_url: string;
|
||||||
props?: Record<string, any>;
|
props?: Record<string, any>;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WindowEventMap {
|
||||||
|
'woonoow:navigation-updated': CustomEvent<{
|
||||||
|
moduleId?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { };
|
export { };
|
||||||
|
|||||||
@@ -13,9 +13,7 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
"types": [],
|
"types": [],
|
||||||
"baseUrl": ".",
|
"paths": { "@/*": ["./src/*"] }
|
||||||
"paths": { "@/*": ["./src/*"] },
|
|
||||||
"ignoreDeprecations": "6.0"
|
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const buttonVariants = cva(
|
|||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
outline:
|
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:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface CurrencySettings {
|
|||||||
/**
|
/**
|
||||||
* Get currency settings from window
|
* Get currency settings from window
|
||||||
*/
|
*/
|
||||||
function getCurrencySettings(): CurrencySettings {
|
export function getCurrencySettings(): CurrencySettings {
|
||||||
const settings = (window as any).woonoowCustomer?.currency;
|
const settings = (window as any).woonoowCustomer?.currency;
|
||||||
|
|
||||||
// Default to USD if not available
|
// Default to USD if not available
|
||||||
|
|||||||
@@ -5,6 +5,48 @@ import './styles/fonts.css';
|
|||||||
import './styles/theme.css';
|
import './styles/theme.css';
|
||||||
import App from './App';
|
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');
|
const el = document.getElementById('woonoow-customer-app');
|
||||||
if (el) {
|
if (el) {
|
||||||
createRoot(el).render(
|
createRoot(el).render(
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ export default function AccountDetails() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploadingAvatar}
|
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'}
|
{uploadingAvatar ? 'Uploading...' : 'Upload Photo'}
|
||||||
</button>
|
</button>
|
||||||
@@ -294,7 +294,7 @@ export default function AccountDetails() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
{saving ? 'Saving...' : 'Save Changes'}
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
@@ -344,7 +344,7 @@ export default function AccountDetails() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
{saving ? 'Updating...' : 'Update Password'}
|
{saving ? 'Updating...' : 'Update Password'}
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ export default function Addresses() {
|
|||||||
<h1 className="text-2xl font-bold">Addresses</h1>
|
<h1 className="text-2xl font-bold">Addresses</h1>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
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" />
|
<Plus className="w-4 h-4" />
|
||||||
Add Address
|
Add Address
|
||||||
@@ -319,7 +319,7 @@ export default function Addresses() {
|
|||||||
<p className="text-gray-600 mb-4">No addresses saved yet</p>
|
<p className="text-gray-600 mb-4">No addresses saved yet</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
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" />
|
<Plus className="w-4 h-4" />
|
||||||
Add Your First Address
|
Add Your First Address
|
||||||
@@ -454,7 +454,7 @@ export default function Addresses() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={loadingFields}
|
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
|
Save Address
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
643
customer-spa/src/pages/Account/AffiliateDashboard.tsx
Normal file
643
customer-spa/src/pages/Account/AffiliateDashboard.tsx
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
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 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: referrals, isLoading: isLoadingReferrals } = useQuery<AffiliateReferral[]>({
|
||||||
|
queryKey: ['affiliate-referrals'],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await api.get<AffiliateReferral[]>('/account/affiliate/referrals');
|
||||||
|
},
|
||||||
|
enabled: !!profile && profile.status === 'active'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 approvedReferrals = (referrals || []).filter((r: any) => r.status === 'approved');
|
||||||
|
const pendingReferrals = (referrals || []).filter((r: any) => r.status === 'pending');
|
||||||
|
|
||||||
|
const totalEarnings = approvedReferrals.reduce((sum: number, r: any) => sum + parseFloat(r.commission_amount), 0);
|
||||||
|
const pendingEarnings = pendingReferrals.reduce((sum: number, r: any) => sum + parseFloat(r.commission_amount), 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">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Your Referral Link</h3>
|
||||||
|
<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>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Recent Referrals</h3>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,15 +40,15 @@ export default function Orders() {
|
|||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
'completed': 'bg-green-100 text-green-800',
|
'completed': 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
'processing': 'bg-blue-100 text-blue-800',
|
'processing': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
'pending': 'bg-yellow-100 text-yellow-800',
|
'pending': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
'on-hold': 'bg-orange-100 text-orange-800',
|
'on-hold': 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
|
||||||
'cancelled': 'bg-red-100 text-red-800',
|
'cancelled': 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
'refunded': 'bg-gray-100 text-gray-800',
|
'refunded': 'bg-gray-100 text-gray-800 dark:bg-gray-800/50 dark:text-gray-400',
|
||||||
'failed': 'bg-red-100 text-red-800',
|
'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) => {
|
const formatDate = (dateString: string) => {
|
||||||
@@ -77,7 +77,7 @@ export default function Orders() {
|
|||||||
<p className="text-gray-600 mb-4">No orders yet</p>
|
<p className="text-gray-600 mb-4">No orders yet</p>
|
||||||
<Link
|
<Link
|
||||||
to="/shop"
|
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
|
Browse Products
|
||||||
</Link>
|
</Link>
|
||||||
@@ -112,7 +112,7 @@ export default function Orders() {
|
|||||||
<span className="font-bold text-lg">{order.total}</span>
|
<span className="font-bold text-lg">{order.total}</span>
|
||||||
<Link
|
<Link
|
||||||
to={`/my-account/orders/${order.id}`}
|
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" />
|
<Eye className="w-4 h-4" />
|
||||||
View
|
View
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
|||||||
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
||||||
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
||||||
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
|
{ 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
|
// 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 === 'wishlist') return isEnabled('wishlist') && wishlistEnabled;
|
||||||
if (item.id === 'licenses') return isEnabled('licensing');
|
if (item.id === 'licenses') return isEnabled('licensing');
|
||||||
if (item.id === 'subscriptions') return isEnabled('subscription');
|
if (item.id === 'subscriptions') return isEnabled('subscription');
|
||||||
|
if (item.id === 'affiliate') return isEnabled('affiliate');
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Licenses from './Licenses';
|
|||||||
import LicenseConnect from './LicenseConnect';
|
import LicenseConnect from './LicenseConnect';
|
||||||
import Subscriptions from './Subscriptions';
|
import Subscriptions from './Subscriptions';
|
||||||
import SubscriptionDetail from './SubscriptionDetail';
|
import SubscriptionDetail from './SubscriptionDetail';
|
||||||
|
import AffiliateDashboard from './AffiliateDashboard';
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
@@ -42,6 +43,7 @@ export default function Account() {
|
|||||||
<Route path="licenses" element={<Licenses />} />
|
<Route path="licenses" element={<Licenses />} />
|
||||||
<Route path="subscriptions" element={<Subscriptions />} />
|
<Route path="subscriptions" element={<Subscriptions />} />
|
||||||
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
|
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
|
||||||
|
<Route path="affiliate" element={<AffiliateDashboard />} />
|
||||||
<Route path="account-details" element={<AccountDetails />} />
|
<Route path="account-details" element={<AccountDetails />} />
|
||||||
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -599,6 +599,18 @@ export default function Checkout() {
|
|||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
|
||||||
try {
|
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
|
// Prepare order data
|
||||||
const orderData = {
|
const orderData = {
|
||||||
items: cart.items.map(item => ({
|
items: cart.items.map(item => ({
|
||||||
@@ -652,6 +664,7 @@ export default function Checkout() {
|
|||||||
custom_fields: customFieldData,
|
custom_fields: customFieldData,
|
||||||
// CAPTCHA token for security validation
|
// CAPTCHA token for security validation
|
||||||
captcha_token: captchaToken,
|
captcha_token: captchaToken,
|
||||||
|
referral_code: referralCode || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Submit order
|
// Submit order
|
||||||
@@ -670,8 +683,16 @@ export default function Checkout() {
|
|||||||
// If user was logged in during this request (guest auto-register),
|
// If user was logged in during this request (guest auto-register),
|
||||||
// we need a full page reload to recognize the auth cookie
|
// we need a full page reload to recognize the auth cookie
|
||||||
if (data.user_logged_in) {
|
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
|
// 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 {
|
} else {
|
||||||
// Already logged in or no login happened - SPA navigate is fine
|
// Already logged in or no login happened - SPA navigate is fine
|
||||||
navigate(thankYouUrl, { replace: true });
|
navigate(thankYouUrl, { replace: true });
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ const OrderPay: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={handlePayment}
|
onClick={handlePayment}
|
||||||
disabled={processing || !selectedGateway}
|
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)}`}
|
{processing ? 'Processing Payment...' : `Pay ${formatPrice(order.total, order.currency)}`}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
||||||
import { useThankYouSettings } from '@/hooks/useAppearanceSettings';
|
import { useThankYouSettings } from '@/hooks/useAppearanceSettings';
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import Container from '@/components/Layout/Container';
|
import Container from '@/components/Layout/Container';
|
||||||
import SEOHead from '@/components/SEOHead';
|
import SEOHead from '@/components/SEOHead';
|
||||||
import { CheckCircle, ShoppingBag, Package, Truck, User, LogIn } from 'lucide-react';
|
import { CheckCircle, ShoppingBag, Package, Truck, User, LogIn } from 'lucide-react';
|
||||||
@@ -13,6 +14,7 @@ export default function ThankYou() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const orderKey = searchParams.get('key');
|
const orderKey = searchParams.get('key');
|
||||||
const { template, backgroundColor, customMessage, elements, isLoading: settingsLoading } = useThankYouSettings();
|
const { template, backgroundColor, customMessage, elements, isLoading: settingsLoading } = useThankYouSettings();
|
||||||
|
const { colorMode } = useTheme();
|
||||||
const [order, setOrder] = useState<any>(null);
|
const [order, setOrder] = useState<any>(null);
|
||||||
const [relatedProducts, setRelatedProducts] = useState<any[]>([]);
|
const [relatedProducts, setRelatedProducts] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -74,7 +76,7 @@ export default function ThankYou() {
|
|||||||
// Render receipt style template
|
// Render receipt style template
|
||||||
if (template === 'receipt') {
|
if (template === 'receipt') {
|
||||||
return (
|
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`} />
|
<SEOHead title="Order Confirmed" description={`Order #${order?.number || orderId} confirmed`} />
|
||||||
<Container>
|
<Container>
|
||||||
<div className="py-12 max-w-2xl mx-auto">
|
<div className="py-12 max-w-2xl mx-auto">
|
||||||
@@ -99,8 +101,8 @@ export default function ThankYou() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom Message */}
|
{/* Custom Message */}
|
||||||
<div className="px-8 py-4 bg-gray-50 border-b border-dashed border-gray-300">
|
<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">{customMessage}</p>
|
<p className="text-sm text-center text-gray-700 dark:text-blue-100">{customMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Order Items */}
|
{/* Order Items */}
|
||||||
@@ -364,7 +366,7 @@ export default function ThankYou() {
|
|||||||
|
|
||||||
// Render basic style template (default)
|
// Render basic style template (default)
|
||||||
return (
|
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`} />
|
<SEOHead title="Order Confirmed" description={`Order #${order?.number || orderId} confirmed`} />
|
||||||
<Container>
|
<Container>
|
||||||
<div className="py-12 max-w-3xl mx-auto">
|
<div className="py-12 max-w-3xl mx-auto">
|
||||||
@@ -378,8 +380,8 @@ export default function ThankYou() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom Message */}
|
{/* Custom Message */}
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
|
<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 text-center">{customMessage}</p>
|
<p className="text-gray-800 dark:text-blue-100 text-center">{customMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Order Details */}
|
{/* Order Details */}
|
||||||
|
|||||||
@@ -329,6 +329,11 @@ class CheckoutController
|
|||||||
return ['error' => $order->get_error_message()];
|
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)
|
// Track if user was logged in during this request (for frontend page reload)
|
||||||
$user_logged_in = false;
|
$user_logged_in = false;
|
||||||
|
|
||||||
@@ -516,6 +521,11 @@ class CheckoutController
|
|||||||
|
|
||||||
$order->save();
|
$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()) {
|
if (!headers_sent()) {
|
||||||
header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1));
|
header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1));
|
||||||
}
|
}
|
||||||
@@ -842,6 +852,7 @@ class CheckoutController
|
|||||||
'shipping_title' => isset($json['shipping_title']) ? sanitize_text_field($json['shipping_title']) : null,
|
'shipping_title' => isset($json['shipping_title']) ? sanitize_text_field($json['shipping_title']) : null,
|
||||||
'custom_fields' => $custom_fields,
|
'custom_fields' => $custom_fields,
|
||||||
'customer_note' => isset($json['customer_note']) ? sanitize_textarea_field($json['customer_note']) : '',
|
'customer_note' => isset($json['customer_note']) ? sanitize_textarea_field($json['customer_note']) : '',
|
||||||
|
'referral_code' => isset($json['referral_code']) ? sanitize_text_field($json['referral_code']) : '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
273
includes/Api/Controllers/AffiliateCustomerController.php
Normal file
273
includes/Api/Controllers/AffiliateCustomerController.php
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace WooNooW\Api\Controllers;
|
||||||
|
|
||||||
|
use WP_REST_Request;
|
||||||
|
use WP_REST_Response;
|
||||||
|
use WP_REST_Server;
|
||||||
|
|
||||||
|
class AffiliateCustomerController
|
||||||
|
{
|
||||||
|
private $namespace = 'woonoow/v1';
|
||||||
|
|
||||||
|
public function register_routes()
|
||||||
|
{
|
||||||
|
register_rest_route($this->namespace, '/account/affiliate', [
|
||||||
|
'methods' => WP_REST_Server::READABLE,
|
||||||
|
'callback' => [$this, 'get_dashboard'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route($this->namespace, '/account/affiliate/apply', [
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'callback' => [$this, 'apply_affiliate'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route($this->namespace, '/account/affiliate/referrals', [
|
||||||
|
'methods' => WP_REST_Server::READABLE,
|
||||||
|
'callback' => [$this, 'get_referrals'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route($this->namespace, '/account/affiliate/payouts', [
|
||||||
|
'methods' => WP_REST_Server::READABLE,
|
||||||
|
'callback' => [$this, 'get_payouts'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route($this->namespace, '/account/affiliate/payment-details', [
|
||||||
|
'methods' => WP_REST_Server::READABLE,
|
||||||
|
'callback' => [$this, 'get_payment_details'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route($this->namespace, '/account/affiliate/payment-details', [
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'callback' => [$this, 'update_payment_details'],
|
||||||
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function check_permission()
|
||||||
|
{
|
||||||
|
return is_user_logged_in();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_dashboard(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE user_id = %d", $user_id), ARRAY_A);
|
||||||
|
|
||||||
|
if (!$affiliate) {
|
||||||
|
return new \WP_Error('not_found', 'Affiliate profile not found', ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get global default rate
|
||||||
|
$global_rate = (float) get_option('woonoow_affiliate_default_rate', 10);
|
||||||
|
|
||||||
|
// Use custom rate if set, otherwise global
|
||||||
|
$effective_rate = !empty($affiliate['custom_commission_rate'])
|
||||||
|
? (float) $affiliate['custom_commission_rate']
|
||||||
|
: $global_rate;
|
||||||
|
|
||||||
|
$affiliate['global_commission_rate'] = $global_rate;
|
||||||
|
$affiliate['commission_rate'] = $effective_rate;
|
||||||
|
|
||||||
|
return rest_ensure_response($affiliate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply_affiliate(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
|
||||||
|
// Check if already applied
|
||||||
|
$exists = $wpdb->get_var($wpdb->prepare("SELECT id FROM $table WHERE user_id = %d", $user_id));
|
||||||
|
if ($exists) {
|
||||||
|
return new \WP_Error('exists', 'You have already applied.', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate simple code
|
||||||
|
$user = wp_get_current_user();
|
||||||
|
$referral_code = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $user->user_login)) . wp_generate_password(4, false);
|
||||||
|
|
||||||
|
$auto_approve = get_option('woonoow_affiliate_auto_approve', false);
|
||||||
|
$status = $auto_approve ? 'active' : 'pending';
|
||||||
|
|
||||||
|
$wpdb->insert($table, [
|
||||||
|
'user_id' => $user_id,
|
||||||
|
'referral_code' => $referral_code,
|
||||||
|
'commission_rate' => get_option('woonoow_affiliate_default_rate', 10), // 10% default
|
||||||
|
'status' => $status
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Trigger email notification for admin
|
||||||
|
$admin_email = get_option('admin_email');
|
||||||
|
do_action('woonoow/email/trigger', 'affiliate_application_received', $admin_email, [
|
||||||
|
'affiliate_name' => $user->display_name,
|
||||||
|
'customer_email' => $user->user_email
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($auto_approve) {
|
||||||
|
do_action('woonoow/email/trigger', 'affiliate_application_approved', $user->user_email, [
|
||||||
|
'affiliate_name' => $user->display_name,
|
||||||
|
'customer_email' => $user->user_email,
|
||||||
|
'referral_code' => $referral_code
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest_ensure_response(['success' => true, 'status' => $status, 'referral_code' => $referral_code]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_referrals(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$referrals_table = $wpdb->prefix . 'woonoow_referrals';
|
||||||
|
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare("SELECT id FROM $affiliates_table WHERE user_id = %d", $user_id));
|
||||||
|
if (!$affiliate) {
|
||||||
|
return rest_ensure_response([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$referrals = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT r.*,
|
||||||
|
COALESCE(NULLIF(r.cancelled_reason, ''), NULL) as cancelled_reason,
|
||||||
|
COALESCE(r.approved_at, r.created_at) as approved_at
|
||||||
|
FROM $referrals_table r
|
||||||
|
WHERE r.affiliate_id = %d
|
||||||
|
ORDER BY r.created_at DESC",
|
||||||
|
$affiliate->id
|
||||||
|
), ARRAY_A);
|
||||||
|
return rest_ensure_response($referrals);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_payouts(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$payouts_table = $wpdb->prefix . 'woonoow_affiliate_payouts';
|
||||||
|
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare("SELECT id FROM $affiliates_table WHERE user_id = %d", $user_id));
|
||||||
|
if (!$affiliate) {
|
||||||
|
return rest_ensure_response([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payouts = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT id, amount, currency, method, status, notes, created_at, completed_at
|
||||||
|
FROM $payouts_table
|
||||||
|
WHERE affiliate_id = %d
|
||||||
|
ORDER BY created_at DESC",
|
||||||
|
$affiliate->id
|
||||||
|
), ARRAY_A);
|
||||||
|
|
||||||
|
return rest_ensure_response($payouts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_payment_details(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT payment_method, payment_details FROM $table WHERE user_id = %d",
|
||||||
|
$user_id
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!$affiliate) {
|
||||||
|
return new \WP_Error('not_found', 'Affiliate not found', ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payment_details = $affiliate->payment_details ? json_decode($affiliate->payment_details, true) : [];
|
||||||
|
|
||||||
|
return rest_ensure_response([
|
||||||
|
'payment_method' => $affiliate->payment_method ?: '',
|
||||||
|
'payment_details' => $payment_details ?: new \stdClass()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update_payment_details(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
|
||||||
|
// Get allowed payment methods from settings
|
||||||
|
$settings = get_option('woonoow_module_affiliate_settings', []);
|
||||||
|
$allowed_methods = $settings['woonoow_affiliate_payment_methods'] ?? ['bank_transfer'];
|
||||||
|
|
||||||
|
$payment_method = sanitize_text_field($request->get_param('payment_method') ?: '');
|
||||||
|
$payment_details_raw = $request->get_param('payment_details') ?: [];
|
||||||
|
|
||||||
|
// Validate payment method is allowed
|
||||||
|
if (!in_array($payment_method, $allowed_methods)) {
|
||||||
|
return new \WP_Error(
|
||||||
|
'invalid_payment_method',
|
||||||
|
'This payment method is not available. Please contact admin.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize payment details based on method
|
||||||
|
$sanitized_details = self::sanitize_payment_details($payment_method, $payment_details_raw);
|
||||||
|
|
||||||
|
$result = $wpdb->update(
|
||||||
|
$table,
|
||||||
|
[
|
||||||
|
'payment_method' => $payment_method,
|
||||||
|
'payment_details' => json_encode($sanitized_details)
|
||||||
|
],
|
||||||
|
['user_id' => $user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result === false) {
|
||||||
|
return new \WP_Error('update_failed', 'Failed to update payment details', ['status' => 500]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest_ensure_response([
|
||||||
|
'success' => true,
|
||||||
|
'payment_method' => $payment_method,
|
||||||
|
'payment_details' => $sanitized_details
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize payment details based on payment method
|
||||||
|
*/
|
||||||
|
private static function sanitize_payment_details($method, $details)
|
||||||
|
{
|
||||||
|
$sanitized = [];
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'bank_transfer':
|
||||||
|
$sanitized['bank_name'] = sanitize_text_field($details['bank_name'] ?? '');
|
||||||
|
$sanitized['account_number'] = sanitize_text_field($details['account_number'] ?? '');
|
||||||
|
$sanitized['account_holder'] = sanitize_text_field($details['account_holder'] ?? '');
|
||||||
|
$sanitized['swift_code'] = sanitize_text_field($details['swift_code'] ?? '');
|
||||||
|
$sanitized['bank_address'] = sanitize_text_field($details['bank_address'] ?? '');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'paypal':
|
||||||
|
case 'wise':
|
||||||
|
case 'skrill':
|
||||||
|
case 'payoneer':
|
||||||
|
$sanitized['email'] = sanitize_email($details['email'] ?? '');
|
||||||
|
$sanitized['name'] = sanitize_text_field($details['name'] ?? '');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'custom':
|
||||||
|
$sanitized['notes'] = sanitize_textarea_field($details['notes'] ?? '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -249,6 +249,9 @@ class ModuleSettingsController extends WP_REST_Controller {
|
|||||||
|
|
||||||
// Type validation
|
// Type validation
|
||||||
$type = $field['type'] ?? 'text';
|
$type = $field['type'] ?? 'text';
|
||||||
|
$min = isset($field['min']) ? $field['min'] : null;
|
||||||
|
$max = isset($field['max']) ? $field['max'] : null;
|
||||||
|
|
||||||
switch ($type) {
|
switch ($type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
case 'textarea':
|
case 'textarea':
|
||||||
@@ -256,9 +259,39 @@ class ModuleSettingsController extends WP_REST_Controller {
|
|||||||
case 'url':
|
case 'url':
|
||||||
$validated[$key] = sanitize_text_field($value);
|
$validated[$key] = sanitize_text_field($value);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'number':
|
case 'number':
|
||||||
$validated[$key] = floatval($value);
|
// Handle empty string as valid for numbers (if min allows 0)
|
||||||
|
if ($value === '' || $value === null) {
|
||||||
|
// Only allow empty if there's a default
|
||||||
|
if (isset($field['default'])) {
|
||||||
|
$validated[$key] = $field['default'];
|
||||||
|
} elseif ($min !== null && $min === 0) {
|
||||||
|
$validated[$key] = 0;
|
||||||
|
} else {
|
||||||
|
$errors[$key] = sprintf(
|
||||||
|
__('%s cannot be empty', 'woonoow'),
|
||||||
|
$field['label'] ?? $key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$num_value = floatval($value);
|
||||||
|
if ($min !== null && $num_value < $min) {
|
||||||
|
$errors[$key] = sprintf(
|
||||||
|
__('%s must be at least %s', 'woonoow'),
|
||||||
|
$field['label'] ?? $key,
|
||||||
|
$min
|
||||||
|
);
|
||||||
|
} elseif ($max !== null && $num_value > $max) {
|
||||||
|
$errors[$key] = sprintf(
|
||||||
|
__('%s must be at most %s', 'woonoow'),
|
||||||
|
$field['label'] ?? $key,
|
||||||
|
$max
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$validated[$key] = $num_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'toggle':
|
case 'toggle':
|
||||||
@@ -277,6 +310,21 @@ class ModuleSettingsController extends WP_REST_Controller {
|
|||||||
$validated[$key] = sanitize_text_field($value);
|
$validated[$key] = sanitize_text_field($value);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'multiselect':
|
||||||
|
// Validate array of values against allowed options
|
||||||
|
if (!is_array($value)) {
|
||||||
|
$value = [];
|
||||||
|
}
|
||||||
|
$allowed_options = $field['options'] ?? [];
|
||||||
|
$valid_values = [];
|
||||||
|
foreach ($value as $v) {
|
||||||
|
if (isset($allowed_options[$v])) {
|
||||||
|
$valid_values[] = sanitize_text_field($v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$validated[$key] = $valid_values;
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
$validated[$key] = $value;
|
$validated[$key] = $value;
|
||||||
|
|||||||
@@ -151,6 +151,10 @@ class ModulesController extends WP_REST_Controller {
|
|||||||
'success' => true,
|
'success' => true,
|
||||||
'module_id' => $module_id,
|
'module_id' => $module_id,
|
||||||
'enabled' => $enabled,
|
'enabled' => $enabled,
|
||||||
|
'enabled_modules' => ModuleRegistry::get_enabled_modules(),
|
||||||
|
'nav_tree' => class_exists('\WooNooW\Compat\NavigationRegistry')
|
||||||
|
? \WooNooW\Compat\NavigationRegistry::get_frontend_nav_tree()
|
||||||
|
: [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -672,6 +672,39 @@ class OrdersController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get related affiliate referral
|
||||||
|
if (ModuleRegistry::is_enabled('affiliate')) {
|
||||||
|
if (class_exists('\WooNooW\Modules\Affiliate\AffiliateTracker')) {
|
||||||
|
global $wpdb;
|
||||||
|
$referral = \WooNooW\Modules\Affiliate\AffiliateTracker::get_referral_for_order($id);
|
||||||
|
if ($referral) {
|
||||||
|
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$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) {
|
||||||
|
$data['affiliate'] = [
|
||||||
|
'has_referral' => true,
|
||||||
|
'referral_id' => (int) $referral->id,
|
||||||
|
'commission' => (float) $referral->commission_amount,
|
||||||
|
'currency' => $referral->currency,
|
||||||
|
'status' => $referral->status,
|
||||||
|
'cancelled_reason' => $referral->cancelled_reason ?: null,
|
||||||
|
'affiliate_id' => (int) $affiliate->id,
|
||||||
|
'affiliate_name' => $affiliate->affiliate_name ?: __('Unknown', 'woonoow'),
|
||||||
|
'affiliate_email' => $affiliate->affiliate_email ?: '',
|
||||||
|
'commission_rate' => (float) $affiliate->commission_rate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Allow plugins to modify response (Level 1 compatibility)
|
// Allow plugins to modify response (Level 1 compatibility)
|
||||||
$data = apply_filters('woonoow/order_api_data', $data, $order, $req);
|
$data = apply_filters('woonoow/order_api_data', $data, $order, $req);
|
||||||
|
|
||||||
|
|||||||
@@ -473,6 +473,14 @@ class ProductsController
|
|||||||
update_post_meta($product->get_id(), '_woonoow_subscription_signup_fee', self::sanitize_number($data['subscription_signup_fee']));
|
update_post_meta($product->get_id(), '_woonoow_subscription_signup_fee', self::sanitize_number($data['subscription_signup_fee']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Affiliate meta
|
||||||
|
if (isset($data['affiliate_enabled'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_affiliate_enabled', $data['affiliate_enabled'] ? 'yes' : 'no');
|
||||||
|
}
|
||||||
|
if (isset($data['affiliate_commission_rate'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_affiliate_commission_rate', self::sanitize_number($data['affiliate_commission_rate']));
|
||||||
|
}
|
||||||
|
|
||||||
// Handle variations for variable products
|
// Handle variations for variable products
|
||||||
if ($type === 'variable' && !empty($data['attributes']) && is_array($data['attributes'])) {
|
if ($type === 'variable' && !empty($data['attributes']) && is_array($data['attributes'])) {
|
||||||
self::save_product_attributes($product, $data['attributes']);
|
self::save_product_attributes($product, $data['attributes']);
|
||||||
@@ -644,6 +652,14 @@ class ProductsController
|
|||||||
update_post_meta($product->get_id(), '_woonoow_subscription_signup_fee', self::sanitize_number($data['subscription_signup_fee']));
|
update_post_meta($product->get_id(), '_woonoow_subscription_signup_fee', self::sanitize_number($data['subscription_signup_fee']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Affiliate meta
|
||||||
|
if (isset($data['affiliate_enabled'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_affiliate_enabled', $data['affiliate_enabled'] ? 'yes' : 'no');
|
||||||
|
}
|
||||||
|
if (isset($data['affiliate_commission_rate'])) {
|
||||||
|
update_post_meta($product->get_id(), '_woonoow_affiliate_commission_rate', self::sanitize_number($data['affiliate_commission_rate']));
|
||||||
|
}
|
||||||
|
|
||||||
// Allow plugins to perform additional updates (Level 1 compatibility)
|
// Allow plugins to perform additional updates (Level 1 compatibility)
|
||||||
do_action('woonoow/product_updated', $product, $data, $request);
|
do_action('woonoow/product_updated', $product, $data, $request);
|
||||||
|
|
||||||
@@ -857,6 +873,10 @@ class ProductsController
|
|||||||
$data['subscription_trial_days'] = get_post_meta($product->get_id(), '_woonoow_subscription_trial_days', true) ?: '';
|
$data['subscription_trial_days'] = get_post_meta($product->get_id(), '_woonoow_subscription_trial_days', true) ?: '';
|
||||||
$data['subscription_signup_fee'] = get_post_meta($product->get_id(), '_woonoow_subscription_signup_fee', true) ?: '';
|
$data['subscription_signup_fee'] = get_post_meta($product->get_id(), '_woonoow_subscription_signup_fee', true) ?: '';
|
||||||
|
|
||||||
|
// Affiliate fields
|
||||||
|
$data['affiliate_enabled'] = get_post_meta($product->get_id(), '_woonoow_affiliate_enabled', true) === 'yes';
|
||||||
|
$data['affiliate_commission_rate'] = get_post_meta($product->get_id(), '_woonoow_affiliate_commission_rate', true) ?: '';
|
||||||
|
|
||||||
// Images array (URLs) for frontend - featured + gallery
|
// Images array (URLs) for frontend - featured + gallery
|
||||||
$images = [];
|
$images = [];
|
||||||
$featured_image_id = $product->get_image_id();
|
$featured_image_id = $product->get_image_id();
|
||||||
|
|||||||
@@ -191,6 +191,14 @@ class Routes
|
|||||||
$onboarding_controller = new OnboardingController();
|
$onboarding_controller = new OnboardingController();
|
||||||
$onboarding_controller->register_routes();
|
$onboarding_controller->register_routes();
|
||||||
|
|
||||||
|
// Affiliate Admin controller
|
||||||
|
$affiliate_admin_controller = new \WooNooW\Api\Controllers\AffiliateAdminController();
|
||||||
|
$affiliate_admin_controller->register_routes();
|
||||||
|
|
||||||
|
// Affiliate Customer controller
|
||||||
|
$affiliate_customer_controller = new \WooNooW\Api\Controllers\AffiliateCustomerController();
|
||||||
|
$affiliate_customer_controller->register_routes();
|
||||||
|
|
||||||
// Frontend controllers (customer-facing)
|
// Frontend controllers (customer-facing)
|
||||||
ShopController::register_routes();
|
ShopController::register_routes();
|
||||||
FrontendCartController::register_routes();
|
FrontendCartController::register_routes();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ if (! defined('ABSPATH')) exit;
|
|||||||
class NavigationRegistry
|
class NavigationRegistry
|
||||||
{
|
{
|
||||||
const NAV_OPTION = 'wnw_nav_tree';
|
const NAV_OPTION = 'wnw_nav_tree';
|
||||||
const NAV_VERSION = '1.3.1'; // Updated Coupons link
|
const NAV_VERSION = '1.3.3'; // Reordered marketing links by usage priority
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize hooks
|
* Initialize hooks
|
||||||
@@ -217,14 +217,19 @@ class NavigationRegistry
|
|||||||
{
|
{
|
||||||
$children = [];
|
$children = [];
|
||||||
|
|
||||||
|
// Coupons - always available
|
||||||
|
$children[] = ['label' => __('Coupons', 'woonoow'), 'mode' => 'spa', 'path' => '/marketing/coupons'];
|
||||||
|
|
||||||
|
// Affiliate - only if module enabled
|
||||||
|
if (\WooNooW\Core\ModuleRegistry::is_enabled('affiliate')) {
|
||||||
|
$children[] = ['label' => __('Affiliates', 'woonoow'), 'mode' => 'spa', 'path' => '/marketing/affiliates'];
|
||||||
|
}
|
||||||
|
|
||||||
// Newsletter - only if module enabled
|
// Newsletter - only if module enabled
|
||||||
if (\WooNooW\Core\ModuleRegistry::is_enabled('newsletter')) {
|
if (\WooNooW\Core\ModuleRegistry::is_enabled('newsletter')) {
|
||||||
$children[] = ['label' => __('Newsletter', 'woonoow'), 'mode' => 'spa', 'path' => '/marketing/newsletter'];
|
$children[] = ['label' => __('Newsletter', 'woonoow'), 'mode' => 'spa', 'path' => '/marketing/newsletter'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coupons - always available
|
|
||||||
$children[] = ['label' => __('Coupons', 'woonoow'), 'mode' => 'spa', 'path' => '/marketing/coupons'];
|
|
||||||
|
|
||||||
return $children;
|
return $children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class ModuleRegistry
|
|||||||
'category' => 'marketing',
|
'category' => 'marketing',
|
||||||
'icon' => 'users',
|
'icon' => 'users',
|
||||||
'default_enabled' => false,
|
'default_enabled' => false,
|
||||||
|
'has_settings' => true,
|
||||||
'features' => [
|
'features' => [
|
||||||
__('Referral tracking', 'woonoow'),
|
__('Referral tracking', 'woonoow'),
|
||||||
__('Commission management', 'woonoow'),
|
__('Commission management', 'woonoow'),
|
||||||
|
|||||||
@@ -110,6 +110,49 @@ class EventRegistry
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// ===== AFFILIATE EVENTS =====
|
||||||
|
'affiliate_application_received' => [
|
||||||
|
'id' => 'affiliate_application_received',
|
||||||
|
'label' => __('Affiliate Application Received', 'woonoow'),
|
||||||
|
'description' => __('When a customer applies to be an affiliate', 'woonoow'),
|
||||||
|
'category' => 'marketing',
|
||||||
|
'recipient_type' => 'staff',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => [
|
||||||
|
'{affiliate_name}' => __('Affiliate Name', 'woonoow'),
|
||||||
|
'{customer_email}' => __('Customer Email', 'woonoow'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'affiliate_application_approved' => [
|
||||||
|
'id' => 'affiliate_application_approved',
|
||||||
|
'label' => __('Affiliate Application Approved', 'woonoow'),
|
||||||
|
'description' => __('When an affiliate application is approved', 'woonoow'),
|
||||||
|
'category' => 'marketing',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => [
|
||||||
|
'{affiliate_name}' => __('Affiliate Name', 'woonoow'),
|
||||||
|
'{referral_code}' => __('Referral Code', 'woonoow'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'affiliate_new_referral' => [
|
||||||
|
'id' => 'affiliate_new_referral',
|
||||||
|
'label' => __('New Affiliate Referral', 'woonoow'),
|
||||||
|
'description' => __('When an affiliate generates a new referral', 'woonoow'),
|
||||||
|
'category' => 'marketing',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => [
|
||||||
|
'{affiliate_name}' => __('Affiliate Name', 'woonoow'),
|
||||||
|
'{commission_amount}' => __('Commission Amount', 'woonoow'),
|
||||||
|
'{currency}' => __('Currency', 'woonoow'),
|
||||||
|
'{order_number}' => __('Order Number', 'woonoow'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
// ===== ORDER INITIATION =====
|
// ===== ORDER INITIATION =====
|
||||||
'order_placed' => [
|
'order_placed' => [
|
||||||
'id' => 'order_placed',
|
'id' => 'order_placed',
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ class DefaultTemplates
|
|||||||
'order_refunded' => self::customer_order_refunded(),
|
'order_refunded' => self::customer_order_refunded(),
|
||||||
'new_customer' => self::customer_new_customer(),
|
'new_customer' => self::customer_new_customer(),
|
||||||
'newsletter_campaign' => self::customer_newsletter_campaign(),
|
'newsletter_campaign' => self::customer_newsletter_campaign(),
|
||||||
|
'affiliate_application_approved' => self::customer_affiliate_application_approved(),
|
||||||
|
'affiliate_new_referral' => self::customer_affiliate_new_referral(),
|
||||||
],
|
],
|
||||||
'staff' => [
|
'staff' => [
|
||||||
'order_placed' => self::staff_order_placed(),
|
'order_placed' => self::staff_order_placed(),
|
||||||
@@ -104,6 +106,7 @@ class DefaultTemplates
|
|||||||
'order_failed' => self::staff_order_failed(),
|
'order_failed' => self::staff_order_failed(),
|
||||||
'order_cancelled' => self::staff_order_cancelled(),
|
'order_cancelled' => self::staff_order_cancelled(),
|
||||||
'order_refunded' => self::staff_order_refunded(),
|
'order_refunded' => self::staff_order_refunded(),
|
||||||
|
'affiliate_application_received' => self::staff_affiliate_application_received(),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -141,6 +144,8 @@ class DefaultTemplates
|
|||||||
'order_refunded' => 'Refund processed for order #{order_number}',
|
'order_refunded' => 'Refund processed for order #{order_number}',
|
||||||
'new_customer' => 'Welcome to {site_name}! 🎁 Exclusive offer inside',
|
'new_customer' => 'Welcome to {site_name}! 🎁 Exclusive offer inside',
|
||||||
'newsletter_campaign' => '{campaign_title}',
|
'newsletter_campaign' => '{campaign_title}',
|
||||||
|
'affiliate_application_approved' => 'You\'re approved! Welcome to the {site_name} Affiliate Program 🤝',
|
||||||
|
'affiliate_new_referral' => 'Ka-ching! 💰 You just earned a new referral commission',
|
||||||
],
|
],
|
||||||
'staff' => [
|
'staff' => [
|
||||||
'order_placed' => '[NEW ORDER] #{order_number} - ${order_total} from {customer_name}',
|
'order_placed' => '[NEW ORDER] #{order_number} - ${order_total} from {customer_name}',
|
||||||
@@ -154,6 +159,7 @@ class DefaultTemplates
|
|||||||
'order_failed' => '[FAILED] #{order_number} - Unable to process',
|
'order_failed' => '[FAILED] #{order_number} - Unable to process',
|
||||||
'order_cancelled' => '[CANCELLED] #{order_number} - Refund may be required',
|
'order_cancelled' => '[CANCELLED] #{order_number} - Refund may be required',
|
||||||
'order_refunded' => '[REFUNDED] #{order_number} - ${order_total} refunded to customer',
|
'order_refunded' => '[REFUNDED] #{order_number} - ${order_total} refunded to customer',
|
||||||
|
'affiliate_application_received' => '[AFFILIATE APP] New application from {affiliate_name}',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -748,6 +754,62 @@ We hope to serve you again soon! Check out our new arrivals:
|
|||||||
[button url="{shop_url}"]Browse New Products[/button]';
|
[button url="{shop_url}"]Browse New Products[/button]';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer: Affiliate Application Approved
|
||||||
|
*/
|
||||||
|
private static function customer_affiliate_application_approved()
|
||||||
|
{
|
||||||
|
return '[card type="hero"]
|
||||||
|
## Welcome to the Affiliate Program, {affiliate_name}! 🤝
|
||||||
|
|
||||||
|
Your application has been approved. You can now start earning commissions by referring customers to {site_name}.
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[card]
|
||||||
|
**Your Details:**
|
||||||
|
Referral Code: {referral_code}
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[card type="success"]
|
||||||
|
**How to Start Earning:**
|
||||||
|
1. Go to your Affiliate Dashboard
|
||||||
|
2. Copy your unique referral link
|
||||||
|
3. Share it on your blog, social media, or with friends
|
||||||
|
4. Earn commissions when they make a purchase!
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[button url="{my_account_url}#/affiliate"]Go To Dashboard[/button]
|
||||||
|
|
||||||
|
[card type="basic"]
|
||||||
|
Have questions about the program? Contact us at {support_email}
|
||||||
|
[/card]';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer: New Affiliate Referral
|
||||||
|
*/
|
||||||
|
private static function customer_affiliate_new_referral()
|
||||||
|
{
|
||||||
|
return '[card type="hero"]
|
||||||
|
## You earned a commission! 💰
|
||||||
|
|
||||||
|
Great job, {affiliate_name}! A customer just placed an order using your referral link.
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[card type="success"]
|
||||||
|
**Referral Details:**
|
||||||
|
Order Number: #{order_number}
|
||||||
|
Commission Earned: {commission_amount} {currency}
|
||||||
|
Status: Pending (Awaiting fulfillment)
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[card]
|
||||||
|
Commissions remain pending until the order is successfully completed and the return period has passed. You can track all your referrals and payouts in your dashboard.
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[button url="{my_account_url}#/affiliate"]View Dashboard[/button]';
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// STAFF TEMPLATES
|
// STAFF TEMPLATES
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -1215,4 +1277,33 @@ Refund Date: {order_date}
|
|||||||
Customer will see refund in their account within 5-10 business days depending on their bank. Flag if customer inquires about missing refund after 14 days.
|
Customer will see refund in their account within 5-10 business days depending on their bank. Flag if customer inquires about missing refund after 14 days.
|
||||||
[/card]';
|
[/card]';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff: Affiliate Application Received
|
||||||
|
* Notifies staff when a new affiliate application is submitted
|
||||||
|
*/
|
||||||
|
private static function staff_affiliate_application_received()
|
||||||
|
{
|
||||||
|
return '[card type="info"]
|
||||||
|
## 📝 New Affiliate Application
|
||||||
|
|
||||||
|
A new affiliate application has been submitted by {affiliate_name}. Please review the application details in the admin dashboard.
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[card]
|
||||||
|
**Application Details:**
|
||||||
|
Name: {affiliate_name}
|
||||||
|
Email: {affiliate_email}
|
||||||
|
Date: {application_date}
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[card type="info"]
|
||||||
|
**Action Items:**
|
||||||
|
☐ Review application details
|
||||||
|
☐ Check applicant\'s social media or website
|
||||||
|
☐ Approve or reject the application in the Woonoow Admin Dashboard
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[button url="{admin_url}"]Review Application[/button]';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -489,6 +489,7 @@ class AccountController {
|
|||||||
'total' => html_entity_decode(strip_tags(wc_price($order->get_total()))),
|
'total' => html_entity_decode(strip_tags(wc_price($order->get_total()))),
|
||||||
'currency' => $order->get_currency(),
|
'currency' => $order->get_currency(),
|
||||||
'payment_method_title' => $payment_title,
|
'payment_method_title' => $payment_title,
|
||||||
|
'items_count' => $order->get_item_count(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($detailed) {
|
if ($detailed) {
|
||||||
|
|||||||
252
includes/Modules/Affiliate/AffiliateLifecycle.php
Normal file
252
includes/Modules/Affiliate/AffiliateLifecycle.php
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiliate Lifecycle
|
||||||
|
*
|
||||||
|
* Handles order status changes (refunds, cancellations) and auto-approvals.
|
||||||
|
*
|
||||||
|
* @package WooNooW\Modules\Affiliate
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Modules\Affiliate;
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
class AffiliateLifecycle
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Initialize lifecycle hooks
|
||||||
|
*/
|
||||||
|
public static function init()
|
||||||
|
{
|
||||||
|
// Cancel/Revert on order refund or cancellation
|
||||||
|
add_action('woocommerce_order_status_refunded', [__CLASS__, 'handle_order_cancelled']);
|
||||||
|
add_action('woocommerce_order_status_cancelled', [__CLASS__, 'handle_order_cancelled']);
|
||||||
|
add_action('woocommerce_order_status_failed', [__CLASS__, 'handle_order_cancelled']);
|
||||||
|
|
||||||
|
// Handle order completion - immediate approval
|
||||||
|
add_action('woocommerce_order_status_completed', [__CLASS__, 'handle_order_completed']);
|
||||||
|
|
||||||
|
// HPOS compatible hooks
|
||||||
|
add_action('woocommerce_order_status_changed', [__CLASS__, 'handle_hpos_status_changed'], 10, 4);
|
||||||
|
add_action('woocommerce_update_order', [__CLASS__, 'handle_order_updated'], 10, 2);
|
||||||
|
|
||||||
|
// Handle order deletion (trash + permanent delete)
|
||||||
|
add_action('before_delete_post', [__CLASS__, 'handle_order_deleted'], 10, 1);
|
||||||
|
add_action('woocommerce_delete_order', [__CLASS__, 'handle_order_deleted'], 10, 1);
|
||||||
|
|
||||||
|
// Action Scheduler Hook for auto-approval
|
||||||
|
add_action('woonoow_approve_referral', [__CLASS__, 'auto_approve_referral']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle cancelled, refunded, or failed orders
|
||||||
|
*/
|
||||||
|
public static function handle_order_cancelled($order_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$referrals_table = $wpdb->prefix . 'woonoow_referrals';
|
||||||
|
|
||||||
|
// Get the order to determine the reason
|
||||||
|
$order = wc_get_order($order_id);
|
||||||
|
$reason = 'order_cancelled';
|
||||||
|
if ($order) {
|
||||||
|
$status = $order->get_status();
|
||||||
|
$reason = 'order_' . $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find pending or approved referral
|
||||||
|
$referral = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT * FROM $referrals_table WHERE order_id = %d AND status IN ('pending', 'approved')",
|
||||||
|
$order_id
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($referral) {
|
||||||
|
// If was already approved, this is a clawback - decrease affiliate earnings
|
||||||
|
if ($referral->status === 'approved') {
|
||||||
|
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"UPDATE $affiliates_table SET total_earnings = total_earnings - %f WHERE id = %d",
|
||||||
|
$referral->commission_amount,
|
||||||
|
$referral->affiliate_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status to rejected with reason
|
||||||
|
$wpdb->update(
|
||||||
|
$referrals_table,
|
||||||
|
[
|
||||||
|
'status' => 'rejected',
|
||||||
|
'cancelled_reason' => $reason,
|
||||||
|
'cancelled_at' => current_time('mysql')
|
||||||
|
],
|
||||||
|
['id' => $referral->id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unschedule action if action scheduler exists
|
||||||
|
if (function_exists('as_unschedule_all_actions')) {
|
||||||
|
as_unschedule_all_actions('woonoow_approve_referral', ['referral_id' => $referral->id], 'woonoow_affiliate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle order completion - check if referral should be approved
|
||||||
|
*/
|
||||||
|
public static function handle_order_completed($order_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$referrals_table = $wpdb->prefix . 'woonoow_referrals';
|
||||||
|
|
||||||
|
// Find pending referral for this order
|
||||||
|
$referral = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT * FROM $referrals_table WHERE order_id = %d AND status = 'pending'",
|
||||||
|
$order_id
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!$referral) return;
|
||||||
|
|
||||||
|
// Check if holding period is 0 (immediate approval on completion)
|
||||||
|
$holding_period = (int) get_option('woonoow_affiliate_holding_period', 14);
|
||||||
|
|
||||||
|
if ($holding_period === 0) {
|
||||||
|
// Immediate approval
|
||||||
|
self::auto_approve_referral($referral->id);
|
||||||
|
} else {
|
||||||
|
// If order was completed BEFORE the scheduled action time, approve now
|
||||||
|
// Otherwise, the scheduled action will approve later
|
||||||
|
// Check if the scheduled action time has already passed
|
||||||
|
$approval_time = strtotime($referral->created_at) + ($holding_period * DAY_IN_SECONDS);
|
||||||
|
|
||||||
|
if (time() >= $approval_time) {
|
||||||
|
self::auto_approve_referral($referral->id);
|
||||||
|
}
|
||||||
|
// If not, the scheduled Action Scheduler job will handle it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel the scheduled auto-approval since we're handling it now
|
||||||
|
if (function_exists('as_unschedule_all_actions')) {
|
||||||
|
as_unschedule_all_actions('woonoow_approve_referral', ['referral_id' => $referral->id], 'woonoow_affiliate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle HPOS order status change
|
||||||
|
*/
|
||||||
|
public static function handle_hpos_status_changed($order_id, $from_status, $to_status, $order)
|
||||||
|
{
|
||||||
|
if ($to_status === 'completed') {
|
||||||
|
self::handle_order_completed($order_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle order update (HPOS compatible)
|
||||||
|
*/
|
||||||
|
public static function handle_order_updated($order_id, $order)
|
||||||
|
{
|
||||||
|
if (!$order) return;
|
||||||
|
|
||||||
|
// Check if order was just completed
|
||||||
|
$status = $order->get_status();
|
||||||
|
if ($status === 'completed') {
|
||||||
|
self::handle_order_completed($order_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle order deletion (permanent delete or trash)
|
||||||
|
*/
|
||||||
|
public static function handle_order_deleted($order_id)
|
||||||
|
{
|
||||||
|
// Check if this is a WooCommerce order
|
||||||
|
$order = wc_get_order($order_id);
|
||||||
|
if (!$order) return;
|
||||||
|
|
||||||
|
// Only process shop orders
|
||||||
|
$post_type = get_post_type($order_id);
|
||||||
|
if ($post_type !== 'shop_order') return;
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$referrals_table = $wpdb->prefix . 'woonoow_referrals';
|
||||||
|
|
||||||
|
// Find any referral for this order
|
||||||
|
$referral = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT * FROM $referrals_table WHERE order_id = %d",
|
||||||
|
$order_id
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($referral) {
|
||||||
|
// If was already approved, this is a clawback - decrease affiliate earnings
|
||||||
|
if ($referral->status === 'approved') {
|
||||||
|
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"UPDATE $affiliates_table SET total_earnings = total_earnings - %f WHERE id = %d",
|
||||||
|
$referral->commission_amount,
|
||||||
|
$referral->affiliate_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as rejected with "order_deleted" reason
|
||||||
|
$wpdb->update(
|
||||||
|
$referrals_table,
|
||||||
|
[
|
||||||
|
'status' => 'rejected',
|
||||||
|
'cancelled_reason' => 'order_deleted',
|
||||||
|
'cancelled_at' => current_time('mysql')
|
||||||
|
],
|
||||||
|
['id' => $referral->id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unschedule any pending approval action
|
||||||
|
if (function_exists('as_unschedule_all_actions')) {
|
||||||
|
as_unschedule_all_actions('woonoow_approve_referral', ['referral_id' => $referral->id], 'woonoow_affiliate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action Scheduler callback for auto-approving a referral after the holding period
|
||||||
|
*/
|
||||||
|
public static function auto_approve_referral($referral_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$referrals_table = $wpdb->prefix . 'woonoow_referrals';
|
||||||
|
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
|
||||||
|
// Find pending referral
|
||||||
|
$referral = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT * FROM $referrals_table WHERE id = %d AND status = 'pending'",
|
||||||
|
$referral_id
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!$referral) return; // Already processed or deleted
|
||||||
|
|
||||||
|
// Double check order status
|
||||||
|
$order = wc_get_order($referral->order_id);
|
||||||
|
if (!$order || in_array($order->get_status(), ['refunded', 'cancelled', 'failed'])) {
|
||||||
|
self::handle_order_cancelled($referral->order_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve referral
|
||||||
|
$wpdb->update(
|
||||||
|
$referrals_table,
|
||||||
|
[
|
||||||
|
'status' => 'approved',
|
||||||
|
'approved_at' => current_time('mysql')
|
||||||
|
],
|
||||||
|
['id' => $referral_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update Affiliate totals
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"UPDATE $affiliates_table SET
|
||||||
|
total_referrals = total_referrals + 1,
|
||||||
|
total_earnings = total_earnings + %f
|
||||||
|
WHERE id = %d",
|
||||||
|
$referral->commission_amount,
|
||||||
|
$referral->affiliate_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
156
includes/Modules/Affiliate/AffiliateManager.php
Normal file
156
includes/Modules/Affiliate/AffiliateManager.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiliate Manager
|
||||||
|
*
|
||||||
|
* Handles database table creation and core management.
|
||||||
|
*
|
||||||
|
* @package WooNooW\Modules\Affiliate
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Modules\Affiliate;
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
class AffiliateManager
|
||||||
|
{
|
||||||
|
private static $affiliates_table = 'woonoow_affiliates';
|
||||||
|
private static $referrals_table = 'woonoow_referrals';
|
||||||
|
private static $payouts_table = 'woonoow_affiliate_payouts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize
|
||||||
|
*/
|
||||||
|
public static function init()
|
||||||
|
{
|
||||||
|
// Initialization logic for the manager if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create database tables
|
||||||
|
*/
|
||||||
|
public static function create_tables()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$charset_collate = $wpdb->get_charset_collate();
|
||||||
|
|
||||||
|
$affiliates_table = $wpdb->prefix . self::$affiliates_table;
|
||||||
|
$referrals_table = $wpdb->prefix . self::$referrals_table;
|
||||||
|
$payouts_table = $wpdb->prefix . self::$payouts_table;
|
||||||
|
|
||||||
|
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
|
||||||
|
|
||||||
|
// Affiliates Table
|
||||||
|
$sql_affiliates = "CREATE TABLE $affiliates_table (
|
||||||
|
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id bigint(20) UNSIGNED NOT NULL,
|
||||||
|
referral_code varchar(50) NOT NULL,
|
||||||
|
coupon_id bigint(20) UNSIGNED DEFAULT NULL,
|
||||||
|
commission_rate decimal(10,2) NOT NULL DEFAULT '0.00',
|
||||||
|
custom_commission_rate decimal(10,2) DEFAULT NULL,
|
||||||
|
status varchar(20) NOT NULL DEFAULT 'pending',
|
||||||
|
total_referrals int(11) NOT NULL DEFAULT 0,
|
||||||
|
total_earnings decimal(19,4) NOT NULL DEFAULT '0.0000',
|
||||||
|
paid_earnings decimal(19,4) NOT NULL DEFAULT '0.0000',
|
||||||
|
payment_bank_name varchar(100) DEFAULT NULL,
|
||||||
|
payment_bank_account varchar(100) DEFAULT NULL,
|
||||||
|
payment_email varchar(100) DEFAULT NULL,
|
||||||
|
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY user_id (user_id),
|
||||||
|
UNIQUE KEY referral_code (referral_code),
|
||||||
|
KEY status (status)
|
||||||
|
) $charset_collate;";
|
||||||
|
|
||||||
|
dbDelta($sql_affiliates);
|
||||||
|
|
||||||
|
// Add custom_commission_rate column if it doesn't exist (for existing installations)
|
||||||
|
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$affiliates_table' AND COLUMN_NAME = 'custom_commission_rate'") == 0) {
|
||||||
|
$wpdb->query("ALTER TABLE $affiliates_table ADD COLUMN custom_commission_rate decimal(10,2) DEFAULT NULL AFTER commission_rate");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add payment details columns if they don't exist (for existing installations)
|
||||||
|
$payment_columns = ['payment_bank_name', 'payment_bank_account', 'payment_email'];
|
||||||
|
foreach ($payment_columns as $col) {
|
||||||
|
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$affiliates_table' AND COLUMN_NAME = '$col'") == 0) {
|
||||||
|
$col_def = $col === 'payment_email' ? "varchar(100) DEFAULT NULL" : "varchar(100) DEFAULT NULL";
|
||||||
|
$wpdb->query("ALTER TABLE $affiliates_table ADD COLUMN $col $col_def AFTER paid_earnings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add flexible payment_details JSON column
|
||||||
|
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$affiliates_table' AND COLUMN_NAME = 'payment_details'") == 0) {
|
||||||
|
$wpdb->query("ALTER TABLE $affiliates_table ADD COLUMN payment_details longtext DEFAULT NULL AFTER payment_email");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add payment_method column
|
||||||
|
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$affiliates_table' AND COLUMN_NAME = 'payment_method'") == 0) {
|
||||||
|
$wpdb->query("ALTER TABLE $affiliates_table ADD COLUMN payment_method varchar(50) DEFAULT NULL AFTER payment_details");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Referrals Table
|
||||||
|
$sql_referrals = "CREATE TABLE $referrals_table (
|
||||||
|
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
affiliate_id bigint(20) UNSIGNED NOT NULL,
|
||||||
|
order_id bigint(20) UNSIGNED NOT NULL,
|
||||||
|
customer_id bigint(20) UNSIGNED DEFAULT NULL,
|
||||||
|
commission_amount decimal(19,4) NOT NULL DEFAULT '0.0000',
|
||||||
|
currency varchar(10) NOT NULL DEFAULT 'USD',
|
||||||
|
status varchar(20) NOT NULL DEFAULT 'pending',
|
||||||
|
cancelled_reason varchar(100) DEFAULT NULL,
|
||||||
|
cancelled_at datetime DEFAULT NULL,
|
||||||
|
utm_source varchar(100) DEFAULT NULL,
|
||||||
|
utm_medium varchar(100) DEFAULT NULL,
|
||||||
|
utm_campaign varchar(255) DEFAULT NULL,
|
||||||
|
utm_content varchar(255) DEFAULT NULL,
|
||||||
|
utm_term varchar(255) DEFAULT NULL,
|
||||||
|
referrer_url varchar(500) DEFAULT NULL,
|
||||||
|
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
approved_at datetime DEFAULT NULL,
|
||||||
|
paid_at datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY affiliate_id (affiliate_id),
|
||||||
|
KEY order_id (order_id),
|
||||||
|
KEY status (status)
|
||||||
|
) $charset_collate;";
|
||||||
|
|
||||||
|
dbDelta($sql_referrals);
|
||||||
|
|
||||||
|
// Add cancelled columns if they don't exist (for existing installations)
|
||||||
|
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$referrals_table' AND COLUMN_NAME = 'cancelled_reason'") == 0) {
|
||||||
|
$wpdb->query("ALTER TABLE $referrals_table ADD COLUMN cancelled_reason varchar(100) DEFAULT NULL AFTER status");
|
||||||
|
}
|
||||||
|
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$referrals_table' AND COLUMN_NAME = 'cancelled_at'") == 0) {
|
||||||
|
$wpdb->query("ALTER TABLE $referrals_table ADD COLUMN cancelled_at datetime DEFAULT NULL AFTER cancelled_reason");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add UTM columns if they don't exist (for existing installations)
|
||||||
|
$utm_columns = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'referrer_url'];
|
||||||
|
foreach ($utm_columns as $col) {
|
||||||
|
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$referrals_table' AND COLUMN_NAME = '$col'") == 0) {
|
||||||
|
$col_def = $col === 'referrer_url' ? "varchar(500) DEFAULT NULL" : ($col === 'utm_campaign' || $col === 'utm_content' || $col === 'utm_term' ? "varchar(255) DEFAULT NULL" : "varchar(100) DEFAULT NULL");
|
||||||
|
$after_col = $col === 'utm_source' ? 'cancelled_at' : ($col === 'utm_medium' ? 'utm_source' : ($col === 'utm_campaign' ? 'utm_medium' : ($col === 'utm_content' ? 'utm_campaign' : ($col === 'utm_term' ? 'utm_content' : 'utm_term'))));
|
||||||
|
$wpdb->query("ALTER TABLE $referrals_table ADD COLUMN $col $col_def AFTER cancelled_at");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payouts Table
|
||||||
|
$sql_payouts = "CREATE TABLE $payouts_table (
|
||||||
|
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
affiliate_id bigint(20) UNSIGNED NOT NULL,
|
||||||
|
amount decimal(19,4) NOT NULL,
|
||||||
|
currency varchar(10) NOT NULL DEFAULT 'USD',
|
||||||
|
method varchar(50) NOT NULL,
|
||||||
|
status varchar(20) NOT NULL DEFAULT 'pending',
|
||||||
|
notes text DEFAULT NULL,
|
||||||
|
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY affiliate_id (affiliate_id),
|
||||||
|
KEY status (status)
|
||||||
|
) $charset_collate;";
|
||||||
|
|
||||||
|
dbDelta($sql_payouts);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
includes/Modules/Affiliate/AffiliateModule.php
Normal file
136
includes/Modules/Affiliate/AffiliateModule.php
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiliate Module Bootstrap
|
||||||
|
*
|
||||||
|
* @package WooNooW\Modules\Affiliate
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Modules\Affiliate;
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
use WooNooW\Core\ModuleRegistry;
|
||||||
|
|
||||||
|
class AffiliateModule
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Initialize the affiliate module
|
||||||
|
*/
|
||||||
|
public static function init()
|
||||||
|
{
|
||||||
|
self::maybe_init_manager();
|
||||||
|
|
||||||
|
// Install tables on module enable
|
||||||
|
add_action('woonoow/module/enabled', [__CLASS__, 'on_module_enabled']);
|
||||||
|
|
||||||
|
// Run migrations on admin init
|
||||||
|
add_action('admin_init', [__CLASS__, 'run_migrations']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize manager if module is enabled
|
||||||
|
*/
|
||||||
|
public static function maybe_init_manager()
|
||||||
|
{
|
||||||
|
if (ModuleRegistry::is_enabled('affiliate')) {
|
||||||
|
self::ensure_tables();
|
||||||
|
AffiliateManager::init();
|
||||||
|
AffiliateTracker::init();
|
||||||
|
AffiliateLifecycle::init();
|
||||||
|
AffiliateSettings::init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure database tables exist and have all required columns
|
||||||
|
*/
|
||||||
|
private static function ensure_tables()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
|
||||||
|
// Check if table exists
|
||||||
|
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
|
||||||
|
AffiliateManager::create_tables();
|
||||||
|
} else {
|
||||||
|
// Run migrations for existing tables
|
||||||
|
self::migrate_existing_tables();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate existing tables to add missing columns
|
||||||
|
*/
|
||||||
|
private static function migrate_existing_tables()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$referrals_table = $wpdb->prefix . 'woonoow_referrals';
|
||||||
|
|
||||||
|
// Add custom_commission_rate column if missing
|
||||||
|
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$table' AND COLUMN_NAME = 'custom_commission_rate'") == 0) {
|
||||||
|
$wpdb->query("ALTER TABLE $table ADD COLUMN custom_commission_rate decimal(10,2) DEFAULT NULL AFTER commission_rate");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add payment detail columns if missing
|
||||||
|
$payment_columns = ['payment_bank_name', 'payment_bank_account', 'payment_email'];
|
||||||
|
foreach ($payment_columns as $col) {
|
||||||
|
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$table' AND COLUMN_NAME = '$col'") == 0) {
|
||||||
|
$wpdb->query("ALTER TABLE $table ADD COLUMN $col varchar(100) DEFAULT NULL AFTER paid_earnings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add flexible payment_details and payment_method columns
|
||||||
|
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$table' AND COLUMN_NAME = 'payment_details'") == 0) {
|
||||||
|
$wpdb->query("ALTER TABLE $table ADD COLUMN payment_details longtext DEFAULT NULL AFTER payment_email");
|
||||||
|
}
|
||||||
|
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$table' AND COLUMN_NAME = 'payment_method'") == 0) {
|
||||||
|
$wpdb->query("ALTER TABLE $table ADD COLUMN payment_method varchar(50) DEFAULT NULL AFTER payment_details");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add referral attribution columns if missing.
|
||||||
|
if ($wpdb->get_var("SHOW TABLES LIKE '$referrals_table'") === $referrals_table) {
|
||||||
|
$referral_columns = [
|
||||||
|
'cancelled_reason' => ['definition' => 'varchar(100) DEFAULT NULL', 'after' => 'status'],
|
||||||
|
'cancelled_at' => ['definition' => 'datetime DEFAULT NULL', 'after' => 'cancelled_reason'],
|
||||||
|
'utm_source' => ['definition' => 'varchar(100) DEFAULT NULL', 'after' => 'cancelled_at'],
|
||||||
|
'utm_medium' => ['definition' => 'varchar(100) DEFAULT NULL', 'after' => 'utm_source'],
|
||||||
|
'utm_campaign' => ['definition' => 'varchar(255) DEFAULT NULL', 'after' => 'utm_medium'],
|
||||||
|
'utm_content' => ['definition' => 'varchar(255) DEFAULT NULL', 'after' => 'utm_campaign'],
|
||||||
|
'utm_term' => ['definition' => 'varchar(255) DEFAULT NULL', 'after' => 'utm_content'],
|
||||||
|
'referrer_url' => ['definition' => 'varchar(500) DEFAULT NULL', 'after' => 'utm_term'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($referral_columns as $column => $schema) {
|
||||||
|
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '$referrals_table' AND COLUMN_NAME = '$column'") == 0) {
|
||||||
|
$wpdb->query("ALTER TABLE $referrals_table ADD COLUMN $column {$schema['definition']} AFTER {$schema['after']}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle module enable
|
||||||
|
*/
|
||||||
|
public static function on_module_enabled($module_id)
|
||||||
|
{
|
||||||
|
if ($module_id === 'affiliate') {
|
||||||
|
AffiliateManager::create_tables();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run migrations (called on admin_init)
|
||||||
|
*/
|
||||||
|
public static function run_migrations()
|
||||||
|
{
|
||||||
|
// Only run once per session (check transient)
|
||||||
|
if (get_transient('woonoow_affiliate_migrated')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::migrate_existing_tables();
|
||||||
|
set_transient('woonoow_affiliate_migrated', true, DAY_IN_SECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
includes/Modules/Affiliate/AffiliateSettings.php
Normal file
71
includes/Modules/Affiliate/AffiliateSettings.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Affiliate Module Settings Schema
|
||||||
|
*
|
||||||
|
* Defines the settings schema for the Affiliate module.
|
||||||
|
*
|
||||||
|
* @package WooNooW\Modules\Affiliate
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Modules\Affiliate;
|
||||||
|
|
||||||
|
class AffiliateSettings {
|
||||||
|
|
||||||
|
public static function init() {
|
||||||
|
// Register settings schema
|
||||||
|
add_filter('woonoow/module_settings_schema', [__CLASS__, 'register_schema']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register affiliate settings schema
|
||||||
|
*/
|
||||||
|
public static function register_schema($schemas) {
|
||||||
|
$schemas['affiliate'] = [
|
||||||
|
'woonoow_affiliate_default_rate' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'label' => __('Default Commission Rate (%)', 'woonoow'),
|
||||||
|
'description' => __('The default commission rate percentage for affiliates.', 'woonoow'),
|
||||||
|
'placeholder' => '10',
|
||||||
|
'default' => 10,
|
||||||
|
'min' => 0,
|
||||||
|
'max' => 100,
|
||||||
|
],
|
||||||
|
'woonoow_affiliate_holding_period' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'label' => __('Holding Period (Days)', 'woonoow'),
|
||||||
|
'description' => __('Number of days before a referral becomes eligible for payout (e.g. to account for refunds). Set to 0 for immediate approval on order completion.', 'woonoow'),
|
||||||
|
'placeholder' => '14',
|
||||||
|
'default' => 14,
|
||||||
|
'min' => 0,
|
||||||
|
],
|
||||||
|
'woonoow_affiliate_payment_methods' => [
|
||||||
|
'type' => 'multiselect',
|
||||||
|
'label' => __('Available Payment Methods', 'woonoow'),
|
||||||
|
'description' => __('Select which payment methods affiliates can use to receive payouts.', 'woonoow'),
|
||||||
|
'options' => [
|
||||||
|
'bank_transfer' => __('Bank Transfer', 'woonoow'),
|
||||||
|
'paypal' => __('PayPal', 'woonoow'),
|
||||||
|
'wise' => __('Wise', 'woonoow'),
|
||||||
|
'skrill' => __('Skrill', 'woonoow'),
|
||||||
|
'payoneer' => __('Payoneer', 'woonoow'),
|
||||||
|
'custom' => __('Custom (Notes)', 'woonoow'),
|
||||||
|
],
|
||||||
|
'default' => ['bank_transfer'],
|
||||||
|
],
|
||||||
|
'woonoow_affiliate_auto_approve' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Auto-Approve Affiliates', 'woonoow'),
|
||||||
|
'description' => __('Automatically approve new affiliate applications.', 'woonoow'),
|
||||||
|
'default' => false,
|
||||||
|
],
|
||||||
|
'woonoow_affiliate_allow_self_referral' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Allow Self-Referrals', 'woonoow'),
|
||||||
|
'description' => __('Allow affiliates to earn commission when their own user account places an order.', 'woonoow'),
|
||||||
|
'default' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $schemas;
|
||||||
|
}
|
||||||
|
}
|
||||||
416
includes/Modules/Affiliate/AffiliateTracker.php
Normal file
416
includes/Modules/Affiliate/AffiliateTracker.php
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiliate Tracker
|
||||||
|
*
|
||||||
|
* Handles referral tracking via cookies and order creation.
|
||||||
|
*
|
||||||
|
* @package WooNooW\Modules\Affiliate
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Modules\Affiliate;
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
class AffiliateTracker
|
||||||
|
{
|
||||||
|
const COOKIE_NAME = 'woonoow_ref';
|
||||||
|
const COOKIE_EXPIRES = 30; // days
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize tracking hooks
|
||||||
|
*/
|
||||||
|
public static function init()
|
||||||
|
{
|
||||||
|
error_log('[AffiliateTracker] init() called');
|
||||||
|
|
||||||
|
// Intercept referral links on init
|
||||||
|
add_action('init', [__CLASS__, 'capture_referral_link']);
|
||||||
|
|
||||||
|
// Hook into WooCommerce order creation (legacy checkout)
|
||||||
|
add_action('woocommerce_checkout_order_processed', [__CLASS__, 'record_referral'], 10, 3);
|
||||||
|
|
||||||
|
// Hook into WooCommerce 8.3+ block checkout
|
||||||
|
add_action('woocommerce_store_api_checkout_order_processed', [__CLASS__, 'record_referral_block'], 10, 1);
|
||||||
|
|
||||||
|
// Hook into new order creation (universal)
|
||||||
|
add_action('woocommerce_new_order', [__CLASS__, 'record_referral_new_order'], 10, 2);
|
||||||
|
|
||||||
|
// Hook for all order status changes
|
||||||
|
add_action('woocommerce_order_status_changed', [__CLASS__, 'handle_order_status_changed'], 10, 4);
|
||||||
|
|
||||||
|
// Hook for REST API order creation
|
||||||
|
add_action('rest_after_insert_shop_order', [__CLASS__, 'handle_rest_order_created'], 10, 3);
|
||||||
|
|
||||||
|
error_log('[AffiliateTracker] All hooks registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle order status change - track completed orders
|
||||||
|
*/
|
||||||
|
public static function handle_order_status_changed($order_id, $from_status, $to_status, $order)
|
||||||
|
{
|
||||||
|
if ($to_status === 'completed') {
|
||||||
|
self::process_order_for_referral($order_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle REST API order creation
|
||||||
|
*/
|
||||||
|
public static function handle_rest_order_created($order, $request, $creating)
|
||||||
|
{
|
||||||
|
if ($creating) {
|
||||||
|
self::process_order_for_referral($order->get_id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record referral from block checkout (Store API)
|
||||||
|
*/
|
||||||
|
public static function record_referral_block($order)
|
||||||
|
{
|
||||||
|
if (is_numeric($order)) {
|
||||||
|
$order = wc_get_order($order);
|
||||||
|
}
|
||||||
|
if ($order) {
|
||||||
|
self::process_order_for_referral($order->get_id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record referral from woocommerce_new_order hook
|
||||||
|
*/
|
||||||
|
public static function record_referral_new_order($order_id, $order)
|
||||||
|
{
|
||||||
|
error_log('[AffiliateTracker] woocommerce_new_order hook fired for order: ' . $order_id);
|
||||||
|
self::process_order_for_referral($order_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture ?ref=CODE from URL and set cookie
|
||||||
|
* Also captures UTM parameters for referral attribution
|
||||||
|
*/
|
||||||
|
public static function capture_referral_link()
|
||||||
|
{
|
||||||
|
error_log('[AffiliateTracker] capture_referral_link called, ref=' . ($_GET['ref'] ?? 'none'));
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'expires' => time() + (self::COOKIE_EXPIRES * DAY_IN_SECONDS),
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => is_ssl(),
|
||||||
|
'samesite' => 'Lax'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Capture referral code
|
||||||
|
if (isset($_GET['ref']) && !empty($_GET['ref'])) {
|
||||||
|
$referral_code = sanitize_text_field($_GET['ref']);
|
||||||
|
$result = setcookie(self::COOKIE_NAME, $referral_code, $options);
|
||||||
|
$_COOKIE[self::COOKIE_NAME] = $referral_code;
|
||||||
|
error_log('[AffiliateTracker] Set woonoow_ref cookie: ' . $referral_code . ', result=' . ($result ? 'true' : 'false'));
|
||||||
|
error_log('[AffiliateTracker] Cookie options: ' . json_encode($options));
|
||||||
|
} else {
|
||||||
|
// Check if cookie already exists from previous visit
|
||||||
|
if (isset($_COOKIE[self::COOKIE_NAME])) {
|
||||||
|
error_log('[AffiliateTracker] No ref param, but existing cookie: ' . $_COOKIE[self::COOKIE_NAME]);
|
||||||
|
} else {
|
||||||
|
error_log('[AffiliateTracker] No ref param and no existing cookie');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture UTM parameters
|
||||||
|
$utm_params = [];
|
||||||
|
$utm_keys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'];
|
||||||
|
|
||||||
|
foreach ($utm_keys as $key) {
|
||||||
|
if (isset($_GET[$key]) && !empty($_GET[$key])) {
|
||||||
|
$utm_params[$key] = sanitize_text_field($_GET[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture referrer URL
|
||||||
|
if (isset($_SERVER['HTTP_REFERER']) && !empty($_SERVER['HTTP_REFERER'])) {
|
||||||
|
$utm_params['referrer_url'] = esc_url_raw($_SERVER['HTTP_REFERER']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store UTM params in cookie if any captured
|
||||||
|
if (!empty($utm_params)) {
|
||||||
|
$utm_json = json_encode($utm_params);
|
||||||
|
setcookie(self::COOKIE_NAME . '_utm', $utm_json, $options);
|
||||||
|
$_COOKIE[self::COOKIE_NAME . '_utm'] = $utm_json;
|
||||||
|
error_log('[AffiliateTracker] Set woonoow_ref_utm cookie');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active affiliate for an order
|
||||||
|
* Checks both coupons and cookies.
|
||||||
|
*/
|
||||||
|
public static function get_affiliate_for_order($order)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
|
||||||
|
// 1. Check if a coupon is used that maps to an affiliate
|
||||||
|
$used_coupons = $order->get_coupon_codes();
|
||||||
|
if (!empty($used_coupons)) {
|
||||||
|
foreach ($used_coupons as $code) {
|
||||||
|
$coupon = new \WC_Coupon($code);
|
||||||
|
if ($coupon->get_id()) {
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT * FROM $table WHERE coupon_id = %d AND status = 'active'",
|
||||||
|
$coupon->get_id()
|
||||||
|
));
|
||||||
|
if ($affiliate) return $affiliate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check referral code stored on the order by SPA checkout
|
||||||
|
$order_referral_code = $order->get_meta('_woonoow_referral_code');
|
||||||
|
if (!empty($order_referral_code)) {
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT * FROM $table WHERE referral_code = %s AND status = 'active'",
|
||||||
|
sanitize_text_field($order_referral_code)
|
||||||
|
));
|
||||||
|
if ($affiliate) return $affiliate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to cookie
|
||||||
|
if (isset($_COOKIE[self::COOKIE_NAME])) {
|
||||||
|
$referral_code = sanitize_text_field($_COOKIE[self::COOKIE_NAME]);
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT * FROM $table WHERE referral_code = %s AND status = 'active'",
|
||||||
|
$referral_code
|
||||||
|
));
|
||||||
|
if ($affiliate) return $affiliate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get referral record for a specific order
|
||||||
|
* Used by admin API to display affiliate info on order details
|
||||||
|
*/
|
||||||
|
public static function get_referral_for_order($order_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$referrals_table = $wpdb->prefix . 'woonoow_referrals';
|
||||||
|
|
||||||
|
return $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT * FROM $referrals_table WHERE order_id = %d",
|
||||||
|
$order_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record referral when order is processed (Checkout)
|
||||||
|
*/
|
||||||
|
public static function record_referral($order_id, $posted_data, $order)
|
||||||
|
{
|
||||||
|
self::process_order_for_referral($order_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record referral when order is created via Admin
|
||||||
|
*/
|
||||||
|
public static function record_referral_admin($post_id)
|
||||||
|
{
|
||||||
|
$order = wc_get_order($post_id);
|
||||||
|
if ($order) {
|
||||||
|
self::process_order_for_referral($post_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process order and create pending referral record
|
||||||
|
*/
|
||||||
|
private static function process_order_for_referral($order_id)
|
||||||
|
{
|
||||||
|
error_log('[AffiliateTracker] process_order_for_referral() called for order: ' . $order_id);
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$order = wc_get_order($order_id);
|
||||||
|
if (!$order) {
|
||||||
|
error_log('[AffiliateTracker] Order not found: ' . $order_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if referral already exists for this order
|
||||||
|
$referrals_table = $wpdb->prefix . 'woonoow_referrals';
|
||||||
|
$exists = $wpdb->get_var($wpdb->prepare("SELECT id FROM $referrals_table WHERE order_id = %d", $order_id));
|
||||||
|
if ($exists) {
|
||||||
|
error_log('[AffiliateTracker] Referral already exists for order: ' . $order_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$affiliate = self::get_affiliate_for_order($order);
|
||||||
|
if (!$affiliate) {
|
||||||
|
error_log('[AffiliateTracker] No affiliate found for order: ' . $order_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log('[AffiliateTracker] Found affiliate: ' . $affiliate->referral_code . ' (rate: ' . ($affiliate->custom_commission_rate ?? 'null') . ')');
|
||||||
|
|
||||||
|
// Prevent self-referrals (unless admin override is enabled)
|
||||||
|
$order_user_id = $order->get_user_id();
|
||||||
|
if ((int)$order_user_id === (int)$affiliate->user_id) {
|
||||||
|
error_log('[AffiliateTracker] Self-referral detected, blocking');
|
||||||
|
$allow_self_referral = get_option('woonoow_affiliate_allow_self_referral', false);
|
||||||
|
if (!$allow_self_referral) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate commission
|
||||||
|
// Priority: Product rate > Affiliate custom rate > Global default
|
||||||
|
$global_default_rate = (float) get_option('woonoow_affiliate_default_rate', 10);
|
||||||
|
$affiliate_custom_rate = !empty($affiliate->custom_commission_rate) ? (float) $affiliate->custom_commission_rate : null;
|
||||||
|
|
||||||
|
// Get order items - try WC method first, fallback to direct DB query
|
||||||
|
$items = self::get_order_items($order_id);
|
||||||
|
error_log('[AffiliateTracker] Order items: ' . count($items));
|
||||||
|
$total_commission = 0;
|
||||||
|
|
||||||
|
if (!empty($items)) {
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$product_id = (int) $item['product_id'];
|
||||||
|
$line_total = (float) $item['line_total'];
|
||||||
|
|
||||||
|
// Check for product-specific affiliate rate
|
||||||
|
$product_rate = get_post_meta($product_id, '_woonoow_affiliate_commission_rate', true);
|
||||||
|
$product_enabled = get_post_meta($product_id, '_woonoow_affiliate_enabled', true) === 'yes';
|
||||||
|
error_log('[AffiliateTracker] Product ' . $product_id . ': enabled=' . ($product_enabled ? 'yes' : 'no') . ', rate=' . $product_rate . ', line_total=' . $line_total);
|
||||||
|
|
||||||
|
// Determine rate for this item (only if product affiliate is enabled)
|
||||||
|
$item_rate = null;
|
||||||
|
if ($product_enabled && $product_rate !== '') {
|
||||||
|
$item_rate = (float) $product_rate;
|
||||||
|
error_log('[AffiliateTracker] Using product rate: ' . $item_rate);
|
||||||
|
} elseif ($affiliate_custom_rate !== null) {
|
||||||
|
$item_rate = $affiliate_custom_rate;
|
||||||
|
error_log('[AffiliateTracker] Using affiliate rate: ' . $item_rate);
|
||||||
|
} else {
|
||||||
|
$item_rate = $global_default_rate;
|
||||||
|
error_log('[AffiliateTracker] Using global rate: ' . $item_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($item_rate > 0 && $line_total > 0) {
|
||||||
|
$total_commission += ($line_total * $item_rate) / 100;
|
||||||
|
error_log('[AffiliateTracker] Added: ' . ($line_total * $item_rate / 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to subtotal if no items (legacy)
|
||||||
|
$subtotal = (float)$order->get_subtotal();
|
||||||
|
$commission_rate = $affiliate_custom_rate ?? $global_default_rate;
|
||||||
|
if ($commission_rate <= 0) return;
|
||||||
|
$total_commission = ($subtotal * $commission_rate) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($total_commission <= 0) {
|
||||||
|
error_log('[AffiliateTracker] Commission is 0, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log('[AffiliateTracker] Total commission: ' . $total_commission);
|
||||||
|
|
||||||
|
// Get UTM parameters from cookie
|
||||||
|
$utm_data = [];
|
||||||
|
if (isset($_COOKIE[self::COOKIE_NAME . '_utm'])) {
|
||||||
|
$utm_data = json_decode($_COOKIE[self::COOKIE_NAME . '_utm'], true) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert pending referral
|
||||||
|
$insert_data = [
|
||||||
|
'affiliate_id' => $affiliate->id,
|
||||||
|
'order_id' => $order_id,
|
||||||
|
'customer_id' => $order->get_user_id() ?: null,
|
||||||
|
'commission_amount' => $total_commission,
|
||||||
|
'currency' => $order->get_currency(),
|
||||||
|
'status' => 'pending'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add UTM data if available
|
||||||
|
if (!empty($utm_data)) {
|
||||||
|
foreach (['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'referrer_url'] as $utm_key) {
|
||||||
|
if (isset($utm_data[$utm_key])) {
|
||||||
|
$insert_data[$utm_key] = $utm_data[$utm_key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$inserted = $wpdb->insert($referrals_table, $insert_data);
|
||||||
|
if (!$inserted) {
|
||||||
|
error_log('[AffiliateTracker] Failed to insert referral for order ' . $order_id . ': ' . $wpdb->last_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$referral_id = $wpdb->insert_id;
|
||||||
|
|
||||||
|
// Fire event for notifications
|
||||||
|
do_action('woonoow/affiliate/referral_received', $referral_id, $affiliate, $order);
|
||||||
|
|
||||||
|
// Trigger email notification to affiliate
|
||||||
|
$user = get_userdata($affiliate->user_id);
|
||||||
|
if ($user) {
|
||||||
|
do_action('woonoow/email/trigger', 'affiliate_new_referral', $user->user_email, [
|
||||||
|
'affiliate_name' => $user->display_name,
|
||||||
|
'commission_amount' => $total_commission,
|
||||||
|
'currency' => $order->get_currency(),
|
||||||
|
'order_number' => $order->get_order_number()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule auto-approval (e.g., 14 days) via Action Scheduler
|
||||||
|
if (function_exists('as_schedule_single_action')) {
|
||||||
|
$approval_days = get_option('woonoow_affiliate_holding_period', 14);
|
||||||
|
$timestamp = time() + ($approval_days * DAY_IN_SECONDS);
|
||||||
|
as_schedule_single_action($timestamp, 'woonoow_approve_referral', ['referral_id' => $referral_id], 'woonoow_affiliate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get order items - direct DB query for HPOS compatibility
|
||||||
|
*/
|
||||||
|
private static function get_order_items($order_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
// First try HPOS order_product_lookup table (more reliable with HPOS)
|
||||||
|
// Force fresh query by using wpdb::query with direct table
|
||||||
|
$order_id_int = (int) $order_id;
|
||||||
|
$items = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT product_id, product_qty, product_net_revenue as line_total
|
||||||
|
FROM {$wpdb->prefix}wc_order_product_lookup
|
||||||
|
WHERE order_id = %d",
|
||||||
|
$order_id_int
|
||||||
|
), ARRAY_A);
|
||||||
|
|
||||||
|
error_log('[AffiliateTracker] get_order_items() HPOS query returned: ' . count($items) . ' items for order ' . $order_id_int);
|
||||||
|
|
||||||
|
// If no results from HPOS table, try legacy order_items table
|
||||||
|
if (empty($items)) {
|
||||||
|
$items = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT oim_product.meta_value as product_id, oim_total.meta_value as line_total
|
||||||
|
FROM {$wpdb->prefix}woocommerce_order_items oi
|
||||||
|
LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta oim_product ON oi.order_item_id = oim_product.order_item_id AND oim_product.meta_key = '_product_id'
|
||||||
|
LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta oim_total ON oi.order_item_id = oim_total.order_item_id AND oim_total.meta_key = '_line_total'
|
||||||
|
WHERE oi.order_id = %d AND oi.order_item_type = 'line_item'",
|
||||||
|
$order_id
|
||||||
|
), ARRAY_A);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to standard format
|
||||||
|
$result = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$product_id = isset($item['product_id']) ? (int) $item['product_id'] : 0;
|
||||||
|
if ($product_id > 0) {
|
||||||
|
$result[] = [
|
||||||
|
'product_id' => $product_id,
|
||||||
|
'line_total' => (float) ($item['line_total'] ?? 0)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
227
snippets/rajaongkir-x-woonoow.php
Normal file
227
snippets/rajaongkir-x-woonoow.php
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rajaongkir Bridge for WooNooW SPA Checkout
|
||||||
|
*
|
||||||
|
* Enables searchable destination field in WooNooW checkout
|
||||||
|
* and bridges data to Rajaongkir plugin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1. REST API Endpoint: Search destinations via Rajaongkir API
|
||||||
|
// ============================================================
|
||||||
|
add_action('rest_api_init', function () {
|
||||||
|
register_rest_route('woonoow/v1', '/rajaongkir/destinations', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => 'woonoow_rajaongkir_search_destinations',
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
'args' => [
|
||||||
|
'search' => [
|
||||||
|
'required' => false,
|
||||||
|
'type' => 'string',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function woonoow_rajaongkir_search_destinations($request)
|
||||||
|
{
|
||||||
|
$search = sanitize_text_field($request->get_param('search') ?? '');
|
||||||
|
|
||||||
|
if (strlen($search) < 3) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Rajaongkir plugin is active
|
||||||
|
if (!class_exists('Cekongkir_API')) {
|
||||||
|
return new WP_Error('rajaongkir_missing', 'Rajaongkir plugin not active', ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Rajaongkir's API class for the search
|
||||||
|
// NOTE: Method is search_destination_api() not search_destination()
|
||||||
|
$api = Cekongkir_API::get_instance();
|
||||||
|
$results = $api->search_destination_api($search);
|
||||||
|
|
||||||
|
if (is_wp_error($results)) {
|
||||||
|
error_log('Rajaongkir search error: ' . $results->get_error_message());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($results)) {
|
||||||
|
error_log('Rajaongkir search returned non-array: ' . print_r($results, true));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format for WooNooW's SearchableSelect component
|
||||||
|
$formatted = [];
|
||||||
|
foreach ($results as $r) {
|
||||||
|
$formatted[] = [
|
||||||
|
'value' => (string) ($r['id'] ?? ''),
|
||||||
|
'label' => $r['label'] ?? $r['text'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit results
|
||||||
|
return array_slice($formatted, 0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 2. Add destination field and hide redundant fields for Indonesia
|
||||||
|
// The destination_id from Rajaongkir contains province/city/subdistrict
|
||||||
|
// ============================================================
|
||||||
|
add_filter('woocommerce_checkout_fields', function ($fields) {
|
||||||
|
// Check if Rajaongkir is active
|
||||||
|
if (!class_exists('Cekongkir_API')) {
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if store sells to Indonesia (check allowed countries)
|
||||||
|
$allowed = WC()->countries->get_allowed_countries();
|
||||||
|
if (!isset($allowed['ID'])) {
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Indonesia is the ONLY allowed country
|
||||||
|
$indonesia_only = count($allowed) === 1 && isset($allowed['ID']);
|
||||||
|
|
||||||
|
// If Indonesia only, hide country/state/city fields (Rajaongkir destination has all this)
|
||||||
|
if ($indonesia_only) {
|
||||||
|
// Hide billing fields
|
||||||
|
if (isset($fields['billing']['billing_last_name'])) {
|
||||||
|
$fields['billing']['billing_last_name']['type'] = 'hidden';
|
||||||
|
$fields['billing']['billing_last_name']['default'] = 'ID';
|
||||||
|
$fields['billing']['billing_last_name']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['billing']['billing_country'])) {
|
||||||
|
$fields['billing']['billing_country']['type'] = 'hidden';
|
||||||
|
$fields['billing']['billing_country']['default'] = 'ID';
|
||||||
|
$fields['billing']['billing_country']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['billing']['billing_state'])) {
|
||||||
|
$fields['billing']['billing_state']['type'] = 'hidden';
|
||||||
|
$fields['billing']['billing_state']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['billing']['billing_city'])) {
|
||||||
|
$fields['billing']['billing_city']['type'] = 'hidden';
|
||||||
|
$fields['billing']['billing_city']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['billing']['billing_postcode'])) {
|
||||||
|
$fields['billing']['billing_postcode']['type'] = 'hidden';
|
||||||
|
$fields['billing']['billing_postcode']['required'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide shipping fields
|
||||||
|
if (isset($fields['shipping']['shipping_last_name'])) {
|
||||||
|
$fields['shipping']['shipping_last_name']['type'] = 'hidden';
|
||||||
|
$fields['shipping']['shipping_last_name']['default'] = 'ID';
|
||||||
|
$fields['shipping']['shipping_last_name']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['shipping']['shipping_country'])) {
|
||||||
|
$fields['shipping']['shipping_country']['type'] = 'hidden';
|
||||||
|
$fields['shipping']['shipping_country']['default'] = 'ID';
|
||||||
|
$fields['shipping']['shipping_country']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['shipping']['shipping_state'])) {
|
||||||
|
$fields['shipping']['shipping_state']['type'] = 'hidden';
|
||||||
|
$fields['shipping']['shipping_state']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['shipping']['shipping_city'])) {
|
||||||
|
$fields['shipping']['shipping_city']['type'] = 'hidden';
|
||||||
|
$fields['shipping']['shipping_city']['required'] = false;
|
||||||
|
}
|
||||||
|
if (isset($fields['shipping']['shipping_postcode'])) {
|
||||||
|
$fields['shipping']['shipping_postcode']['type'] = 'hidden';
|
||||||
|
$fields['shipping']['shipping_postcode']['required'] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cart needs shipping
|
||||||
|
$needs_shipping = true;
|
||||||
|
if (function_exists('WC') && WC()->cart) {
|
||||||
|
$needs_shipping = WC()->cart->needs_shipping();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destination field definition (reused for billing and shipping)
|
||||||
|
$destination_field = [
|
||||||
|
'type' => $needs_shipping ? 'searchable_select' : 'hidden',
|
||||||
|
'label' => __('Destination (Province, City, Subdistrict)', 'woonoow'),
|
||||||
|
'required' => $indonesia_only && $needs_shipping, // Required if Indonesia only and needs shipping
|
||||||
|
'priority' => 85,
|
||||||
|
'class' => ['form-row-wide'],
|
||||||
|
'placeholder' => __('Search destination...', 'woonoow'),
|
||||||
|
// WooNooW-specific: API endpoint configuration
|
||||||
|
// NOTE: Path is relative to /wp-json/woonoow/v1
|
||||||
|
'search_endpoint' => '/rajaongkir/destinations',
|
||||||
|
'search_param' => 'search',
|
||||||
|
'min_chars' => 3,
|
||||||
|
// Custom attribute to indicate this is for Indonesia only
|
||||||
|
'custom_attributes' => [
|
||||||
|
'data-show-for-country' => 'ID',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add to billing (used when "Ship to different address" is NOT checked)
|
||||||
|
$fields['billing']['billing_destination_id'] = $destination_field;
|
||||||
|
|
||||||
|
// Add to shipping (used when "Ship to different address" IS checked)
|
||||||
|
$fields['shipping']['shipping_destination_id'] = $destination_field;
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}, 20); // Priority 20 to run after Rajaongkir's own filter
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 3. Bridge WooNooW shipping data to Rajaongkir session
|
||||||
|
// Sets destination_id in WC session for Rajaongkir to use
|
||||||
|
// ============================================================
|
||||||
|
add_action('woonoow/shipping/before_calculate', function ($shipping, $items) {
|
||||||
|
// Check if Rajaongkir is active
|
||||||
|
if (!class_exists('Cekongkir_API')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Indonesia-only stores, always set country to ID
|
||||||
|
$allowed = WC()->countries->get_allowed_countries();
|
||||||
|
$indonesia_only = count($allowed) === 1 && isset($allowed['ID']);
|
||||||
|
|
||||||
|
if ($indonesia_only) {
|
||||||
|
WC()->customer->set_shipping_country('ID');
|
||||||
|
WC()->customer->set_billing_country('ID');
|
||||||
|
} elseif (!empty($shipping['country'])) {
|
||||||
|
WC()->customer->set_shipping_country($shipping['country']);
|
||||||
|
WC()->customer->set_billing_country($shipping['country']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process Rajaongkir for Indonesia
|
||||||
|
$country = $shipping['country'] ?? WC()->customer->get_shipping_country();
|
||||||
|
if ($country !== 'ID') {
|
||||||
|
// Clear destination for non-Indonesia
|
||||||
|
WC()->session->__unset('selected_destination_id');
|
||||||
|
WC()->session->__unset('selected_destination_label');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get destination_id from shipping data (various possible keys)
|
||||||
|
$destination_id = $shipping['destination_id']
|
||||||
|
?? $shipping['shipping_destination_id']
|
||||||
|
?? $shipping['billing_destination_id']
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
if (empty($destination_id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set session for Rajaongkir
|
||||||
|
WC()->session->set('selected_destination_id', intval($destination_id));
|
||||||
|
|
||||||
|
// Also set label if provided
|
||||||
|
$label = $shipping['destination_label']
|
||||||
|
?? $shipping['shipping_destination_id_label']
|
||||||
|
?? $shipping['billing_destination_id_label']
|
||||||
|
?? '';
|
||||||
|
if ($label) {
|
||||||
|
WC()->session->set('selected_destination_label', sanitize_text_field($label));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear shipping cache to force recalculation
|
||||||
|
WC()->session->set('shipping_for_package_0', false);
|
||||||
|
}, 10, 2);
|
||||||
@@ -45,6 +45,7 @@ add_action('plugins_loaded', function () {
|
|||||||
WooNooW\Modules\Licensing\LicensingModule::init();
|
WooNooW\Modules\Licensing\LicensingModule::init();
|
||||||
WooNooW\Modules\Subscription\SubscriptionModule::init();
|
WooNooW\Modules\Subscription\SubscriptionModule::init();
|
||||||
WooNooW\Modules\Software\SoftwareModule::init();
|
WooNooW\Modules\Software\SoftwareModule::init();
|
||||||
|
WooNooW\Modules\Affiliate\AffiliateModule::init();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activation/Deactivation hooks
|
// Activation/Deactivation hooks
|
||||||
|
|||||||
Reference in New Issue
Block a user