chore: batch supporting UI, settings schema, templates, and docs updates
This commit is contained in:
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 || []);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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':
|
||||||
@@ -258,7 +261,37 @@ class ModuleSettingsController extends WP_REST_Controller {
|
|||||||
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':
|
||||||
@@ -278,6 +311,21 @@ class ModuleSettingsController extends WP_REST_Controller {
|
|||||||
}
|
}
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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]';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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);
|
||||||
Reference in New Issue
Block a user