dvijaykrishnan commited on
Commit
e7abb94
·
1 Parent(s): dc08763

feat: Implement affiliate product proposal submission and moderation system.

Browse files
src/actions/submit-proposal.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use server';
2
+
3
+ import { db } from '@/lib/db';
4
+ import { affiliateProposals } from '@/lib/db/schema';
5
+ import { revalidatePath } from 'next/cache';
6
+
7
+ export async function submitAffiliateProposal(data: {
8
+ videoId: string;
9
+ creatorId: string;
10
+ objectId?: string;
11
+ submitterName?: string;
12
+ submitterEmail?: string;
13
+ productUrl: string;
14
+ affiliateUrl: string;
15
+ productName: string;
16
+ price?: number;
17
+ imageUrl?: string;
18
+ note?: string;
19
+ }) {
20
+ try {
21
+ const [proposal] = await db.insert(affiliateProposals).values({
22
+ videoId: data.videoId,
23
+ creatorId: data.creatorId,
24
+ objectId: data.objectId,
25
+ submitterName: data.submitterName,
26
+ submitterEmail: data.submitterEmail,
27
+ productUrl: data.productUrl,
28
+ affiliateUrl: data.affiliateUrl,
29
+ productName: data.productName,
30
+ price: data.price,
31
+ imageUrl: data.imageUrl,
32
+ note: data.note,
33
+ status: 'PENDING',
34
+ }).returning();
35
+
36
+ revalidatePath('/dashboard/requests');
37
+ return { success: true, id: proposal.id };
38
+ } catch (error: any) {
39
+ console.error('Failed to submit affiliate proposal:', error);
40
+ return { success: false, error: error.message || 'Failed to submit proposal' };
41
+ }
42
+ }
src/app/dashboard/requests/page.tsx CHANGED
@@ -9,12 +9,14 @@ import {
9
  youtubeVideos,
10
  marketplaceMatches,
11
  interestPledges,
 
12
  } from '@/lib/db/schema';
13
  import { eq, desc, and, isNull, sql } from 'drizzle-orm';
14
  import { Clock, ArrowLeft, ExternalLink } from 'lucide-react';
15
  import { Card, CardContent } from '@/components/ui/card';
16
  import { RequestManager } from './request-manager';
17
  import { ProductsAwaitingLinks } from './products-awaiting-links';
 
18
  import Link from 'next/link';
19
  import { UserNav } from '@/components/auth/user-nav';
20
  import { Badge } from '@/components/ui/badge';
@@ -61,6 +63,7 @@ export default async function RequestsPage() {
61
  thumbnailUrl: detectedObjects.thumbnailUrl,
62
  videoId: detectedObjects.videoId,
63
  videoTitle: youtubeVideos.title,
 
64
  pledgeCount: sql<number>`count(distinct ${interestPledges.id})::int`,
65
  })
66
  .from(detectedObjects)
@@ -75,7 +78,7 @@ export default async function RequestsPage() {
75
  isNull(marketplaceMatches.affiliateUrl)
76
  )
77
  )
78
- .groupBy(detectedObjects.id, youtubeVideos.id, youtubeVideos.title)
79
  .having(sql`count(distinct ${interestPledges.id}) > 0`);
80
 
81
  const productsAwaitingLinks = productsAwaitingLinksRaw.map(p => ({
@@ -83,6 +86,35 @@ export default async function RequestsPage() {
83
  pledgeCount: Number(p.pledgeCount),
84
  }));
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  return (
87
  <div className="container mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
88
  <div className="flex items-center justify-between">
@@ -139,6 +171,11 @@ export default async function RequestsPage() {
139
  </Card>
140
  </div>
141
 
 
 
 
 
 
142
  {/* Products Awaiting Links Section */}
143
  {productsAwaitingLinks.length > 0 && (
144
  <ProductsAwaitingLinks products={productsAwaitingLinks} />
 
9
  youtubeVideos,
10
  marketplaceMatches,
11
  interestPledges,
12
+ affiliateProposals,
13
  } from '@/lib/db/schema';
14
  import { eq, desc, and, isNull, sql } from 'drizzle-orm';
15
  import { Clock, ArrowLeft, ExternalLink } from 'lucide-react';
16
  import { Card, CardContent } from '@/components/ui/card';
17
  import { RequestManager } from './request-manager';
18
  import { ProductsAwaitingLinks } from './products-awaiting-links';
19
+ import { ProposalManager } from './proposal-manager';
20
  import Link from 'next/link';
21
  import { UserNav } from '@/components/auth/user-nav';
22
  import { Badge } from '@/components/ui/badge';
 
63
  thumbnailUrl: detectedObjects.thumbnailUrl,
64
  videoId: detectedObjects.videoId,
65
  videoTitle: youtubeVideos.title,
66
+ creatorId: youtubeChannels.creatorId,
67
  pledgeCount: sql<number>`count(distinct ${interestPledges.id})::int`,
68
  })
69
  .from(detectedObjects)
 
78
  isNull(marketplaceMatches.affiliateUrl)
79
  )
80
  )
81
+ .groupBy(detectedObjects.id, youtubeVideos.id, youtubeVideos.title, youtubeChannels.creatorId)
82
  .having(sql`count(distinct ${interestPledges.id}) > 0`);
83
 
84
  const productsAwaitingLinks = productsAwaitingLinksRaw.map(p => ({
 
86
  pledgeCount: Number(p.pledgeCount),
87
  }));
88
 
89
+ // Fetch pending proposals
90
+ const proposalsRaw = await db
91
+ .select({
92
+ id: affiliateProposals.id,
93
+ productName: affiliateProposals.productName,
94
+ productUrl: affiliateProposals.productUrl,
95
+ affiliateUrl: affiliateProposals.affiliateUrl,
96
+ submitterName: affiliateProposals.submitterName,
97
+ submitterEmail: affiliateProposals.submitterEmail,
98
+ note: affiliateProposals.note,
99
+ status: affiliateProposals.status,
100
+ createdAt: affiliateProposals.createdAt,
101
+ videoTitle: youtubeVideos.title,
102
+ })
103
+ .from(affiliateProposals)
104
+ .innerJoin(youtubeVideos, eq(affiliateProposals.videoId, youtubeVideos.id))
105
+ .where(
106
+ and(
107
+ eq(affiliateProposals.creatorId, session.user.id),
108
+ eq(affiliateProposals.status, 'PENDING')
109
+ )
110
+ )
111
+ .orderBy(desc(affiliateProposals.createdAt));
112
+
113
+ const proposals = proposalsRaw.map(p => ({
114
+ ...p,
115
+ status: p.status as 'PENDING' | 'APPROVED' | 'REJECTED'
116
+ }));
117
+
118
  return (
119
  <div className="container mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
120
  <div className="flex items-center justify-between">
 
171
  </Card>
172
  </div>
173
 
174
+ {/* Proposals Section */}
175
+ {proposals.length > 0 && (
176
+ <ProposalManager proposals={proposals} />
177
+ )}
178
+
179
  {/* Products Awaiting Links Section */}
180
  {productsAwaitingLinks.length > 0 && (
181
  <ProductsAwaitingLinks products={productsAwaitingLinks} />
src/app/dashboard/requests/products-awaiting-links.tsx CHANGED
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
7
  import { ExternalLink, Link as LinkIcon, Users } from 'lucide-react';
8
  import Image from 'next/image';
9
  import Link from 'next/link';
 
10
 
11
  interface ProductAwaitingLink {
12
  id: string;
@@ -14,6 +15,7 @@ interface ProductAwaitingLink {
14
  thumbnailUrl: string | null;
15
  videoId: string;
16
  videoTitle: string;
 
17
  pledgeCount: number;
18
  }
19
 
@@ -92,12 +94,17 @@ export function ProductsAwaitingLinks({ products }: ProductsAwaitingLinksProps)
92
  </div>
93
 
94
  {/* Action Button */}
95
- <div className="flex-shrink-0">
 
 
 
 
 
 
96
  <Link href={`/dashboard/moderation?status=approved&highlight=${product.id}`}>
97
- <Button variant="default" className="gap-2">
98
- <LinkIcon className="h-4 w-4" />
99
- Add Link
100
- <ExternalLink className="h-3 w-3" />
101
  </Button>
102
  </Link>
103
  </div>
 
7
  import { ExternalLink, Link as LinkIcon, Users } from 'lucide-react';
8
  import Image from 'next/image';
9
  import Link from 'next/link';
10
+ import { AffiliateSubmissionDialog } from '@/features/vault/components/affiliate-submission-dialog';
11
 
12
  interface ProductAwaitingLink {
13
  id: string;
 
15
  thumbnailUrl: string | null;
16
  videoId: string;
17
  videoTitle: string;
18
+ creatorId: string;
19
  pledgeCount: number;
20
  }
21
 
 
94
  </div>
95
 
96
  {/* Action Button */}
97
+ <div className="flex flex-col gap-2">
98
+ <AffiliateSubmissionDialog
99
+ videoId={product.videoId}
100
+ creatorId={product.creatorId}
101
+ objectId={product.id}
102
+ objectName={product.objectName}
103
+ />
104
  <Link href={`/dashboard/moderation?status=approved&highlight=${product.id}`}>
105
+ <Button variant="ghost" size="sm" className="gap-2 text-xs">
106
+ <LinkIcon className="h-3 w-3" />
107
+ Manual Add
 
108
  </Button>
109
  </Link>
110
  </div>
src/app/dashboard/requests/proposal-manager.tsx ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useTransition } 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 { Check, X, Link2, ExternalLink, User } from 'lucide-react';
8
+ import { approveProposal, rejectProposal } from '@/features/moderation/actions/handle-proposal';
9
+ import { toast } from 'sonner';
10
+ import { formatDistanceToNow } from 'date-fns';
11
+
12
+ interface Proposal {
13
+ id: string;
14
+ productName: string;
15
+ productUrl: string;
16
+ affiliateUrl: string;
17
+ submitterName: string | null;
18
+ submitterEmail: string | null;
19
+ note: string | null;
20
+ status: 'PENDING' | 'APPROVED' | 'REJECTED';
21
+ createdAt: Date;
22
+ videoTitle: string;
23
+ }
24
+
25
+ interface ProposalManagerProps {
26
+ proposals: Proposal[];
27
+ }
28
+
29
+ export function ProposalManager({ proposals }: ProposalManagerProps) {
30
+ const [isPending, startTransition] = useTransition();
31
+
32
+ const handleApprove = (id: string) => {
33
+ startTransition(async () => {
34
+ const result = await approveProposal(id);
35
+ if (result.success) {
36
+ toast.success('Proposal approved and added to Vault!');
37
+ } else {
38
+ toast.error(result.error || 'Failed to approve');
39
+ }
40
+ });
41
+ };
42
+
43
+ const handleReject = (id: string) => {
44
+ startTransition(async () => {
45
+ const result = await rejectProposal(id);
46
+ if (result.success) {
47
+ toast.success('Proposal rejected');
48
+ } else {
49
+ toast.error(result.error || 'Failed to reject');
50
+ }
51
+ });
52
+ };
53
+
54
+ if (proposals.length === 0) return null;
55
+
56
+ return (
57
+ <div className="space-y-6">
58
+ <div className="flex items-center gap-2">
59
+ <Link2 className="h-5 w-5 text-primary" />
60
+ <h2 className="text-2xl font-bold text-white">Link Proposals</h2>
61
+ <Badge variant="outline" className="border-primary/20 text-primary">
62
+ {proposals.length} New
63
+ </Badge>
64
+ </div>
65
+
66
+ <div className="grid gap-4">
67
+ {proposals.map((proposal) => (
68
+ <Card key={proposal.id} className="glass border-white/10 hover:border-white/20 transition-all overflow-hidden">
69
+ <CardHeader className="pb-3 border-b border-white/5 bg-white/5">
70
+ <div className="flex items-center justify-between">
71
+ <div className="space-y-1">
72
+ <div className="flex items-center gap-2">
73
+ <CardTitle className="text-lg">{proposal.productName}</CardTitle>
74
+ <Badge variant="secondary" className="bg-primary/10 text-primary border-primary/10">
75
+ Pending Review
76
+ </Badge>
77
+ </div>
78
+ <p className="text-xs text-muted-foreground flex items-center gap-1">
79
+ Submitted {formatDistanceToNow(new Date(proposal.createdAt))} ago •
80
+ <span className="text-white/40 ml-1 truncate max-w-[200px] inline-block">{proposal.videoTitle}</span>
81
+ </p>
82
+ </div>
83
+ <div className="flex items-center gap-2">
84
+ <Button
85
+ size="sm"
86
+ variant="ghost"
87
+ onClick={() => handleReject(proposal.id)}
88
+ disabled={isPending}
89
+ className="text-red-400 hover:text-red-300 hover:bg-red-400/10"
90
+ >
91
+ <X className="h-4 w-4 mr-1.5" />
92
+ Reject
93
+ </Button>
94
+ <Button
95
+ size="sm"
96
+ onClick={() => handleApprove(proposal.id)}
97
+ disabled={isPending}
98
+ className="bg-green-600 hover:bg-green-500 text-white"
99
+ >
100
+ <Check className="h-4 w-4 mr-1.5" />
101
+ Approve & List
102
+ </Button>
103
+ </div>
104
+ </div>
105
+ </CardHeader>
106
+ <CardContent className="py-4 grid sm:grid-cols-2 gap-6">
107
+ <div className="space-y-4">
108
+ <div className="space-y-1.5">
109
+ <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Original Link</span>
110
+ <a
111
+ href={proposal.productUrl}
112
+ target="_blank"
113
+ rel="noopener noreferrer"
114
+ className="flex items-center gap-2 text-sm text-blue-400 hover:underline group"
115
+ >
116
+ View Product Page
117
+ <ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
118
+ </a>
119
+ </div>
120
+ <div className="space-y-1.5">
121
+ <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Affiliate Link</span>
122
+ <div className="bg-white/5 border border-white/10 rounded-md p-2 flex items-center justify-between">
123
+ <span className="text-xs font-mono truncate text-gray-300">{proposal.affiliateUrl}</span>
124
+ <a href={proposal.affiliateUrl} target="_blank" rel="noopener noreferrer">
125
+ <Button variant="ghost" size="icon" className="h-6 w-6">
126
+ <ExternalLink className="h-3 w-3" />
127
+ </Button>
128
+ </a>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ <div className="space-y-4">
133
+ <div className="space-y-1.5">
134
+ <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Submitter Details</span>
135
+ <div className="flex items-center gap-2 text-sm text-gray-200">
136
+ <User className="h-3.5 w-3.5 text-muted-foreground" />
137
+ {proposal.submitterName || 'Anonymous'}
138
+ {proposal.submitterEmail && (
139
+ <span className="text-muted-foreground ml-1">({proposal.submitterEmail})</span>
140
+ )}
141
+ </div>
142
+ </div>
143
+ {proposal.note && (
144
+ <div className="space-y-1.5">
145
+ <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Note</span>
146
+ <p className="text-sm text-gray-300 italic border-l-2 border-primary/30 pl-3">
147
+ "{proposal.note}"
148
+ </p>
149
+ </div>
150
+ )}
151
+ </div>
152
+ </CardContent>
153
+ </Card>
154
+ ))}
155
+ </div>
156
+ </div>
157
+ );
158
+ }
src/app/vault/[creatorSlug]/video/[videoId]/page.tsx CHANGED
@@ -9,6 +9,7 @@ import { VaultGridSkeleton } from '@/features/vault/components/vault-grid-skelet
9
  import { auth } from '@/lib/auth';
10
  import { headers } from 'next/headers';
11
  import { ProductRequestDialog } from '@/features/vault/components/product-request-dialog';
 
12
 
13
  interface VideoPageProps {
14
  params: Promise<{
@@ -76,7 +77,10 @@ export default async function VideoPage({ params }: VideoPageProps) {
76
  {categories.length > 0 && (
77
  <CategoryFilter categories={categories} />
78
  )}
79
- <ProductRequestDialog videoId={videoId} creatorId={video.channel.creatorId} />
 
 
 
80
  </div>
81
 
82
  {/* Content Grid (Products only) */}
 
9
  import { auth } from '@/lib/auth';
10
  import { headers } from 'next/headers';
11
  import { ProductRequestDialog } from '@/features/vault/components/product-request-dialog';
12
+ import { AffiliateSubmissionDialog } from '@/features/vault/components/affiliate-submission-dialog';
13
 
14
  interface VideoPageProps {
15
  params: Promise<{
 
77
  {categories.length > 0 && (
78
  <CategoryFilter categories={categories} />
79
  )}
80
+ <div className="flex gap-4">
81
+ <ProductRequestDialog videoId={videoId} creatorId={video.channel.creatorId} />
82
+ <AffiliateSubmissionDialog videoId={videoId} creatorId={video.channel.creatorId} />
83
+ </div>
84
  </div>
85
 
86
  {/* Content Grid (Products only) */}
src/features/moderation/actions/handle-proposal.ts ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use server';
2
+
3
+ import { db } from '@/lib/db';
4
+ import { affiliateProposals, marketplaceMatches, detectedObjects } from '@/lib/db/schema';
5
+ import { eq, and } from 'drizzle-orm';
6
+ import { revalidatePath } from 'next/cache';
7
+
8
+ export async function approveProposal(proposalId: string) {
9
+ try {
10
+ const [proposal] = await db
11
+ .select()
12
+ .from(affiliateProposals)
13
+ .where(eq(affiliateProposals.id, proposalId))
14
+ .limit(1);
15
+
16
+ if (!proposal) {
17
+ return { success: false, error: 'Proposal not found' };
18
+ }
19
+
20
+ let targetObjectId = proposal.objectId;
21
+
22
+ await db.transaction(async (tx) => {
23
+ // 1. If no objectId, create a manual detection record
24
+ if (!targetObjectId) {
25
+ const [newObject] = await tx.insert(detectedObjects).values({
26
+ videoId: proposal.videoId,
27
+ objectName: proposal.productName,
28
+ category: 'Other',
29
+ confidenceScore: 1.0,
30
+ frameTimestamp: 0,
31
+ thumbnailUrl: proposal.imageUrl,
32
+ status: 'approved',
33
+ moderationStatus: 'APPROVED',
34
+ moderatedAt: new Date(),
35
+ moderatedBy: proposal.creatorId,
36
+ detectionMetadata: { source: 'manual_proposal', proposalId },
37
+ }).returning();
38
+ targetObjectId = newObject.id;
39
+ } else {
40
+ // Update existing object moderation status
41
+ await tx.update(detectedObjects)
42
+ .set({ moderationStatus: 'APPROVED' })
43
+ .where(eq(detectedObjects.id, targetObjectId));
44
+ }
45
+
46
+ // 2. Detect marketplace from URL
47
+ let marketplace: 'amazon' | 'ebay' | 'etsy' = 'amazon';
48
+ const url = proposal.productUrl.toLowerCase();
49
+ if (url.includes('ebay')) marketplace = 'ebay';
50
+ else if (url.includes('etsy')) marketplace = 'etsy';
51
+
52
+ // 3. Create marketplace match
53
+ await tx.insert(marketplaceMatches).values({
54
+ objectId: targetObjectId!,
55
+ marketplace,
56
+ productId: 'MANUAL',
57
+ productName: proposal.productName,
58
+ price: proposal.price || 0,
59
+ availabilityStatus: 'IN_STOCK',
60
+ affiliateUrl: proposal.affiliateUrl,
61
+ imageUrl: proposal.imageUrl,
62
+ });
63
+
64
+ // 4. Mark proposal as approved
65
+ await tx.update(affiliateProposals)
66
+ .set({ status: 'APPROVED', objectId: targetObjectId })
67
+ .where(eq(affiliateProposals.id, proposalId));
68
+ });
69
+
70
+ revalidatePath('/dashboard/requests');
71
+ return { success: true };
72
+ } catch (error: any) {
73
+ console.error('Failed to approve proposal:', error);
74
+ return { success: false, error: error.message || 'Failed to approve proposal' };
75
+ }
76
+ }
77
+
78
+ export async function rejectProposal(proposalId: string) {
79
+ try {
80
+ await db.update(affiliateProposals)
81
+ .set({ status: 'REJECTED' })
82
+ .where(eq(affiliateProposals.id, proposalId));
83
+
84
+ revalidatePath('/dashboard/requests');
85
+ return { success: true };
86
+ } catch (error: any) {
87
+ console.error('Failed to reject proposal:', error);
88
+ return { success: false, error: error.message || 'Failed to reject proposal' };
89
+ }
90
+ }
src/features/vault/components/affiliate-submission-dialog.tsx ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import React, { useState, useTransition } from 'react';
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ DialogTrigger,
12
+ } from '@/components/ui/dialog';
13
+ import { Button } from '@/components/ui/button';
14
+ import { Input } from '@/components/ui/input';
15
+ import { Label } from '@/components/ui/label';
16
+ import { Textarea } from '@/components/ui/textarea';
17
+ import { Link2, Loader2, Package } from 'lucide-react';
18
+ import { submitAffiliateProposal } from '@/actions/submit-proposal';
19
+ import { toast } from 'sonner';
20
+
21
+ interface AffiliateSubmissionDialogProps {
22
+ videoId: string;
23
+ creatorId: string;
24
+ objectId?: string;
25
+ objectName?: string;
26
+ trigger?: React.ReactNode;
27
+ }
28
+
29
+ export function AffiliateSubmissionDialog({
30
+ videoId,
31
+ creatorId,
32
+ objectId,
33
+ objectName,
34
+ trigger
35
+ }: AffiliateSubmissionDialogProps) {
36
+ const [open, setOpen] = useState(false);
37
+ const [isPending, startTransition] = useTransition();
38
+ const [formData, setFormData] = useState({
39
+ productName: objectName || '',
40
+ productUrl: '',
41
+ affiliateUrl: '',
42
+ note: '',
43
+ submitterName: '',
44
+ submitterEmail: '',
45
+ });
46
+
47
+ const handleSubmit = async (e: React.FormEvent) => {
48
+ e.preventDefault();
49
+
50
+ if (!formData.productName || !formData.productUrl || !formData.affiliateUrl) {
51
+ toast.error('Please fill in all required fields');
52
+ return;
53
+ }
54
+
55
+ startTransition(async () => {
56
+ const result = await submitAffiliateProposal({
57
+ videoId,
58
+ creatorId,
59
+ objectId,
60
+ productName: formData.productName,
61
+ productUrl: formData.productUrl,
62
+ affiliateUrl: formData.affiliateUrl,
63
+ note: formData.note || undefined,
64
+ submitterName: formData.submitterName || undefined,
65
+ submitterEmail: formData.submitterEmail || undefined,
66
+ });
67
+
68
+ if (result.success) {
69
+ toast.success('Thank you! Your link proposal has been sent to the creator.');
70
+ setOpen(false);
71
+ setFormData({
72
+ productName: objectName || '',
73
+ productUrl: '',
74
+ affiliateUrl: '',
75
+ note: '',
76
+ submitterName: '',
77
+ submitterEmail: '',
78
+ });
79
+ } else {
80
+ toast.error(result.error || 'Failed to submit proposal');
81
+ }
82
+ });
83
+ };
84
+
85
+ return (
86
+ <Dialog open={open} onOpenChange={setOpen}>
87
+ <DialogTrigger asChild>
88
+ {trigger || (
89
+ <Button variant="outline" size="sm" className="gap-2 rounded-full border-primary/20 hover:border-primary/40 text-xs">
90
+ <Link2 className="w-3.5 h-3.5" />
91
+ I found the link!
92
+ </Button>
93
+ )}
94
+ </DialogTrigger>
95
+ <DialogContent className="sm:max-w-[500px] glass border-white/10 text-white">
96
+ <DialogHeader>
97
+ <DialogTitle className="text-xl font-bold tracking-tight">Submit Product Link</DialogTitle>
98
+ <DialogDescription className="text-muted-foreground">
99
+ Found this product somewhere else? Help the creator by sharing the link!
100
+ </DialogDescription>
101
+ </DialogHeader>
102
+ <form onSubmit={handleSubmit} className="space-y-4 py-4">
103
+ <div className="space-y-2">
104
+ <Label htmlFor="productName">Product Name*</Label>
105
+ <Input
106
+ id="productName"
107
+ placeholder="e.g. Sony A7 III Camera"
108
+ value={formData.productName}
109
+ onChange={(e) => setFormData({ ...formData, productName: e.target.value })}
110
+ className="bg-white/5 border-white/10 text-white"
111
+ required
112
+ />
113
+ </div>
114
+ <div className="grid grid-cols-1 gap-4">
115
+ <div className="space-y-2">
116
+ <Label htmlFor="productUrl">Original Product URL*</Label>
117
+ <Input
118
+ id="productUrl"
119
+ placeholder="https://amazon.com/..."
120
+ value={formData.productUrl}
121
+ onChange={(e) => setFormData({ ...formData, productUrl: e.target.value })}
122
+ className="bg-white/5 border-white/10 text-white"
123
+ required
124
+ />
125
+ </div>
126
+ <div className="space-y-2">
127
+ <Label htmlFor="affiliateUrl">Your Suggested Link (Affiliate)*</Label>
128
+ <Input
129
+ id="affiliateUrl"
130
+ placeholder="https://amzn.to/..."
131
+ value={formData.affiliateUrl}
132
+ onChange={(e) => setFormData({ ...formData, affiliateUrl: e.target.value })}
133
+ className="bg-white/5 border-white/10 text-white"
134
+ required
135
+ />
136
+ </div>
137
+ </div>
138
+ <div className="space-y-2">
139
+ <Label htmlFor="note">Note to Creator (Optional)</Label>
140
+ <Textarea
141
+ id="note"
142
+ placeholder="e.g. This is the exact model used in the video..."
143
+ value={formData.note}
144
+ onChange={(e) => setFormData({ ...formData, note: e.target.value })}
145
+ className="min-h-[80px] bg-white/5 border-white/10 text-white"
146
+ />
147
+ </div>
148
+ <div className="grid grid-cols-2 gap-4">
149
+ <div className="space-y-2">
150
+ <Label htmlFor="submitterName">Your Name (Optional)</Label>
151
+ <Input
152
+ id="submitterName"
153
+ placeholder="Alex"
154
+ value={formData.submitterName}
155
+ onChange={(e) => setFormData({ ...formData, submitterName: e.target.value })}
156
+ className="bg-white/5 border-white/10 text-white"
157
+ />
158
+ </div>
159
+ <div className="space-y-2">
160
+ <Label htmlFor="submitterEmail">Your Email (Optional)</Label>
161
+ <Input
162
+ id="submitterEmail"
163
+ type="email"
164
+ placeholder="alex@example.com"
165
+ value={formData.submitterEmail}
166
+ onChange={(e) => setFormData({ ...formData, submitterEmail: e.target.value })}
167
+ className="bg-white/5 border-white/10 text-white"
168
+ />
169
+ </div>
170
+ </div>
171
+ <DialogFooter className="pt-4">
172
+ <Button
173
+ type="submit"
174
+ disabled={isPending}
175
+ className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-semibold"
176
+ >
177
+ {isPending ? (
178
+ <>
179
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
180
+ Submitting Proposal...
181
+ </>
182
+ ) : (
183
+ "Submit Link Proposal"
184
+ )}
185
+ </Button>
186
+ </DialogFooter>
187
+ </form>
188
+ </DialogContent>
189
+ </Dialog>
190
+ );
191
+ }
src/lib/db/schema.ts CHANGED
@@ -447,6 +447,12 @@ export const requestStatus = pgEnum('request_status', [
447
  'DISMISSED',
448
  ]);
449
 
 
 
 
 
 
 
450
  export const productRequests = pgTable('product_requests', {
451
  id: text('id').notNull().primaryKey().$defaultFn(() => crypto.randomUUID()),
452
  videoId: text('video_id')
@@ -464,6 +470,23 @@ export const productRequests = pgTable('product_requests', {
464
  ...timestamps,
465
  });
466
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  export type ProductRequest = typeof productRequests.$inferSelect;
468
  export type InsertProductRequest = typeof productRequests.$inferInsert;
469
 
@@ -485,6 +508,7 @@ export const youtubeVideosRelations = relations(youtubeVideos, ({ one, many }) =
485
  }),
486
  detections: many(detectedObjects),
487
  requests: many(productRequests),
 
488
  }));
489
 
490
  export const detectedObjectsRelations = relations(detectedObjects, ({ one, many }) => ({
@@ -512,3 +536,18 @@ export const productRequestsRelations = relations(productRequests, ({ one }) =>
512
  references: [users.id],
513
  }),
514
  }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  'DISMISSED',
448
  ]);
449
 
450
+ export const affiliateSubmissionStatus = pgEnum('affiliate_submission_status', [
451
+ 'PENDING', // Awaiting creator review
452
+ 'APPROVED', // Creator accepted and added to Vault
453
+ 'REJECTED', // Creator rejected
454
+ ]);
455
+
456
  export const productRequests = pgTable('product_requests', {
457
  id: text('id').notNull().primaryKey().$defaultFn(() => crypto.randomUUID()),
458
  videoId: text('video_id')
 
470
  ...timestamps,
471
  });
472
 
473
+ export const affiliateProposals = pgTable('affiliate_proposals', {
474
+ id: text('id').notNull().primaryKey().$defaultFn(() => crypto.randomUUID()),
475
+ objectId: text('object_id').references(() => detectedObjects.id, { onDelete: 'set null' }),
476
+ videoId: text('video_id').notNull().references(() => youtubeVideos.id, { onDelete: 'cascade' }),
477
+ creatorId: text('creator_id').notNull().references(() => users.id),
478
+ submitterName: text('submitter_name'),
479
+ submitterEmail: text('submitter_email'),
480
+ productUrl: text('product_url').notNull(),
481
+ affiliateUrl: text('affiliate_url').notNull(),
482
+ productName: text('product_name').notNull(),
483
+ price: real('price'),
484
+ imageUrl: text('image_url'),
485
+ note: text('note'),
486
+ status: affiliateSubmissionStatus('status').notNull().default('PENDING'),
487
+ ...timestamps,
488
+ });
489
+
490
  export type ProductRequest = typeof productRequests.$inferSelect;
491
  export type InsertProductRequest = typeof productRequests.$inferInsert;
492
 
 
508
  }),
509
  detections: many(detectedObjects),
510
  requests: many(productRequests),
511
+ proposals: many(affiliateProposals),
512
  }));
513
 
514
  export const detectedObjectsRelations = relations(detectedObjects, ({ one, many }) => ({
 
536
  references: [users.id],
537
  }),
538
  }));
539
+
540
+ export const affiliateProposalsRelations = relations(affiliateProposals, ({ one }) => ({
541
+ video: one(youtubeVideos, {
542
+ fields: [affiliateProposals.videoId],
543
+ references: [youtubeVideos.id],
544
+ }),
545
+ creator: one(users, {
546
+ fields: [affiliateProposals.creatorId],
547
+ references: [users.id],
548
+ }),
549
+ object: one(detectedObjects, {
550
+ fields: [affiliateProposals.objectId],
551
+ references: [detectedObjects.id],
552
+ }),
553
+ }));