Files
WooNooW/customer-spa/src/pages/Account/AffiliateDashboard.tsx

684 lines
32 KiB
TypeScript

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, Tag } from 'lucide-react';
import { toast } from 'sonner';
import { Link } from 'react-router-dom';
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;
total_earnings: number;
pending_earnings: number;
collections_enabled?: boolean;
}
interface PaginatedReferrals {
referrals: AffiliateReferral[];
total: number;
page: number;
limit: number;
total_pages: 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: referralsResponse, isLoading: isLoadingReferrals } = useQuery<PaginatedReferrals>({
queryKey: ['affiliate-referrals'],
queryFn: async () => {
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[]>({
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 totalEarnings = profile.total_earnings || 0;
const pendingEarnings = profile.pending_earnings || 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">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Your Referral Link</h3>
<div className="flex items-center gap-4">
{profile?.collections_enabled !== false && (
<Link
to="/my-account/affiliate/collections"
className="text-sm font-medium text-primary hover:opacity-80 flex items-center"
>
My Collections & Smart Links <ChevronRight className="w-4 h-4 ml-1" />
</Link>
)}
<Link
to="/my-account/affiliate/links"
className="text-sm font-medium text-primary hover:opacity-80 flex items-center"
>
Build Links <ChevronRight className="w-4 h-4 ml-1" />
</Link>
</div>
</div>
<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>
<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>
) : !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>
)}
{(ref.utm_campaign || ref.utm_source) && (
<span className="flex items-center gap-1 text-purple-600">
<Tag className="w-3 h-3" />
{[ref.utm_campaign, ref.utm_source].filter(Boolean).join(' / ')}
</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>
);
}