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 NewsletterSubscribers from '@/routes/Marketing/Newsletter/Subscribers';
|
||||||
import NewsletterCampaignsList from '@/routes/Marketing/Campaigns';
|
import NewsletterCampaignsList from '@/routes/Marketing/Campaigns';
|
||||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
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 MorePage from '@/routes/More';
|
||||||
import Help from '@/routes/Help';
|
import Help from '@/routes/Help';
|
||||||
import Onboarding from '@/routes/Onboarding';
|
import Onboarding from '@/routes/Onboarding';
|
||||||
@@ -247,6 +251,12 @@ export function AppRoutes() {
|
|||||||
<Route path="campaigns" element={<NewsletterCampaignsList />} />
|
<Route path="campaigns" element={<NewsletterCampaignsList />} />
|
||||||
<Route path="campaigns/:id" element={<CampaignEdit />} />
|
<Route path="campaigns/:id" element={<CampaignEdit />} />
|
||||||
</Route>
|
</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) */}
|
{/* Legacy Redirects for Newsletter (using component to preserve params) */}
|
||||||
<Route path="/marketing/campaigns" element={<Navigate to="/marketing/newsletter/campaigns" replace />} />
|
<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 { useNavigate } from 'react-router-dom';
|
||||||
import { Mail, Tag } from 'lucide-react';
|
import { Mail, Tag, Users } from 'lucide-react';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { useModules } from '@/hooks/useModules';
|
||||||
|
import { DocLink } from '@/components/DocLink';
|
||||||
|
|
||||||
interface MarketingCard {
|
interface MarketingCard {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -10,24 +12,35 @@ interface MarketingCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cards: MarketingCard[] = [
|
const cards: MarketingCard[] = [
|
||||||
{
|
|
||||||
title: __('Newsletter'),
|
|
||||||
description: __('Manage subscribers and send email campaigns'),
|
|
||||||
icon: Mail,
|
|
||||||
to: '/marketing/newsletter',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: __('Coupons'),
|
title: __('Coupons'),
|
||||||
description: __('Discounts, promotions, and coupon codes'),
|
description: __('Discounts, promotions, and coupon codes'),
|
||||||
icon: Tag,
|
icon: Tag,
|
||||||
to: '/marketing/coupons',
|
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() {
|
export default function Marketing() {
|
||||||
const navigate = useNavigate();
|
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 (
|
return (
|
||||||
<div className="w-full space-y-6">
|
<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>
|
<h1 className="text-2xl font-bold tracking-tight">{__('Marketing')}</h1>
|
||||||
<DocLink />
|
<DocLink />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{cards.map((card) => (
|
{activeCards.map((card) => (
|
||||||
<button
|
<button
|
||||||
key={card.to}
|
key={card.to}
|
||||||
onClick={() => navigate(card.to)}
|
onClick={() => navigate(card.to)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { api } from '@/lib/api';
|
|||||||
import { OrdersApi } from '@/lib/api/orders';
|
import { OrdersApi } from '@/lib/api/orders';
|
||||||
import { formatRelativeOrDate } from '@/lib/dates';
|
import { formatRelativeOrDate } from '@/lib/dates';
|
||||||
import { formatMoney } from '@/lib/currency';
|
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 { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -495,6 +495,49 @@ export default function OrderShow() {
|
|||||||
<div className="text-sm whitespace-pre-wrap">{order.customer_note}</div>
|
<div className="text-sm whitespace-pre-wrap">{order.customer_note}</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export type ProductFormData = {
|
|||||||
subscription_interval?: string;
|
subscription_interval?: string;
|
||||||
subscription_trial_days?: string;
|
subscription_trial_days?: string;
|
||||||
subscription_signup_fee?: string;
|
subscription_signup_fee?: string;
|
||||||
|
// Affiliate
|
||||||
|
affiliate_enabled?: boolean;
|
||||||
|
affiliate_commission_rate?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -103,6 +106,9 @@ export function ProductFormTabbed({
|
|||||||
const [subscriptionInterval, setSubscriptionInterval] = useState(initial?.subscription_interval || '1');
|
const [subscriptionInterval, setSubscriptionInterval] = useState(initial?.subscription_interval || '1');
|
||||||
const [subscriptionTrialDays, setSubscriptionTrialDays] = useState(initial?.subscription_trial_days || '');
|
const [subscriptionTrialDays, setSubscriptionTrialDays] = useState(initial?.subscription_trial_days || '');
|
||||||
const [subscriptionSignupFee, setSubscriptionSignupFee] = useState(initial?.subscription_signup_fee || '');
|
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);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Update form state when initial data changes (for edit mode)
|
// Update form state when initial data changes (for edit mode)
|
||||||
@@ -140,6 +146,9 @@ export function ProductFormTabbed({
|
|||||||
setSubscriptionInterval(initial.subscription_interval || '1');
|
setSubscriptionInterval(initial.subscription_interval || '1');
|
||||||
setSubscriptionTrialDays(initial.subscription_trial_days || '');
|
setSubscriptionTrialDays(initial.subscription_trial_days || '');
|
||||||
setSubscriptionSignupFee(initial.subscription_signup_fee || '');
|
setSubscriptionSignupFee(initial.subscription_signup_fee || '');
|
||||||
|
// Affiliate
|
||||||
|
setAffiliateEnabled(initial.affiliate_enabled || false);
|
||||||
|
setAffiliateCommissionRate(initial.affiliate_commission_rate || '');
|
||||||
}
|
}
|
||||||
}, [initial, mode]);
|
}, [initial, mode]);
|
||||||
|
|
||||||
@@ -209,6 +218,9 @@ export function ProductFormTabbed({
|
|||||||
subscription_interval: subscriptionEnabled ? subscriptionInterval : undefined,
|
subscription_interval: subscriptionEnabled ? subscriptionInterval : undefined,
|
||||||
subscription_trial_days: subscriptionEnabled ? subscriptionTrialDays : undefined,
|
subscription_trial_days: subscriptionEnabled ? subscriptionTrialDays : undefined,
|
||||||
subscription_signup_fee: subscriptionEnabled ? subscriptionSignupFee : undefined,
|
subscription_signup_fee: subscriptionEnabled ? subscriptionSignupFee : undefined,
|
||||||
|
// Affiliate
|
||||||
|
affiliate_enabled: affiliateEnabled,
|
||||||
|
affiliate_commission_rate: affiliateEnabled ? affiliateCommissionRate : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await onSubmit(payload);
|
await onSubmit(payload);
|
||||||
@@ -277,6 +289,10 @@ export function ProductFormTabbed({
|
|||||||
setSubscriptionTrialDays={setSubscriptionTrialDays}
|
setSubscriptionTrialDays={setSubscriptionTrialDays}
|
||||||
subscriptionSignupFee={subscriptionSignupFee}
|
subscriptionSignupFee={subscriptionSignupFee}
|
||||||
setSubscriptionSignupFee={setSubscriptionSignupFee}
|
setSubscriptionSignupFee={setSubscriptionSignupFee}
|
||||||
|
affiliateEnabled={affiliateEnabled}
|
||||||
|
setAffiliateEnabled={setAffiliateEnabled}
|
||||||
|
affiliateCommissionRate={affiliateCommissionRate}
|
||||||
|
setAffiliateCommissionRate={setAffiliateCommissionRate}
|
||||||
/>
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Separator } from '@/components/ui/separator';
|
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 { toast } from 'sonner';
|
||||||
import { getStoreCurrency } from '@/lib/currency';
|
import { getStoreCurrency } from '@/lib/currency';
|
||||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||||
import { openWPMediaGallery } from '@/lib/wp-media';
|
import { openWPMediaGallery } from '@/lib/wp-media';
|
||||||
|
import { useModules } from '@/hooks/useModules';
|
||||||
|
|
||||||
type GeneralTabProps = {
|
type GeneralTabProps = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -63,6 +64,11 @@ type GeneralTabProps = {
|
|||||||
setSubscriptionTrialDays?: (value: string) => void;
|
setSubscriptionTrialDays?: (value: string) => void;
|
||||||
subscriptionSignupFee?: string;
|
subscriptionSignupFee?: string;
|
||||||
setSubscriptionSignupFee?: (value: string) => void;
|
setSubscriptionSignupFee?: (value: string) => void;
|
||||||
|
// Affiliate
|
||||||
|
affiliateEnabled?: boolean;
|
||||||
|
setAffiliateEnabled?: (value: boolean) => void;
|
||||||
|
affiliateCommissionRate?: string;
|
||||||
|
setAffiliateCommissionRate?: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GeneralTab({
|
export function GeneralTab({
|
||||||
@@ -109,6 +115,10 @@ export function GeneralTab({
|
|||||||
setSubscriptionTrialDays,
|
setSubscriptionTrialDays,
|
||||||
subscriptionSignupFee,
|
subscriptionSignupFee,
|
||||||
setSubscriptionSignupFee,
|
setSubscriptionSignupFee,
|
||||||
|
affiliateEnabled,
|
||||||
|
setAffiliateEnabled,
|
||||||
|
affiliateCommissionRate,
|
||||||
|
setAffiliateCommissionRate,
|
||||||
}: GeneralTabProps) {
|
}: GeneralTabProps) {
|
||||||
const savingsPercent =
|
const savingsPercent =
|
||||||
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
|
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
|
||||||
@@ -116,6 +126,7 @@ export function GeneralTab({
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const store = getStoreCurrency();
|
const store = getStoreCurrency();
|
||||||
|
const { isEnabled } = useModules();
|
||||||
|
|
||||||
// Copy link state and helpers
|
// Copy link state and helpers
|
||||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||||
@@ -459,7 +470,7 @@ export function GeneralTab({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Licensing option */}
|
{/* Licensing option */}
|
||||||
{setLicensingEnabled && (
|
{isEnabled('licensing') && setLicensingEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -533,7 +544,7 @@ export function GeneralTab({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Subscription option */}
|
{/* Subscription option */}
|
||||||
{setSubscriptionEnabled && (
|
{isEnabled('subscription') && setSubscriptionEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -672,6 +672,39 @@ class OrdersController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get related affiliate referral
|
||||||
|
if (ModuleRegistry::is_enabled('affiliate')) {
|
||||||
|
if (class_exists('\WooNooW\Modules\Affiliate\AffiliateTracker')) {
|
||||||
|
global $wpdb;
|
||||||
|
$referral = \WooNooW\Modules\Affiliate\AffiliateTracker::get_referral_for_order($id);
|
||||||
|
if ($referral) {
|
||||||
|
$affiliates_table = $wpdb->prefix . 'woonoow_affiliates';
|
||||||
|
$affiliate = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT a.*, u.display_name as affiliate_name, u.user_email as affiliate_email
|
||||||
|
FROM $affiliates_table a
|
||||||
|
LEFT JOIN $wpdb->users u ON a.user_id = u.ID
|
||||||
|
WHERE a.id = %d",
|
||||||
|
$referral->affiliate_id
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($affiliate) {
|
||||||
|
$data['affiliate'] = [
|
||||||
|
'has_referral' => true,
|
||||||
|
'referral_id' => (int) $referral->id,
|
||||||
|
'commission' => (float) $referral->commission_amount,
|
||||||
|
'currency' => $referral->currency,
|
||||||
|
'status' => $referral->status,
|
||||||
|
'cancelled_reason' => $referral->cancelled_reason ?: null,
|
||||||
|
'affiliate_id' => (int) $affiliate->id,
|
||||||
|
'affiliate_name' => $affiliate->affiliate_name ?: __('Unknown', 'woonoow'),
|
||||||
|
'affiliate_email' => $affiliate->affiliate_email ?: '',
|
||||||
|
'commission_rate' => (float) $affiliate->commission_rate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Allow plugins to modify response (Level 1 compatibility)
|
// Allow plugins to modify response (Level 1 compatibility)
|
||||||
$data = apply_filters('woonoow/order_api_data', $data, $order, $req);
|
$data = apply_filters('woonoow/order_api_data', $data, $order, $req);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user