feat(customer): add affiliate dashboard entry in account area
This commit is contained in:
643
customer-spa/src/pages/Account/AffiliateDashboard.tsx
Normal file
643
customer-spa/src/pages/Account/AffiliateDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
|||||||
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
||||||
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
||||||
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
|
{ 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
|
// 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 === 'wishlist') return isEnabled('wishlist') && wishlistEnabled;
|
||||||
if (item.id === 'licenses') return isEnabled('licensing');
|
if (item.id === 'licenses') return isEnabled('licensing');
|
||||||
if (item.id === 'subscriptions') return isEnabled('subscription');
|
if (item.id === 'subscriptions') return isEnabled('subscription');
|
||||||
|
if (item.id === 'affiliate') return isEnabled('affiliate');
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Licenses from './Licenses';
|
|||||||
import LicenseConnect from './LicenseConnect';
|
import LicenseConnect from './LicenseConnect';
|
||||||
import Subscriptions from './Subscriptions';
|
import Subscriptions from './Subscriptions';
|
||||||
import SubscriptionDetail from './SubscriptionDetail';
|
import SubscriptionDetail from './SubscriptionDetail';
|
||||||
|
import AffiliateDashboard from './AffiliateDashboard';
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
@@ -42,6 +43,7 @@ export default function Account() {
|
|||||||
<Route path="licenses" element={<Licenses />} />
|
<Route path="licenses" element={<Licenses />} />
|
||||||
<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="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>
|
||||||
|
|||||||
@@ -489,6 +489,7 @@ class AccountController {
|
|||||||
'total' => html_entity_decode(strip_tags(wc_price($order->get_total()))),
|
'total' => html_entity_decode(strip_tags(wc_price($order->get_total()))),
|
||||||
'currency' => $order->get_currency(),
|
'currency' => $order->get_currency(),
|
||||||
'payment_method_title' => $payment_title,
|
'payment_method_title' => $payment_title,
|
||||||
|
'items_count' => $order->get_item_count(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($detailed) {
|
if ($detailed) {
|
||||||
|
|||||||
Reference in New Issue
Block a user