feat(customer): add affiliate dashboard entry in account area

This commit is contained in:
Dwindi Ramadhana
2026-06-01 00:58:01 +07:00
parent 322c0e739d
commit 6d2b1fb9ca
4 changed files with 648 additions and 0 deletions

View File

@@ -0,0 +1,643 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Copy, CheckCircle, Activity, DollarSign, ChevronRight, Clock, Info, Wallet, CreditCard } from 'lucide-react';
import { toast } from 'sonner';
import { formatPrice, getCurrencySettings } from '@/lib/currency';
// Affiliate types
interface AffiliateProfile {
status: 'active' | 'pending' | 'approved' | 'rejected';
referral_code: string;
commission_rate: number;
custom_commission_rate: number | null;
global_commission_rate: number;
}
interface AffiliateReferral {
id: number;
status: 'pending' | 'approved' | 'rejected';
commission_amount: string;
created_at: string;
order_id: number;
currency: string;
approved_at?: string;
cancelled_reason?: string;
}
interface AffiliatePayout {
id: number;
amount: string;
currency: string;
method: string;
status: string;
notes: string;
created_at: string;
completed_at: string;
}
interface PaymentDetails {
payment_method: string;
payment_details: {
bank_name?: string;
account_number?: string;
account_holder?: string;
swift_code?: string;
bank_address?: string;
email?: string;
name?: string;
notes?: string;
};
}
interface AffiliateSettings {
woonoow_affiliate_payment_methods?: string[];
}
// Payment method labels
const PAYMENT_METHOD_LABELS: Record<string, string> = {
bank_transfer: 'Bank Transfer',
paypal: 'PayPal',
wise: 'Wise',
skrill: 'Skrill',
payoneer: 'Payoneer',
custom: 'Custom (Other)',
};
// Format amount using site's currency settings
function formatAmount(amount: number | string, currency?: string): string {
const amountNum = typeof amount === 'string' ? parseFloat(amount) : amount;
const settings = getCurrencySettings();
const decimals = currency === 'IDR' ? 0 : settings.decimals;
const formatted = formatNumberWithSeparators(amountNum, decimals, settings.thousandSeparator, settings.decimalSeparator);
return `${settings.symbol}${formatted}`;
}
function formatNumberWithSeparators(
value: number,
decimals: number,
thousandSeparator: string,
decimalSeparator: string
): string {
const rounded = value.toFixed(decimals);
const [integerPart, decimalPart] = rounded.split('.');
const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator);
if (decimals > 0 && decimalPart) {
return `${formattedInteger}${decimalSeparator}${decimalPart}`;
}
return formattedInteger;
}
export default function AffiliateDashboard() {
const queryClient = useQueryClient();
const [copied, setCopied] = useState(false);
const [showPaymentForm, setShowPaymentForm] = useState(false);
const [selectedMethod, setSelectedMethod] = useState('');
const [paymentFormData, setPaymentFormData] = useState<Record<string, string>>({});
// Fetch affiliate settings for available payment methods
const { data: settings } = useQuery<AffiliateSettings>({
queryKey: ['affiliate-settings'],
queryFn: async () => {
try {
return await api.get('/modules/affiliate/settings');
} catch {
return { woonoow_affiliate_payment_methods: ['bank_transfer'] };
}
},
});
const availableMethods = settings?.woonoow_affiliate_payment_methods || ['bank_transfer'];
// Fetch dashboard info
const { data: profile, isLoading } = useQuery<AffiliateProfile | null>({
queryKey: ['affiliate-profile'],
queryFn: async () => {
try {
const response = await api.get<AffiliateProfile>('/account/affiliate');
return response;
} catch (err: any) {
if (err.status === 404) return null;
throw err;
}
},
retry: false
});
// Fetch referrals
const { data: referrals, isLoading: isLoadingReferrals } = useQuery<AffiliateReferral[]>({
queryKey: ['affiliate-referrals'],
queryFn: async () => {
return await api.get<AffiliateReferral[]>('/account/affiliate/referrals');
},
enabled: !!profile && profile.status === 'active'
});
// Fetch payout history
const { data: payouts = [], isLoading: isLoadingPayouts } = useQuery<AffiliatePayout[]>({
queryKey: ['affiliate-payouts'],
queryFn: async () => {
return await api.get<AffiliatePayout[]>('/account/affiliate/payouts');
},
enabled: !!profile && profile.status === 'active'
});
// Fetch payment details
const { data: savedPaymentDetails } = useQuery<PaymentDetails>({
queryKey: ['affiliate-payment-details'],
queryFn: async () => {
return await api.get<PaymentDetails>('/account/affiliate/payment-details');
},
enabled: !!profile && profile.status === 'active'
});
// Initialize form when data loads OR when edit mode is opened
const initFormFromSaved = React.useCallback(() => {
if (savedPaymentDetails?.payment_method) {
setSelectedMethod(savedPaymentDetails.payment_method);
setPaymentFormData(savedPaymentDetails.payment_details || {});
}
}, [savedPaymentDetails]);
// Initialize when saved data changes and form is open
React.useEffect(() => {
if (showPaymentForm && savedPaymentDetails) {
initFormFromSaved();
}
}, [showPaymentForm, savedPaymentDetails, initFormFromSaved]);
const updatePaymentMutation = useMutation({
mutationFn: async (data: { payment_method: string; payment_details: Record<string, string> }) => {
return await api.post('/account/affiliate/payment-details', data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['affiliate-payment-details'] });
toast.success('Payment details saved successfully!');
setShowPaymentForm(false);
},
onError: () => {
toast.error('Failed to save payment details.');
}
});
const applyMutation = useMutation({
mutationFn: async () => {
return await api.post('/account/affiliate/apply');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['affiliate-profile'] });
toast.success('Successfully applied for the affiliate program!');
},
onError: () => {
toast.error('Failed to apply. Please try again later.');
}
});
if (isLoading) {
return <div className="text-center py-8">Loading...</div>;
}
if (!profile) {
return (
<div className="space-y-6 max-w-2xl">
<h2 className="text-2xl font-bold">Affiliate Program</h2>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 rounded-lg p-6">
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">Join our Affiliate Program</h3>
<p className="text-blue-800 dark:text-blue-200 mb-4">
Earn commissions by referring customers to our store! You'll get a unique referral link that tracks any purchases made by your referrals.
</p>
<Button
onClick={() => applyMutation.mutate()}
disabled={applyMutation.isPending}
>
{applyMutation.isPending ? 'Applying...' : 'Apply Now'}
</Button>
</div>
</div>
);
}
if (profile.status === 'pending') {
return (
<div className="space-y-6 max-w-2xl">
<h2 className="text-2xl font-bold">Affiliate Program</h2>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800/50 rounded-lg p-6">
<h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-100 mb-2">Application Pending</h3>
<p className="text-yellow-800 dark:text-yellow-200">
Your application is currently being reviewed. We will notify you once it's approved.
</p>
</div>
</div>
);
}
const woonoowConfig = (window as any).woonoowCustomer || {};
const basePath = woonoowConfig.basePath || '';
const shopPath = basePath ? `${basePath}/shop` : '/shop';
const referralLink = `${window.location.origin}${shopPath}?ref=${profile.referral_code}`;
const handleCopy = () => {
navigator.clipboard.writeText(referralLink);
setCopied(true);
toast.success('Referral link copied to clipboard!');
setTimeout(() => setCopied(false), 2000);
};
const approvedReferrals = (referrals || []).filter((r: any) => r.status === 'approved');
const pendingReferrals = (referrals || []).filter((r: any) => r.status === 'pending');
const totalEarnings = approvedReferrals.reduce((sum: number, r: any) => sum + parseFloat(r.commission_amount), 0);
const pendingEarnings = pendingReferrals.reduce((sum: number, r: any) => sum + parseFloat(r.commission_amount), 0);
const handleSavePayment = () => {
if (!selectedMethod) {
toast.error('Please select a payment method');
return;
}
updatePaymentMutation.mutate({
payment_method: selectedMethod,
payment_details: paymentFormData
});
};
const renderPaymentFields = () => {
switch (selectedMethod) {
case 'bank_transfer':
return (
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-1 block">Bank Name</label>
<Input
value={paymentFormData.bank_name || ''}
onChange={(e) => setPaymentFormData({ ...paymentFormData, bank_name: e.target.value })}
placeholder="e.g., Bank Central Asia"
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Account Holder Name</label>
<Input
value={paymentFormData.account_holder || ''}
onChange={(e) => setPaymentFormData({ ...paymentFormData, account_holder: e.target.value })}
placeholder="Full name as on account"
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Account Number</label>
<Input
value={paymentFormData.account_number || ''}
onChange={(e) => setPaymentFormData({ ...paymentFormData, account_number: e.target.value })}
placeholder="Account number"
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">SWIFT / IBAN Code</label>
<Input
value={paymentFormData.swift_code || ''}
onChange={(e) => setPaymentFormData({ ...paymentFormData, swift_code: e.target.value })}
placeholder="e.g., CENAIDJA (for Indonesian banks)"
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">Bank Address (Optional)</label>
<Input
value={paymentFormData.bank_address || ''}
onChange={(e) => setPaymentFormData({ ...paymentFormData, bank_address: e.target.value })}
placeholder="Bank branch address"
/>
</div>
</div>
);
case 'paypal':
case 'wise':
case 'skrill':
case 'payoneer':
return (
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-1 block">Account Name</label>
<Input
value={paymentFormData.name || ''}
onChange={(e) => setPaymentFormData({ ...paymentFormData, name: e.target.value })}
placeholder="Your name"
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">{PAYMENT_METHOD_LABELS[selectedMethod]} Email</label>
<Input
type="email"
value={paymentFormData.email || ''}
onChange={(e) => setPaymentFormData({ ...paymentFormData, email: e.target.value })}
placeholder="your@email.com"
/>
</div>
</div>
);
case 'custom':
return (
<div>
<label className="text-sm font-medium mb-1 block">Payment Instructions</label>
<textarea
className="w-full px-3 py-2 border rounded-lg bg-background text-sm min-h-[100px]"
value={paymentFormData.notes || ''}
onChange={(e) => setPaymentFormData({ ...paymentFormData, notes: e.target.value })}
placeholder="Describe how you would like to receive payment..."
/>
</div>
);
default:
return null;
}
};
const getCurrentPaymentDisplay = () => {
if (!savedPaymentDetails?.payment_method || !savedPaymentDetails?.payment_details) {
return 'Not configured';
}
const method = savedPaymentDetails.payment_method;
const details = savedPaymentDetails.payment_details;
switch (method) {
case 'bank_transfer':
return `${details.bank_name || 'Bank'} - ${details.account_number || '****'}`;
case 'paypal':
case 'wise':
case 'skrill':
case 'payoneer':
return details.email || 'Not set';
case 'custom':
return 'Custom payment';
default:
return 'Not configured';
}
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">Affiliate Dashboard</h2>
<p className="text-gray-500 mt-1">Manage your referrals and view your earnings.</p>
</div>
{/* Stats Cards - Improved styling */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white p-5 rounded-lg border shadow-sm">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-gray-400" />
<span className="text-xs text-gray-500 uppercase tracking-wide">Total Earnings</span>
</div>
<p className="text-2xl font-bold text-gray-900">{formatAmount(totalEarnings)}</p>
</div>
<div className="bg-white p-5 rounded-lg border shadow-sm">
<div className="flex items-center gap-2 mb-1">
<Activity className="w-4 h-4 text-gray-400" />
<span className="text-xs text-gray-500 uppercase tracking-wide">Pending</span>
</div>
<p className="text-2xl font-bold text-yellow-600">{formatAmount(pendingEarnings)}</p>
</div>
<div className="bg-white p-5 rounded-lg border shadow-sm">
<div className="flex items-center gap-2 mb-1">
<CheckCircle className="w-4 h-4 text-gray-400" />
<span className="text-xs text-gray-500 uppercase tracking-wide">Commission Rate</span>
</div>
<p className="text-2xl font-bold text-gray-900">{profile.commission_rate}%</p>
{profile.custom_commission_rate ? (
<p className="text-xs text-green-600">Custom rate</p>
) : (
<p className="text-xs text-gray-400">Default (global: {profile.global_commission_rate}%)</p>
)}
</div>
</div>
{/* Referral Link */}
<div className="bg-white p-6 rounded-lg border">
<h3 className="text-lg font-semibold mb-4">Your Referral Link</h3>
<div className="flex gap-2">
<Input
value={referralLink}
readOnly
className="bg-gray-50 font-mono text-sm"
/>
<Button variant="outline" onClick={handleCopy} className="shrink-0 w-32">
{copied ? (
<><CheckCircle className="w-4 h-4 mr-2" /> Copied</>
) : (
<><Copy className="w-4 h-4 mr-2" /> Copy Link</>
)}
</Button>
</div>
<p className="text-sm text-gray-500 mt-2">
Share this link with your audience. When they make a purchase, you'll earn a {profile.commission_rate}% commission.
</p>
</div>
{/* Referrals */}
<div>
<h3 className="text-lg font-semibold mb-4">Recent Referrals</h3>
{isLoadingReferrals ? (
<div className="text-center py-8 text-gray-500">Loading referrals...</div>
) : !referrals || referrals.length === 0 ? (
<div className="text-center py-8 text-gray-500 border rounded-lg bg-gray-50">
No referrals yet. Share your link to start earning!
</div>
) : (
<div className="space-y-4">
{referrals.map((ref: any) => {
const createdDate = new Date(ref.created_at);
const approvedDate = ref.approved_at ? new Date(ref.approved_at) : null;
return (
<div key={ref.id} className="bg-white p-4 rounded-lg border hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">Order #{ref.order_id}</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
ref.status === 'approved'
? 'bg-green-100 text-green-800'
: ref.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}>
{ref.status}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{createdDate.toLocaleDateString('id-ID', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</span>
{ref.status === 'approved' && approvedDate && (
<span className="flex items-center gap-1 text-green-600">
<CheckCircle className="w-3 h-3" />
Approved {approvedDate.toLocaleDateString('id-ID', {
month: 'short',
day: 'numeric'
})}
</span>
)}
</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-gray-900">
{formatAmount(ref.commission_amount, ref.currency)}
</div>
</div>
</div>
{ref.status === 'rejected' && ref.cancelled_reason && (
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
<div className="text-xs text-red-800 dark:text-red-200">
Reason: {ref.cancelled_reason}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Payout History */}
{payouts.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-4">Payout History</h3>
<div className="space-y-3">
{payouts.map((payout) => {
const payoutDate = new Date(payout.created_at);
return (
<div key={payout.id} className="bg-white p-4 rounded-lg border">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${
payout.status === 'completed'
? 'bg-green-100 text-green-600'
: 'bg-yellow-100 text-yellow-600'
}`}>
<Wallet className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-gray-900">
{formatAmount(payout.amount, payout.currency)}
</div>
<div className="text-sm text-gray-500 capitalize">
{payout.method.replace('_', ' ')}
</div>
</div>
</div>
<div className="text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
payout.status === 'completed'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{payout.status}
</span>
<div className="text-xs text-gray-400 mt-1">
{payoutDate.toLocaleDateString('id-ID', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</div>
</div>
</div>
{payout.notes && (
<div className="mt-3 pt-3 border-t text-xs text-gray-500">
{payout.notes}
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Payment Details Section */}
<div className="bg-white p-6 rounded-lg border">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<CreditCard className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 className="text-lg font-semibold">Payment Details</h3>
<p className="text-xs text-gray-500">{getCurrentPaymentDisplay()}</p>
</div>
</div>
{!showPaymentForm && (
<Button variant="outline" size="sm" onClick={() => setShowPaymentForm(true)}>
{savedPaymentDetails?.payment_method ? 'Edit' : 'Add'}
</Button>
)}
</div>
{showPaymentForm ? (
<div className="space-y-4">
{/* Payment Method Selection */}
<div>
<label className="text-sm font-medium mb-2 block">Payment Method</label>
<div className="flex flex-wrap gap-2">
{availableMethods.map((method) => (
<button
key={method}
type="button"
onClick={() => {
setSelectedMethod(method);
setPaymentFormData({});
}}
className={`px-4 py-2 rounded-lg border text-sm transition-colors ${
selectedMethod === method
? 'bg-purple-100 border-purple-500 text-purple-700'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
{PAYMENT_METHOD_LABELS[method] || method}
</button>
))}
</div>
</div>
{/* Conditional Fields */}
{selectedMethod && renderPaymentFields()}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={() => setShowPaymentForm(false)}>
Cancel
</Button>
<Button
onClick={handleSavePayment}
disabled={updatePaymentMutation.isPending || !selectedMethod}
>
{updatePaymentMutation.isPending ? 'Saving...' : 'Save Payment Details'}
</Button>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">
Configure how you want to receive your affiliate payouts.
</p>
)}
</div>
</div>
);
}

View File

@@ -56,6 +56,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
{ id: 'affiliate', label: 'Affiliate', path: '/my-account/affiliate', icon: User },
];
// Filter out wishlist if module disabled or settings disabled, licenses if licensing disabled
@@ -63,6 +64,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
if (item.id === 'wishlist') return isEnabled('wishlist') && wishlistEnabled;
if (item.id === 'licenses') return isEnabled('licensing');
if (item.id === 'subscriptions') return isEnabled('subscription');
if (item.id === 'affiliate') return isEnabled('affiliate');
return true;
});

View File

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

View File

@@ -489,6 +489,7 @@ class AccountController {
'total' => html_entity_decode(strip_tags(wc_price($order->get_total()))),
'currency' => $order->get_currency(),
'payment_method_title' => $payment_title,
'items_count' => $order->get_item_count(),
];
if ($detailed) {