Affiliate module: fix referral approval lifecycle and settings reads
This commit is contained in:
@@ -135,7 +135,7 @@ export default function AffiliatesReferrals() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
const totalCommissions = filteredReferrals.reduce((sum, r) => sum + parseFloat(r.commission_amount || 0), 0);
|
const totalCommissions = filteredReferrals.reduce((sum, r) => sum + parseFloat(r.commission_amount || '0'), 0);
|
||||||
const pendingCount = filteredReferrals.filter(r => r.status === 'pending').length;
|
const pendingCount = filteredReferrals.filter(r => r.status === 'pending').length;
|
||||||
const approvedCount = filteredReferrals.filter(r => r.status === 'approved').length;
|
const approvedCount = filteredReferrals.filter(r => r.status === 'approved').length;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Copy, CheckCircle, Activity, DollarSign, ChevronRight, Clock, Info, Wallet, CreditCard } from 'lucide-react';
|
import { Copy, CheckCircle, Activity, DollarSign, ChevronRight, Clock, Info, Wallet, CreditCard } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { formatPrice, getCurrencySettings } from '@/lib/currency';
|
import { formatPrice, getCurrencySettings } from '@/lib/currency';
|
||||||
|
|
||||||
// Affiliate types
|
// Affiliate types
|
||||||
@@ -14,6 +15,16 @@ interface AffiliateProfile {
|
|||||||
commission_rate: number;
|
commission_rate: number;
|
||||||
custom_commission_rate: number | null;
|
custom_commission_rate: number | null;
|
||||||
global_commission_rate: number;
|
global_commission_rate: number;
|
||||||
|
total_earnings: number;
|
||||||
|
pending_earnings: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedReferrals {
|
||||||
|
referrals: AffiliateReferral[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total_pages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AffiliateReferral {
|
interface AffiliateReferral {
|
||||||
@@ -130,13 +141,14 @@ export default function AffiliateDashboard() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch referrals
|
// Fetch referrals
|
||||||
const { data: referrals, isLoading: isLoadingReferrals } = useQuery<AffiliateReferral[]>({
|
const { data: referralsResponse, isLoading: isLoadingReferrals } = useQuery<PaginatedReferrals>({
|
||||||
queryKey: ['affiliate-referrals'],
|
queryKey: ['affiliate-referrals'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return await api.get<AffiliateReferral[]>('/account/affiliate/referrals');
|
return await api.get<PaginatedReferrals>('/account/affiliate/referrals?limit=5');
|
||||||
},
|
},
|
||||||
enabled: !!profile && profile.status === 'active'
|
enabled: !!profile && profile.status === 'active'
|
||||||
});
|
});
|
||||||
|
const referrals = referralsResponse?.referrals || [];
|
||||||
|
|
||||||
// Fetch payout history
|
// Fetch payout history
|
||||||
const { data: payouts = [], isLoading: isLoadingPayouts } = useQuery<AffiliatePayout[]>({
|
const { data: payouts = [], isLoading: isLoadingPayouts } = useQuery<AffiliatePayout[]>({
|
||||||
@@ -248,11 +260,8 @@ export default function AffiliateDashboard() {
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const approvedReferrals = (referrals || []).filter((r: any) => r.status === 'approved');
|
const totalEarnings = profile.total_earnings || 0;
|
||||||
const pendingReferrals = (referrals || []).filter((r: any) => r.status === 'pending');
|
const pendingEarnings = profile.pending_earnings || 0;
|
||||||
|
|
||||||
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 = () => {
|
const handleSavePayment = () => {
|
||||||
if (!selectedMethod) {
|
if (!selectedMethod) {
|
||||||
@@ -443,7 +452,17 @@ export default function AffiliateDashboard() {
|
|||||||
|
|
||||||
{/* Referrals */}
|
{/* Referrals */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Recent Referrals</h3>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">Recent Referrals</h3>
|
||||||
|
{referralsResponse && referralsResponse.total > 5 && (
|
||||||
|
<Link
|
||||||
|
to="/my-account/affiliate/referrals"
|
||||||
|
className="text-sm font-medium text-primary hover:opacity-80 flex items-center transition-opacity"
|
||||||
|
>
|
||||||
|
View All <ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoadingReferrals ? (
|
{isLoadingReferrals ? (
|
||||||
<div className="text-center py-8 text-gray-500">Loading referrals...</div>
|
<div className="text-center py-8 text-gray-500">Loading referrals...</div>
|
||||||
@@ -463,13 +482,12 @@ export default function AffiliateDashboard() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-gray-900">Order #{ref.order_id}</span>
|
<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 ${
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ref.status === 'approved'
|
||||||
ref.status === 'approved'
|
|
||||||
? 'bg-green-100 text-green-800'
|
? 'bg-green-100 text-green-800'
|
||||||
: ref.status === 'pending'
|
: ref.status === 'pending'
|
||||||
? 'bg-yellow-100 text-yellow-800'
|
? 'bg-yellow-100 text-yellow-800'
|
||||||
: 'bg-red-100 text-red-800'
|
: 'bg-red-100 text-red-800'
|
||||||
}`}>
|
}`}>
|
||||||
{ref.status}
|
{ref.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -527,11 +545,10 @@ export default function AffiliateDashboard() {
|
|||||||
<div key={payout.id} className="bg-white p-4 rounded-lg border">
|
<div key={payout.id} className="bg-white p-4 rounded-lg border">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`p-2 rounded-lg ${
|
<div className={`p-2 rounded-lg ${payout.status === 'completed'
|
||||||
payout.status === 'completed'
|
|
||||||
? 'bg-green-100 text-green-600'
|
? 'bg-green-100 text-green-600'
|
||||||
: 'bg-yellow-100 text-yellow-600'
|
: 'bg-yellow-100 text-yellow-600'
|
||||||
}`}>
|
}`}>
|
||||||
<Wallet className="w-5 h-5" />
|
<Wallet className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -544,11 +561,10 @@ export default function AffiliateDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${payout.status === 'completed'
|
||||||
payout.status === 'completed'
|
|
||||||
? 'bg-green-100 text-green-800'
|
? 'bg-green-100 text-green-800'
|
||||||
: 'bg-yellow-100 text-yellow-800'
|
: 'bg-yellow-100 text-yellow-800'
|
||||||
}`}>
|
}`}>
|
||||||
{payout.status}
|
{payout.status}
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xs text-gray-400 mt-1">
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
@@ -605,11 +621,10 @@ export default function AffiliateDashboard() {
|
|||||||
setSelectedMethod(method);
|
setSelectedMethod(method);
|
||||||
setPaymentFormData({});
|
setPaymentFormData({});
|
||||||
}}
|
}}
|
||||||
className={`px-4 py-2 rounded-lg border text-sm transition-colors ${
|
className={`px-4 py-2 rounded-lg border text-sm transition-colors ${selectedMethod === method
|
||||||
selectedMethod === method
|
|
||||||
? 'bg-purple-100 border-purple-500 text-purple-700'
|
? 'bg-purple-100 border-purple-500 text-purple-700'
|
||||||
: 'bg-white border-gray-200 hover:bg-gray-50'
|
: 'bg-white border-gray-200 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{PAYMENT_METHOD_LABELS[method] || method}
|
{PAYMENT_METHOD_LABELS[method] || method}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
206
customer-spa/src/pages/Account/AffiliateReferrals.tsx
Normal file
206
customer-spa/src/pages/Account/AffiliateReferrals.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { CheckCircle, Clock, Info, ArrowLeft, ChevronLeft, ChevronRight, Search, User } from 'lucide-react';
|
||||||
|
import { getCurrencySettings } from '@/lib/currency';
|
||||||
|
|
||||||
|
interface AffiliateReferral {
|
||||||
|
id: number;
|
||||||
|
status: 'pending' | 'approved' | 'rejected';
|
||||||
|
commission_amount: string;
|
||||||
|
created_at: string;
|
||||||
|
order_id: number;
|
||||||
|
currency: string;
|
||||||
|
approved_at?: string;
|
||||||
|
cancelled_reason?: string;
|
||||||
|
customer_name?: string;
|
||||||
|
customer_email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedReferrals {
|
||||||
|
referrals: AffiliateReferral[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(amount: number | string, currency?: string): string {
|
||||||
|
const amountNum = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||||
|
const settings = getCurrencySettings();
|
||||||
|
const decimals = currency === 'IDR' ? 0 : settings.decimals;
|
||||||
|
const rounded = amountNum.toFixed(decimals);
|
||||||
|
const [integerPart, decimalPart] = rounded.split('.');
|
||||||
|
const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, settings.thousandSeparator);
|
||||||
|
|
||||||
|
const formattedNum = (decimals > 0 && decimalPart)
|
||||||
|
? `${formattedInteger}${settings.decimalSeparator}${decimalPart}`
|
||||||
|
: formattedInteger;
|
||||||
|
|
||||||
|
return `${settings.symbol}${formattedNum}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AffiliateReferrals() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [orderIdSearch, setOrderIdSearch] = useState('');
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
// Simple debounce for search
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedSearch(orderIdSearch);
|
||||||
|
setPage(1); // Reset page on new search
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [orderIdSearch]);
|
||||||
|
|
||||||
|
const { data: response, isLoading } = useQuery<PaginatedReferrals>({
|
||||||
|
queryKey: ['affiliate-referrals-full', page, limit, debouncedSearch],
|
||||||
|
queryFn: async () => {
|
||||||
|
const searchParam = debouncedSearch ? `&order_id=${encodeURIComponent(debouncedSearch)}` : '';
|
||||||
|
return await api.get<PaginatedReferrals>(`/account/affiliate/referrals?limit=${limit}&page=${page}${searchParam}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const referrals = response?.referrals || [];
|
||||||
|
const totalPages = response?.total_pages || 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/my-account/affiliate">
|
||||||
|
<Button variant="outline" size="icon" className="h-8 w-8 rounded-full flex-shrink-0">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">All Referrals</h2>
|
||||||
|
<p className="text-gray-500 mt-1 text-sm">
|
||||||
|
{response ? `Showing ${referrals.length} of ${response.total} referrals` : 'Loading referrals...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:max-w-xs">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by Order ID..."
|
||||||
|
className="pl-9 w-full"
|
||||||
|
value={orderIdSearch}
|
||||||
|
onChange={(e) => setOrderIdSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">Loading referrals...</div>
|
||||||
|
) : referrals.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-500 border rounded-lg bg-gray-50">
|
||||||
|
No referrals found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{referrals.map((ref: AffiliateReferral) => {
|
||||||
|
const createdDate = new Date(ref.created_at);
|
||||||
|
const approvedDate = ref.approved_at ? new Date(ref.approved_at) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={ref.id} className="bg-white p-4 rounded-lg border hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-900">Order #{ref.order_id}</span>
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
ref.status === 'approved'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: ref.status === 'pending'
|
||||||
|
? 'bg-yellow-100 text-yellow-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{ref.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ref.customer_name && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-600 mt-1">
|
||||||
|
<User className="w-3.5 h-3.5" />
|
||||||
|
<span>{ref.customer_name}</span>
|
||||||
|
{ref.customer_email && <span className="text-gray-400">({ref.customer_email})</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500 mt-2">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{createdDate.toLocaleDateString('id-ID', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{ref.status === 'approved' && approvedDate && (
|
||||||
|
<span className="flex items-center gap-1 text-green-600">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Approved {approvedDate.toLocaleDateString('id-ID', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-lg font-bold text-gray-900">
|
||||||
|
{formatAmount(ref.commission_amount, ref.currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ref.status === 'rejected' && ref.cancelled_reason && (
|
||||||
|
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Info className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-xs text-red-800 dark:text-red-200">
|
||||||
|
Reason: {ref.cancelled_reason}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-4">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" /> Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
Next <ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import LicenseConnect from './LicenseConnect';
|
|||||||
import Subscriptions from './Subscriptions';
|
import Subscriptions from './Subscriptions';
|
||||||
import SubscriptionDetail from './SubscriptionDetail';
|
import SubscriptionDetail from './SubscriptionDetail';
|
||||||
import AffiliateDashboard from './AffiliateDashboard';
|
import AffiliateDashboard from './AffiliateDashboard';
|
||||||
|
import AffiliateReferrals from './AffiliateReferrals';
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
@@ -44,6 +45,7 @@ export default function Account() {
|
|||||||
<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="affiliate" element={<AffiliateDashboard />} />
|
||||||
|
<Route path="affiliate/referrals" element={<AffiliateReferrals />} />
|
||||||
<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>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace WooNooW\Api\Controllers;
|
|||||||
use WP_REST_Request;
|
use WP_REST_Request;
|
||||||
use WP_REST_Response;
|
use WP_REST_Response;
|
||||||
use WP_REST_Server;
|
use WP_REST_Server;
|
||||||
|
use WooNooW\Modules\Affiliate\AffiliateSettings;
|
||||||
|
|
||||||
class AffiliateCustomerController
|
class AffiliateCustomerController
|
||||||
{
|
{
|
||||||
@@ -74,8 +75,20 @@ class AffiliateCustomerController
|
|||||||
? (float) $affiliate['custom_commission_rate']
|
? (float) $affiliate['custom_commission_rate']
|
||||||
: $global_rate;
|
: $global_rate;
|
||||||
|
|
||||||
|
$referrals_table = $wpdb->prefix . 'woonoow_referrals';
|
||||||
|
$earnings = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT
|
||||||
|
SUM(CASE WHEN status = 'approved' THEN commission_amount ELSE 0 END) as total_earnings,
|
||||||
|
SUM(CASE WHEN status = 'pending' THEN commission_amount ELSE 0 END) as pending_earnings
|
||||||
|
FROM $referrals_table
|
||||||
|
WHERE affiliate_id = %d",
|
||||||
|
$affiliate['id']
|
||||||
|
));
|
||||||
|
|
||||||
$affiliate['global_commission_rate'] = $global_rate;
|
$affiliate['global_commission_rate'] = $global_rate;
|
||||||
$affiliate['commission_rate'] = $effective_rate;
|
$affiliate['commission_rate'] = $effective_rate;
|
||||||
|
$affiliate['total_earnings'] = $earnings->total_earnings ?: 0;
|
||||||
|
$affiliate['pending_earnings'] = $earnings->pending_earnings ?: 0;
|
||||||
|
|
||||||
return rest_ensure_response($affiliate);
|
return rest_ensure_response($affiliate);
|
||||||
}
|
}
|
||||||
@@ -136,16 +149,51 @@ class AffiliateCustomerController
|
|||||||
return rest_ensure_response([]);
|
return rest_ensure_response([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$referrals = $wpdb->get_results($wpdb->prepare(
|
$limit = (int) $request->get_param('limit');
|
||||||
"SELECT r.*,
|
$page = max(1, (int) $request->get_param('page'));
|
||||||
|
$order_id = $request->get_param('order_id') ? (int) $request->get_param('order_id') : null;
|
||||||
|
|
||||||
|
$where = $wpdb->prepare("WHERE r.affiliate_id = %d", $affiliate->id);
|
||||||
|
if ($order_id) {
|
||||||
|
$where .= $wpdb->prepare(" AND r.order_id = %d", $order_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT r.*,
|
||||||
COALESCE(NULLIF(r.cancelled_reason, ''), NULL) as cancelled_reason,
|
COALESCE(NULLIF(r.cancelled_reason, ''), NULL) as cancelled_reason,
|
||||||
COALESCE(r.approved_at, r.created_at) as approved_at
|
COALESCE(r.approved_at, r.created_at) as approved_at
|
||||||
FROM $referrals_table r
|
FROM $referrals_table r
|
||||||
WHERE r.affiliate_id = %d
|
$where
|
||||||
ORDER BY r.created_at DESC",
|
ORDER BY r.created_at DESC";
|
||||||
$affiliate->id
|
|
||||||
), ARRAY_A);
|
if ($limit > 0) {
|
||||||
return rest_ensure_response($referrals);
|
$offset = ($page - 1) * $limit;
|
||||||
|
$sql .= $wpdb->prepare(" LIMIT %d OFFSET %d", $limit, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
$referrals = $wpdb->get_results($sql, ARRAY_A);
|
||||||
|
|
||||||
|
$total = $wpdb->get_var("SELECT COUNT(r.id) FROM $referrals_table r $where");
|
||||||
|
|
||||||
|
// Attach customer data if enabled
|
||||||
|
if (!empty($referrals) && AffiliateSettings::get_setting('woonoow_affiliate_share_customer_data', false)) {
|
||||||
|
foreach ($referrals as &$ref) {
|
||||||
|
if (!empty($ref['order_id'])) {
|
||||||
|
$order = wc_get_order($ref['order_id']);
|
||||||
|
if ($order) {
|
||||||
|
$ref['customer_name'] = trim($order->get_billing_first_name() . ' ' . $order->get_billing_last_name());
|
||||||
|
$ref['customer_email'] = $order->get_billing_email();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest_ensure_response([
|
||||||
|
'referrals' => $referrals,
|
||||||
|
'total' => (int) $total,
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit > 0 ? $limit : (int) $total,
|
||||||
|
'total_pages' => $limit > 0 ? ceil($total / $limit) : 1
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get_payouts(WP_REST_Request $request)
|
public function get_payouts(WP_REST_Request $request)
|
||||||
|
|||||||
@@ -107,11 +107,13 @@ class AffiliateLifecycle
|
|||||||
if (!$referral) return;
|
if (!$referral) return;
|
||||||
|
|
||||||
// Check if holding period is 0 (immediate approval on completion)
|
// Check if holding period is 0 (immediate approval on completion)
|
||||||
$holding_period = (int) get_option('woonoow_affiliate_holding_period', 14);
|
$holding_period = (int) AffiliateSettings::get_setting('woonoow_affiliate_holding_period', 14);
|
||||||
|
$handled_now = false;
|
||||||
|
|
||||||
if ($holding_period === 0) {
|
if ($holding_period === 0) {
|
||||||
// Immediate approval
|
// Immediate approval
|
||||||
self::auto_approve_referral($referral->id);
|
self::auto_approve_referral($referral->id);
|
||||||
|
$handled_now = true;
|
||||||
} else {
|
} else {
|
||||||
// If order was completed BEFORE the scheduled action time, approve now
|
// If order was completed BEFORE the scheduled action time, approve now
|
||||||
// Otherwise, the scheduled action will approve later
|
// Otherwise, the scheduled action will approve later
|
||||||
@@ -120,12 +122,13 @@ class AffiliateLifecycle
|
|||||||
|
|
||||||
if (time() >= $approval_time) {
|
if (time() >= $approval_time) {
|
||||||
self::auto_approve_referral($referral->id);
|
self::auto_approve_referral($referral->id);
|
||||||
|
$handled_now = true;
|
||||||
}
|
}
|
||||||
// If not, the scheduled Action Scheduler job will handle it
|
// If not, the scheduled Action Scheduler job will handle it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel the scheduled auto-approval since we're handling it now
|
// Only unschedule if we actually approved now.
|
||||||
if (function_exists('as_unschedule_all_actions')) {
|
if ($handled_now && function_exists('as_unschedule_all_actions')) {
|
||||||
as_unschedule_all_actions('woonoow_approve_referral', ['referral_id' => $referral->id], 'woonoow_affiliate');
|
as_unschedule_all_actions('woonoow_approve_referral', ['referral_id' => $referral->id], 'woonoow_affiliate');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,13 +225,23 @@ class AffiliateLifecycle
|
|||||||
|
|
||||||
if (!$referral) return; // Already processed or deleted
|
if (!$referral) return; // Already processed or deleted
|
||||||
|
|
||||||
// Double check order status
|
// Double check order status.
|
||||||
|
// Referrals must never be approved before the order is completed.
|
||||||
$order = wc_get_order($referral->order_id);
|
$order = wc_get_order($referral->order_id);
|
||||||
if (!$order || in_array($order->get_status(), ['refunded', 'cancelled', 'failed'])) {
|
if (!$order) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order_status = $order->get_status();
|
||||||
|
if (in_array($order_status, ['refunded', 'cancelled', 'failed'])) {
|
||||||
self::handle_order_cancelled($referral->order_id);
|
self::handle_order_cancelled($referral->order_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($order_status !== 'completed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Approve referral
|
// Approve referral
|
||||||
$wpdb->update(
|
$wpdb->update(
|
||||||
$referrals_table,
|
$referrals_table,
|
||||||
|
|||||||
@@ -64,8 +64,32 @@ class AffiliateSettings {
|
|||||||
'description' => __('Allow affiliates to earn commission when their own user account places an order.', 'woonoow'),
|
'description' => __('Allow affiliates to earn commission when their own user account places an order.', 'woonoow'),
|
||||||
'default' => false,
|
'default' => false,
|
||||||
],
|
],
|
||||||
|
'woonoow_affiliate_share_customer_data' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Share Customer Data with Affiliates', 'woonoow'),
|
||||||
|
'description' => __('Allow affiliates to see the name and email of the customers they refer.', 'woonoow'),
|
||||||
|
'default' => false,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
return $schemas;
|
return $schemas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read Affiliate module setting from module settings storage with legacy fallback.
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @param mixed $default
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public static function get_setting($key, $default = null)
|
||||||
|
{
|
||||||
|
$module_settings = get_option('woonoow_module_affiliate_settings', []);
|
||||||
|
if (is_array($module_settings) && array_key_exists($key, $module_settings)) {
|
||||||
|
return $module_settings[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback for older installs that may store direct option keys.
|
||||||
|
return get_option($key, $default);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ class AffiliateTracker
|
|||||||
|
|
||||||
// Schedule auto-approval (e.g., 14 days) via Action Scheduler
|
// Schedule auto-approval (e.g., 14 days) via Action Scheduler
|
||||||
if (function_exists('as_schedule_single_action')) {
|
if (function_exists('as_schedule_single_action')) {
|
||||||
$approval_days = get_option('woonoow_affiliate_holding_period', 14);
|
$approval_days = (int) AffiliateSettings::get_setting('woonoow_affiliate_holding_period', 14);
|
||||||
$timestamp = time() + ($approval_days * DAY_IN_SECONDS);
|
$timestamp = time() + ($approval_days * DAY_IN_SECONDS);
|
||||||
as_schedule_single_action($timestamp, 'woonoow_approve_referral', ['referral_id' => $referral_id], 'woonoow_affiliate');
|
as_schedule_single_action($timestamp, 'woonoow_approve_referral', ['referral_id' => $referral_id], 'woonoow_affiliate');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user