dvijaykrishnan's picture
feat: implement bulk affiliate link generation in moderation queue
2e35c2f
Raw
History Blame Contribute Delete
27.8 kB
'use client';
import React, { useState, useTransition } from 'react';
import { Card, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { approveDetection } from '../actions/approve-detection';
import { rejectDetection } from '../actions/reject-detection';
import { bulkApprove } from '../actions/bulk-approve';
import { bulkReject } from '../actions/bulk-reject';
import { triggerBulkMarketplaceMatchAction } from '@/features/marketplace/actions/trigger-bulk-marketplace-match';
import { resetDetection } from '../actions/edit-detection';
import { EditDetectionDialog } from './edit-detection-dialog';
import { AddProductDialog } from './add-product-dialog';
import { Check, X, ShieldCheck, ShoppingCart, Filter, CheckCircle2, Clock, CheckCircle, ShieldAlert, MousePointerClick, Heart, Sparkles } from 'lucide-react';
import Image from 'next/image';
interface Detection {
detection: {
id: string;
objectName: string;
category: string;
confidenceScore: number;
moderationStatus: 'PENDING' | 'APPROVED' | 'REJECTED';
thumbnailUrl?: string | null;
createdAt: Date;
};
video: {
id: string;
title: string;
thumbnailUrl: string | null;
};
marketplaceMatches: any[];
clickCount: number;
interestPledgeCount: number;
}
interface ModerationQueueProps {
initialDetections: Detection[];
userId: string;
videos: { id: string; title: string }[];
stats: { pending: number; approved: number; rejected: number };
initialStatus?: 'PENDING' | 'APPROVED' | 'REJECTED';
}
export function ModerationQueue({ initialDetections, userId, videos, stats, initialStatus = 'PENDING' }: ModerationQueueProps) {
const [detections, setDetections] = useState(initialDetections);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [filterStatus, setFilterStatus] = useState<'PENDING' | 'APPROVED' | 'REJECTED'>(initialStatus);
const [minConfidence, setMinConfidence] = useState(0);
const [filterVideo, setFilterVideo] = useState<string>('all');
const [filterHasLink, setFilterHasLink] = useState<string>('all'); // 'all' | 'with-link' | 'without-link'
const [sortBy, setSortBy] = useState<string>('date'); // 'date' | 'clicks' | 'interest' | 'confidence'
const [isPending, startTransition] = useTransition();
let filteredDetections = detections.filter(d => {
// Status and confidence filter
if (d.detection.moderationStatus !== filterStatus) return false;
if (d.detection.confidenceScore < minConfidence) return false;
// Video filter
if (filterVideo !== 'all' && d.video.id !== filterVideo) return false;
// Affiliate link filter
if (filterHasLink === 'with-link' && d.marketplaceMatches.length === 0) return false;
if (filterHasLink === 'without-link' && d.marketplaceMatches.length > 0) return false;
return true;
});
// Sorting
filteredDetections = [...filteredDetections].sort((a, b) => {
switch (sortBy) {
case 'clicks':
return b.clickCount - a.clickCount;
case 'interest':
return b.interestPledgeCount - a.interestPledgeCount;
case 'confidence':
return b.detection.confidenceScore - a.detection.confidenceScore;
case 'date':
default:
return b.detection.createdAt.getTime() - a.detection.createdAt.getTime();
}
});
const toggleSelection = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
const toggleSelectAll = () => {
if (selectedIds.length === filteredDetections.length) {
setSelectedIds([]);
} else {
setSelectedIds(filteredDetections.map(d => d.detection.id));
}
};
const handleApprove = async (id: string) => {
startTransition(async () => {
const result = await approveDetection(id);
if (result.success) {
setDetections(prev => prev.map(d =>
d.detection.id === id ? { ...d, detection: { ...d.detection, moderationStatus: 'APPROVED' } } : d
));
toast.success('Detection approved');
} else {
toast.error(result.error);
}
});
};
const handleReject = async (id: string) => {
startTransition(async () => {
const result = await rejectDetection(id);
if (result.success) {
setDetections(prev => prev.map(d =>
d.detection.id === id ? { ...d, detection: { ...d.detection, moderationStatus: 'REJECTED' } } : d
));
toast.success('Detection rejected');
} else {
toast.error(result.error);
}
});
};
const handleBulkApprove = async () => {
startTransition(async () => {
const result = await bulkApprove(selectedIds);
if (result.success) {
setDetections(prev => prev.map(d =>
selectedIds.includes(d.detection.id) ? { ...d, detection: { ...d.detection, moderationStatus: 'APPROVED' } } : d
));
setSelectedIds([]);
toast.success(`Approved ${result.count} items`);
} else {
toast.error(result.error);
}
});
};
const handleBulkReject = async () => {
startTransition(async () => {
const result = await bulkReject(selectedIds);
if (result.success) {
setDetections(prev => prev.map(d =>
selectedIds.includes(d.detection.id) ? { ...d, detection: { ...d.detection, moderationStatus: 'REJECTED' } } : d
));
setSelectedIds([]);
toast.success(`Rejected ${result.count} items`);
} else {
toast.error(result.error);
}
});
};
const handleBulkGenerateLinks = async () => {
startTransition(async () => {
const result = await triggerBulkMarketplaceMatchAction(selectedIds);
if (result.success) {
toast.success(`Generation triggered for ${result.count} items. Results will appear in a few moments.`);
setSelectedIds([]);
} else {
toast.error(result.error || 'Failed to trigger link generation');
}
});
};
const handleReset = async (id: string) => {
startTransition(async () => {
const result = await resetDetection(id);
if (result.success) {
setDetections(prev => prev.map(d =>
d.detection.id === id ? { ...d, detection: { ...d.detection, moderationStatus: 'PENDING' } } : d
));
toast.success('Detection reset to pending');
} else {
toast.error(result.error);
}
});
};
const statCards = [
{ status: 'PENDING' as const, title: 'Pending Review', count: stats.pending, icon: <Clock className="w-5 h-5 text-amber-500" />, description: 'Items awaiting your approval', activeClasses: 'ring-2 ring-amber-500 bg-amber-500/10 border-amber-500/50' },
{ status: 'APPROVED' as const, title: 'Approved', count: stats.approved, icon: <CheckCircle className="w-5 h-5 text-emerald-500" />, description: 'Successfully added to Vault', activeClasses: 'ring-2 ring-emerald-500 bg-emerald-500/10 border-emerald-500/50' },
{ status: 'REJECTED' as const, title: 'Rejected', count: stats.rejected, icon: <ShieldAlert className="w-5 h-5 text-rose-500" />, description: 'Hidden from your Vault', activeClasses: 'ring-2 ring-rose-500 bg-rose-500/10 border-rose-500/50' },
];
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{statCards.map(({ status, title, count, icon, description, activeClasses }) => (
<button
key={status}
type="button"
onClick={() => setFilterStatus(status)}
className={`text-left p-4 rounded-lg border transition-all group ${filterStatus === status ? activeClasses : 'bg-card/40 backdrop-blur-md border-border/40 hover:bg-card/60'
}`}
>
<div className="flex items-center justify-between pb-2">
<span className="text-sm font-medium">{title}</span>
<div className="p-2 bg-background/50 rounded-lg group-hover:scale-110 transition-transform">
{icon}
</div>
</div>
<div className="text-3xl font-bold">{count}</div>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</button>
))}
</div>
<div className="flex flex-col gap-4 bg-card/50 p-4 rounded-lg border border-border/50 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-4">
<div className="space-y-1">
<Label>Status</Label>
<Select value={filterStatus} onValueChange={(v: any) => setFilterStatus(v)}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="PENDING">Pending</SelectItem>
<SelectItem value="APPROVED">Approved</SelectItem>
<SelectItem value="REJECTED">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="min-confidence">Min Confidence ({Math.round(minConfidence * 100)}%)</Label>
<Input
id="min-confidence"
type="range"
min="0"
max="1"
step="0.05"
value={minConfidence}
onChange={(e) => setMinConfidence(parseFloat(e.target.value))}
className="w-[150px]"
/>
</div>
<div className="space-y-1">
<Label>Video</Label>
<Select value={filterVideo} onValueChange={setFilterVideo}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="All Videos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Videos</SelectItem>
{videos.map(v => (
<SelectItem key={v.id} value={v.id}>{v.title.slice(0, 40)}{v.title.length > 40 ? '...' : ''}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Affiliate Link</Label>
<Select value={filterHasLink} onValueChange={setFilterHasLink}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Items</SelectItem>
<SelectItem value="with-link">With Link</SelectItem>
<SelectItem value="without-link">No Link</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Sort By</Label>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Sort By" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date">Date (Newest)</SelectItem>
<SelectItem value="clicks">Most Clicks</SelectItem>
<SelectItem value="interest">Most Interest</SelectItem>
<SelectItem value="confidence">Confidence</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {filteredDetections.length} of {detections.filter(d => d.detection.moderationStatus === filterStatus).length} items
</div>
<AddProductDialog
videos={videos}
onAdd={(newItem) => {
setDetections(prev => [{
detection: {
id: newItem.id,
objectName: newItem.objectName,
category: newItem.category,
confidenceScore: 1,
moderationStatus: 'PENDING',
thumbnailUrl: newItem.thumbnailUrl ?? null,
createdAt: new Date(),
},
video: {
id: newItem.videoId,
title: newItem.videoTitle,
thumbnailUrl: '',
},
marketplaceMatches: newItem.hasMarketplaceMatch ? [{ id: 'new', marketplace: 'amazon', productName: '', affiliateUrl: '' }] : [],
clickCount: 0,
interestPledgeCount: 0,
}, ...prev]);
}}
/>
</div>
</div>
{selectedIds.length > 0 && (
<div className="flex items-center gap-2 bg-primary/10 px-4 py-2 rounded-full border border-primary/20 animate-in fade-in slide-in-from-top-2">
<span className="text-sm font-medium mr-2">{selectedIds.length} selected</span>
<Button size="sm" onClick={handleBulkApprove} disabled={isPending}>
<Check className="w-4 h-4 mr-1" /> Approve
</Button>
<Button size="sm" variant="outline" className="bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500 border-none" onClick={handleBulkGenerateLinks} disabled={isPending}>
<Sparkles className="w-4 h-4 mr-1" /> Generate Links
</Button>
<Button size="sm" variant="destructive" onClick={handleBulkReject} disabled={isPending}>
<X className="w-4 h-4 mr-1" /> Reject
</Button>
</div>
)}
<div className="flex items-center gap-2 mb-4">
<Checkbox
id="select-all"
checked={selectedIds.length === filteredDetections.length && filteredDetections.length > 0}
onCheckedChange={toggleSelectAll}
/>
<Label htmlFor="select-all" className="cursor-pointer">Select All Visible</Label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredDetections.length > 0 ? (
filteredDetections.map((d) => {
const primaryMatch = d.marketplaceMatches[0];
const productImage = primaryMatch?.imageUrl;
const snapshotImage = d.detection.thumbnailUrl;
// Main image priority: Product Image -> Snapshot
const mainImage = productImage || snapshotImage;
// PiP Logic: Show Snapshot if we have BOTH images and they are different
// (i.e. snapshot is not just a fallback copy of product image)
const isSnapshotFallback = snapshotImage === productImage;
const showPip = snapshotImage && productImage && !isSnapshotFallback;
return (
<Card key={d.detection.id} className={`group overflow-hidden transition-all hover:ring-2 hover:ring-primary/50 ${selectedIds.includes(d.detection.id) ? 'ring-2 ring-primary' : 'bg-card/40 backdrop-blur-md border-border/40'}`}>
<div className="relative aspect-square overflow-hidden bg-muted">
{mainImage ? (
<Image
src={mainImage}
alt={d.detection.objectName}
fill
className="object-contain transition-transform group-hover:scale-105"
/>
) : (
<div className="w-full h-full bg-muted flex items-center justify-center">
<Filter className="w-8 h-8 text-muted-foreground" />
</div>
)}
{/* Video Snapshot Inset (PiP) */}
{showPip && (
<div className="absolute bottom-2 left-2 w-16 h-16 rounded-lg overflow-hidden border-2 border-background shadow-xl z-20 transition-transform duration-300 group-hover:translate-x-1 group-hover:-translate-y-1">
<Image
src={snapshotImage!}
alt="In video"
fill
className="object-cover"
sizes="64px"
/>
<div className="absolute inset-0 bg-black/20" />
<div className="absolute bottom-0 left-0 right-0 bg-black/60 py-0.5 px-0.5">
<span className="text-[6px] font-bold text-white uppercase tracking-tighter block text-center">
IN VIDEO
</span>
</div>
</div>
)}
<div className="absolute top-2 left-2">
<Checkbox
checked={selectedIds.includes(d.detection.id)}
onCheckedChange={() => toggleSelection(d.detection.id)}
className="bg-background/80 backdrop-blur-sm"
/>
</div>
<div className="absolute top-2 right-2 flex flex-col gap-1 items-end">
<Badge variant="secondary" className="bg-background/80 backdrop-blur-sm">
{Math.round(d.detection.confidenceScore * 100)}%
</Badge>
{d.clickCount > 0 && (
<Badge variant="secondary" className="bg-blue-500/90 text-white backdrop-blur-sm flex items-center gap-1 text-xs px-1.5 py-0.5">
<MousePointerClick className="h-3 w-3" />
{d.clickCount}
</Badge>
)}
{d.interestPledgeCount > 0 && (
<Badge variant="secondary" className="bg-rose-500/90 text-white backdrop-blur-sm flex items-center gap-1 text-xs px-1.5 py-0.5">
<Heart className="h-3 w-3" />
{d.interestPledgeCount}
</Badge>
)}
</div>
{d.marketplaceMatches.length > 0 && (
<div className="absolute bottom-2 right-2">
<Badge variant="default" className="bg-emerald-500/90 gap-1">
<ShoppingCart className="w-3 h-3" /> {d.marketplaceMatches.length} matches
</Badge>
</div>
)}
</div>
<CardHeader className="p-4 py-3 space-y-1">
<div className="flex justify-between items-start">
<CardTitle className="text-lg font-bold truncate pr-2">
{d.detection.objectName}
</CardTitle>
<EditDetectionDialog
detection={d.detection}
marketplaceMatches={d.marketplaceMatches}
onSave={(updated: { objectName: string; category: string; thumbnailUrl?: string }) => {
setDetections(prev => prev.map(item =>
item.detection.id === d.detection.id ? { ...item, detection: { ...item.detection, ...updated } } : item
));
}}
/>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline" className="text-[10px] uppercase">
{d.detection.category}
</Badge>
<span className="text-[10px] truncate">• {d.video.title}</span>
</div>
</CardHeader>
<CardFooter className="p-4 pt-0 gap-2">
{filterStatus === 'PENDING' ? (
<>
<Button
variant="secondary"
className="grow bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500 border-none"
onClick={() => handleApprove(d.detection.id)}
disabled={isPending}
>
<Check className="w-4 h-4 mr-2" /> Approve
</Button>
<Button
variant="secondary"
className="grow bg-rose-500/10 hover:bg-rose-500/20 text-rose-500 border-none"
onClick={() => handleReject(d.detection.id)}
disabled={isPending}
>
<X className="w-4 h-4 mr-2" /> Reject
</Button>
</>
) : (
<div className="flex items-center text-sm font-medium gap-2 py-2 text-muted-foreground">
{filterStatus === 'APPROVED' ? (
<><CheckCircle2 className="w-4 h-4 text-emerald-500" /> Approved</>
) : (
<><X className="w-4 h-4 text-rose-500" /> Rejected</>
)}
<Button variant="link" size="sm" className="h-auto p-0 ml-auto" onClick={() => handleReset(d.detection.id)} disabled={isPending}>
Reset
</Button>
</div>
)}
</CardFooter>
</Card>
);
})
) : (
<div className="col-span-full py-20 text-center space-y-4 bg-muted/20 rounded-xl border-2 border-dashed border-border/50">
<div className="flex justify-center">
<ShieldCheck className="w-12 h-12 text-muted-foreground opacity-20" />
</div>
<div className="space-y-2">
<h3 className="text-xl font-semibold">Queue Clear!</h3>
<p className="text-muted-foreground">No {filterStatus.toLowerCase()} items found with current filters.</p>
</div>
<Button variant="outline" onClick={() => { setFilterStatus('PENDING'); setMinConfidence(0); }}>
View Pending Queue
</Button>
</div>
)}
</div>
</div>
);
}