Affiliate module: fix referral approval lifecycle and settings reads

This commit is contained in:
Dwindi Ramadhana
2026-06-02 00:37:20 +07:00
parent f3c4ee7124
commit fec786daa6
8 changed files with 344 additions and 36 deletions

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Copy, CheckCircle, Activity, DollarSign, ChevronRight, Clock, Info, Wallet, CreditCard } from 'lucide-react';
import { toast } from 'sonner';
import { Link } from 'react-router-dom';
import { formatPrice, getCurrencySettings } from '@/lib/currency';
// Affiliate types
@@ -14,6 +15,16 @@ interface AffiliateProfile {
commission_rate: number;
custom_commission_rate: number | null;
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 {
@@ -130,13 +141,14 @@ export default function AffiliateDashboard() {
});
// Fetch referrals
const { data: referrals, isLoading: isLoadingReferrals } = useQuery<AffiliateReferral[]>({
const { data: referralsResponse, isLoading: isLoadingReferrals } = useQuery<PaginatedReferrals>({
queryKey: ['affiliate-referrals'],
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'
});
const referrals = referralsResponse?.referrals || [];
// Fetch payout history
const { data: payouts = [], isLoading: isLoadingPayouts } = useQuery<AffiliatePayout[]>({
@@ -248,11 +260,8 @@ export default function AffiliateDashboard() {
setTimeout(() => setCopied(false), 2000);
};
const approvedReferrals = (referrals || []).filter((r: any) => r.status === 'approved');
const pendingReferrals = (referrals || []).filter((r: any) => r.status === 'pending');
const totalEarnings = approvedReferrals.reduce((sum: number, r: any) => sum + parseFloat(r.commission_amount), 0);
const pendingEarnings = pendingReferrals.reduce((sum: number, r: any) => sum + parseFloat(r.commission_amount), 0);
const totalEarnings = profile.total_earnings || 0;
const pendingEarnings = profile.pending_earnings || 0;
const handleSavePayment = () => {
if (!selectedMethod) {
@@ -443,7 +452,17 @@ export default function AffiliateDashboard() {
{/* Referrals */}
<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 ? (
<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="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'
<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'
}`}>
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}>
{ref.status}
</span>
</div>
@@ -527,11 +545,10 @@ export default function AffiliateDashboard() {
<div key={payout.id} className="bg-white p-4 rounded-lg border">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${
payout.status === 'completed'
<div className={`p-2 rounded-lg ${payout.status === 'completed'
? 'bg-green-100 text-green-600'
: 'bg-yellow-100 text-yellow-600'
}`}>
}`}>
<Wallet className="w-5 h-5" />
</div>
<div>
@@ -544,11 +561,10 @@ export default function AffiliateDashboard() {
</div>
</div>
<div className="text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
payout.status === 'completed'
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${payout.status === 'completed'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
}`}>
{payout.status}
</span>
<div className="text-xs text-gray-400 mt-1">
@@ -605,11 +621,10 @@ export default function AffiliateDashboard() {
setSelectedMethod(method);
setPaymentFormData({});
}}
className={`px-4 py-2 rounded-lg border text-sm transition-colors ${
selectedMethod === method
className={`px-4 py-2 rounded-lg border text-sm transition-colors ${selectedMethod === method
? 'bg-purple-100 border-purple-500 text-purple-700'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
}`}
>
{PAYMENT_METHOD_LABELS[method] || method}
</button>

View 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>
);
}

View File

@@ -14,6 +14,7 @@ import LicenseConnect from './LicenseConnect';
import Subscriptions from './Subscriptions';
import SubscriptionDetail from './SubscriptionDetail';
import AffiliateDashboard from './AffiliateDashboard';
import AffiliateReferrals from './AffiliateReferrals';
export default function Account() {
const user = (window as any).woonoowCustomer?.user;
@@ -44,6 +45,7 @@ export default function Account() {
<Route path="subscriptions" element={<Subscriptions />} />
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
<Route path="affiliate" element={<AffiliateDashboard />} />
<Route path="affiliate/referrals" element={<AffiliateReferrals />} />
<Route path="account-details" element={<AccountDetails />} />
<Route path="*" element={<Navigate to="/my-account" replace />} />
</Routes>