feat(admin): add affiliate marketing screens and admin order integration

This commit is contained in:
Dwindi Ramadhana
2026-06-01 00:57:53 +07:00
parent 53209c4381
commit 322c0e739d
10 changed files with 1242 additions and 15 deletions

View File

@@ -73,6 +73,10 @@ import NewsletterLayout from '@/routes/Marketing/Newsletter';
import NewsletterSubscribers from '@/routes/Marketing/Newsletter/Subscribers';
import NewsletterCampaignsList from '@/routes/Marketing/Campaigns';
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
import AffiliatesLayout from '@/routes/Marketing/Affiliates';
import AffiliatesList from '@/routes/Marketing/Affiliates/List';
import AffiliatesReferrals from '@/routes/Marketing/Affiliates/Referrals';
import AffiliatesPayouts from '@/routes/Marketing/Affiliates/Payouts';
import MorePage from '@/routes/More';
import Help from '@/routes/Help';
import Onboarding from '@/routes/Onboarding';
@@ -247,6 +251,12 @@ export function AppRoutes() {
<Route path="campaigns" element={<NewsletterCampaignsList />} />
<Route path="campaigns/:id" element={<CampaignEdit />} />
</Route>
<Route path="/marketing/affiliates" element={<AffiliatesLayout />}>
<Route index element={<Navigate to="list" replace />} />
<Route path="list" element={<AffiliatesList />} />
<Route path="referrals" element={<AffiliatesReferrals />} />
<Route path="payouts" element={<AffiliatesPayouts />} />
</Route>
{/* Legacy Redirects for Newsletter (using component to preserve params) */}
<Route path="/marketing/campaigns" element={<Navigate to="/marketing/newsletter/campaigns" replace />} />

View File

@@ -0,0 +1,252 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Search, CheckCircle, XCircle, Edit, Pencil, Users, TrendingUp, DollarSign } from 'lucide-react';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { showSuccessToast, showErrorToast } from '@/lib/errorHandling';
import { formatMoney } from '@/lib/currency';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
export default function AffiliatesList() {
const [searchQuery, setSearchQuery] = useState('');
const [editingAffiliate, setEditingAffiliate] = useState<any>(null);
const [editRate, setEditRate] = useState('');
const queryClient = useQueryClient();
const { data: affiliates, isLoading } = useQuery({
queryKey: ['admin-affiliates'],
queryFn: async () => {
const response: any = await api.get('/admin/affiliates');
return Array.isArray(response) ? response : (response?.data || []);
},
});
const updateMutation = useMutation({
mutationFn: async ({ id, rate }: { id: number; rate: number }) => {
return api.post(`/admin/affiliates/${id}/update`, { custom_commission_rate: rate });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-affiliates'] });
setEditingAffiliate(null);
showSuccessToast(__('Commission rate updated successfully'));
},
onError: (err: any) => {
showErrorToast(err, __('Failed to update commission rate'));
}
});
const approveMutation = useMutation({
mutationFn: async (id: number) => {
await api.post(`/admin/affiliates/${id}/approve`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-affiliates'] });
}
});
const filteredAffiliates = (affiliates || []).filter((aff: any) => {
const code = aff.referral_code || '';
return code.toLowerCase().includes(searchQuery.toLowerCase());
});
// Stats
const totalAffiliates = (affiliates || []).length;
const activeAffiliates = (affiliates || []).filter((a: any) => a.status === 'active').length;
const pendingAffiliates = (affiliates || []).filter((a: any) => a.status === 'pending').length;
const totalEarnings = (affiliates || []).reduce((sum: number, a: any) => sum + parseFloat(a.total_earnings || 0), 0);
const totalPayable = (affiliates || []).reduce((sum: number, a: any) => sum + parseFloat(a.payable_balance || 0), 0);
const formatCurrency = (value: string | number) => {
try {
return formatMoney(value);
} catch {
const num = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(num)) return '—';
return `IDR ${num.toLocaleString()}`;
}
};
return (
<div className="space-y-6">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<Users className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<div className="text-sm text-muted-foreground">{__('Total Affiliates')}</div>
<div className="text-2xl font-bold">{totalAffiliates}</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div>
<div className="text-sm text-muted-foreground">{__('Active')}</div>
<div className="text-2xl font-bold">{activeAffiliates}</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<TrendingUp className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<div className="text-sm text-muted-foreground">{__('Total Earnings')}</div>
<div className="text-2xl font-bold">{formatCurrency(totalEarnings)}</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<DollarSign className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<div className="text-sm text-muted-foreground">{__('Payable Balance')}</div>
<div className="text-2xl font-bold">{formatCurrency(totalPayable)}</div>
</div>
</div>
</div>
</div>
{/* Search and Table */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder={__('Search by referral code...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="!pl-9"
/>
</div>
</div>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
{__('Loading affiliates...')}
</div>
) : filteredAffiliates.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{searchQuery ? __('No affiliates found matching your search') : __('No affiliates yet')}
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('User ID')}</TableHead>
<TableHead>{__('Code')}</TableHead>
<TableHead>{__('Rate')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead className="text-right">{__('Earnings')}</TableHead>
<TableHead className="text-right">{__('Payable')}</TableHead>
<TableHead className="text-right">{__('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAffiliates.map((affiliate: any) => (
<TableRow key={affiliate.id}>
<TableCell className="font-medium">{affiliate.user_id}</TableCell>
<TableCell>{affiliate.referral_code}</TableCell>
<TableCell>
{editingAffiliate?.id === affiliate.id ? (
<div className="flex items-center gap-2">
<Input
type="number"
value={editRate}
onChange={(e) => setEditRate(e.target.value)}
className="w-20 h-8 text-sm"
min="0"
max="100"
step="0.01"
/>
<Button
size="sm"
variant="outline"
onClick={() => updateMutation.mutate({ id: affiliate.id, rate: parseFloat(editRate) })}
disabled={updateMutation.isPending}
>
{__('Save')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingAffiliate(null)}
>
{__('Cancel')}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<span>
{affiliate.custom_commission_rate !== null && affiliate.custom_commission_rate !== undefined && affiliate.custom_commission_rate !== ''
? `${parseFloat(affiliate.custom_commission_rate).toFixed(1)}% (custom)`
: `${parseFloat(affiliate.commission_rate || 10).toFixed(1)}% (default)`}
</span>
<button
onClick={() => {
setEditingAffiliate(affiliate);
const rate = affiliate.custom_commission_rate ?? affiliate.commission_rate ?? 10;
setEditRate(rate.toString());
}}
className="p-1 hover:bg-muted rounded"
title={__('Edit commission rate')}
>
<Pencil className="h-3 w-3" />
</button>
</div>
)}
</TableCell>
<TableCell>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
affiliate.status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300'
}`}>
{affiliate.status}
</span>
</TableCell>
<TableCell className="text-right">{formatCurrency(affiliate.total_earnings)}</TableCell>
<TableCell className="text-right text-green-600 font-medium">
{formatCurrency(affiliate.payable_balance || 0)}
</TableCell>
<TableCell className="text-right">
{affiliate.status === 'pending' && (
<Button
variant="outline"
size="sm"
onClick={() => approveMutation.mutate(affiliate.id)}
disabled={approveMutation.isPending}
>
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
{__('Approve')}
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,344 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { showSuccessToast, showErrorToast } from '@/lib/errorHandling';
import { DollarSign, Plus, CreditCard, Building, ArrowRight } from 'lucide-react';
import { formatMoney } from '@/lib/currency';
interface Payout {
id: number;
affiliate_id: number;
affiliate_name?: string;
affiliate_email?: string;
amount: string;
currency: string;
method: string;
status: string;
notes: string;
created_at: string;
completed_at: string;
}
interface Affiliate {
id: number;
user_id: number;
user_name?: string;
user_email?: string;
referral_code: string;
total_earnings: string;
paid_earnings: string;
payable_balance: number;
total_referrals: number;
status: string;
}
export default function AffiliatesPayouts() {
const qc = useQueryClient();
const [showCreateForm, setShowCreateForm] = useState(false);
const [selectedAffiliateId, setSelectedAffiliateId] = useState<number | null>(null);
const [amount, setAmount] = useState('');
const [method, setMethod] = useState('bank_transfer');
// Fetch affiliates for dropdown
const { data: affiliates = [], isLoading: isLoadingAffiliates } = useQuery<Affiliate[]>({
queryKey: ['admin-affiliates'],
queryFn: () => api.get('/admin/affiliates'),
});
// Fetch payouts
const { data: payouts = [], isLoading: isLoadingPayouts } = useQuery<Payout[]>({
queryKey: ['admin-payouts'],
queryFn: () => api.get('/admin/affiliates/payouts'),
});
// Get selected affiliate balance
const selectedAffiliate = affiliates.find(a => a.id === selectedAffiliateId);
const payableBalance = selectedAffiliate?.payable_balance || 0;
// Create payout mutation
const createPayoutMutation = useMutation({
mutationFn: async (data: { affiliate_id: number; amount: number; method: string }) => {
return api.post('/admin/affiliates/payouts', data);
},
onSuccess: (result: any) => {
qc.invalidateQueries({ queryKey: ['admin-payouts'] });
qc.invalidateQueries({ queryKey: ['admin-affiliates'] });
showSuccessToast(__('Payout created successfully!') + (result.coupon_code ? ` Coupon: ${result.coupon_code}` : ''));
setShowCreateForm(false);
setSelectedAffiliateId(null);
setAmount('');
setMethod('bank_transfer');
},
onError: (err: any) => {
showErrorToast(err, __('Failed to create payout'));
},
});
const handleCreatePayout = () => {
if (!selectedAffiliateId || !amount) return;
const amountNum = parseFloat(amount);
if (amountNum <= 0) {
showErrorToast({ message: 'Amount must be greater than 0' }, __('Invalid amount'));
return;
}
if (amountNum > payableBalance) {
showErrorToast({ message: `Amount exceeds payable balance of ${payableBalance}` }, __('Amount too high'));
return;
}
createPayoutMutation.mutate({
affiliate_id: selectedAffiliateId,
amount: amountNum,
method
});
};
const formatCurrency = (value: string | number, currency?: string) => {
try {
return formatMoney(value, { currency });
} catch {
const num = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(num)) return '—';
return `${currency || 'IDR'} ${num.toLocaleString()}`;
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">{__('Affiliate Payouts')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{__('Manage affiliate commission payouts')}
</p>
</div>
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="w-4 h-4 mr-2" />
{__('Create Payout')}
</Button>
</div>
{/* Create Payout Modal */}
{showCreateForm && (
<div className="bg-white dark:bg-gray-800 rounded-lg border p-6 space-y-4">
<h3 className="text-lg font-semibold">{__('Create New Payout')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Affiliate Selection */}
<div>
<label className="text-sm font-medium mb-2 block">{__('Affiliate')}</label>
<Select
value={selectedAffiliateId?.toString() || ''}
onValueChange={(v) => {
setSelectedAffiliateId(parseInt(v));
setAmount(''); // Reset amount when affiliate changes
}}
>
<SelectTrigger>
<SelectValue placeholder={__('Select affiliate')} />
</SelectTrigger>
<SelectContent>
{affiliates
.filter(a => a.status === 'active')
.filter(a => (a.payable_balance || 0) > 0)
.map(affiliate => (
<SelectItem key={affiliate.id} value={affiliate.id.toString()}>
{affiliate.user_name || affiliate.user_email} (Payable: {formatCurrency(affiliate.payable_balance)})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Method Selection */}
<div>
<label className="text-sm font-medium mb-2 block">{__('Payment Method')}</label>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bank_transfer">
<div className="flex items-center gap-2">
<Building className="w-4 h-4" />
{__('Bank Transfer')}
</div>
</SelectItem>
<SelectItem value="store_credit">
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4" />
{__('Store Credit')}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Amount Input */}
{selectedAffiliateId && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{__('Amount')}</label>
<span className="text-xs text-muted-foreground">
{__('Available:')} {formatCurrency(payableBalance)}
</span>
</div>
<div className="flex gap-2">
<Input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0"
min="0"
max={payableBalance}
step="0.01"
/>
<Button
variant="outline"
onClick={() => setAmount(payableBalance.toString())}
disabled={payableBalance <= 0}
>
{__('Pay All')}
</Button>
</div>
{method === 'store_credit' && parseFloat(amount) > 0 && (
<p className="text-xs text-muted-foreground">
{__('A store credit coupon will be generated and emailed to the affiliate.')}
</p>
)}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={() => setShowCreateForm(false)}>
{__('Cancel')}
</Button>
<Button
onClick={handleCreatePayout}
disabled={!selectedAffiliateId || !amount || createPayoutMutation.isPending}
>
{createPayoutMutation.isPending ? __('Creating...') : __('Create Payout')}
</Button>
</div>
</div>
)}
{/* Payouts Table */}
<div className="rounded-lg border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-4 py-3 text-left font-medium">{__('Affiliate')}</th>
<th className="px-4 py-3 text-left font-medium">{__('Amount')}</th>
<th className="px-4 py-3 text-left font-medium">{__('Method')}</th>
<th className="px-4 py-3 text-left font-medium">{__('Status')}</th>
<th className="px-4 py-3 text-left font-medium">{__('Date')}</th>
<th className="px-4 py-3 text-left font-medium">{__('Notes')}</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoadingPayouts ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
{__('Loading...')}
</td>
</tr>
) : payouts.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
{__('No payouts yet')}
</td>
</tr>
) : (
payouts.map((payout) => (
<tr key={payout.id} className="hover:bg-gray-50/50 dark:hover:bg-gray-800/50">
<td className="px-4 py-3">
<div>
<div className="font-medium">{payout.affiliate_name || '—'}</div>
<div className="text-xs text-muted-foreground">{payout.affiliate_email}</div>
</div>
</td>
<td className="px-4 py-3 font-medium">
{formatCurrency(payout.amount, payout.currency)}
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1 text-xs">
{payout.method === 'store_credit' ? (
<>
<CreditCard className="w-3 h-3" />
{__('Store Credit')}
</>
) : (
<>
<Building className="w-3 h-3" />
{__('Bank Transfer')}
</>
)}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
payout.status === 'completed'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: payout.status === 'pending'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
}`}>
{payout.status}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
{payout.completed_at ? formatDate(payout.completed_at) : '—'}
</td>
<td className="px-4 py-3 text-muted-foreground text-xs max-w-[200px] truncate">
{payout.notes || '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="text-sm text-muted-foreground">{__('Total Payouts')}</div>
<div className="text-2xl font-bold mt-1">{payouts.length}</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="text-sm text-muted-foreground">{__('Total Paid')}</div>
<div className="text-2xl font-bold mt-1">
{formatCurrency(payouts.reduce((sum, p) => sum + parseFloat(p.amount || '0'), 0))}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="text-sm text-muted-foreground">{__('Affiliates with Balance')}</div>
<div className="text-2xl font-bold mt-1">
{affiliates.filter(a => (a.payable_balance || 0) > 0).length}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,357 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, Filter, X, Download } from 'lucide-react';
import { formatMoney } from '@/lib/currency';
interface Affiliate {
id: number;
user_id: number;
user_name?: string;
referral_code: string;
}
interface Referral {
id: number;
affiliate_id: number;
affiliate_name?: string;
order_id: number;
status: string;
commission_amount: string;
currency: string;
created_at: string;
approved_at?: string;
}
export default function AffiliatesReferrals() {
const [filters, setFilters] = useState({
affiliate_id: '',
status: '',
date_start: '',
date_end: '',
order_id: '',
});
const [showFilters, setShowFilters] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// Fetch affiliates for filter dropdown
const { data: affiliates = [] } = useQuery<Affiliate[]>({
queryKey: ['admin-affiliates'],
queryFn: async () => {
const response: any = await api.get('/admin/affiliates');
return Array.isArray(response) ? response : (response?.data || []);
},
});
// Build query params
const queryParams = new URLSearchParams();
if (filters.affiliate_id) queryParams.set('affiliate_id', filters.affiliate_id);
if (filters.status) queryParams.set('status', filters.status);
if (filters.date_start) queryParams.set('date_start', filters.date_start);
if (filters.date_end) queryParams.set('date_end', filters.date_end);
if (filters.order_id) queryParams.set('order_id', filters.order_id);
// Fetch referrals with filters
const { data: referrals = [], isLoading } = useQuery<Referral[]>({
queryKey: ['admin-referrals', filters],
queryFn: async () => {
const queryString = queryParams.toString();
const url = queryString ? `/admin/affiliates/referrals?${queryString}` : '/admin/affiliates/referrals';
const response: any = await api.get(url);
return Array.isArray(response) ? response : (response?.data || []);
},
});
// Client-side search filter (must be after referrals is defined)
const filteredReferrals = (referrals || []).filter((ref) => {
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
ref.order_id.toString().includes(query) ||
(ref.affiliate_name || '').toLowerCase().includes(query)
);
}
return true;
});
// Export to CSV
const exportToCSV = () => {
const headers = ['ID', 'Affiliate', 'Order ID', 'Status', 'Commission', 'Currency', 'Created At'];
const rows = filteredReferrals.map(ref => [
ref.id,
ref.affiliate_name || `Affiliate #${ref.affiliate_id}`,
ref.order_id,
ref.status,
ref.commission_amount,
ref.currency,
new Date(ref.created_at).toISOString()
]);
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `affiliate-referrals-${new Date().toISOString().split('T')[0]}.csv`;
link.click();
};
const clearFilters = () => {
setFilters({
affiliate_id: '',
status: '',
date_start: '',
date_end: '',
order_id: '',
});
setSearchQuery('');
};
const hasActiveFilters = Object.values(filters).some(v => v !== '');
const formatCurrency = (amount: string | number, currency?: string) => {
try {
return formatMoney(amount, { currency });
} catch {
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(num)) return '—';
return `${currency || 'IDR'} ${num.toLocaleString()}`;
}
};
// Stats
const totalCommissions = filteredReferrals.reduce((sum, r) => sum + parseFloat(r.commission_amount || 0), 0);
const pendingCount = filteredReferrals.filter(r => r.status === 'pending').length;
const approvedCount = filteredReferrals.filter(r => r.status === 'approved').length;
return (
<div className="space-y-6">
{/* Header with search, filter toggle, and export */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder={__('Search by order ID or affiliate...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="!pl-9"
/>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={exportToCSV}
disabled={filteredReferrals.length === 0}
>
<Download className="w-4 h-4 mr-2" />
{__('Export CSV')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowFilters(!showFilters)}
>
<Filter className="w-4 h-4 mr-2" />
{__('Filters')}
{hasActiveFilters && (
<span className="ml-2 bg-primary text-primary-foreground rounded-full w-5 h-5 text-xs flex items-center justify-center">
{Object.values(filters).filter(v => v !== '').length}
</span>
)}
</Button>
</div>
</div>
{/* Stats summary text */}
<p className="text-sm text-muted-foreground">
{filteredReferrals.length} referral(s) {hasActiveFilters || searchQuery ? '(filtered)' : ''}
</p>
{/* Filter Panel */}
{showFilters && (
<div className="bg-white dark:bg-gray-800 rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">{__('Filter Referrals')}</h4>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
<X className="w-4 h-4 mr-1" />
{__('Clear All')}
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Affiliate Filter */}
<div>
<label className="text-sm font-medium mb-2 block">{__('Affiliate')}</label>
<Select
value={filters.affiliate_id}
onValueChange={(v) => setFilters({ ...filters, affiliate_id: v })}
>
<SelectTrigger>
<SelectValue placeholder={__('All affiliates')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{__('All affiliates')}</SelectItem>
{affiliates.map((aff) => (
<SelectItem key={aff.id} value={aff.id.toString()}>
{aff.user_name || aff.referral_code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Status Filter */}
<div>
<label className="text-sm font-medium mb-2 block">{__('Status')}</label>
<Select
value={filters.status}
onValueChange={(v) => setFilters({ ...filters, status: v })}
>
<SelectTrigger>
<SelectValue placeholder={__('All statuses')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{__('All statuses')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="approved">{__('Approved')}</SelectItem>
<SelectItem value="rejected">{__('Rejected')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Order ID Filter */}
<div>
<label className="text-sm font-medium mb-2 block">{__('Order ID')}</label>
<Input
type="number"
placeholder={__('Order ID')}
value={filters.order_id}
onChange={(e) => setFilters({ ...filters, order_id: e.target.value })}
/>
</div>
{/* Date Start */}
<div>
<label className="text-sm font-medium mb-2 block">{__('From Date')}</label>
<Input
type="date"
value={filters.date_start}
onChange={(e) => setFilters({ ...filters, date_start: e.target.value })}
/>
</div>
{/* Date End */}
<div>
<label className="text-sm font-medium mb-2 block">{__('To Date')}</label>
<Input
type="date"
value={filters.date_end}
onChange={(e) => setFilters({ ...filters, date_end: e.target.value })}
/>
</div>
</div>
</div>
)}
{/* Stats Summary */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="text-sm text-muted-foreground">{__('Total Referrals')}</div>
<div className="text-2xl font-bold mt-1">{filteredReferrals.length}</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="text-sm text-muted-foreground">{__('Total Commission')}</div>
<div className="text-2xl font-bold mt-1">{formatCurrency(totalCommissions)}</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="text-sm text-muted-foreground">{__('Pending')}</div>
<div className="text-2xl font-bold mt-1 text-yellow-600">{pendingCount}</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border">
<div className="text-sm text-muted-foreground">{__('Approved')}</div>
<div className="text-2xl font-bold mt-1 text-green-600">{approvedCount}</div>
</div>
</div>
{/* Referrals Table */}
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
{__('Loading referrals...')}
</div>
) : filteredReferrals.length === 0 ? (
<div className="text-center py-8 text-muted-foreground border rounded-lg">
{hasActiveFilters || searchQuery ? __('No referrals match your filters') : __('No referrals yet')}
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('ID')}</TableHead>
<TableHead>{__('Affiliate')}</TableHead>
<TableHead>{__('Order ID')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Date')}</TableHead>
<TableHead className="text-right">{__('Commission')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredReferrals.map((ref) => (
<TableRow key={ref.id}>
<TableCell className="font-medium">#{ref.id}</TableCell>
<TableCell>
<div>
<div className="font-medium">{ref.affiliate_name || `Affiliate #${ref.affiliate_id}`}</div>
</div>
</TableCell>
<TableCell>#{ref.order_id}</TableCell>
<TableCell>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
ref.status === 'approved'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
: ref.status === 'pending'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
}`}>
{ref.status}
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(ref.created_at).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(ref.commission_amount, ref.currency)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,108 @@
import React from 'react';
import { useNavigate, useLocation, Outlet, Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Users, Link as LinkIcon, DollarSign, Activity } from 'lucide-react';
import { __ } from '@/lib/i18n';
import { useModules } from '@/hooks/useModules';
import { cn } from '@/lib/utils';
import { DocLink } from '@/components/DocLink';
export default function AffiliatesLayout() {
const navigate = useNavigate();
const location = useLocation();
const { isEnabled } = useModules();
// Show disabled state if affiliate module is off
if (!isEnabled('affiliate')) {
return (
<div className="w-full space-y-6">
<div>
<div className="flex items-center">
<h1 className="text-2xl font-bold tracking-tight">{__('Affiliate Program')}</h1>
<DocLink />
</div>
<p className="text-muted-foreground mt-2">{__('Affiliate module is disabled')}</p>
</div>
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
<Users className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
<h3 className="font-semibold text-lg mb-2">{__('Affiliate Module Disabled')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{__('The affiliate module is currently disabled. Enable it in Settings > Modules to use this feature.')}
</p>
<Button onClick={() => navigate('/settings/modules')}>
{__('Go to Module Settings')}
</Button>
</div>
</div>
);
}
const navItems = [
{
id: 'list',
label: __('All Affiliates'),
icon: Users,
path: '/marketing/affiliates/list',
isActive: (path: string) => path.includes('/list')
},
{
id: 'referrals',
label: __('Referrals'),
icon: LinkIcon,
path: '/marketing/affiliates/referrals',
isActive: (path: string) => path.includes('/referrals')
},
{
id: 'payouts',
label: __('Payouts'),
icon: DollarSign,
path: '/marketing/affiliates/payouts',
isActive: (path: string) => path.includes('/payouts')
}
];
return (
<div className="w-full space-y-6">
<div>
<div className="flex items-center">
<h1 className="text-2xl font-bold tracking-tight">{__('Affiliate Program')}</h1>
<DocLink />
</div>
<p className="text-muted-foreground mt-2">{__('Manage affiliates, track referrals, and process payouts')}</p>
</div>
<div className="flex flex-col lg:flex-row gap-6">
{/* Sidebar Navigation */}
<div className="w-full lg:w-56 flex-shrink-0 space-y-4">
<nav className="space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = item.isActive(location.pathname);
return (
<Link
key={item.id}
to={item.path}
className={cn(
'w-full text-left px-4 py-2.5 rounded-md text-sm font-medium transition-colors flex items-center gap-3',
'outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
active
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'text-muted-foreground hover:bg-muted hover:text-foreground active:bg-muted active:text-foreground focus:bg-muted focus:text-foreground'
)}
>
<Icon className="w-4 h-4" />
{item.label}
</Link>
);
})}
</nav>
</div>
{/* Content Area */}
<div className="flex-1 min-w-0">
<Outlet />
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,8 @@
import { useNavigate } from 'react-router-dom';
import { Mail, Tag } from 'lucide-react';
import { Mail, Tag, Users } from 'lucide-react';
import { __ } from '@/lib/i18n';
import { useModules } from '@/hooks/useModules';
import { DocLink } from '@/components/DocLink';
interface MarketingCard {
title: string;
@@ -10,24 +12,35 @@ interface MarketingCard {
}
const cards: MarketingCard[] = [
{
title: __('Newsletter'),
description: __('Manage subscribers and send email campaigns'),
icon: Mail,
to: '/marketing/newsletter',
},
{
title: __('Coupons'),
description: __('Discounts, promotions, and coupon codes'),
icon: Tag,
to: '/marketing/coupons',
},
{
title: __('Newsletter'),
description: __('Manage subscribers and send email campaigns'),
icon: Mail,
to: '/marketing/newsletter',
},
];
import { DocLink } from '@/components/DocLink';
export default function Marketing() {
const navigate = useNavigate();
const { isEnabled } = useModules();
const activeCards = [...cards];
// Add Affiliates card conditionally if module is enabled
if (isEnabled('affiliate')) {
activeCards.splice(1, 0, {
title: __('Affiliates'),
description: __('Manage affiliate program and referrals'),
icon: Users,
to: '/marketing/affiliates',
});
}
return (
<div className="w-full space-y-6">
@@ -36,11 +49,11 @@ export default function Marketing() {
<h1 className="text-2xl font-bold tracking-tight">{__('Marketing')}</h1>
<DocLink />
</div>
<p className="text-muted-foreground mt-2">{__('Newsletter, campaigns, and promotions')}</p>
<p className="text-muted-foreground mt-2">{__('Coupons, affiliates, and newsletter campaigns')}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{cards.map((card) => (
{activeCards.map((card) => (
<button
key={card.to}
onClick={() => navigate(card.to)}

View File

@@ -5,7 +5,7 @@ import { api } from '@/lib/api';
import { OrdersApi } from '@/lib/api/orders';
import { formatRelativeOrDate } from '@/lib/dates';
import { formatMoney } from '@/lib/currency';
import { ExternalLink, Loader2, Ticket, FileText, RefreshCw } from 'lucide-react';
import { ExternalLink, Loader2, Ticket, FileText, RefreshCw, Users, Gift } from 'lucide-react';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import {
AlertDialog,
@@ -495,6 +495,49 @@ export default function OrderShow() {
<div className="text-sm whitespace-pre-wrap">{order.customer_note}</div>
</div>
)}
{/* Affiliate Referral Info */}
{order.affiliate && order.affiliate.has_referral && (
<div className="rounded border p-4 bg-green-50/50 dark:bg-green-900/10">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-green-600" />
<div className="text-xs opacity-60">{__('Affiliate')}</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{__('Affiliate')}</span>
<span className="font-medium">{order.affiliate.affiliate_name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{__('Commission Rate')}</span>
<span className="font-medium">{order.affiliate.commission_rate}%</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{__('Commission')}</span>
<span className="font-medium">
<Money value={order.affiliate.commission} currency={order.affiliate.currency} />
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{__('Status')}</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
order.affiliate.status === 'approved'
? 'bg-green-100 text-green-800'
: order.affiliate.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: order.affiliate.status === 'rejected'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-700'
}`}>
{order.affiliate.status}
{order.affiliate.cancelled_reason && (
<span className="ml-1 text-xs opacity-70">({order.affiliate.cancelled_reason.replace('order_', '')})</span>
)}
</span>
</div>
</div>
</div>
)}
</div>
</div>
)}

View File

@@ -47,6 +47,9 @@ export type ProductFormData = {
subscription_interval?: string;
subscription_trial_days?: string;
subscription_signup_fee?: string;
// Affiliate
affiliate_enabled?: boolean;
affiliate_commission_rate?: string;
};
type Props = {
@@ -103,6 +106,9 @@ export function ProductFormTabbed({
const [subscriptionInterval, setSubscriptionInterval] = useState(initial?.subscription_interval || '1');
const [subscriptionTrialDays, setSubscriptionTrialDays] = useState(initial?.subscription_trial_days || '');
const [subscriptionSignupFee, setSubscriptionSignupFee] = useState(initial?.subscription_signup_fee || '');
// Affiliate state
const [affiliateEnabled, setAffiliateEnabled] = useState(initial?.affiliate_enabled || false);
const [affiliateCommissionRate, setAffiliateCommissionRate] = useState(initial?.affiliate_commission_rate || '');
const [submitting, setSubmitting] = useState(false);
// Update form state when initial data changes (for edit mode)
@@ -140,6 +146,9 @@ export function ProductFormTabbed({
setSubscriptionInterval(initial.subscription_interval || '1');
setSubscriptionTrialDays(initial.subscription_trial_days || '');
setSubscriptionSignupFee(initial.subscription_signup_fee || '');
// Affiliate
setAffiliateEnabled(initial.affiliate_enabled || false);
setAffiliateCommissionRate(initial.affiliate_commission_rate || '');
}
}, [initial, mode]);
@@ -209,6 +218,9 @@ export function ProductFormTabbed({
subscription_interval: subscriptionEnabled ? subscriptionInterval : undefined,
subscription_trial_days: subscriptionEnabled ? subscriptionTrialDays : undefined,
subscription_signup_fee: subscriptionEnabled ? subscriptionSignupFee : undefined,
// Affiliate
affiliate_enabled: affiliateEnabled,
affiliate_commission_rate: affiliateEnabled ? affiliateCommissionRate : undefined,
};
await onSubmit(payload);
@@ -277,6 +289,10 @@ export function ProductFormTabbed({
setSubscriptionTrialDays={setSubscriptionTrialDays}
subscriptionSignupFee={subscriptionSignupFee}
setSubscriptionSignupFee={setSubscriptionSignupFee}
affiliateEnabled={affiliateEnabled}
setAffiliateEnabled={setAffiliateEnabled}
affiliateCommissionRate={affiliateCommissionRate}
setAffiliateCommissionRate={setAffiliateCommissionRate}
/>
</FormSection>

View File

@@ -8,11 +8,12 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key, Repeat } from 'lucide-react';
import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key, Repeat, Percent } from 'lucide-react';
import { toast } from 'sonner';
import { getStoreCurrency } from '@/lib/currency';
import { RichTextEditor } from '@/components/RichTextEditor';
import { openWPMediaGallery } from '@/lib/wp-media';
import { useModules } from '@/hooks/useModules';
type GeneralTabProps = {
name: string;
@@ -63,6 +64,11 @@ type GeneralTabProps = {
setSubscriptionTrialDays?: (value: string) => void;
subscriptionSignupFee?: string;
setSubscriptionSignupFee?: (value: string) => void;
// Affiliate
affiliateEnabled?: boolean;
setAffiliateEnabled?: (value: boolean) => void;
affiliateCommissionRate?: string;
setAffiliateCommissionRate?: (value: string) => void;
};
export function GeneralTab({
@@ -109,6 +115,10 @@ export function GeneralTab({
setSubscriptionTrialDays,
subscriptionSignupFee,
setSubscriptionSignupFee,
affiliateEnabled,
setAffiliateEnabled,
affiliateCommissionRate,
setAffiliateCommissionRate,
}: GeneralTabProps) {
const savingsPercent =
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
@@ -116,6 +126,7 @@ export function GeneralTab({
: 0;
const store = getStoreCurrency();
const { isEnabled } = useModules();
// Copy link state and helpers
const [copiedLink, setCopiedLink] = useState<string | null>(null);
@@ -459,7 +470,7 @@ export function GeneralTab({
</div>
{/* Licensing option */}
{setLicensingEnabled && (
{isEnabled('licensing') && setLicensingEnabled && (
<>
<div className="flex items-center space-x-2">
<Checkbox
@@ -533,7 +544,7 @@ export function GeneralTab({
)}
{/* Subscription option */}
{setSubscriptionEnabled && (
{isEnabled('subscription') && setSubscriptionEnabled && (
<>
<div className="flex items-center space-x-2">
<Checkbox
@@ -617,6 +628,46 @@ export function GeneralTab({
)}
</>
)}
{/* Affiliate option */}
{isEnabled('affiliate') && setAffiliateEnabled && (
<>
<div className="flex items-center space-x-2">
<Checkbox
id="affiliate-enabled"
checked={affiliateEnabled || false}
onCheckedChange={(checked) => setAffiliateEnabled(checked as boolean)}
/>
<Label htmlFor="affiliate-enabled" className="cursor-pointer font-normal flex items-center gap-1">
<Percent className="h-3 w-3" />
{__('Enable affiliate commission for this product')}
</Label>
</div>
{/* Affiliate settings panel */}
{affiliateEnabled && (
<div className="ml-6 p-3 bg-muted/50 rounded-lg space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">{__('Custom Commission Rate (%)')}</Label>
<Input
type="number"
min="0"
step="0.01"
placeholder={__('Global default')}
value={affiliateCommissionRate || ''}
onChange={(e) => setAffiliateCommissionRate?.(e.target.value)}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('Leave empty to use the global affiliate commission rate.')}
</p>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>