feat(admin): add affiliate marketing screens and admin order integration
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
252
admin-spa/src/routes/Marketing/Affiliates/List.tsx
Normal file
252
admin-spa/src/routes/Marketing/Affiliates/List.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
344
admin-spa/src/routes/Marketing/Affiliates/Payouts.tsx
Normal file
344
admin-spa/src/routes/Marketing/Affiliates/Payouts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
357
admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx
Normal file
357
admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
admin-spa/src/routes/Marketing/Affiliates/index.tsx
Normal file
108
admin-spa/src/routes/Marketing/Affiliates/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user