diff --git a/admin-spa/src/components/layout/AppRoutes.tsx b/admin-spa/src/components/layout/AppRoutes.tsx index 9fec865..4231dad 100644 --- a/admin-spa/src/components/layout/AppRoutes.tsx +++ b/admin-spa/src/components/layout/AppRoutes.tsx @@ -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() { } /> } /> + }> + } /> + } /> + } /> + } /> + {/* Legacy Redirects for Newsletter (using component to preserve params) */} } /> diff --git a/admin-spa/src/routes/Marketing/Affiliates/List.tsx b/admin-spa/src/routes/Marketing/Affiliates/List.tsx new file mode 100644 index 0000000..291579b --- /dev/null +++ b/admin-spa/src/routes/Marketing/Affiliates/List.tsx @@ -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(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 ( +
+ {/* Stats Cards */} +
+
+
+
+ +
+
+
{__('Total Affiliates')}
+
{totalAffiliates}
+
+
+
+
+
+
+ +
+
+
{__('Active')}
+
{activeAffiliates}
+
+
+
+
+
+
+ +
+
+
{__('Total Earnings')}
+
{formatCurrency(totalEarnings)}
+
+
+
+
+
+
+ +
+
+
{__('Payable Balance')}
+
{formatCurrency(totalPayable)}
+
+
+
+
+ + {/* Search and Table */} +
+
+ + setSearchQuery(e.target.value)} + className="!pl-9" + /> +
+
+ + {isLoading ? ( +
+ {__('Loading affiliates...')} +
+ ) : filteredAffiliates.length === 0 ? ( +
+ {searchQuery ? __('No affiliates found matching your search') : __('No affiliates yet')} +
+ ) : ( +
+ + + + {__('User ID')} + {__('Code')} + {__('Rate')} + {__('Status')} + {__('Earnings')} + {__('Payable')} + {__('Actions')} + + + + {filteredAffiliates.map((affiliate: any) => ( + + {affiliate.user_id} + {affiliate.referral_code} + + {editingAffiliate?.id === affiliate.id ? ( +
+ setEditRate(e.target.value)} + className="w-20 h-8 text-sm" + min="0" + max="100" + step="0.01" + /> + + +
+ ) : ( +
+ + {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)`} + + +
+ )} +
+ + + {affiliate.status} + + + {formatCurrency(affiliate.total_earnings)} + + {formatCurrency(affiliate.payable_balance || 0)} + + + {affiliate.status === 'pending' && ( + + )} + +
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/admin-spa/src/routes/Marketing/Affiliates/Payouts.tsx b/admin-spa/src/routes/Marketing/Affiliates/Payouts.tsx new file mode 100644 index 0000000..1d43f41 --- /dev/null +++ b/admin-spa/src/routes/Marketing/Affiliates/Payouts.tsx @@ -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(null); + const [amount, setAmount] = useState(''); + const [method, setMethod] = useState('bank_transfer'); + + // Fetch affiliates for dropdown + const { data: affiliates = [], isLoading: isLoadingAffiliates } = useQuery({ + queryKey: ['admin-affiliates'], + queryFn: () => api.get('/admin/affiliates'), + }); + + // Fetch payouts + const { data: payouts = [], isLoading: isLoadingPayouts } = useQuery({ + 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 ( +
+
+
+

{__('Affiliate Payouts')}

+

+ {__('Manage affiliate commission payouts')} +

+
+ +
+ + {/* Create Payout Modal */} + {showCreateForm && ( +
+

{__('Create New Payout')}

+ +
+ {/* Affiliate Selection */} +
+ + +
+ + {/* Method Selection */} +
+ + +
+
+ + {/* Amount Input */} + {selectedAffiliateId && ( +
+
+ + + {__('Available:')} {formatCurrency(payableBalance)} + +
+
+ setAmount(e.target.value)} + placeholder="0" + min="0" + max={payableBalance} + step="0.01" + /> + +
+ {method === 'store_credit' && parseFloat(amount) > 0 && ( +

+ {__('A store credit coupon will be generated and emailed to the affiliate.')} +

+ )} +
+ )} + + {/* Actions */} +
+ + +
+
+ )} + + {/* Payouts Table */} +
+
+ + + + + + + + + + + + + {isLoadingPayouts ? ( + + + + ) : payouts.length === 0 ? ( + + + + ) : ( + payouts.map((payout) => ( + + + + + + + + + )) + )} + +
{__('Affiliate')}{__('Amount')}{__('Method')}{__('Status')}{__('Date')}{__('Notes')}
+ {__('Loading...')} +
+ {__('No payouts yet')} +
+
+
{payout.affiliate_name || '—'}
+
{payout.affiliate_email}
+
+
+ {formatCurrency(payout.amount, payout.currency)} + + + {payout.method === 'store_credit' ? ( + <> + + {__('Store Credit')} + + ) : ( + <> + + {__('Bank Transfer')} + + )} + + + + {payout.status} + + + {payout.completed_at ? formatDate(payout.completed_at) : '—'} + + {payout.notes || '—'} +
+
+
+ + {/* Summary Stats */} +
+
+
{__('Total Payouts')}
+
{payouts.length}
+
+
+
{__('Total Paid')}
+
+ {formatCurrency(payouts.reduce((sum, p) => sum + parseFloat(p.amount || '0'), 0))} +
+
+
+
{__('Affiliates with Balance')}
+
+ {affiliates.filter(a => (a.payable_balance || 0) > 0).length} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx b/admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx new file mode 100644 index 0000000..70bdf4d --- /dev/null +++ b/admin-spa/src/routes/Marketing/Affiliates/Referrals.tsx @@ -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({ + 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({ + 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 ( +
+ {/* Header with search, filter toggle, and export */} +
+
+
+ + setSearchQuery(e.target.value)} + className="!pl-9" + /> +
+
+
+ + +
+
+ + {/* Stats summary text */} +

+ {filteredReferrals.length} referral(s) {hasActiveFilters || searchQuery ? '(filtered)' : ''} +

+ + {/* Filter Panel */} + {showFilters && ( +
+
+

{__('Filter Referrals')}

+ {hasActiveFilters && ( + + )} +
+ +
+ {/* Affiliate Filter */} +
+ + +
+ + {/* Status Filter */} +
+ + +
+ + {/* Order ID Filter */} +
+ + setFilters({ ...filters, order_id: e.target.value })} + /> +
+ + {/* Date Start */} +
+ + setFilters({ ...filters, date_start: e.target.value })} + /> +
+ + {/* Date End */} +
+ + setFilters({ ...filters, date_end: e.target.value })} + /> +
+
+
+ )} + + {/* Stats Summary */} +
+
+
{__('Total Referrals')}
+
{filteredReferrals.length}
+
+
+
{__('Total Commission')}
+
{formatCurrency(totalCommissions)}
+
+
+
{__('Pending')}
+
{pendingCount}
+
+
+
{__('Approved')}
+
{approvedCount}
+
+
+ + {/* Referrals Table */} + {isLoading ? ( +
+ {__('Loading referrals...')} +
+ ) : filteredReferrals.length === 0 ? ( +
+ {hasActiveFilters || searchQuery ? __('No referrals match your filters') : __('No referrals yet')} +
+ ) : ( +
+ + + + {__('ID')} + {__('Affiliate')} + {__('Order ID')} + {__('Status')} + {__('Date')} + {__('Commission')} + + + + {filteredReferrals.map((ref) => ( + + #{ref.id} + +
+
{ref.affiliate_name || `Affiliate #${ref.affiliate_id}`}
+
+
+ #{ref.order_id} + + + {ref.status} + + + + {new Date(ref.created_at).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + + + {formatCurrency(ref.commission_amount, ref.currency)} + +
+ ))} +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/admin-spa/src/routes/Marketing/Affiliates/index.tsx b/admin-spa/src/routes/Marketing/Affiliates/index.tsx new file mode 100644 index 0000000..9a8e764 --- /dev/null +++ b/admin-spa/src/routes/Marketing/Affiliates/index.tsx @@ -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 ( +
+
+
+

{__('Affiliate Program')}

+ +
+

{__('Affiliate module is disabled')}

+
+
+ +

{__('Affiliate Module Disabled')}

+

+ {__('The affiliate module is currently disabled. Enable it in Settings > Modules to use this feature.')} +

+ +
+
+ ); + } + + 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 ( +
+
+
+

{__('Affiliate Program')}

+ +
+

{__('Manage affiliates, track referrals, and process payouts')}

+
+ +
+ {/* Sidebar Navigation */} +
+ +
+ + {/* Content Area */} +
+ +
+
+
+ ); +} diff --git a/admin-spa/src/routes/Marketing/index.tsx b/admin-spa/src/routes/Marketing/index.tsx index 2217575..5a5db74 100644 --- a/admin-spa/src/routes/Marketing/index.tsx +++ b/admin-spa/src/routes/Marketing/index.tsx @@ -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 (
@@ -36,11 +49,11 @@ export default function Marketing() {

{__('Marketing')}

-

{__('Newsletter, campaigns, and promotions')}

+

{__('Coupons, affiliates, and newsletter campaigns')}

- {cards.map((card) => ( + {activeCards.map((card) => (
)} + + {/* Affiliate Referral Info */} + {order.affiliate && order.affiliate.has_referral && ( +
+
+ +
{__('Affiliate')}
+
+
+
+ {__('Affiliate')} + {order.affiliate.affiliate_name} +
+
+ {__('Commission Rate')} + {order.affiliate.commission_rate}% +
+
+ {__('Commission')} + + + +
+
+ {__('Status')} + + {order.affiliate.status} + {order.affiliate.cancelled_reason && ( + ({order.affiliate.cancelled_reason.replace('order_', '')}) + )} + +
+
+
+ )} )} diff --git a/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx b/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx index 267adf2..0c10c83 100644 --- a/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx +++ b/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx @@ -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} /> diff --git a/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx b/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx index f0af18e..afcf014 100644 --- a/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx +++ b/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx @@ -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(null); @@ -459,7 +470,7 @@ export function GeneralTab({ {/* Licensing option */} - {setLicensingEnabled && ( + {isEnabled('licensing') && setLicensingEnabled && ( <>
)} + + {/* Affiliate option */} + {isEnabled('affiliate') && setAffiliateEnabled && ( + <> +
+ setAffiliateEnabled(checked as boolean)} + /> + +
+ + {/* Affiliate settings panel */} + {affiliateEnabled && ( +
+
+
+ + setAffiliateCommissionRate?.(e.target.value)} + className="mt-1" + /> +

+ {__('Leave empty to use the global affiliate commission rate.')} +

+
+
+
+ )} + + )}
diff --git a/includes/Api/OrdersController.php b/includes/Api/OrdersController.php index c249f6c..40bb5e5 100644 --- a/includes/Api/OrdersController.php +++ b/includes/Api/OrdersController.php @@ -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) $data = apply_filters('woonoow/order_api_data', $data, $order, $req);