dvijaykrishnan commited on
Commit
931547f
·
1 Parent(s): 792119a

feat: Implement a new product form for creating and editing product details, including image uploads and marketplace metadata fetching, and refine video analysis skipping logic.

Browse files
src/features/moderation/actions/edit-detection.ts CHANGED
@@ -22,10 +22,18 @@ export interface EditDetectionData {
22
  category?: 'Tech' | 'Fashion' | 'Furniture' | 'Audio' | 'Other';
23
  thumbnailUrl?: string;
24
  marketplaceLinkOverrides?: Record<string, string>; // matchId → new affiliateUrl
 
 
 
 
 
 
25
  newMarketplaceMatch?: NewMarketplaceMatch;
26
  }
27
 
28
  export async function editDetection(detectionId: string, data: EditDetectionData) {
 
 
29
  try {
30
  const session = await auth.api.getSession({
31
  headers: await headers(),
@@ -74,6 +82,12 @@ export async function editDetection(detectionId: string, data: EditDetectionData
74
  editHistory: [...(currentMetadata.editHistory || []), historyEntry]
75
  };
76
 
 
 
 
 
 
 
77
  // Update detection
78
  await db
79
  .update(detectedObjects)
@@ -101,6 +115,27 @@ export async function editDetection(detectionId: string, data: EditDetectionData
101
  }
102
  }
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  // Insert a new marketplace match if provided
105
  if (data.newMarketplaceMatch) {
106
  await db
 
22
  category?: 'Tech' | 'Fashion' | 'Furniture' | 'Audio' | 'Other';
23
  thumbnailUrl?: string;
24
  marketplaceLinkOverrides?: Record<string, string>; // matchId → new affiliateUrl
25
+ marketplaceMatchUpdates?: Record<string, {
26
+ marketplace?: 'amazon' | 'ebay' | 'etsy';
27
+ productName?: string;
28
+ price?: number;
29
+ affiliateUrl?: string;
30
+ }>;
31
  newMarketplaceMatch?: NewMarketplaceMatch;
32
  }
33
 
34
  export async function editDetection(detectionId: string, data: EditDetectionData) {
35
+ console.log(`[editDetection] Input for ${detectionId}:`, JSON.stringify(data, null, 2));
36
+ console.log(`[editDetection] Starting update for ${detectionId}`, JSON.stringify(data, null, 2));
37
  try {
38
  const session = await auth.api.getSession({
39
  headers: await headers(),
 
82
  editHistory: [...(currentMetadata.editHistory || []), historyEntry]
83
  };
84
 
85
+ console.log(`[editDetection] Updating detection:`, {
86
+ objectName: data.objectName,
87
+ category: data.category,
88
+ thumbnailUrl: data.thumbnailUrl
89
+ });
90
+
91
  // Update detection
92
  await db
93
  .update(detectedObjects)
 
115
  }
116
  }
117
 
118
+ // Update detection matches with full details
119
+ if (data.marketplaceMatchUpdates) {
120
+ for (const [matchId, updates] of Object.entries(data.marketplaceMatchUpdates)) {
121
+ await db
122
+ .update(marketplaceMatches)
123
+ .set({
124
+ ...(updates.marketplace ? { marketplace: updates.marketplace } : {}),
125
+ ...(updates.productName ? { productName: updates.productName } : {}),
126
+ ...(updates.price !== undefined ? { price: updates.price } : {}),
127
+ ...(updates.affiliateUrl ? { affiliateUrl: updates.affiliateUrl } : {}),
128
+ updatedAt: new Date(),
129
+ })
130
+ .where(
131
+ and(
132
+ eq(marketplaceMatches.id, matchId),
133
+ eq(marketplaceMatches.objectId, detectionId)
134
+ )
135
+ );
136
+ }
137
+ }
138
+
139
  // Insert a new marketplace match if provided
140
  if (data.newMarketplaceMatch) {
141
  await db
src/features/moderation/components/add-product-dialog.tsx CHANGED
@@ -1,22 +1,17 @@
1
- import React, { useState, useTransition, useEffect } from 'react';
2
  import {
3
  Dialog,
4
  DialogContent,
5
  DialogDescription,
6
- DialogFooter,
7
  DialogHeader,
8
  DialogTitle,
9
  DialogTrigger,
10
  } from '@/components/ui/dialog';
11
  import { Button } from '@/components/ui/button';
12
- import { Input } from '@/components/ui/input';
13
- import { Label } from '@/components/ui/label';
14
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
15
- import { Plus, Save, Search, Loader2, Upload, Trash2, Image as ImageIcon, PlaySquare } from 'lucide-react';
16
  import { addDetection } from '../actions/add-detection';
17
- import { fetchProductMetadata } from '../actions/fetch-product-metadata';
18
- import { uploadProductImage } from '../actions/upload-product-image';
19
  import { toast } from 'sonner';
 
20
 
21
  interface Video {
22
  id: string;
@@ -39,218 +34,40 @@ interface AddProductDialogProps {
39
 
40
  export function AddProductDialog({ videos, onAdd }: AddProductDialogProps) {
41
  const [open, setOpen] = useState(false);
42
- const [videoId, setVideoId] = useState(videos[0]?.id || '');
43
- const [objectName, setObjectName] = useState('');
44
- const [category, setCategory] = useState('Tech');
45
- const [thumbnailUrl, setThumbnailUrl] = useState('');
46
- const [frameTimestamp, setFrameTimestamp] = useState('0');
47
- const [marketplace, setMarketplace] = useState('');
48
- const [productName, setProductName] = useState('');
49
- const [price, setPrice] = useState('');
50
- const [affiliateUrl, setAffiliateUrl] = useState('');
51
- const [snapshotUrl, setSnapshotUrl] = useState('');
52
  const [isPending, startTransition] = useTransition();
53
- const [isFetching, setIsFetching] = useState(false);
54
- const [isUploading, setIsUploading] = useState(false);
55
- const [isUploadingSnapshot, setIsUploadingSnapshot] = useState(false);
56
-
57
- // Auto-select video if videos list changes or when opening
58
- useEffect(() => {
59
- if (open && videos.length === 1) {
60
- setVideoId(videos[0].id);
61
- }
62
- }, [open, videos]);
63
-
64
- // Clipboard Paste Listener
65
- useEffect(() => {
66
- const handlePaste = async (e: ClipboardEvent) => {
67
- if (!open) return;
68
-
69
- // Only paste into the snapshot if we are not currently focused on an input
70
- // that might want the paste data (like the image URL input)
71
- const target = e.target as HTMLElement;
72
- const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA';
73
-
74
- // If it's an image in the clipboard, always prioritize snapshot
75
- const items = e.clipboardData?.items;
76
- if (!items) return;
77
-
78
- for (let i = 0; i < items.length; i++) {
79
- if (items[i].type.indexOf('image') !== -1) {
80
- let file = items[i].getAsFile();
81
- if (!file) continue;
82
-
83
- // Ensure file has a name for storage, as clipboard blobs often don't
84
- if (!file.name || file.name === 'blob') {
85
- const extension = file.type.split('/')[1] || 'png';
86
- file = new File([file], `paste-${Date.now()}.${extension}`, { type: file.type });
87
- }
88
-
89
- if (file.size > 10 * 1024 * 1024) {
90
- toast.error('Image too large (max 10MB)');
91
- continue;
92
- }
93
-
94
- setIsUploadingSnapshot(true);
95
- const formData = new FormData();
96
- formData.append('file', file);
97
-
98
- try {
99
- const result = await uploadProductImage(formData);
100
- if (result.success && result.url) {
101
- setSnapshotUrl(result.url);
102
- toast.success('Snapshot pasted from clipboard!');
103
- } else {
104
- toast.error(result.error || 'Failed to upload pasted image');
105
- }
106
- } catch (err: any) {
107
- console.error('Paste upload error:', err);
108
- toast.error(`Error uploading pasted image: ${err.message || 'Unknown error'}`);
109
- } finally {
110
- setIsUploadingSnapshot(false);
111
- }
112
- break;
113
- }
114
- }
115
- };
116
-
117
- window.addEventListener('paste', handlePaste);
118
- return () => window.removeEventListener('paste', handlePaste);
119
- }, [open]);
120
-
121
- const resetForm = () => {
122
- setVideoId(videos[0]?.id || '');
123
- setObjectName('');
124
- setCategory('Tech');
125
- setThumbnailUrl('');
126
- setFrameTimestamp('0');
127
- setMarketplace('');
128
- setProductName('');
129
- setPrice('');
130
- setAffiliateUrl('');
131
- };
132
-
133
- const handleFetchMetadata = async () => {
134
- if (!affiliateUrl) return;
135
-
136
- setIsFetching(true);
137
- try {
138
- const result = await fetchProductMetadata(affiliateUrl);
139
- if (result.success && result.data) {
140
- const { productName, price, marketplace, imageUrl } = result.data;
141
- setObjectName(productName);
142
- setProductName(productName);
143
- setPrice(price.toString());
144
- setMarketplace(marketplace);
145
- if (imageUrl) {
146
- console.log('FETCH_DEBUG: Received imageUrl', imageUrl);
147
- setThumbnailUrl(imageUrl);
148
- } else {
149
- console.warn('FETCH_DEBUG: No imageUrl returned');
150
- }
151
- toast.success('Product info fetched!');
152
- } else {
153
- toast.error(result.error || 'Could not fetch product info');
154
- }
155
- } catch (err) {
156
- toast.error('Error fetching product info');
157
- } finally {
158
- setIsFetching(false);
159
- }
160
- };
161
-
162
- const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
163
- const file = e.target.files?.[0];
164
- if (!file) return;
165
-
166
- setIsUploading(true);
167
- const formData = new FormData();
168
- formData.append('file', file);
169
-
170
- try {
171
- const result = await uploadProductImage(formData);
172
- if (result.success && result.url) {
173
- setThumbnailUrl(result.url);
174
- toast.success('Image uploaded!');
175
- } else {
176
- toast.error(result.error || 'Failed to upload image');
177
- }
178
- } catch (err) {
179
- toast.error('Error uploading image');
180
- } finally {
181
- setIsUploading(false);
182
- }
183
- };
184
-
185
- const handleSnapshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
186
- const file = e.target.files?.[0];
187
- if (!file) return;
188
-
189
- setIsUploadingSnapshot(true);
190
- const formData = new FormData();
191
- formData.append('file', file);
192
-
193
- try {
194
- const result = await uploadProductImage(formData);
195
- if (result.success && result.url) {
196
- setSnapshotUrl(result.url);
197
- toast.success('Snapshot uploaded!');
198
- } else {
199
- toast.error(result.error || 'Failed to upload snapshot');
200
- }
201
- } catch (err) {
202
- toast.error('Error uploading snapshot');
203
- } finally {
204
- setIsUploadingSnapshot(false);
205
- }
206
- };
207
-
208
- const handleSave = () => {
209
- if (!videoId || !objectName || !category) {
210
- toast.error('Please fill in required fields');
211
- return;
212
- }
213
 
 
214
  startTransition(async () => {
215
  const result = await addDetection({
216
- videoId,
217
- objectName,
218
- category: category as any,
219
- thumbnailUrl: snapshotUrl || undefined, // Video snapshot
220
- frameTimestamp: parseInt(frameTimestamp, 10) || 0,
221
- marketplaceMatch: marketplace && (affiliateUrl || productName)
222
  ? {
223
- marketplace: marketplace as any,
224
- productName: productName || objectName,
225
- price: parseFloat(price) || 0,
226
- affiliateUrl: affiliateUrl || '',
227
- imageUrl: thumbnailUrl || undefined, // Marketplace product image
228
  }
229
  : undefined,
230
  });
231
 
232
  if (result.success) {
233
- const selectedVideo = videos.find(v => v.id === videoId);
234
  onAdd({
235
  id: result.detectionId!,
236
- videoId,
237
  videoTitle: selectedVideo?.title ?? '',
238
- objectName,
239
- category,
240
- frameTimestamp: parseInt(frameTimestamp, 10) || 0,
241
- thumbnailUrl: snapshotUrl || thumbnailUrl || undefined,
242
- hasMarketplaceMatch: !!(marketplace && (affiliateUrl || productName)),
243
  });
244
  setOpen(false);
245
- // Reset form
246
- setObjectName('');
247
- setCategory('Tech');
248
- setThumbnailUrl('');
249
- setSnapshotUrl('');
250
- setProductName('');
251
- setPrice('');
252
- setAffiliateUrl('');
253
- setMarketplace('');
254
  toast.success('Product added successfully');
255
  } else {
256
  toast.error(result.error || 'Failed to add product');
@@ -276,255 +93,13 @@ export function AddProductDialog({ videos, onAdd }: AddProductDialogProps) {
276
  </DialogDescription>
277
  </DialogHeader>
278
 
279
- <div className="grid gap-6 py-4">
280
- {/* Link Section - Prioritized */}
281
- <div className="space-y-4 p-4 rounded-xl bg-primary/5 border border-primary/10">
282
- <div className="grid gap-2">
283
- <Label htmlFor="affiliate-url" className="text-sm font-medium text-primary">Affiliate or Product URL</Label>
284
- <div className="flex gap-2">
285
- <Input
286
- id="affiliate-url"
287
- value={affiliateUrl}
288
- onChange={(e) => setAffiliateUrl(e.target.value)}
289
- placeholder="Paste Amazon, eBay, or Etsy link..."
290
- className="bg-background/50"
291
- onBlur={() => {
292
- if (affiliateUrl && !objectName) handleFetchMetadata();
293
- }}
294
- />
295
- <Button
296
- size="icon"
297
- onClick={handleFetchMetadata}
298
- disabled={isFetching || !affiliateUrl}
299
- className="shrink-0"
300
- >
301
- {isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
302
- </Button>
303
- </div>
304
- <p className="text-[10px] text-muted-foreground">
305
- Supported: Amazon, eBay, Etsy. We'll try to fetch price, name, and image.
306
- </p>
307
- </div>
308
-
309
- {marketplace && (
310
- <div className="grid grid-cols-2 gap-4 animate-in fade-in slide-in-from-top-2">
311
- <div className="grid gap-2">
312
- <Label className="text-xs">Marketplace</Label>
313
- <Select value={marketplace} onValueChange={setMarketplace}>
314
- <SelectTrigger className="h-8 text-xs bg-background/50">
315
- <SelectValue />
316
- </SelectTrigger>
317
- <SelectContent>
318
- <SelectItem value="amazon">Amazon</SelectItem>
319
- <SelectItem value="ebay">eBay</SelectItem>
320
- <SelectItem value="etsy">Etsy</SelectItem>
321
- </SelectContent>
322
- </Select>
323
- </div>
324
- <div className="grid gap-2">
325
- <Label htmlFor="price" className="text-xs">Price ($)</Label>
326
- <Input
327
- id="price"
328
- type="number"
329
- value={price}
330
- onChange={(e) => setPrice(e.target.value)}
331
- className="h-8 text-xs bg-background/50"
332
- />
333
- </div>
334
- </div>
335
- )}
336
- </div>
337
-
338
- <div className="grid gap-4 px-1">
339
- <div className="grid gap-2">
340
- <Label htmlFor="object-name">Object / Product Name *</Label>
341
- <Input
342
- id="object-name"
343
- value={objectName}
344
- onChange={(e) => setObjectName(e.target.value)}
345
- placeholder="e.g. Sony WH-1000XM5"
346
- />
347
- </div>
348
-
349
- <div className="grid grid-cols-1 gap-4">
350
- <div className="grid gap-2">
351
- <Label>Video *</Label>
352
- <Select value={videoId} onValueChange={setVideoId}>
353
- <SelectTrigger className="w-full">
354
- <SelectValue placeholder="Select video" />
355
- </SelectTrigger>
356
- <SelectContent>
357
- {videos.map(v => (
358
- <SelectItem key={v.id} value={v.id}>{v.title}</SelectItem>
359
- ))}
360
- </SelectContent>
361
- </Select>
362
- </div>
363
- <div className="grid gap-2">
364
- <Label>Category *</Label>
365
- <Select value={category} onValueChange={setCategory}>
366
- <SelectTrigger className="w-full">
367
- <SelectValue />
368
- </SelectTrigger>
369
- <SelectContent>
370
- <SelectItem value="Tech">Tech</SelectItem>
371
- <SelectItem value="Fashion">Fashion</SelectItem>
372
- <SelectItem value="Furniture">Furniture</SelectItem>
373
- <SelectItem value="Audio">Audio</SelectItem>
374
- <SelectItem value="Other">Other</SelectItem>
375
- </SelectContent>
376
- </Select>
377
- </div>
378
- </div>
379
-
380
- <div className="grid grid-cols-1 gap-4">
381
- <div className="grid gap-2">
382
- <Label htmlFor="timestamp">Video Timestamp (sec)</Label>
383
- <Input
384
- id="timestamp"
385
- type="number"
386
- value={frameTimestamp}
387
- onChange={(e) => setFrameTimestamp(e.target.value)}
388
- />
389
- </div>
390
- <div className="grid grid-cols-2 gap-6">
391
- {/* Product Image Section */}
392
- <div className="space-y-3">
393
- <Label className="text-sm font-semibold flex items-center gap-2 h-6">
394
- <ImageIcon className="h-4 w-4 text-primary" />
395
- Product Image
396
- </Label>
397
- <div className="space-y-3">
398
- <div className="relative aspect-square w-full rounded-xl overflow-hidden border border-white/10 bg-white/5 group">
399
- {thumbnailUrl ? (
400
- <>
401
- <img
402
- src={thumbnailUrl}
403
- alt="Product"
404
- className="w-full h-full object-contain"
405
- onError={() => {
406
- console.error('IMAGE_LOAD_ERROR: Failed to load', thumbnailUrl);
407
- // Optional: toast.error('Check your internet or image URL');
408
- }}
409
- />
410
- <div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-all duration-200">
411
- <Button variant="destructive" size="icon" onClick={() => setThumbnailUrl('')} className="rounded-full shadow-xl transform scale-90 hover:scale-100 transition-transform">
412
- <Trash2 className="h-4 w-4" />
413
- </Button>
414
- </div>
415
- </>
416
- ) : (
417
- <div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground p-4 text-center">
418
- <ImageIcon className="h-8 w-8 mb-2 opacity-20" />
419
- <span className="text-[10px]">Marketplace or Custom Image</span>
420
- </div>
421
- )}
422
- {isUploading && (
423
- <div className="absolute inset-0 bg-black/60 backdrop-blur-[2px] flex items-center justify-center">
424
- <Loader2 className="h-6 w-6 animate-spin text-primary" />
425
- </div>
426
- )}
427
- </div>
428
- <div className="flex gap-2">
429
- <div className="relative flex-1">
430
- <Input
431
- value={thumbnailUrl}
432
- onChange={(e) => setThumbnailUrl(e.target.value)}
433
- placeholder="Image URL"
434
- className="h-9 text-xs pr-8 bg-background/50"
435
- />
436
- </div>
437
- <div className="relative">
438
- <input type="file" id="prod-upload" className="hidden" accept="image/*" onChange={handleImageUpload} disabled={isUploading} />
439
- <Button variant="outline" size="icon" className="h-9 w-9 shrink-0" asChild disabled={isUploading}>
440
- <label htmlFor="prod-upload" className="cursor-pointer flex items-center justify-center w-full h-full">
441
- <Upload className="h-4 w-4" />
442
- </label>
443
- </Button>
444
- </div>
445
- </div>
446
- </div>
447
- </div>
448
-
449
- {/* In-Video Snapshot Section */}
450
- <div className="space-y-3">
451
- <Label className="text-sm font-semibold flex items-center gap-2 h-6">
452
- <PlaySquare className="h-4 w-4 text-secondary" />
453
- In-Video Snapshot
454
- <span className="ml-auto text-[10px] font-normal text-muted-foreground bg-white/5 px-1.5 py-0.5 rounded border border-white/10 animate-pulse">
455
- Cmd+V to Paste
456
- </span>
457
- </Label>
458
- <div className="space-y-3">
459
- <div className="relative aspect-square w-full rounded-xl overflow-hidden border border-white/10 bg-white/5 group">
460
- {snapshotUrl ? (
461
- <>
462
- <img src={snapshotUrl} alt="Snapshot" className="w-full h-full object-cover" />
463
- <div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-all duration-200">
464
- <Button variant="destructive" size="icon" onClick={() => setSnapshotUrl('')} className="rounded-full shadow-xl transform scale-90 hover:scale-100 transition-transform">
465
- <Trash2 className="h-4 w-4" />
466
- </Button>
467
- </div>
468
- </>
469
- ) : (
470
- <div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground p-4 text-center">
471
- <PlaySquare className="h-8 w-8 mb-2 opacity-20" />
472
- <span className="text-[10px]">Actual moment from video</span>
473
- </div>
474
- )}
475
- {isUploadingSnapshot && (
476
- <div className="absolute inset-0 bg-black/60 backdrop-blur-[2px] flex items-center justify-center">
477
- <Loader2 className="h-6 w-6 animate-spin text-secondary" />
478
- </div>
479
- )}
480
- </div>
481
- <div className="flex gap-2">
482
- <div className="relative flex-1">
483
- <Input
484
- value={snapshotUrl}
485
- onChange={(e) => setSnapshotUrl(e.target.value)}
486
- placeholder="Snapshot URL"
487
- className="h-9 text-xs pr-8 bg-background/50"
488
- />
489
- </div>
490
- <div className="relative">
491
- <input type="file" id="snap-upload" className="hidden" accept="image/*" onChange={handleSnapshotUpload} disabled={isUploadingSnapshot} />
492
- <Button variant="outline" size="icon" className="h-9 w-9 shrink-0" asChild disabled={isUploadingSnapshot}>
493
- <label htmlFor="snap-upload" className="cursor-pointer flex items-center justify-center w-full h-full">
494
- <Upload className="h-4 w-4" />
495
- </label>
496
- </Button>
497
- </div>
498
- </div>
499
- </div>
500
- </div>
501
- </div>
502
- </div>
503
- </div>
504
- </div>
505
-
506
- <DialogFooter className="gap-2 sm:gap-0">
507
- <Button variant="ghost" onClick={() => { setOpen(false); resetForm(); }} disabled={isPending}>
508
- Cancel
509
- </Button>
510
- <Button
511
- onClick={handleSave}
512
- disabled={isPending || !videoId || !objectName || !category}
513
- className="bg-primary hover:bg-primary/90 shadow-lg shadow-primary/20"
514
- >
515
- {isPending ? (
516
- <>
517
- <Loader2 className="w-4 h-4 mr-2 animate-spin" />
518
- Adding...
519
- </>
520
- ) : (
521
- <>
522
- <Save className="w-4 h-4 mr-2" />
523
- Add to Vault
524
- </>
525
- )}
526
- </Button>
527
- </DialogFooter>
528
  </DialogContent>
529
  </Dialog>
530
  );
 
1
+ import React, { useState, useTransition } from 'react';
2
  import {
3
  Dialog,
4
  DialogContent,
5
  DialogDescription,
 
6
  DialogHeader,
7
  DialogTitle,
8
  DialogTrigger,
9
  } from '@/components/ui/dialog';
10
  import { Button } from '@/components/ui/button';
11
+ import { Plus } from 'lucide-react';
 
 
 
12
  import { addDetection } from '../actions/add-detection';
 
 
13
  import { toast } from 'sonner';
14
+ import { ProductForm, ProductFormData } from './product-form';
15
 
16
  interface Video {
17
  id: string;
 
34
 
35
  export function AddProductDialog({ videos, onAdd }: AddProductDialogProps) {
36
  const [open, setOpen] = useState(false);
 
 
 
 
 
 
 
 
 
 
37
  const [isPending, startTransition] = useTransition();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
+ const handleSubmit = (data: ProductFormData) => {
40
  startTransition(async () => {
41
  const result = await addDetection({
42
+ videoId: data.videoId,
43
+ objectName: data.objectName,
44
+ category: data.category as any,
45
+ thumbnailUrl: data.snapshotUrl || data.thumbnailUrl || undefined, // Prioritize snapshot, fallback to product image
46
+ frameTimestamp: data.frameTimestamp,
47
+ marketplaceMatch: data.marketplaceData
48
  ? {
49
+ marketplace: data.marketplaceData.marketplace as any,
50
+ productName: data.marketplaceData.productName,
51
+ price: parseFloat(data.marketplaceData.price) || 0,
52
+ affiliateUrl: data.marketplaceData.affiliateUrl,
53
+ imageUrl: data.thumbnailUrl || undefined, // Provide product image for marketplace match
54
  }
55
  : undefined,
56
  });
57
 
58
  if (result.success) {
59
+ const selectedVideo = videos.find(v => v.id === data.videoId);
60
  onAdd({
61
  id: result.detectionId!,
62
+ videoId: data.videoId,
63
  videoTitle: selectedVideo?.title ?? '',
64
+ objectName: data.objectName,
65
+ category: data.category,
66
+ frameTimestamp: data.frameTimestamp, // Ensure int
67
+ thumbnailUrl: data.snapshotUrl || data.thumbnailUrl || undefined,
68
+ hasMarketplaceMatch: !!data.marketplaceData,
69
  });
70
  setOpen(false);
 
 
 
 
 
 
 
 
 
71
  toast.success('Product added successfully');
72
  } else {
73
  toast.error(result.error || 'Failed to add product');
 
93
  </DialogDescription>
94
  </DialogHeader>
95
 
96
+ <ProductForm
97
+ mode="create"
98
+ videos={videos}
99
+ onSubmit={handleSubmit}
100
+ onCancel={() => setOpen(false)}
101
+ isSubmitting={isPending}
102
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  </DialogContent>
104
  </Dialog>
105
  );
src/features/moderation/components/edit-detection-dialog.tsx CHANGED
@@ -5,32 +5,23 @@ import {
5
  Dialog,
6
  DialogContent,
7
  DialogDescription,
8
- DialogFooter,
9
  DialogHeader,
10
  DialogTitle,
11
  DialogTrigger,
12
  } from '@/components/ui/dialog';
13
- import { Badge } from '@/components/ui/badge';
14
  import { Button } from '@/components/ui/button';
15
- import { Input } from '@/components/ui/input';
16
- import { Label } from '@/components/ui/label';
17
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
18
- import { Edit2, Save, Plus } from 'lucide-react';
19
  import { editDetection } from '../actions/edit-detection';
20
  import { toast } from 'sonner';
 
21
 
22
  interface MarketplaceMatch {
23
  id: string;
24
  marketplace: string;
25
  productName: string;
26
  affiliateUrl: string;
27
- }
28
-
29
- interface NewMatch {
30
- marketplace: string;
31
- productName: string;
32
- price: string;
33
- affiliateUrl: string;
34
  }
35
 
36
  interface EditDetectionDialogProps {
@@ -39,6 +30,7 @@ interface EditDetectionDialogProps {
39
  objectName: string;
40
  category: string;
41
  thumbnailUrl?: string | null;
 
42
  };
43
  marketplaceMatches: MarketplaceMatch[];
44
  onSave: (updated: { objectName: string; category: string; thumbnailUrl?: string }) => void;
@@ -46,35 +38,57 @@ interface EditDetectionDialogProps {
46
 
47
  export function EditDetectionDialog({ detection, marketplaceMatches, onSave }: EditDetectionDialogProps) {
48
  const [open, setOpen] = useState(false);
49
- const [name, setName] = useState(detection.objectName);
50
- const [category, setCategory] = useState(detection.category);
51
- const [thumbnailUrl, setThumbnailUrl] = useState(detection.thumbnailUrl ?? '');
52
- const [linkOverrides, setLinkOverrides] = useState<Record<string, string>>({});
53
- const [newMatch, setNewMatch] = useState<NewMatch>({ marketplace: '', productName: '', price: '', affiliateUrl: '' });
54
- const [showNewMatch, setShowNewMatch] = useState(false);
55
  const [isPending, startTransition] = useTransition();
56
 
57
- const handleSave = () => {
 
 
 
58
  startTransition(async () => {
59
- const result = await editDetection(detection.id, {
60
- objectName: name,
61
- category: category as any,
62
- thumbnailUrl: thumbnailUrl || undefined,
63
- marketplaceLinkOverrides: Object.keys(linkOverrides).length > 0 ? linkOverrides : undefined,
64
- newMarketplaceMatch: showNewMatch && newMatch.marketplace && newMatch.affiliateUrl
65
- ? { marketplace: newMatch.marketplace as any, productName: newMatch.productName, price: parseFloat(newMatch.price) || 0, affiliateUrl: newMatch.affiliateUrl }
66
- : undefined,
67
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  if (result.success) {
70
- onSave({ objectName: name, category, thumbnailUrl: thumbnailUrl || undefined });
 
 
 
 
71
  setOpen(false);
72
- setLinkOverrides({});
73
- setNewMatch({ marketplace: '', productName: '', price: '', affiliateUrl: '' });
74
- setShowNewMatch(false);
75
  toast.success('Detection updated');
76
  } else {
77
- toast.error(result.error);
78
  }
79
  });
80
  };
@@ -86,137 +100,40 @@ export function EditDetectionDialog({ detection, marketplaceMatches, onSave }: E
86
  <Edit2 className="w-4 h-4" />
87
  </Button>
88
  </DialogTrigger>
89
- <DialogContent className="sm:max-w-[500px] bg-card/95 backdrop-blur-md border-border/50">
90
  <DialogHeader>
91
- <DialogTitle>Edit Detection</DialogTitle>
 
 
92
  <DialogDescription>
93
  Manually override the AI-detected object details.
94
  </DialogDescription>
95
  </DialogHeader>
96
- <div className="grid gap-4 py-4">
97
- <div className="grid gap-2">
98
- <Label htmlFor="name">Object Name</Label>
99
- <Input
100
- id="name"
101
- value={name}
102
- onChange={(e) => setName(e.target.value)}
103
- placeholder="e.g. Sony WH-1000XM4"
104
- />
105
- </div>
106
- <div className="grid gap-2">
107
- <Label htmlFor="category">Category</Label>
108
- <Select value={category} onValueChange={setCategory}>
109
- <SelectTrigger>
110
- <SelectValue placeholder="Select category" />
111
- </SelectTrigger>
112
- <SelectContent>
113
- <SelectItem value="Tech">Tech</SelectItem>
114
- <SelectItem value="Fashion">Fashion</SelectItem>
115
- <SelectItem value="Furniture">Furniture</SelectItem>
116
- <SelectItem value="Audio">Audio</SelectItem>
117
- <SelectItem value="Other">Other</SelectItem>
118
- </SelectContent>
119
- </Select>
120
- </div>
121
- <div className="grid gap-2">
122
- <Label htmlFor="thumbnail-url">Thumbnail URL</Label>
123
- <Input
124
- id="thumbnail-url"
125
- value={thumbnailUrl}
126
- onChange={(e) => setThumbnailUrl(e.target.value)}
127
- placeholder="https://... (object detection crop image)"
128
- />
129
- </div>
130
- {marketplaceMatches.length > 0 && (
131
- <div className="grid gap-2">
132
- <Label>Marketplace Links</Label>
133
- <div className="space-y-2 max-h-32 overflow-y-auto pr-1">
134
- {marketplaceMatches.map((match) => (
135
- <div key={match.id} className="flex items-center gap-2">
136
- <Badge variant="outline" className="text-xs whitespace-nowrap shrink-0">{match.marketplace}</Badge>
137
- <Input
138
- value={linkOverrides[match.id] ?? match.affiliateUrl}
139
- onChange={(e) => setLinkOverrides(prev => ({ ...prev, [match.id]: e.target.value }))}
140
- placeholder="Affiliate URL"
141
- className="text-xs"
142
- />
143
- </div>
144
- ))}
145
- </div>
146
- </div>
147
- )}
148
- <div className="grid gap-2">
149
- <button
150
- type="button"
151
- onClick={() => setShowNewMatch(!showNewMatch)}
152
- className="flex items-center gap-1.5 text-sm text-primary hover:text-primary/80 transition-colors w-fit"
153
- >
154
- <Plus className="w-3.5 h-3.5" />
155
- {showNewMatch ? 'Hide new link' : 'Add marketplace link'}
156
- </button>
157
- {showNewMatch && (
158
- <div className="border border-border/50 rounded-lg p-3 space-y-3 bg-card/50">
159
- <div className="grid gap-2">
160
- <Label className="text-xs">Marketplace</Label>
161
- <Select value={newMatch.marketplace} onValueChange={(v) => setNewMatch(prev => ({ ...prev, marketplace: v }))}>
162
- <SelectTrigger className="text-xs">
163
- <SelectValue placeholder="Select marketplace" />
164
- </SelectTrigger>
165
- <SelectContent>
166
- <SelectItem value="amazon">Amazon</SelectItem>
167
- <SelectItem value="ebay">eBay</SelectItem>
168
- <SelectItem value="etsy">Etsy</SelectItem>
169
- </SelectContent>
170
- </Select>
171
- </div>
172
- <div className="grid gap-2">
173
- <Label htmlFor="new-product-name" className="text-xs">Product Name</Label>
174
- <Input
175
- id="new-product-name"
176
- value={newMatch.productName}
177
- onChange={(e) => setNewMatch(prev => ({ ...prev, productName: e.target.value }))}
178
- placeholder="e.g. Sony WH-1000XM5"
179
- className="text-xs"
180
- />
181
- </div>
182
- <div className="grid grid-cols-2 gap-2">
183
- <div className="grid gap-2">
184
- <Label htmlFor="new-price" className="text-xs">Price ($)</Label>
185
- <Input
186
- id="new-price"
187
- type="number"
188
- min="0"
189
- step="0.01"
190
- value={newMatch.price}
191
- onChange={(e) => setNewMatch(prev => ({ ...prev, price: e.target.value }))}
192
- placeholder="29.99"
193
- className="text-xs"
194
- />
195
- </div>
196
- </div>
197
- <div className="grid gap-2">
198
- <Label htmlFor="new-affiliate-url" className="text-xs">Affiliate URL</Label>
199
- <Input
200
- id="new-affiliate-url"
201
- value={newMatch.affiliateUrl}
202
- onChange={(e) => setNewMatch(prev => ({ ...prev, affiliateUrl: e.target.value }))}
203
- placeholder="https://amazon.com/..."
204
- className="text-xs"
205
- />
206
- </div>
207
- </div>
208
- )}
209
- </div>
210
- </div>
211
- <DialogFooter>
212
- <Button variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
213
- Cancel
214
- </Button>
215
- <Button onClick={handleSave} disabled={isPending}>
216
- {isPending ? 'Saving...' : 'Save Changes'}
217
- {!isPending && <Save className="w-4 h-4 ml-2" />}
218
- </Button>
219
- </DialogFooter>
220
  </DialogContent>
221
  </Dialog>
222
  );
 
5
  Dialog,
6
  DialogContent,
7
  DialogDescription,
 
8
  DialogHeader,
9
  DialogTitle,
10
  DialogTrigger,
11
  } from '@/components/ui/dialog';
 
12
  import { Button } from '@/components/ui/button';
13
+ import { Edit2 } from 'lucide-react';
 
 
 
14
  import { editDetection } from '../actions/edit-detection';
15
  import { toast } from 'sonner';
16
+ import { ProductForm, ProductFormData } from './product-form';
17
 
18
  interface MarketplaceMatch {
19
  id: string;
20
  marketplace: string;
21
  productName: string;
22
  affiliateUrl: string;
23
+ price?: number; // Optional in interface, but helps if available
24
+ imageUrl?: string; // Product Image from marketplace
 
 
 
 
 
25
  }
26
 
27
  interface EditDetectionDialogProps {
 
30
  objectName: string;
31
  category: string;
32
  thumbnailUrl?: string | null;
33
+ frameTimestamp?: number; // Add if available in detection object from parent, usually it is
34
  };
35
  marketplaceMatches: MarketplaceMatch[];
36
  onSave: (updated: { objectName: string; category: string; thumbnailUrl?: string }) => void;
 
38
 
39
  export function EditDetectionDialog({ detection, marketplaceMatches, onSave }: EditDetectionDialogProps) {
40
  const [open, setOpen] = useState(false);
 
 
 
 
 
 
41
  const [isPending, startTransition] = useTransition();
42
 
43
+ // Determine primary match (first one)
44
+ const primaryMatch = marketplaceMatches[0];
45
+
46
+ const handleSubmit = (data: ProductFormData) => {
47
  startTransition(async () => {
48
+ // Prepare update payload
49
+ const payload: any = {
50
+ objectName: data.objectName,
51
+ category: data.category as any,
52
+ thumbnailUrl: data.snapshotUrl || data.thumbnailUrl || undefined,
53
+ };
54
+
55
+ // Handle Marketplace Data
56
+ if (data.marketplaceData) {
57
+ if (primaryMatch) {
58
+ // Update existing primary match
59
+ payload.marketplaceMatchUpdates = {
60
+ [primaryMatch.id]: {
61
+ marketplace: data.marketplaceData.marketplace as any,
62
+ productName: data.marketplaceData.productName,
63
+ price: parseFloat(data.marketplaceData.price) || 0,
64
+ affiliateUrl: data.marketplaceData.affiliateUrl,
65
+ }
66
+ };
67
+ } else {
68
+ // Create new match
69
+ payload.newMarketplaceMatch = {
70
+ marketplace: data.marketplaceData.marketplace as any,
71
+ productName: data.marketplaceData.productName,
72
+ price: parseFloat(data.marketplaceData.price) || 0,
73
+ affiliateUrl: data.marketplaceData.affiliateUrl,
74
+ };
75
+ }
76
+ }
77
+
78
+ console.log('[EditDetectionDialog] Submitting payload:', payload);
79
+
80
+ const result = await editDetection(detection.id, payload);
81
 
82
  if (result.success) {
83
+ onSave({
84
+ objectName: data.objectName,
85
+ category: data.category,
86
+ thumbnailUrl: data.snapshotUrl || data.thumbnailUrl || undefined
87
+ });
88
  setOpen(false);
 
 
 
89
  toast.success('Detection updated');
90
  } else {
91
+ toast.error(result.error || 'Failed to update detection');
92
  }
93
  });
94
  };
 
100
  <Edit2 className="w-4 h-4" />
101
  </Button>
102
  </DialogTrigger>
103
+ <DialogContent className="sm:max-w-[540px] bg-card/95 backdrop-blur-xl border-white/10 max-h-[90vh] overflow-y-auto">
104
  <DialogHeader>
105
+ <DialogTitle className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-amber-400">
106
+ Edit Detection
107
+ </DialogTitle>
108
  <DialogDescription>
109
  Manually override the AI-detected object details.
110
  </DialogDescription>
111
  </DialogHeader>
112
+
113
+ <ProductForm
114
+ mode="edit"
115
+ initialData={{
116
+ videoId: '', // Not needed for edit
117
+ objectName: detection.objectName,
118
+ category: detection.category,
119
+ // If we have a marketplace match with an image, use that as "Product Image"
120
+ // Otherwise, fallback to empty string (user can upload one)
121
+ thumbnailUrl: primaryMatch?.imageUrl || '',
122
+ // The detection thumbnail is actually the snapshot from the video
123
+ // If it matches the product image (fallback), show empty snapshot to encourage upload
124
+ snapshotUrl: (detection.thumbnailUrl && detection.thumbnailUrl === primaryMatch?.imageUrl) ? '' : (detection.thumbnailUrl || ''),
125
+ frameTimestamp: detection.frameTimestamp || 0,
126
+ marketplaceData: primaryMatch ? {
127
+ marketplace: primaryMatch.marketplace,
128
+ productName: primaryMatch.productName,
129
+ price: primaryMatch.price?.toString() || '',
130
+ affiliateUrl: primaryMatch.affiliateUrl,
131
+ } : undefined
132
+ }}
133
+ onSubmit={handleSubmit}
134
+ onCancel={() => setOpen(false)}
135
+ isSubmitting={isPending}
136
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  </DialogContent>
138
  </Dialog>
139
  );
src/features/moderation/components/moderation-queue.tsx CHANGED
@@ -61,14 +61,14 @@ export function ModerationQueue({ initialDetections, userId, videos, stats, init
61
  // Status and confidence filter
62
  if (d.detection.moderationStatus !== filterStatus) return false;
63
  if (d.detection.confidenceScore < minConfidence) return false;
64
-
65
  // Video filter
66
  if (filterVideo !== 'all' && d.video.id !== filterVideo) return false;
67
-
68
  // Affiliate link filter
69
  if (filterHasLink === 'with-link' && d.marketplaceMatches.length === 0) return false;
70
  if (filterHasLink === 'without-link' && d.marketplaceMatches.length > 0) return false;
71
-
72
  return true;
73
  });
74
 
@@ -187,9 +187,8 @@ export function ModerationQueue({ initialDetections, userId, videos, stats, init
187
  key={status}
188
  type="button"
189
  onClick={() => setFilterStatus(status)}
190
- className={`text-left p-4 rounded-lg border transition-all group ${
191
- filterStatus === status ? activeClasses : 'bg-card/40 backdrop-blur-md border-border/40 hover:bg-card/60'
192
- }`}
193
  >
194
  <div className="flex items-center justify-between pb-2">
195
  <span className="text-sm font-medium">{title}</span>
@@ -279,41 +278,41 @@ export function ModerationQueue({ initialDetections, userId, videos, stats, init
279
  Showing {filteredDetections.length} of {detections.filter(d => d.detection.moderationStatus === filterStatus).length} items
280
  </div>
281
  <AddProductDialog
282
- videos={videos}
283
- onAdd={(newItem) => {
284
- setDetections(prev => [{
285
- detection: {
286
- id: newItem.id,
287
- objectName: newItem.objectName,
288
- category: newItem.category,
289
- confidenceScore: 1,
290
- moderationStatus: 'PENDING',
291
- thumbnailUrl: newItem.thumbnailUrl ?? null,
292
- createdAt: new Date(),
293
- },
294
- video: {
295
- id: newItem.videoId,
296
- title: newItem.videoTitle,
297
- thumbnailUrl: '',
298
- },
299
- marketplaceMatches: newItem.hasMarketplaceMatch ? [{ id: 'new', marketplace: 'amazon', productName: '', affiliateUrl: '' }] : [],
300
- clickCount: 0,
301
- interestPledgeCount: 0,
302
- }, ...prev]);
303
- }}
304
- />
305
  </div>
306
  </div>
307
 
308
  {selectedIds.length > 0 && (
309
  <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">
310
- <span className="text-sm font-medium mr-2">{selectedIds.length} selected</span>
311
- <Button size="sm" onClick={handleBulkApprove} disabled={isPending}>
312
- <Check className="w-4 h-4 mr-1" /> Approve
313
- </Button>
314
- <Button size="sm" variant="destructive" onClick={handleBulkReject} disabled={isPending}>
315
- <X className="w-4 h-4 mr-1" /> Reject
316
- </Button>
317
  </div>
318
  )}
319
 
@@ -328,112 +327,145 @@ export function ModerationQueue({ initialDetections, userId, videos, stats, init
328
 
329
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
330
  {filteredDetections.length > 0 ? (
331
- filteredDetections.map((d) => (
332
- <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'}`}>
333
- <div className="relative aspect-video overflow-hidden">
334
- {d.detection.thumbnailUrl ? (
335
- <Image
336
- src={d.detection.thumbnailUrl}
337
- alt={d.detection.objectName}
338
- fill
339
- className="object-cover transition-transform group-hover:scale-105"
340
- />
341
- ) : (
342
- <div className="w-full h-full bg-muted flex items-center justify-center">
343
- <Filter className="w-8 h-8 text-muted-foreground" />
344
- </div>
345
- )}
346
- <div className="absolute top-2 left-2">
347
- <Checkbox
348
- checked={selectedIds.includes(d.detection.id)}
349
- onCheckedChange={() => toggleSelection(d.detection.id)}
350
- className="bg-background/80 backdrop-blur-sm"
351
- />
352
- </div>
353
- <div className="absolute top-2 right-2 flex flex-col gap-1 items-end">
354
- <Badge variant="secondary" className="bg-background/80 backdrop-blur-sm">
355
- {Math.round(d.detection.confidenceScore * 100)}%
356
- </Badge>
357
- {d.clickCount > 0 && (
358
- <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">
359
- <MousePointerClick className="h-3 w-3" />
360
- {d.clickCount}
361
- </Badge>
362
  )}
363
- {d.interestPledgeCount > 0 && (
364
- <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">
365
- <Heart className="h-3 w-3" />
366
- {d.interestPledgeCount}
367
- </Badge>
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  )}
369
- </div>
370
- {d.marketplaceMatches.length > 0 && (
371
- <div className="absolute bottom-2 left-2">
372
- <Badge variant="default" className="bg-emerald-500/90 gap-1">
373
- <ShoppingCart className="w-3 h-3" /> {d.marketplaceMatches.length} matches
 
 
 
 
 
374
  </Badge>
 
 
 
 
 
 
 
 
 
 
 
 
375
  </div>
376
- )}
377
- </div>
378
-
379
- <CardHeader className="p-4 py-3 space-y-1">
380
- <div className="flex justify-between items-start">
381
- <CardTitle className="text-lg font-bold truncate pr-2">
382
- {d.detection.objectName}
383
- </CardTitle>
384
- <EditDetectionDialog
385
- detection={d.detection}
386
- marketplaceMatches={d.marketplaceMatches}
387
- onSave={(updated: { objectName: string; category: string; thumbnailUrl?: string }) => {
388
- setDetections(prev => prev.map(item =>
389
- item.detection.id === d.detection.id ? { ...item, detection: { ...item.detection, ...updated } } : item
390
- ));
391
- }}
392
- />
393
- </div>
394
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
395
- <Badge variant="outline" className="text-[10px] uppercase">
396
- {d.detection.category}
397
- </Badge>
398
- <span className="text-[10px] truncate">• {d.video.title}</span>
399
  </div>
400
- </CardHeader>
401
 
402
- <CardFooter className="p-4 pt-0 gap-2">
403
- {filterStatus === 'PENDING' ? (
404
- <>
405
- <Button
406
- variant="secondary"
407
- className="grow bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500 border-none"
408
- onClick={() => handleApprove(d.detection.id)}
409
- disabled={isPending}
410
- >
411
- <Check className="w-4 h-4 mr-2" /> Approve
412
- </Button>
413
- <Button
414
- variant="secondary"
415
- className="grow bg-rose-500/10 hover:bg-rose-500/20 text-rose-500 border-none"
416
- onClick={() => handleReject(d.detection.id)}
417
- disabled={isPending}
418
- >
419
- <X className="w-4 h-4 mr-2" /> Reject
420
- </Button>
421
- </>
422
- ) : (
423
- <div className="flex items-center text-sm font-medium gap-2 py-2 text-muted-foreground">
424
- {filterStatus === 'APPROVED' ? (
425
- <><CheckCircle2 className="w-4 h-4 text-emerald-500" /> Approved</>
426
- ) : (
427
- <><X className="w-4 h-4 text-rose-500" /> Rejected</>
428
- )}
429
- <Button variant="link" size="sm" className="h-auto p-0 ml-auto" onClick={() => handleReset(d.detection.id)} disabled={isPending}>
430
- Reset
431
- </Button>
432
  </div>
433
- )}
434
- </CardFooter>
435
- </Card>
436
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  ) : (
438
  <div className="col-span-full py-20 text-center space-y-4 bg-muted/20 rounded-xl border-2 border-dashed border-border/50">
439
  <div className="flex justify-center">
 
61
  // Status and confidence filter
62
  if (d.detection.moderationStatus !== filterStatus) return false;
63
  if (d.detection.confidenceScore < minConfidence) return false;
64
+
65
  // Video filter
66
  if (filterVideo !== 'all' && d.video.id !== filterVideo) return false;
67
+
68
  // Affiliate link filter
69
  if (filterHasLink === 'with-link' && d.marketplaceMatches.length === 0) return false;
70
  if (filterHasLink === 'without-link' && d.marketplaceMatches.length > 0) return false;
71
+
72
  return true;
73
  });
74
 
 
187
  key={status}
188
  type="button"
189
  onClick={() => setFilterStatus(status)}
190
+ 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'
191
+ }`}
 
192
  >
193
  <div className="flex items-center justify-between pb-2">
194
  <span className="text-sm font-medium">{title}</span>
 
278
  Showing {filteredDetections.length} of {detections.filter(d => d.detection.moderationStatus === filterStatus).length} items
279
  </div>
280
  <AddProductDialog
281
+ videos={videos}
282
+ onAdd={(newItem) => {
283
+ setDetections(prev => [{
284
+ detection: {
285
+ id: newItem.id,
286
+ objectName: newItem.objectName,
287
+ category: newItem.category,
288
+ confidenceScore: 1,
289
+ moderationStatus: 'PENDING',
290
+ thumbnailUrl: newItem.thumbnailUrl ?? null,
291
+ createdAt: new Date(),
292
+ },
293
+ video: {
294
+ id: newItem.videoId,
295
+ title: newItem.videoTitle,
296
+ thumbnailUrl: '',
297
+ },
298
+ marketplaceMatches: newItem.hasMarketplaceMatch ? [{ id: 'new', marketplace: 'amazon', productName: '', affiliateUrl: '' }] : [],
299
+ clickCount: 0,
300
+ interestPledgeCount: 0,
301
+ }, ...prev]);
302
+ }}
303
+ />
304
  </div>
305
  </div>
306
 
307
  {selectedIds.length > 0 && (
308
  <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">
309
+ <span className="text-sm font-medium mr-2">{selectedIds.length} selected</span>
310
+ <Button size="sm" onClick={handleBulkApprove} disabled={isPending}>
311
+ <Check className="w-4 h-4 mr-1" /> Approve
312
+ </Button>
313
+ <Button size="sm" variant="destructive" onClick={handleBulkReject} disabled={isPending}>
314
+ <X className="w-4 h-4 mr-1" /> Reject
315
+ </Button>
316
  </div>
317
  )}
318
 
 
327
 
328
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
329
  {filteredDetections.length > 0 ? (
330
+ filteredDetections.map((d) => {
331
+ const primaryMatch = d.marketplaceMatches[0];
332
+ const productImage = primaryMatch?.imageUrl;
333
+ const snapshotImage = d.detection.thumbnailUrl;
334
+
335
+ // Main image priority: Product Image -> Snapshot
336
+ const mainImage = productImage || snapshotImage;
337
+
338
+ // PiP Logic: Show Snapshot if we have BOTH images and they are different
339
+ // (i.e. snapshot is not just a fallback copy of product image)
340
+ const isSnapshotFallback = snapshotImage === productImage;
341
+ const showPip = snapshotImage && productImage && !isSnapshotFallback;
342
+
343
+ return (
344
+ <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'}`}>
345
+ <div className="relative aspect-square overflow-hidden bg-muted">
346
+ {mainImage ? (
347
+ <Image
348
+ src={mainImage}
349
+ alt={d.detection.objectName}
350
+ fill
351
+ className="object-contain transition-transform group-hover:scale-105"
352
+ />
353
+ ) : (
354
+ <div className="w-full h-full bg-muted flex items-center justify-center">
355
+ <Filter className="w-8 h-8 text-muted-foreground" />
356
+ </div>
 
 
 
 
357
  )}
358
+
359
+ {/* Video Snapshot Inset (PiP) */}
360
+ {showPip && (
361
+ <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">
362
+ <Image
363
+ src={snapshotImage!}
364
+ alt="In video"
365
+ fill
366
+ className="object-cover"
367
+ sizes="64px"
368
+ />
369
+ <div className="absolute inset-0 bg-black/20" />
370
+ <div className="absolute bottom-0 left-0 right-0 bg-black/60 py-0.5 px-0.5">
371
+ <span className="text-[6px] font-bold text-white uppercase tracking-tighter block text-center">
372
+ IN VIDEO
373
+ </span>
374
+ </div>
375
+ </div>
376
  )}
377
+ <div className="absolute top-2 left-2">
378
+ <Checkbox
379
+ checked={selectedIds.includes(d.detection.id)}
380
+ onCheckedChange={() => toggleSelection(d.detection.id)}
381
+ className="bg-background/80 backdrop-blur-sm"
382
+ />
383
+ </div>
384
+ <div className="absolute top-2 right-2 flex flex-col gap-1 items-end">
385
+ <Badge variant="secondary" className="bg-background/80 backdrop-blur-sm">
386
+ {Math.round(d.detection.confidenceScore * 100)}%
387
  </Badge>
388
+ {d.clickCount > 0 && (
389
+ <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">
390
+ <MousePointerClick className="h-3 w-3" />
391
+ {d.clickCount}
392
+ </Badge>
393
+ )}
394
+ {d.interestPledgeCount > 0 && (
395
+ <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">
396
+ <Heart className="h-3 w-3" />
397
+ {d.interestPledgeCount}
398
+ </Badge>
399
+ )}
400
  </div>
401
+ {d.marketplaceMatches.length > 0 && (
402
+ <div className="absolute bottom-2 right-2">
403
+ <Badge variant="default" className="bg-emerald-500/90 gap-1">
404
+ <ShoppingCart className="w-3 h-3" /> {d.marketplaceMatches.length} matches
405
+ </Badge>
406
+ </div>
407
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  </div>
 
409
 
410
+ <CardHeader className="p-4 py-3 space-y-1">
411
+ <div className="flex justify-between items-start">
412
+ <CardTitle className="text-lg font-bold truncate pr-2">
413
+ {d.detection.objectName}
414
+ </CardTitle>
415
+ <EditDetectionDialog
416
+ detection={d.detection}
417
+ marketplaceMatches={d.marketplaceMatches}
418
+ onSave={(updated: { objectName: string; category: string; thumbnailUrl?: string }) => {
419
+ setDetections(prev => prev.map(item =>
420
+ item.detection.id === d.detection.id ? { ...item, detection: { ...item.detection, ...updated } } : item
421
+ ));
422
+ }}
423
+ />
424
+ </div>
425
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
426
+ <Badge variant="outline" className="text-[10px] uppercase">
427
+ {d.detection.category}
428
+ </Badge>
429
+ <span className="text-[10px] truncate">• {d.video.title}</span>
 
 
 
 
 
 
 
 
 
 
430
  </div>
431
+ </CardHeader>
432
+
433
+ <CardFooter className="p-4 pt-0 gap-2">
434
+ {filterStatus === 'PENDING' ? (
435
+ <>
436
+ <Button
437
+ variant="secondary"
438
+ className="grow bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500 border-none"
439
+ onClick={() => handleApprove(d.detection.id)}
440
+ disabled={isPending}
441
+ >
442
+ <Check className="w-4 h-4 mr-2" /> Approve
443
+ </Button>
444
+ <Button
445
+ variant="secondary"
446
+ className="grow bg-rose-500/10 hover:bg-rose-500/20 text-rose-500 border-none"
447
+ onClick={() => handleReject(d.detection.id)}
448
+ disabled={isPending}
449
+ >
450
+ <X className="w-4 h-4 mr-2" /> Reject
451
+ </Button>
452
+ </>
453
+ ) : (
454
+ <div className="flex items-center text-sm font-medium gap-2 py-2 text-muted-foreground">
455
+ {filterStatus === 'APPROVED' ? (
456
+ <><CheckCircle2 className="w-4 h-4 text-emerald-500" /> Approved</>
457
+ ) : (
458
+ <><X className="w-4 h-4 text-rose-500" /> Rejected</>
459
+ )}
460
+ <Button variant="link" size="sm" className="h-auto p-0 ml-auto" onClick={() => handleReset(d.detection.id)} disabled={isPending}>
461
+ Reset
462
+ </Button>
463
+ </div>
464
+ )}
465
+ </CardFooter>
466
+ </Card>
467
+ );
468
+ })
469
  ) : (
470
  <div className="col-span-full py-20 text-center space-y-4 bg-muted/20 rounded-xl border-2 border-dashed border-border/50">
471
  <div className="flex justify-center">
src/features/moderation/components/product-form.tsx ADDED
@@ -0,0 +1,483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Button, buttonVariants } from '@/components/ui/button';
3
+ import { Input } from '@/components/ui/input';
4
+ import { Label } from '@/components/ui/label';
5
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
6
+ import { Search, Loader2, Upload, Trash2, Image as ImageIcon, PlaySquare, Save } from 'lucide-react';
7
+ import { fetchProductMetadata } from '../actions/fetch-product-metadata';
8
+ import { uploadProductImage } from '../actions/upload-product-image';
9
+ import { toast } from 'sonner';
10
+
11
+ export interface ProductFormData {
12
+ videoId: string;
13
+ objectName: string;
14
+ category: string;
15
+ frameTimestamp: number;
16
+ thumbnailUrl: string; // Product Image
17
+ snapshotUrl: string; // In-Video Snapshot
18
+ marketplaceData?: {
19
+ marketplace: string;
20
+ productName: string;
21
+ price: string;
22
+ affiliateUrl: string;
23
+ };
24
+ }
25
+
26
+ interface ProductFormProps {
27
+ mode: 'create' | 'edit';
28
+ initialData?: Partial<ProductFormData>;
29
+ videos?: { id: string; title: string }[];
30
+ onSubmit: (data: ProductFormData) => void;
31
+ onCancel: () => void;
32
+ isSubmitting: boolean;
33
+ }
34
+
35
+ export function ProductForm({ mode, initialData, videos = [], onSubmit, onCancel, isSubmitting }: ProductFormProps) {
36
+ const [videoId, setVideoId] = useState(initialData?.videoId || videos[0]?.id || '');
37
+ const [objectName, setObjectName] = useState(initialData?.objectName || '');
38
+ const [category, setCategory] = useState(initialData?.category || 'Tech');
39
+ const [frameTimestamp, setFrameTimestamp] = useState(initialData?.frameTimestamp?.toString() || '0');
40
+
41
+ // Images
42
+ const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
43
+ const [snapshotUrl, setSnapshotUrl] = useState(initialData?.snapshotUrl || '');
44
+
45
+ // Marketplace
46
+ const [affiliateUrl, setAffiliateUrl] = useState(initialData?.marketplaceData?.affiliateUrl || '');
47
+ const [marketplace, setMarketplace] = useState(initialData?.marketplaceData?.marketplace || '');
48
+ const [productName, setProductName] = useState(initialData?.marketplaceData?.productName || '');
49
+ const [price, setPrice] = useState(initialData?.marketplaceData?.price || '');
50
+
51
+ const [isFetching, setIsFetching] = useState(false);
52
+ const [isUploading, setIsUploading] = useState(false);
53
+ const [isUploadingSnapshot, setIsUploadingSnapshot] = useState(false);
54
+
55
+ // Auto-select video if only one
56
+ useEffect(() => {
57
+ if (mode === 'create' && videos.length === 1 && !videoId) {
58
+ setVideoId(videos[0].id);
59
+ }
60
+ }, [mode, videos, videoId]);
61
+
62
+ // Clipboard Paste Listener
63
+ useEffect(() => {
64
+ const handlePaste = async (e: ClipboardEvent) => {
65
+ // Only paste into the snapshot if we are not currently focused on an input
66
+ const target = e.target as HTMLElement;
67
+ const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA';
68
+
69
+ // Allow pasting URL into URL inputs, but intercept images for upload
70
+ const items = e.clipboardData?.items;
71
+ if (!items) return;
72
+
73
+ for (let i = 0; i < items.length; i++) {
74
+ if (items[i].type.indexOf('image') !== -1) {
75
+ if (isInput) return; // Don't interrupt paste if user is typing
76
+
77
+ let file = items[i].getAsFile();
78
+ if (!file) continue;
79
+
80
+ if (!file.name || file.name === 'blob') {
81
+ const extension = file.type.split('/')[1] || 'png';
82
+ file = new File([file], `paste-${Date.now()}.${extension}`, { type: file.type });
83
+ }
84
+
85
+ if (file.size > 10 * 1024 * 1024) {
86
+ toast.error('Image too large (max 10MB)');
87
+ continue;
88
+ }
89
+
90
+ setIsUploadingSnapshot(true);
91
+ const formData = new FormData();
92
+ formData.append('file', file);
93
+
94
+ try {
95
+ const result = await uploadProductImage(formData);
96
+ if (result.success && result.url) {
97
+ setSnapshotUrl(result.url);
98
+ toast.success('Snapshot pasted from clipboard!');
99
+ } else {
100
+ toast.error(result.error || 'Failed to upload pasted image');
101
+ }
102
+ } catch (err: any) {
103
+ toast.error(`Error uploading pasted image: ${err.message || 'Unknown error'}`);
104
+ } finally {
105
+ setIsUploadingSnapshot(false);
106
+ }
107
+ break;
108
+ }
109
+ }
110
+ };
111
+
112
+ window.addEventListener('paste', handlePaste);
113
+ return () => window.removeEventListener('paste', handlePaste);
114
+ }, []);
115
+
116
+ const handleFetchMetadata = async () => {
117
+ if (!affiliateUrl) return;
118
+
119
+ setIsFetching(true);
120
+ try {
121
+ const result = await fetchProductMetadata(affiliateUrl);
122
+ if (result.success && result.data) {
123
+ const { productName: fetchedName, price: fetchedPrice, marketplace: fetchedMarket, imageUrl } = result.data;
124
+
125
+ // Only update fields if they are empty or we want to overwrite
126
+ if (!objectName) setObjectName(fetchedName);
127
+ if (!productName) setProductName(fetchedName);
128
+ setPrice(fetchedPrice.toString());
129
+ setMarketplace(fetchedMarket);
130
+ if (imageUrl && !thumbnailUrl) {
131
+ setThumbnailUrl(imageUrl);
132
+ }
133
+ toast.success('Product info fetched!');
134
+ } else {
135
+ toast.error(result.error || 'Could not fetch product info');
136
+ }
137
+ } catch (err) {
138
+ toast.error('Error fetching product info');
139
+ } finally {
140
+ setIsFetching(false);
141
+ }
142
+ };
143
+
144
+ const handleUpload = async (file: File, type: 'product' | 'snapshot') => {
145
+ const setUrl = type === 'product' ? setThumbnailUrl : setSnapshotUrl;
146
+ const setLoading = type === 'product' ? setIsUploading : setIsUploadingSnapshot;
147
+
148
+ setLoading(true);
149
+ const formData = new FormData();
150
+ formData.append('file', file);
151
+
152
+ try {
153
+ const result = await uploadProductImage(formData);
154
+ if (result.success && result.url) {
155
+ setUrl(result.url);
156
+ toast.success('Image uploaded!');
157
+ } else {
158
+ toast.error(result.error || 'Failed to upload image');
159
+ }
160
+ } catch (err) {
161
+ toast.error('Error uploading image');
162
+ } finally {
163
+ setLoading(false);
164
+ }
165
+ };
166
+
167
+ const handleSubmit = () => {
168
+ if (mode === 'create' && !videoId) {
169
+ toast.error('Please select a video');
170
+ return;
171
+ }
172
+ if (!objectName || !category) {
173
+ toast.error('Please fill in required fields');
174
+ return;
175
+ }
176
+
177
+ const data: ProductFormData = {
178
+ videoId,
179
+ objectName,
180
+ category,
181
+ frameTimestamp: parseInt(frameTimestamp, 10) || 0,
182
+ thumbnailUrl,
183
+ snapshotUrl,
184
+ };
185
+
186
+ if (marketplace && (affiliateUrl || productName)) {
187
+ data.marketplaceData = {
188
+ marketplace,
189
+ productName: productName || objectName,
190
+ price,
191
+ affiliateUrl
192
+ };
193
+ }
194
+
195
+ onSubmit(data);
196
+ };
197
+
198
+ return (
199
+ <div className="grid gap-6 py-4">
200
+ {/* Link Section - Prioritized */}
201
+ <div className="space-y-4 p-4 rounded-xl bg-primary/5 border border-primary/10">
202
+ <div className="grid gap-2">
203
+ <Label htmlFor="affiliate-url" className="text-sm font-medium text-primary">Affiliate or Product URL</Label>
204
+ <div className="flex gap-2">
205
+ <Input
206
+ id="affiliate-url"
207
+ value={affiliateUrl}
208
+ onChange={(e) => setAffiliateUrl(e.target.value)}
209
+ placeholder="Paste Amazon, eBay, or Etsy link..."
210
+ className="bg-background/50"
211
+ onBlur={() => {
212
+ if (affiliateUrl && !objectName) handleFetchMetadata();
213
+ }}
214
+ />
215
+ <Button
216
+ size="icon"
217
+ onClick={handleFetchMetadata}
218
+ disabled={isFetching || !affiliateUrl}
219
+ className="shrink-0"
220
+ type="button"
221
+ >
222
+ {isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
223
+ </Button>
224
+ </div>
225
+ </div>
226
+
227
+ {(marketplace || affiliateUrl) && (
228
+ <div className="grid grid-cols-2 gap-4 animate-in fade-in slide-in-from-top-2">
229
+ <div className="grid gap-2">
230
+ <Label className="text-xs">Marketplace</Label>
231
+ <Select value={marketplace} onValueChange={setMarketplace}>
232
+ <SelectTrigger className="h-8 text-xs bg-background/50">
233
+ <SelectValue placeholder="Select..." />
234
+ </SelectTrigger>
235
+ <SelectContent>
236
+ <SelectItem value="amazon">Amazon</SelectItem>
237
+ <SelectItem value="ebay">eBay</SelectItem>
238
+ <SelectItem value="etsy">Etsy</SelectItem>
239
+ </SelectContent>
240
+ </Select>
241
+ </div>
242
+ <div className="grid gap-2">
243
+ <Label htmlFor="price" className="text-xs">Price ($)</Label>
244
+ <Input
245
+ id="price"
246
+ type="number"
247
+ value={price}
248
+ onChange={(e) => setPrice(e.target.value)}
249
+ className="h-8 text-xs bg-background/50"
250
+ />
251
+ </div>
252
+ </div>
253
+ )}
254
+ </div>
255
+
256
+ <div className="grid gap-4 px-1">
257
+ <div className="grid gap-2">
258
+ <Label htmlFor="object-name">Object / Product Name *</Label>
259
+ <Input
260
+ id="object-name"
261
+ value={objectName}
262
+ onChange={(e) => setObjectName(e.target.value)}
263
+ placeholder="e.g. Sony WH-1000XM5"
264
+ />
265
+ </div>
266
+
267
+ <div className="grid grid-cols-1 gap-4">
268
+ <div className="grid gap-2">
269
+ <Label>Video {mode === 'create' ? '*' : ''}</Label>
270
+ {mode === 'create' ? (
271
+ <Select value={videoId} onValueChange={setVideoId}>
272
+ <SelectTrigger className="w-full">
273
+ <SelectValue placeholder="Select video" />
274
+ </SelectTrigger>
275
+ <SelectContent>
276
+ {videos.map(v => (
277
+ <SelectItem key={v.id} value={v.id}>{v.title}</SelectItem>
278
+ ))}
279
+ </SelectContent>
280
+ </Select>
281
+ ) : (
282
+ <div className="p-2 rounded border border-input bg-muted/50 text-sm text-muted-foreground">
283
+ {videos.find(v => v.id === videoId)?.title || 'Current Video'}
284
+ </div>
285
+ )}
286
+ </div>
287
+ <div className="grid gap-2">
288
+ <Label>Category *</Label>
289
+ <Select value={category} onValueChange={setCategory}>
290
+ <SelectTrigger className="w-full">
291
+ <SelectValue />
292
+ </SelectTrigger>
293
+ <SelectContent>
294
+ <SelectItem value="Tech">Tech</SelectItem>
295
+ <SelectItem value="Fashion">Fashion</SelectItem>
296
+ <SelectItem value="Furniture">Furniture</SelectItem>
297
+ <SelectItem value="Audio">Audio</SelectItem>
298
+ <SelectItem value="Other">Other</SelectItem>
299
+ </SelectContent>
300
+ </Select>
301
+ </div>
302
+ </div>
303
+
304
+ <div className="grid grid-cols-1 gap-4">
305
+ <div className="grid gap-2">
306
+ <Label htmlFor="timestamp">Video Timestamp (sec)</Label>
307
+ <Input
308
+ id="timestamp"
309
+ type="number"
310
+ value={frameTimestamp}
311
+ onChange={(e) => setFrameTimestamp(e.target.value)}
312
+ />
313
+ </div>
314
+ <div className="grid grid-cols-2 gap-6">
315
+ {/* Product Image Section */}
316
+ <ImageUpload
317
+ label="Product Image"
318
+ icon={<ImageIcon className="h-4 w-4 text-primary" />}
319
+ url={thumbnailUrl}
320
+ onChange={setThumbnailUrl}
321
+ onUpload={(file) => handleUpload(file, 'product')}
322
+ isUploading={isUploading}
323
+ placeholderText="Marketplace or Custom Image"
324
+ />
325
+
326
+ {/* In-Video Snapshot Section */}
327
+ <ImageUpload
328
+ label="In-Video Snapshot"
329
+ icon={<PlaySquare className="h-4 w-4 text-secondary" />}
330
+ url={snapshotUrl}
331
+ onChange={setSnapshotUrl}
332
+ onUpload={(file) => handleUpload(file, 'snapshot')}
333
+ isUploading={isUploadingSnapshot}
334
+ placeholderText="Actual moment from video"
335
+ extraLabel={
336
+ <span className="ml-auto text-[10px] font-normal text-muted-foreground bg-white/5 px-1.5 py-0.5 rounded border border-white/10 animate-pulse">
337
+ Cmd+V to Paste
338
+ </span>
339
+ }
340
+ />
341
+ </div>
342
+ </div>
343
+ </div>
344
+
345
+ <div className="flex justify-end gap-2 pt-4">
346
+ <Button variant="ghost" onClick={onCancel} disabled={isSubmitting}>
347
+ Cancel
348
+ </Button>
349
+ <Button
350
+ onClick={handleSubmit}
351
+ disabled={isSubmitting || !objectName || !category}
352
+ className="bg-primary hover:bg-primary/90 shadow-lg shadow-primary/20"
353
+ >
354
+ {isSubmitting ? (
355
+ <>
356
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
357
+ {mode === 'create' ? 'Adding...' : 'Saving...'}
358
+ </>
359
+ ) : (
360
+ <>
361
+ <Save className="w-4 h-4 mr-2" />
362
+ {mode === 'create' ? 'Add to Vault' : 'Save Changes'}
363
+ </>
364
+ )}
365
+ </Button>
366
+ </div>
367
+ </div>
368
+ );
369
+ }
370
+
371
+ // Helper component for Image sections
372
+ function ImageUpload({
373
+ label, icon, url, onChange, onUpload, isUploading, placeholderText, extraLabel
374
+ }: {
375
+ label: string,
376
+ icon: React.ReactNode,
377
+ url: string,
378
+ onChange: (val: string) => void,
379
+ onUpload: (file: File) => void,
380
+ isUploading: boolean,
381
+ placeholderText: string,
382
+ extraLabel?: React.ReactNode
383
+ }) {
384
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
385
+
386
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
387
+ if (e.target.files?.[0]) {
388
+ onUpload(e.target.files[0]);
389
+ }
390
+ // Reset input value to allow selecting same file again
391
+ if (fileInputRef.current) {
392
+ fileInputRef.current.value = '';
393
+ }
394
+ };
395
+
396
+ return (
397
+ <div className="space-y-3">
398
+ <Label className="text-sm font-semibold flex items-center gap-2 h-6">
399
+ {icon}
400
+ {label}
401
+ {extraLabel}
402
+ </Label>
403
+ <div className="space-y-3">
404
+ <div className="relative aspect-square w-full rounded-xl overflow-hidden border border-white/10 bg-white/5 group">
405
+ {url ? (
406
+ <>
407
+ <img
408
+ src={url}
409
+ alt={label}
410
+ className="w-full h-full object-contain bg-black/20"
411
+ onError={(e) => console.error('Image load error', e)}
412
+ />
413
+ <div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-2 transition-all duration-200">
414
+ <Button
415
+ variant="secondary"
416
+ size="sm"
417
+ onClick={() => fileInputRef.current?.click()}
418
+ className="h-8 px-3 rounded-full shadow-xl transform scale-90 hover:scale-100 transition-transform font-medium text-xs bg-white text-black hover:bg-white/90"
419
+ >
420
+ <Upload className="h-3 w-3 mr-1.5" />
421
+ Replace
422
+ </Button>
423
+ <Button
424
+ variant="destructive"
425
+ size="icon"
426
+ onClick={() => onChange('')}
427
+ className="h-8 w-8 rounded-full shadow-xl transform scale-90 hover:scale-100 transition-transform"
428
+ >
429
+ <Trash2 className="h-4 w-4" />
430
+ </Button>
431
+ </div>
432
+ </>
433
+ ) : (
434
+ <div
435
+ className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground p-4 text-center cursor-pointer hover:bg-white/5 transition-colors"
436
+ onClick={() => fileInputRef.current?.click()}
437
+ >
438
+ {React.isValidElement(icon)
439
+ ? React.cloneElement(icon as React.ReactElement<{ className?: string }>, { className: "h-8 w-8 mb-2 opacity-20" })
440
+ : icon}
441
+ <span className="text-[10px]">{placeholderText}</span>
442
+ <span className="mt-2 text-[10px] text-primary/70 font-medium">Click to Upload</span>
443
+ </div>
444
+ )}
445
+ {isUploading && (
446
+ <div className="absolute inset-0 bg-black/60 backdrop-blur-[2px] flex items-center justify-center z-10">
447
+ <Loader2 className="h-6 w-6 animate-spin text-primary" />
448
+ </div>
449
+ )}
450
+ </div>
451
+ <div className="flex gap-2">
452
+ <div className="relative flex-1">
453
+ <Input
454
+ value={url}
455
+ onChange={(e) => onChange(e.target.value)}
456
+ placeholder="Image URL"
457
+ className="h-9 text-xs pr-8 bg-background/50"
458
+ />
459
+ </div>
460
+ <div className="relative">
461
+ <Input
462
+ ref={fileInputRef}
463
+ type="file"
464
+ className="hidden"
465
+ accept="image/*"
466
+ onChange={handleFileChange}
467
+ disabled={isUploading}
468
+ />
469
+ <Button
470
+ variant="outline"
471
+ size="icon"
472
+ className="h-9 w-9 shrink-0"
473
+ onClick={() => fileInputRef.current?.click()}
474
+ disabled={isUploading}
475
+ >
476
+ <Upload className="h-4 w-4" />
477
+ </Button>
478
+ </div>
479
+ </div>
480
+ </div>
481
+ </div>
482
+ );
483
+ }
src/features/moderation/services/moderation.service.ts CHANGED
@@ -33,7 +33,8 @@ export class ModerationService {
33
  'productName', ${marketplaceMatches.productName},
34
  'price', ${marketplaceMatches.price},
35
  'availability', ${marketplaceMatches.availabilityStatus},
36
- 'affiliateUrl', ${marketplaceMatches.affiliateUrl}
 
37
  )
38
  ) FILTER (WHERE ${marketplaceMatches.id} IS NOT NULL),
39
  '[]'
 
33
  'productName', ${marketplaceMatches.productName},
34
  'price', ${marketplaceMatches.price},
35
  'availability', ${marketplaceMatches.availabilityStatus},
36
+ 'affiliateUrl', ${marketplaceMatches.affiliateUrl},
37
+ 'imageUrl', ${marketplaceMatches.imageUrl}
38
  )
39
  ) FILTER (WHERE ${marketplaceMatches.id} IS NOT NULL),
40
  '[]'
src/features/vault/services/showcase.service.ts CHANGED
@@ -392,7 +392,15 @@ export class ShowcaseService {
392
  .where(eq(youtubeChannels.id, currentChannel.id));
393
  }
394
 
395
- // If video is failed or pending, allow re-trigger of analysis
 
 
 
 
 
 
 
 
396
  if (!options.skipAnalysis && (existing.scanStatus === 'failed' || existing.scanStatus === 'pending')) {
397
  console.log(`Re-triggering analysis for ${videoId} (status: ${existing.scanStatus})`);
398
  await inngest.send({
@@ -461,6 +469,7 @@ export class ShowcaseService {
461
  viewCount: 0,
462
  publishedAt: new Date(),
463
  availabilityStatus: 'available',
 
464
  }).returning();
465
 
466
  // 6. Trigger Inngest
 
392
  .where(eq(youtubeChannels.id, currentChannel.id));
393
  }
394
 
395
+ // If skipAnalysis is requested, force status to completed to avoid stuck pending state
396
+ if (options.skipAnalysis && (existing.scanStatus === 'pending' || existing.scanStatus === 'in_progress')) {
397
+ console.log(`Forcing scanStatus to 'completed' for existing video ${videoId} (was ${existing.scanStatus})`);
398
+ await db.update(youtubeVideos)
399
+ .set({ scanStatus: 'completed' })
400
+ .where(eq(youtubeVideos.id, existing.id));
401
+ }
402
+
403
+ // If video is failed or pending, allow re-trigger of analysis (only if NOT skipping)
404
  if (!options.skipAnalysis && (existing.scanStatus === 'failed' || existing.scanStatus === 'pending')) {
405
  console.log(`Re-triggering analysis for ${videoId} (status: ${existing.scanStatus})`);
406
  await inngest.send({
 
469
  viewCount: 0,
470
  publishedAt: new Date(),
471
  availabilityStatus: 'available',
472
+ scanStatus: options.skipAnalysis ? 'completed' : 'pending',
473
  }).returning();
474
 
475
  // 6. Trigger Inngest