Skip to content

Commit 739d9db

Browse files
authored
Add Affiliate Payouts page to admin dashboard (#7100)
## Summary - New "Affiliate Payouts" page in admin dashboard for reviewing and processing affiliate commission payouts - Fetches pending payouts from GoAffPro API (respects 30-day lag period, $10 minimum) - Shows order source breakdown (organic vs ads) by parsing landing page URLs for Google Ads / Facebook Ads / UTM params - Transfer button sends Stripe Transfer to affiliate's connected account, then marks as paid in GoAffPro - Confirmation dialog with full details before each transfer ## Files - `web/admin/app/(protected)/dashboard/affiliate-payouts/page.tsx` — admin page - `web/admin/app/api/omi/affiliate-payouts/route.ts` — API route (GET pending, POST transfer) - `web/admin/hooks/useAffiliatePayouts.ts` — data fetching hook - `web/admin/components/dashboard/sidebar.tsx` — added nav entry ## Deploy note `GOAFFPRO_ACCESS_TOKEN` needs to be added to admin Cloud Run secrets (couldn't update workflow file due to OAuth scope) ## Test plan - [ ] Verify pending payouts load from GoAffPro - [ ] Verify organic/ads badge breakdown on orders - [ ] Test transfer flow with a test Stripe account - [ ] Verify GoAffPro marks affiliate as paid after transfer 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 6c13e66 + 2597e74 commit 739d9db

6 files changed

Lines changed: 797 additions & 5 deletions

File tree

.github/workflows/gcp_admin.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ jobs:
9898
POSTHOG_PERSONAL_API_KEY=WEB_ADMIN_POSTHOG_PERSONAL_API_KEY:latest
9999
POSTHOG_PROJECT_ID=WEB_ADMIN_POSTHOG_PROJECT_ID:latest
100100
POSTHOG_HOST=WEB_ADMIN_POSTHOG_HOST:latest
101+
GOAFFPRO_ACCESS_TOKEN=GOAFFPRO_ACCESS_TOKEN:latest
101102
102103
- name: Show Output
103104
run: echo ${{ steps.deploy.outputs.url }}
Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
5+
import { Badge } from '@/components/ui/badge';
6+
import { Button } from '@/components/ui/button';
7+
import { Skeleton } from '@/components/ui/skeleton';
8+
import {
9+
Table,
10+
TableBody,
11+
TableCell,
12+
TableHead,
13+
TableHeader,
14+
TableRow,
15+
} from '@/components/ui/table';
16+
import {
17+
Dialog,
18+
DialogContent,
19+
DialogDescription,
20+
DialogFooter,
21+
DialogHeader,
22+
DialogTitle,
23+
} from '@/components/ui/dialog';
24+
import { useAffiliatePayouts, AffiliatePayout } from '@/hooks/useAffiliatePayouts';
25+
import { formatCurrency } from '@/lib/utils';
26+
import {
27+
DollarSign,
28+
Users,
29+
RefreshCw,
30+
Send,
31+
AlertTriangle,
32+
CheckCircle,
33+
Megaphone,
34+
MousePointerClick,
35+
} from 'lucide-react';
36+
import { toast } from 'sonner';
37+
38+
export default function AffiliatePayoutsPage() {
39+
const { affiliates, loading, error, refresh, transfer } = useAffiliatePayouts();
40+
const [transferring, setTransferring] = useState<number | null>(null);
41+
const [confirmDialog, setConfirmDialog] = useState<AffiliatePayout | null>(null);
42+
43+
const totalPending = affiliates.reduce((sum, a) => sum + a.pending_amount, 0);
44+
const withStripe = affiliates.filter((a) => a.stripe_account_id);
45+
const withoutStripe = affiliates.filter((a) => !a.stripe_account_id);
46+
47+
const handleTransfer = async (affiliate: AffiliatePayout) => {
48+
if (!affiliate.stripe_account_id) {
49+
toast.error('Affiliate has no Stripe account connected');
50+
return;
51+
}
52+
setTransferring(affiliate.affiliate_id);
53+
try {
54+
const result = await transfer(affiliate.affiliate_id);
55+
if (result.partial) {
56+
toast.warning(result.warning);
57+
} else {
58+
toast.success(
59+
`Transferred ${formatCurrency(affiliate.pending_amount)} to ${affiliate.name} (${result.transfer_id})`
60+
);
61+
}
62+
setConfirmDialog(null);
63+
refresh();
64+
} catch (err) {
65+
toast.error(err instanceof Error ? err.message : 'Transfer failed');
66+
} finally {
67+
setTransferring(null);
68+
}
69+
};
70+
71+
if (loading) {
72+
return (
73+
<div className="space-y-6">
74+
<div>
75+
<h1 className="text-3xl font-bold">Affiliate Payouts</h1>
76+
<p className="text-muted-foreground">
77+
Review and process affiliate commission payouts
78+
</p>
79+
</div>
80+
<div className="grid gap-4 md:grid-cols-3">
81+
{[...Array(3)].map((_, i) => (
82+
<Card key={i}>
83+
<CardContent className="pt-6">
84+
<Skeleton className="h-8 w-32 mb-2" />
85+
<Skeleton className="h-4 w-20" />
86+
</CardContent>
87+
</Card>
88+
))}
89+
</div>
90+
<Card>
91+
<CardContent className="pt-6">
92+
{[...Array(5)].map((_, i) => (
93+
<Skeleton key={i} className="h-12 w-full mb-2" />
94+
))}
95+
</CardContent>
96+
</Card>
97+
</div>
98+
);
99+
}
100+
101+
if (error) {
102+
return (
103+
<div className="space-y-6">
104+
<div>
105+
<h1 className="text-3xl font-bold">Affiliate Payouts</h1>
106+
</div>
107+
<Card>
108+
<CardContent className="pt-6 text-center text-destructive">
109+
{error}
110+
</CardContent>
111+
</Card>
112+
</div>
113+
);
114+
}
115+
116+
return (
117+
<div className="space-y-6">
118+
<div className="flex items-center justify-between">
119+
<div>
120+
<h1 className="text-3xl font-bold">Affiliate Payouts</h1>
121+
<p className="text-muted-foreground">
122+
Review and process affiliate commission payouts
123+
</p>
124+
</div>
125+
<Button variant="outline" size="sm" onClick={refresh}>
126+
<RefreshCw className="h-4 w-4 mr-2" />
127+
Refresh
128+
</Button>
129+
</div>
130+
131+
{/* Summary cards */}
132+
<div className="grid gap-4 md:grid-cols-3">
133+
<Card>
134+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
135+
<CardTitle className="text-sm font-medium">Pending</CardTitle>
136+
<DollarSign className="h-4 w-4 text-muted-foreground" />
137+
</CardHeader>
138+
<CardContent>
139+
<div className="text-2xl font-bold">{formatCurrency(totalPending)}</div>
140+
<p className="text-xs text-muted-foreground mt-1">
141+
{affiliates.length} affiliates above $10 minimum
142+
</p>
143+
</CardContent>
144+
</Card>
145+
<Card>
146+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
147+
<CardTitle className="text-sm font-medium">Stripe Connected</CardTitle>
148+
<CheckCircle className="h-4 w-4 text-muted-foreground" />
149+
</CardHeader>
150+
<CardContent>
151+
<div className="text-2xl font-bold">{withStripe.length}</div>
152+
<p className="text-xs text-muted-foreground mt-1">
153+
Ready for transfer
154+
</p>
155+
</CardContent>
156+
</Card>
157+
<Card>
158+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
159+
<CardTitle className="text-sm font-medium">No Stripe</CardTitle>
160+
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
161+
</CardHeader>
162+
<CardContent>
163+
<div className="text-2xl font-bold">{withoutStripe.length}</div>
164+
<p className="text-xs text-muted-foreground mt-1">
165+
Need to connect Stripe first
166+
</p>
167+
</CardContent>
168+
</Card>
169+
</div>
170+
171+
{/* Affiliates ready for payout */}
172+
{withStripe.length > 0 && (
173+
<Card>
174+
<CardHeader>
175+
<CardTitle className="flex items-center gap-2">
176+
<Send className="h-5 w-5" />
177+
Ready for Transfer ({withStripe.length})
178+
</CardTitle>
179+
</CardHeader>
180+
<CardContent>
181+
<Table>
182+
<TableHeader>
183+
<TableRow>
184+
<TableHead>Affiliate</TableHead>
185+
<TableHead>Ref Code</TableHead>
186+
<TableHead>Orders</TableHead>
187+
<TableHead>Source</TableHead>
188+
<TableHead className="text-right">Earned</TableHead>
189+
<TableHead className="text-right">Paid</TableHead>
190+
<TableHead className="text-right">Pending</TableHead>
191+
<TableHead></TableHead>
192+
</TableRow>
193+
</TableHeader>
194+
<TableBody>
195+
{withStripe.map((a) => (
196+
<TableRow key={a.affiliate_id}>
197+
<TableCell>
198+
<div>
199+
<p className="font-medium">{a.name}</p>
200+
<p className="text-xs text-muted-foreground">{a.email}</p>
201+
</div>
202+
</TableCell>
203+
<TableCell>
204+
<code className="text-xs">{a.ref_code}</code>
205+
</TableCell>
206+
<TableCell>{a.total_orders}</TableCell>
207+
<TableCell>
208+
<div className="flex items-center gap-2">
209+
{a.organic_orders > 0 && (
210+
<Badge variant="secondary" className="text-xs">
211+
<MousePointerClick className="h-3 w-3 mr-1" />
212+
{a.organic_orders}
213+
</Badge>
214+
)}
215+
{a.ad_orders > 0 && (
216+
<Badge variant="outline" className="text-xs">
217+
<Megaphone className="h-3 w-3 mr-1" />
218+
{a.ad_orders}
219+
</Badge>
220+
)}
221+
</div>
222+
</TableCell>
223+
<TableCell className="text-right">
224+
{formatCurrency(a.total_earned)}
225+
</TableCell>
226+
<TableCell className="text-right">
227+
{formatCurrency(a.total_paid)}
228+
</TableCell>
229+
<TableCell className="text-right font-medium">
230+
{formatCurrency(a.pending_amount)}
231+
</TableCell>
232+
<TableCell>
233+
<Button
234+
size="sm"
235+
onClick={() => setConfirmDialog(a)}
236+
disabled={transferring === a.affiliate_id}
237+
>
238+
{transferring === a.affiliate_id ? (
239+
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
240+
) : (
241+
<Send className="h-3 w-3 mr-1" />
242+
)}
243+
Transfer
244+
</Button>
245+
</TableCell>
246+
</TableRow>
247+
))}
248+
</TableBody>
249+
</Table>
250+
</CardContent>
251+
</Card>
252+
)}
253+
254+
{/* Affiliates without Stripe */}
255+
{withoutStripe.length > 0 && (
256+
<Card>
257+
<CardHeader>
258+
<CardTitle className="flex items-center gap-2">
259+
<Users className="h-5 w-5" />
260+
No Stripe Connected ({withoutStripe.length})
261+
</CardTitle>
262+
</CardHeader>
263+
<CardContent>
264+
<Table>
265+
<TableHeader>
266+
<TableRow>
267+
<TableHead>Affiliate</TableHead>
268+
<TableHead>Ref Code</TableHead>
269+
<TableHead>Payment Method</TableHead>
270+
<TableHead>Orders</TableHead>
271+
<TableHead className="text-right">Pending</TableHead>
272+
</TableRow>
273+
</TableHeader>
274+
<TableBody>
275+
{withoutStripe.map((a) => (
276+
<TableRow key={a.affiliate_id}>
277+
<TableCell>
278+
<div>
279+
<p className="font-medium">{a.name}</p>
280+
<p className="text-xs text-muted-foreground">{a.email}</p>
281+
</div>
282+
</TableCell>
283+
<TableCell>
284+
<code className="text-xs">{a.ref_code}</code>
285+
</TableCell>
286+
<TableCell>
287+
<Badge variant="outline">{a.payment_method || 'none'}</Badge>
288+
</TableCell>
289+
<TableCell>{a.total_orders}</TableCell>
290+
<TableCell className="text-right font-medium">
291+
{formatCurrency(a.pending_amount)}
292+
</TableCell>
293+
</TableRow>
294+
))}
295+
</TableBody>
296+
</Table>
297+
</CardContent>
298+
</Card>
299+
)}
300+
301+
{/* Confirm transfer dialog */}
302+
<Dialog open={!!confirmDialog} onOpenChange={() => setConfirmDialog(null)}>
303+
<DialogContent>
304+
<DialogHeader>
305+
<DialogTitle>Confirm Transfer</DialogTitle>
306+
<DialogDescription>
307+
Transfer {confirmDialog && formatCurrency(confirmDialog.pending_amount)} to{' '}
308+
{confirmDialog?.name}?
309+
</DialogDescription>
310+
</DialogHeader>
311+
{confirmDialog && (
312+
<div className="space-y-3 text-sm">
313+
<div className="flex justify-between">
314+
<span className="text-muted-foreground">Affiliate</span>
315+
<span className="font-medium">{confirmDialog.name}</span>
316+
</div>
317+
<div className="flex justify-between">
318+
<span className="text-muted-foreground">Email</span>
319+
<span>{confirmDialog.email}</span>
320+
</div>
321+
<div className="flex justify-between">
322+
<span className="text-muted-foreground">Stripe Account</span>
323+
<code className="text-xs">{confirmDialog.stripe_account_id}</code>
324+
</div>
325+
<div className="flex justify-between">
326+
<span className="text-muted-foreground">Amount</span>
327+
<span className="font-bold text-lg">
328+
{formatCurrency(confirmDialog.pending_amount)}
329+
</span>
330+
</div>
331+
<div className="flex justify-between">
332+
<span className="text-muted-foreground">Orders</span>
333+
<span>
334+
{confirmDialog.total_orders} total ({confirmDialog.organic_orders} organic,{' '}
335+
{confirmDialog.ad_orders} from ads)
336+
</span>
337+
</div>
338+
</div>
339+
)}
340+
<DialogFooter>
341+
<Button variant="outline" onClick={() => setConfirmDialog(null)}>
342+
Cancel
343+
</Button>
344+
<Button
345+
onClick={() => confirmDialog && handleTransfer(confirmDialog)}
346+
disabled={transferring !== null}
347+
>
348+
{transferring ? (
349+
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
350+
) : (
351+
<Send className="h-4 w-4 mr-2" />
352+
)}
353+
Confirm Transfer
354+
</Button>
355+
</DialogFooter>
356+
</DialogContent>
357+
</Dialog>
358+
</div>
359+
);
360+
}

0 commit comments

Comments
 (0)