dvijaykrishnan commited on
Commit
5fe7352
·
1 Parent(s): 939e209

feat: Implement new key metrics including trend data and demand signals, and introduce a product requests section.

Browse files
src/app/dashboard/requests/page.tsx CHANGED
@@ -2,11 +2,19 @@ import { auth } from '@/lib/auth';
2
  import { headers } from 'next/headers';
3
  import { redirect } from 'next/navigation';
4
  import { db } from '@/lib/db';
5
- import { productRequests, youtubeChannels } from '@/lib/db/schema';
6
- import { eq, desc } from 'drizzle-orm';
 
 
 
 
 
 
 
7
  import { Clock, ArrowLeft, ExternalLink } from 'lucide-react';
8
  import { Card, CardContent } from '@/components/ui/card';
9
  import { RequestManager } from './request-manager';
 
10
  import Link from 'next/link';
11
  import { UserNav } from '@/components/auth/user-nav';
12
  import { Badge } from '@/components/ui/badge';
@@ -45,6 +53,36 @@ export default async function RequestsPage() {
45
  title: v.title
46
  })));
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  return (
49
  <div className="container mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
50
  <div className="flex items-center justify-between">
@@ -69,26 +107,60 @@ export default async function RequestsPage() {
69
  <div className="space-y-2">
70
  <h1 className="text-4xl font-extrabold tracking-tight">Viewer Requests</h1>
71
  <p className="text-muted-foreground text-lg">
72
- Manage products requested by your fans. Convert high-intent requests directly into your Vault.
73
  </p>
74
  </div>
75
 
76
- {requests.length === 0 ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  <Card className="bg-card/40 border-dashed border-white/10 py-20">
78
  <CardContent className="flex flex-col items-center text-center">
79
  <div className="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center mb-6">
80
  <Clock className="h-10 w-10 text-primary" />
81
  </div>
82
- <h3 className="text-2xl font-semibold text-white">No requests yet</h3>
83
  <p className="text-muted-foreground max-w-md mt-2 text-sm">
84
- As viewers explore your vault, their requests for unlisted products will appear here.
85
  It's a great way to discover what your fans are actually looking for!
86
  </p>
87
  </CardContent>
88
  </Card>
89
- ) : (
90
  <RequestManager requests={requests as any} videos={videos} />
91
- )}
92
  </div>
93
  );
94
  }
 
2
  import { headers } from 'next/headers';
3
  import { redirect } from 'next/navigation';
4
  import { db } from '@/lib/db';
5
+ import {
6
+ productRequests,
7
+ youtubeChannels,
8
+ detectedObjects,
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';
 
53
  title: v.title
54
  })));
55
 
56
+ // Fetch products with interest pledges but no affiliate link
57
+ const productsAwaitingLinksRaw = await db
58
+ .select({
59
+ id: detectedObjects.id,
60
+ objectName: detectedObjects.objectName,
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)
67
+ .innerJoin(youtubeVideos, eq(detectedObjects.videoId, youtubeVideos.id))
68
+ .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
69
+ .leftJoin(marketplaceMatches, eq(detectedObjects.id, marketplaceMatches.objectId))
70
+ .leftJoin(interestPledges, eq(marketplaceMatches.id, interestPledges.marketplaceMatchId))
71
+ .where(
72
+ and(
73
+ eq(youtubeChannels.creatorId, session.user.id),
74
+ eq(detectedObjects.moderationStatus, 'APPROVED'),
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 => ({
82
+ ...p,
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">
 
107
  <div className="space-y-2">
108
  <h1 className="text-4xl font-extrabold tracking-tight">Viewer Requests</h1>
109
  <p className="text-muted-foreground text-lg">
110
+ Manage products requested by your fans and add affiliate links to detected products.
111
  </p>
112
  </div>
113
 
114
+ {/* Summary Stats */}
115
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
116
+ <Card className="glass border-white/10">
117
+ <CardContent className="pt-6">
118
+ <div className="text-2xl font-bold text-gray-200">
119
+ {productsAwaitingLinks.length}
120
+ </div>
121
+ <p className="text-sm text-gray-400 mt-1">Products Awaiting Links</p>
122
+ </CardContent>
123
+ </Card>
124
+ <Card className="glass border-white/10">
125
+ <CardContent className="pt-6">
126
+ <div className="text-2xl font-bold text-gray-200">
127
+ {requests.length}
128
+ </div>
129
+ <p className="text-sm text-gray-400 mt-1">Product Requests</p>
130
+ </CardContent>
131
+ </Card>
132
+ <Card className="glass border-white/10">
133
+ <CardContent className="pt-6">
134
+ <div className="text-2xl font-bold text-orange-400">
135
+ {productsAwaitingLinks.length + requests.length}
136
+ </div>
137
+ <p className="text-sm text-gray-400 mt-1">Total Pending Demand</p>
138
+ </CardContent>
139
+ </Card>
140
+ </div>
141
+
142
+ {/* Products Awaiting Links Section */}
143
+ {productsAwaitingLinks.length > 0 && (
144
+ <ProductsAwaitingLinks products={productsAwaitingLinks} />
145
+ )}
146
+
147
+ {/* Product Requests Section */}
148
+ {requests.length === 0 && productsAwaitingLinks.length === 0 ? (
149
  <Card className="bg-card/40 border-dashed border-white/10 py-20">
150
  <CardContent className="flex flex-col items-center text-center">
151
  <div className="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center mb-6">
152
  <Clock className="h-10 w-10 text-primary" />
153
  </div>
154
+ <h3 className="text-2xl font-semibold text-white">No pending demand yet</h3>
155
  <p className="text-muted-foreground max-w-md mt-2 text-sm">
156
+ As viewers explore your vault, their requests and interests will appear here.
157
  It's a great way to discover what your fans are actually looking for!
158
  </p>
159
  </CardContent>
160
  </Card>
161
+ ) : requests.length > 0 ? (
162
  <RequestManager requests={requests as any} videos={videos} />
163
+ ) : null}
164
  </div>
165
  );
166
  }
src/app/dashboard/requests/products-awaiting-links.tsx ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
5
+ import { Badge } from '@/components/ui/badge';
6
+ import { Button } from '@/components/ui/button';
7
+ import { 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;
13
+ objectName: string;
14
+ thumbnailUrl: string | null;
15
+ videoId: string;
16
+ videoTitle: string;
17
+ pledgeCount: number;
18
+ }
19
+
20
+ interface ProductsAwaitingLinksProps {
21
+ products: ProductAwaitingLink[];
22
+ }
23
+
24
+ export function ProductsAwaitingLinks({ products }: ProductsAwaitingLinksProps) {
25
+ const [sortBy, setSortBy] = useState<'pledges' | 'name'>('pledges');
26
+
27
+ const sortedProducts = [...products].sort((a, b) => {
28
+ if (sortBy === 'pledges') {
29
+ return b.pledgeCount - a.pledgeCount;
30
+ }
31
+ return a.objectName.localeCompare(b.objectName);
32
+ });
33
+
34
+ return (
35
+ <Card className="glass border-white/10">
36
+ <CardHeader>
37
+ <div className="flex items-center justify-between">
38
+ <div>
39
+ <CardTitle className="text-2xl">Products Awaiting Links</CardTitle>
40
+ <p className="text-sm text-muted-foreground mt-1">
41
+ AI-detected products with viewer interest - add affiliate links to capture demand
42
+ </p>
43
+ </div>
44
+ <div className="flex items-center gap-2">
45
+ <select
46
+ value={sortBy}
47
+ onChange={(e) => setSortBy(e.target.value as 'pledges' | 'name')}
48
+ className="px-3 py-1.5 rounded-md bg-white/5 border border-white/10 text-sm text-gray-200"
49
+ >
50
+ <option value="pledges">Sort by Interest</option>
51
+ <option value="name">Sort by Name</option>
52
+ </select>
53
+ </div>
54
+ </div>
55
+ </CardHeader>
56
+ <CardContent className="space-y-4">
57
+ {sortedProducts.map((product) => (
58
+ <div
59
+ key={product.id}
60
+ className="flex items-center gap-4 p-4 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 transition-all duration-200"
61
+ >
62
+ {/* Thumbnail */}
63
+ <div className="flex-shrink-0 w-20 h-20 rounded-md overflow-hidden bg-white/10 relative">
64
+ {product.thumbnailUrl ? (
65
+ <Image
66
+ src={product.thumbnailUrl}
67
+ alt={product.objectName}
68
+ fill
69
+ className="object-cover"
70
+ />
71
+ ) : (
72
+ <div className="w-full h-full flex items-center justify-center text-gray-500">
73
+ <LinkIcon className="h-8 w-8" />
74
+ </div>
75
+ )}
76
+ </div>
77
+
78
+ {/* Product Info */}
79
+ <div className="flex-1 min-w-0">
80
+ <h3 className="font-semibold text-lg text-gray-200 truncate">
81
+ {product.objectName}
82
+ </h3>
83
+ <p className="text-sm text-gray-400 truncate">
84
+ {product.videoTitle}
85
+ </p>
86
+ <div className="flex items-center gap-2 mt-2">
87
+ <Badge variant="secondary" className="bg-orange-500/20 text-orange-400 border-orange-500/20">
88
+ <Users className="h-3 w-3 mr-1" />
89
+ {product.pledgeCount} {product.pledgeCount === 1 ? 'viewer wants' : 'viewers want'} this
90
+ </Badge>
91
+ </div>
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>
104
+ </div>
105
+ ))}
106
+
107
+ {products.length === 0 && (
108
+ <div className="text-center py-8 text-muted-foreground">
109
+ <p>No products awaiting links at the moment.</p>
110
+ </div>
111
+ )}
112
+ </CardContent>
113
+ </Card>
114
+ );
115
+ }
src/features/analytics/actions/get-top-evergreen-videos.ts CHANGED
@@ -6,7 +6,7 @@ import { TopVideosAnalyticsService } from '../services/top-videos-analytics.serv
6
  import type { TopEvergreenVideo, EngagedProduct } from '../types/analytics.types';
7
 
8
  export async function getEngagedVideos(
9
- sortBy: 'clicks' | 'revenue' = 'clicks'
10
  ): Promise<TopEvergreenVideo[]> {
11
  const session = await auth.api.getSession({ headers: await headers() });
12
  if (!session?.user?.id) throw new Error('Unauthorized');
@@ -14,7 +14,7 @@ export async function getEngagedVideos(
14
  }
15
 
16
  export async function getEngagedProducts(
17
- sortBy: 'clicks' | 'revenue' = 'clicks'
18
  ): Promise<EngagedProduct[]> {
19
  const session = await auth.api.getSession({ headers: await headers() });
20
  if (!session?.user?.id) throw new Error('Unauthorized');
 
6
  import type { TopEvergreenVideo, EngagedProduct } from '../types/analytics.types';
7
 
8
  export async function getEngagedVideos(
9
+ sortBy: 'clicks' = 'clicks'
10
  ): Promise<TopEvergreenVideo[]> {
11
  const session = await auth.api.getSession({ headers: await headers() });
12
  if (!session?.user?.id) throw new Error('Unauthorized');
 
14
  }
15
 
16
  export async function getEngagedProducts(
17
+ sortBy: 'clicks' = 'clicks'
18
  ): Promise<EngagedProduct[]> {
19
  const session = await auth.api.getSession({ headers: await headers() });
20
  if (!session?.user?.id) throw new Error('Unauthorized');
src/features/analytics/components/key-metrics-row.tsx CHANGED
@@ -60,7 +60,7 @@ export function KeyMetricsRow() {
60
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
61
  {/* Active Items */}
62
  <Link href="/dashboard/moderation?status=approved" className="block">
63
- <Card className="bg-[#0A0B14] border-gray-800 hover:border-gray-700 transition-colors cursor-pointer hover:shadow-lg">
64
  <CardHeader className="pb-3">
65
  <div className="flex items-center justify-between">
66
  <CardTitle className="text-sm font-medium text-gray-400">Active Items</CardTitle>
@@ -71,31 +71,51 @@ export function KeyMetricsRow() {
71
  <div className="text-3xl font-bold text-gray-200">
72
  {metricsData.activeItems}
73
  </div>
74
- <p className="text-xs text-gray-500 mt-1">Approved items</p>
 
 
 
 
75
  </CardContent>
76
  </Card>
77
  </Link>
78
 
79
- {/* Conversion Rate */}
80
- <Card className="bg-[#0A0B14] border-gray-800 hover:border-gray-700 transition-colors">
81
- <CardHeader className="pb-3">
82
- <div className="flex items-center justify-between">
83
- <CardTitle className="text-sm font-medium text-gray-400">Conversion Rate</CardTitle>
84
- <TrendingUp className="h-4 w-4 text-gray-400" />
85
- </div>
86
- </CardHeader>
87
- <CardContent>
88
- <div className="text-3xl font-bold text-gray-200">
89
- {metricsData.conversionRate.toFixed(1)}%
90
- </div>
91
- <p className="text-xs text-gray-500 mt-1">
92
- {metricsData.totalInterestPledges} pledges from {metricsData.totalClicks} clicks
93
- </p>
94
- </CardContent>
95
- </Card>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
  {/* Total Clicks */}
98
- <Card className="bg-[#0A0B14] border-gray-800 hover:border-gray-700 transition-colors">
99
  <CardHeader className="pb-3">
100
  <div className="flex items-center justify-between">
101
  <CardTitle className="text-sm font-medium text-gray-400">Total Clicks</CardTitle>
@@ -119,7 +139,14 @@ export function KeyMetricsRow() {
119
  <div className="text-3xl font-bold text-gray-200">
120
  {metricsData.totalClicks.toLocaleString()}
121
  </div>
122
- <p className="text-xs text-gray-500 mt-1">Product link clicks</p>
 
 
 
 
 
 
 
123
  </CardContent>
124
  </Card>
125
  </div>
@@ -130,13 +157,13 @@ function KeyMetricsRowSkeleton() {
130
  return (
131
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
132
  {[...Array(3)].map((_, i) => (
133
- <Card key={i} className="bg-[#0A0B14] border-gray-800">
134
  <CardHeader className="pb-3">
135
- <Skeleton className="h-4 w-32 bg-gray-800" />
136
  </CardHeader>
137
  <CardContent>
138
- <Skeleton className="h-10 w-20 bg-gray-800" />
139
- <Skeleton className="h-3 w-40 mt-2 bg-gray-800" />
140
  </CardContent>
141
  </Card>
142
  ))}
 
60
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
61
  {/* Active Items */}
62
  <Link href="/dashboard/moderation?status=approved" className="block">
63
+ <Card className="glass border-white/10 hover:bg-white/[0.07] transition-all duration-300 cursor-pointer hover:shadow-lg">
64
  <CardHeader className="pb-3">
65
  <div className="flex items-center justify-between">
66
  <CardTitle className="text-sm font-medium text-gray-400">Active Items</CardTitle>
 
71
  <div className="text-3xl font-bold text-gray-200">
72
  {metricsData.activeItems}
73
  </div>
74
+ <div className="flex items-center gap-1.5 mt-1">
75
+ <p className="text-xs text-gray-500">
76
+ {metricsData.activeItemsTrend > 0 ? '+' : ''}{metricsData.activeItemsTrend} this week
77
+ </p>
78
+ </div>
79
  </CardContent>
80
  </Card>
81
  </Link>
82
 
83
+ {/* Pending Demand */}
84
+ <Link href="/dashboard/requests" className="block">
85
+ <Card className="glass border-white/10 hover:bg-white/[0.07] transition-all duration-300 cursor-pointer hover:shadow-lg">
86
+ <CardHeader className="pb-3">
87
+ <div className="flex items-center justify-between">
88
+ <CardTitle className="text-sm font-medium text-gray-400">Pending Demand</CardTitle>
89
+ <svg
90
+ xmlns="http://www.w3.org/2000/svg"
91
+ viewBox="0 0 24 24"
92
+ fill="currentColor"
93
+ className="h-4 w-4 text-orange-400"
94
+ >
95
+ <path fillRule="evenodd" d="M12.963 2.286a.75.75 0 00-1.071-.136 9.742 9.742 0 00-3.539 6.177A7.547 7.547 0 016.648 6.61a.75.75 0 00-1.152-.082A9 9 0 1015.68 4.534a7.46 7.46 0 01-2.717-2.248zM15.75 14.25a3.75 3.75 0 11-7.313-1.172c.628.465 1.35.81 2.133 1a5.99 5.99 0 011.925-3.545 3.75 3.75 0 013.255 3.717z" clipRule="evenodd" />
96
+ </svg>
97
+ </div>
98
+ </CardHeader>
99
+ <CardContent>
100
+ <div className="text-3xl font-bold text-gray-200">
101
+ {metricsData.pendingDemand}
102
+ </div>
103
+ <div className="flex items-center gap-1.5 mt-1">
104
+ {metricsData.pendingDemandTrend > 0 && (
105
+ <span className="text-xs font-medium text-orange-500">
106
+ +{metricsData.pendingDemandTrend} this week
107
+ </span>
108
+ )}
109
+ <p className="text-xs text-gray-500">
110
+ opportunities to add links
111
+ </p>
112
+ </div>
113
+ </CardContent>
114
+ </Card>
115
+ </Link>
116
 
117
  {/* Total Clicks */}
118
+ <Card className="glass border-white/10 hover:bg-white/[0.07] transition-all duration-300">
119
  <CardHeader className="pb-3">
120
  <div className="flex items-center justify-between">
121
  <CardTitle className="text-sm font-medium text-gray-400">Total Clicks</CardTitle>
 
139
  <div className="text-3xl font-bold text-gray-200">
140
  {metricsData.totalClicks.toLocaleString()}
141
  </div>
142
+ <div className="flex items-center gap-1.5 mt-1">
143
+ {metricsData.totalClicksTrend !== 0 && (
144
+ <span className={`text-xs font-medium ${metricsData.totalClicksTrend > 0 ? 'text-green-500' : 'text-red-500'}`}>
145
+ {metricsData.totalClicksTrend > 0 ? '↑' : '↓'} {Math.abs(metricsData.totalClicksTrend)}%
146
+ </span>
147
+ )}
148
+ <p className="text-xs text-gray-500">vs last week</p>
149
+ </div>
150
  </CardContent>
151
  </Card>
152
  </div>
 
157
  return (
158
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
159
  {[...Array(3)].map((_, i) => (
160
+ <Card key={i} className="glass border-white/10">
161
  <CardHeader className="pb-3">
162
+ <Skeleton className="h-4 w-32 bg-white/5" />
163
  </CardHeader>
164
  <CardContent>
165
+ <Skeleton className="h-10 w-20 bg-white/5" />
166
+ <Skeleton className="h-3 w-40 mt-2 bg-white/5" />
167
  </CardContent>
168
  </Card>
169
  ))}
src/features/analytics/components/top-evergreen-videos.tsx CHANGED
@@ -8,7 +8,7 @@ import { getEngagedVideos, getEngagedProducts } from '../actions/get-top-evergre
8
  import type { TopEvergreenVideo, EngagedProduct } from '../types/analytics.types';
9
 
10
  type Tab = 'videos' | 'products';
11
- type SortBy = 'clicks' | 'revenue';
12
 
13
  export function TopEvergreenVideos({ creatorSlug }: { creatorSlug?: string } = {}) {
14
  const [tab, setTab] = useState<Tab>('videos');
@@ -52,35 +52,16 @@ export function TopEvergreenVideos({ creatorSlug }: { creatorSlug?: string } = {
52
  <button
53
  key={t}
54
  onClick={() => setTab(t)}
55
- className={`px-3 py-1 rounded-md text-sm capitalize transition-colors ${
56
- tab === t
57
  ? 'bg-gray-700 text-gray-100'
58
  : 'text-gray-400 hover:text-gray-200'
59
- }`}
60
  >
61
  {t}
62
  </button>
63
  ))}
64
  </div>
65
 
66
- {/* Sort controls */}
67
- <div className="flex items-center gap-1">
68
- <span className="text-xs text-gray-500 mr-1">Sort:</span>
69
- {(['clicks', 'revenue'] as SortBy[]).map(field => (
70
- <button
71
- key={field}
72
- onClick={() => toggleSort(field)}
73
- className={`inline-flex items-center gap-0.5 px-2 py-0.5 rounded text-xs capitalize transition-colors ${
74
- sortBy === field
75
- ? 'bg-gray-700 text-gray-100'
76
- : 'text-gray-500 hover:text-gray-300'
77
- }`}
78
- >
79
- {field}
80
- {sortBy === field && <ArrowDown className="h-3 w-3" />}
81
- </button>
82
- ))}
83
- </div>
84
  </div>
85
  </CardHeader>
86
 
@@ -147,9 +128,9 @@ function VideoRow({ video, creatorSlug }: { video: TopEvergreenVideo; creatorSlu
147
  </div>
148
  </div>
149
 
150
- <div className="flex-shrink-0 text-right space-y-0.5">
151
- <p className="text-sm text-green-500 font-medium">${video.revenue.toFixed(2)}</p>
152
- <p className="text-xs text-gray-500">{video.clicks} clicks</p>
153
  </div>
154
 
155
  {creatorSlug && (
@@ -186,11 +167,10 @@ function ProductRow({ product }: { product: EngagedProduct }) {
186
  <div className="flex-1 min-w-0">
187
  <p className="text-sm font-medium text-gray-200 truncate">{product.productName}</p>
188
  <div className="flex items-center gap-2 mt-0.5">
189
- <span className={`text-xs px-1.5 py-0.5 rounded capitalize ${
190
- product.marketplace === 'amazon' ? 'bg-amber-900/40 text-amber-300' :
191
- product.marketplace === 'ebay' ? 'bg-blue-900/40 text-blue-300' :
192
- 'bg-pink-900/40 text-pink-300'
193
- }`}>
194
  {product.marketplace}
195
  </span>
196
  <span className="text-xs text-gray-500">${product.price.toFixed(2)}</span>
@@ -198,9 +178,9 @@ function ProductRow({ product }: { product: EngagedProduct }) {
198
  </div>
199
  </div>
200
 
201
- <div className="flex-shrink-0 text-right space-y-0.5">
202
- <p className="text-sm text-green-500 font-medium">${product.revenue.toFixed(2)}</p>
203
- <p className="text-xs text-gray-500">{product.clicks} clicks</p>
204
  </div>
205
 
206
  <a
 
8
  import type { TopEvergreenVideo, EngagedProduct } from '../types/analytics.types';
9
 
10
  type Tab = 'videos' | 'products';
11
+ type SortBy = 'clicks';
12
 
13
  export function TopEvergreenVideos({ creatorSlug }: { creatorSlug?: string } = {}) {
14
  const [tab, setTab] = useState<Tab>('videos');
 
52
  <button
53
  key={t}
54
  onClick={() => setTab(t)}
55
+ className={`px-3 py-1 rounded-md text-sm capitalize transition-colors ${tab === t
 
56
  ? 'bg-gray-700 text-gray-100'
57
  : 'text-gray-400 hover:text-gray-200'
58
+ }`}
59
  >
60
  {t}
61
  </button>
62
  ))}
63
  </div>
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  </div>
66
  </CardHeader>
67
 
 
128
  </div>
129
  </div>
130
 
131
+ <div className="flex-shrink-0 text-right self-center">
132
+ <p className="text-sm font-bold text-primary">{video.clicks.toLocaleString()}</p>
133
+ <p className="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">Clicks</p>
134
  </div>
135
 
136
  {creatorSlug && (
 
167
  <div className="flex-1 min-w-0">
168
  <p className="text-sm font-medium text-gray-200 truncate">{product.productName}</p>
169
  <div className="flex items-center gap-2 mt-0.5">
170
+ <span className={`text-xs px-1.5 py-0.5 rounded capitalize ${product.marketplace === 'amazon' ? 'bg-amber-900/40 text-amber-300' :
171
+ product.marketplace === 'ebay' ? 'bg-blue-900/40 text-blue-300' :
172
+ 'bg-pink-900/40 text-pink-300'
173
+ }`}>
 
174
  {product.marketplace}
175
  </span>
176
  <span className="text-xs text-gray-500">${product.price.toFixed(2)}</span>
 
178
  </div>
179
  </div>
180
 
181
+ <div className="flex-shrink-0 text-right self-center mr-2">
182
+ <p className="text-sm font-bold text-primary">{product.clicks.toLocaleString()}</p>
183
+ <p className="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">Clicks</p>
184
  </div>
185
 
186
  <a
src/features/analytics/config/top-videos.config.ts CHANGED
@@ -23,24 +23,14 @@ export const TOP_VIDEOS_CONFIG = {
23
  /**
24
  * Default sort option
25
  */
26
- DEFAULT_SORT_BY: 'revenue' as const,
27
 
28
  /**
29
  * Maximum number of videos that can be requested
30
  */
31
  MAX_LIMIT: 10,
32
 
33
- /**
34
- * Minimum revenue threshold for a video to be considered (USD)
35
- */
36
- MIN_REVENUE_THRESHOLD: 0,
37
 
38
- /**
39
- * Mock commission per click (USD)
40
- * TODO: Replace with actual affiliate commission rates when affiliate_transactions table is available
41
- * Note: Real Amazon affiliate rates are 1-10% of sale price, not a flat rate
42
- */
43
- MOCK_COMMISSION_PER_CLICK: 2.5,
44
 
45
  /**
46
  * Time period options in days
 
23
  /**
24
  * Default sort option
25
  */
26
+ DEFAULT_SORT_BY: 'clicks' as const,
27
 
28
  /**
29
  * Maximum number of videos that can be requested
30
  */
31
  MAX_LIMIT: 10,
32
 
 
 
 
 
33
 
 
 
 
 
 
 
34
 
35
  /**
36
  * Time period options in days
src/features/analytics/services/key-metrics-analytics.service.ts CHANGED
@@ -2,7 +2,7 @@
2
  * Key Metrics Analytics Service
3
  *
4
  * Business logic for calculating key metrics for the dashboard.
5
- * Provides data for active items count and conversion rate.
6
  */
7
 
8
  import { db } from '@/lib/db';
@@ -13,8 +13,9 @@ import {
13
  youtubeChannels,
14
  productClicks,
15
  interestPledges,
 
16
  } from '@/lib/db/schema';
17
- import { sql, eq, and, count } from 'drizzle-orm';
18
  import { KeyMetricsData } from '../types/analytics.types';
19
  import * as Sentry from '@sentry/nextjs';
20
 
@@ -70,10 +71,10 @@ export class KeyMetricsAnalyticsService {
70
  }
71
 
72
  /**
73
- * Get total number of interest pledges for a specific creator
74
  * @param creatorId - The creator's user ID
75
  */
76
- static async getTotalInterestPledges(creatorId: string): Promise<number> {
77
  try {
78
  const result = await db
79
  .select({ count: count() })
@@ -86,59 +87,83 @@ export class KeyMetricsAnalyticsService {
86
 
87
  return result[0]?.count ?? 0;
88
  } catch (error) {
89
- console.error('Error fetching total interest pledges:', error);
90
  Sentry.captureException(error);
91
  throw error;
92
  }
93
  }
94
 
95
  /**
96
- * Calculate conversion rate (interest pledges / clicks) * 100%
97
  * @param creatorId - The creator's user ID
98
  */
99
- static async getConversionRate(creatorId: string): Promise<number> {
100
  try {
101
- const [totalClicks, totalInterestPledges] = await Promise.all([
102
- this.getTotalClicks(creatorId),
103
- this.getTotalInterestPledges(creatorId),
104
- ]);
105
-
106
- // Handle division by zero
107
- if (totalClicks === 0) {
108
- return 0;
109
- }
110
 
111
- const conversionRate = (totalInterestPledges / totalClicks) * 100;
112
- return Math.round(conversionRate * 10) / 10; // Round to 1 decimal place
113
  } catch (error) {
114
- console.error('Error calculating conversion rate:', error);
115
  Sentry.captureException(error);
116
  throw error;
117
  }
118
  }
119
 
120
  /**
121
- * Get all key metrics for a specific creator
122
  * @param creatorId - The creator's user ID
123
  */
124
  static async getKeyMetrics(creatorId: string): Promise<KeyMetricsData> {
125
  try {
126
- const [activeItems, totalClicks, totalInterestPledges] = await Promise.all([
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  this.getActiveItemsCount(creatorId),
128
- this.getTotalClicks(creatorId),
129
- this.getTotalInterestPledges(creatorId),
 
 
 
 
 
130
  ]);
131
 
132
- // Calculate conversion rate
133
- const conversionRate = totalClicks === 0
134
- ? 0
135
- : Math.round((totalInterestPledges / totalClicks) * 1000) / 10; // Round to 1 decimal place
 
 
 
136
 
137
  return {
138
  activeItems,
139
- conversionRate,
 
 
 
 
140
  totalClicks,
141
- totalInterestPledges,
142
  };
143
  } catch (error) {
144
  console.error('Error fetching key metrics:', error);
@@ -146,4 +171,63 @@ export class KeyMetricsAnalyticsService {
146
  throw error;
147
  }
148
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  }
 
2
  * Key Metrics Analytics Service
3
  *
4
  * Business logic for calculating key metrics for the dashboard.
5
+ * Provides data for active items count and pending demand.
6
  */
7
 
8
  import { db } from '@/lib/db';
 
13
  youtubeChannels,
14
  productClicks,
15
  interestPledges,
16
+ productRequests as productRequestsTable,
17
  } from '@/lib/db/schema';
18
+ import { eq, and, count, gte, lte } from 'drizzle-orm';
19
  import { KeyMetricsData } from '../types/analytics.types';
20
  import * as Sentry from '@sentry/nextjs';
21
 
 
71
  }
72
 
73
  /**
74
+ * Get total number of "I Want This" clicks (interest pledges) for a specific creator
75
  * @param creatorId - The creator's user ID
76
  */
77
+ static async getWantThisClicks(creatorId: string): Promise<number> {
78
  try {
79
  const result = await db
80
  .select({ count: count() })
 
87
 
88
  return result[0]?.count ?? 0;
89
  } catch (error) {
90
+ console.error('Error fetching "I Want This" clicks:', error);
91
  Sentry.captureException(error);
92
  throw error;
93
  }
94
  }
95
 
96
  /**
97
+ * Get total number of product requests for a specific creator
98
  * @param creatorId - The creator's user ID
99
  */
100
+ static async getProductRequests(creatorId: string): Promise<number> {
101
  try {
102
+ const result = await db
103
+ .select({ count: count() })
104
+ .from(productRequestsTable)
105
+ .where(
106
+ and(
107
+ eq(productRequestsTable.creatorId, creatorId),
108
+ eq(productRequestsTable.status, 'PENDING')
109
+ )
110
+ );
111
 
112
+ return result[0]?.count ?? 0;
 
113
  } catch (error) {
114
+ console.error('Error fetching product requests:', error);
115
  Sentry.captureException(error);
116
  throw error;
117
  }
118
  }
119
 
120
  /**
121
+ * Get all key metrics for a specific creator with 7-day trends
122
  * @param creatorId - The creator's user ID
123
  */
124
  static async getKeyMetrics(creatorId: string): Promise<KeyMetricsData> {
125
  try {
126
+ const now = new Date();
127
+ const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
128
+ const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
129
+
130
+ const [
131
+ activeItems,
132
+ newItemsThisWeek,
133
+ currentClicks,
134
+ previousClicks,
135
+ totalWantThisClicks,
136
+ newWantThisThisWeek,
137
+ totalProductRequests,
138
+ newRequestsThisWeek,
139
+ ] = await Promise.all([
140
  this.getActiveItemsCount(creatorId),
141
+ this.getFilteredCount(detectedObjects, 'createdAt', creatorId, sevenDaysAgo, now, { moderationStatus: 'APPROVED' }),
142
+ this.getFilteredCount(productClicks, 'clickedAt', creatorId, sevenDaysAgo, now),
143
+ this.getFilteredCount(productClicks, 'clickedAt', creatorId, fourteenDaysAgo, sevenDaysAgo),
144
+ this.getWantThisClicks(creatorId),
145
+ this.getFilteredCount(interestPledges, 'createdAt', creatorId, sevenDaysAgo, now),
146
+ this.getProductRequests(creatorId),
147
+ this.getFilteredCount(productRequestsTable, 'createdAt', creatorId, sevenDaysAgo, now, { status: 'PENDING' }),
148
  ]);
149
 
150
+ // Calculate trends
151
+ const totalClicks = await this.getTotalClicks(creatorId);
152
+ const clicksTrend = previousClicks === 0 ? 0 : ((currentClicks - previousClicks) / previousClicks) * 100;
153
+
154
+ // Pending demand = product requests + "I want this" clicks
155
+ const pendingDemand = totalProductRequests + totalWantThisClicks;
156
+ const pendingDemandTrend = newRequestsThisWeek + newWantThisThisWeek;
157
 
158
  return {
159
  activeItems,
160
+ activeItemsTrend: newItemsThisWeek,
161
+ pendingDemand,
162
+ pendingDemandTrend,
163
+ productRequests: totalProductRequests,
164
+ wantThisClicks: totalWantThisClicks,
165
  totalClicks,
166
+ totalClicksTrend: Math.round(clicksTrend),
167
  };
168
  } catch (error) {
169
  console.error('Error fetching key metrics:', error);
 
171
  throw error;
172
  }
173
  }
174
+
175
+ /**
176
+ * Helper to get counts with time and creator filters
177
+ */
178
+ private static async getFilteredCount(
179
+ table: any,
180
+ dateColumn: string,
181
+ creatorId: string,
182
+ start: Date,
183
+ end: Date,
184
+ extraFilters: Record<string, any> = {}
185
+ ): Promise<number> {
186
+ let query = db
187
+ .select({ count: count() })
188
+ .from(table);
189
+
190
+ // Join chains depend on the table
191
+ if (table === detectedObjects) {
192
+ query = query
193
+ .innerJoin(youtubeVideos, eq(detectedObjects.videoId, youtubeVideos.id))
194
+ .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id)) as any;
195
+ } else if (table === productClicks || table === interestPledges) {
196
+ query = query
197
+ .innerJoin(marketplaceMatches, eq((table as any).marketplaceMatchId, marketplaceMatches.id))
198
+ .innerJoin(detectedObjects, eq(marketplaceMatches.objectId, detectedObjects.id))
199
+ .innerJoin(youtubeVideos, eq(detectedObjects.videoId, youtubeVideos.id))
200
+ .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id)) as any;
201
+ } else if (table === productRequestsTable) {
202
+ // Product requests are tied directly to creator, no joins needed
203
+ const conditions = [
204
+ eq(productRequestsTable.creatorId, creatorId),
205
+ gte((table as any)[dateColumn], start),
206
+ lte((table as any)[dateColumn], end),
207
+ ];
208
+
209
+ if (extraFilters.status) {
210
+ conditions.push(eq(productRequestsTable.status, extraFilters.status));
211
+ }
212
+
213
+ const result = await db
214
+ .select({ count: count() })
215
+ .from(table)
216
+ .where(and(...conditions));
217
+ return result[0]?.count ?? 0;
218
+ }
219
+
220
+ const conditions = [
221
+ eq(youtubeChannels.creatorId, creatorId),
222
+ gte((table as any)[dateColumn], start),
223
+ lte((table as any)[dateColumn], end),
224
+ ];
225
+
226
+ if (extraFilters.moderationStatus) {
227
+ conditions.push(eq(detectedObjects.moderationStatus, extraFilters.moderationStatus));
228
+ }
229
+
230
+ const result = await query.where(and(...conditions));
231
+ return result[0]?.count ?? 0;
232
+ }
233
  }
src/features/analytics/services/top-videos-analytics.service.ts CHANGED
@@ -1,7 +1,7 @@
1
  /**
2
  * Top Videos Analytics Service
3
  *
4
- * Service for fetching and analyzing top performing videos by revenue/clicks.
5
  * Implements caching strategy to reduce database load.
6
  */
7
 
@@ -9,7 +9,7 @@ import { db } from '@/lib/db';
9
  import { youtubeVideos, detectedObjects, marketplaceMatches, productClicks, youtubeChannels } from '@/lib/db/schema';
10
  import { eq, and, desc, sql, gte } from 'drizzle-orm';
11
  import { TOP_VIDEOS_CONFIG } from '../config/top-videos.config';
12
- import type { TopEvergreenVideo, TopVideosConfig, EngagedProduct } from '../types/analytics.types';
13
 
14
  /**
15
  * In-memory cache with TTL (5 minutes like demand heatmap)
@@ -44,38 +44,25 @@ export class TopVideosAnalyticsService {
44
  * @param creatorId - The creator's user ID
45
  * @param config - Configuration for limit, time period, and sort order
46
  * @returns Array of top performing videos
47
- * @throws Error if creatorId is invalid or database query fails
48
- *
49
- * @example
50
- * ```typescript
51
- * const topVideos = await TopVideosAnalyticsService.getTopEvergreenVideos(
52
- * 'user-123',
53
- * { limit: 3, timePeriod: 'all_time', sortBy: 'revenue' }
54
- * );
55
- * ```
56
  */
57
  static async getTopEvergreenVideos(
58
  creatorId: string,
59
  config: Partial<TopVideosConfig> = {}
60
  ): Promise<TopEvergreenVideo[]> {
61
- // Validate input
62
  if (!creatorId || typeof creatorId !== 'string') {
63
  throw new Error('Invalid creatorId: must be a non-empty string');
64
  }
65
 
66
- // Apply defaults
67
  const finalConfig: TopVideosConfig = {
68
  limit: config.limit ?? TOP_VIDEOS_CONFIG.DEFAULT_LIMIT,
69
  timePeriod: config.timePeriod ?? TOP_VIDEOS_CONFIG.DEFAULT_TIME_PERIOD,
70
- sortBy: config.sortBy ?? TOP_VIDEOS_CONFIG.DEFAULT_SORT_BY,
71
  };
72
 
73
- // Validate limit
74
  if (finalConfig.limit < 1 || finalConfig.limit > TOP_VIDEOS_CONFIG.MAX_LIMIT) {
75
  throw new Error(`Invalid limit: must be between 1 and ${TOP_VIDEOS_CONFIG.MAX_LIMIT}`);
76
  }
77
 
78
- // Check cache first
79
  const cacheKey = `${creatorId}-${finalConfig.timePeriod}-${finalConfig.sortBy}-${finalConfig.limit}`;
80
  const cached = cache.get(cacheKey);
81
  if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
@@ -83,22 +70,18 @@ export class TopVideosAnalyticsService {
83
  }
84
 
85
  try {
86
- // Calculate time filter if needed
87
  let timeFilter = undefined;
88
  if (finalConfig.timePeriod !== 'all_time') {
89
- const daysAgo = finalConfig.timePeriod === 'last_7_days'
90
- ? TOP_VIDEOS_CONFIG.TIME_PERIODS.LAST_7_DAYS
91
  : TOP_VIDEOS_CONFIG.TIME_PERIODS.LAST_30_DAYS;
92
  const cutoffDate = new Date();
93
  cutoffDate.setDate(cutoffDate.getDate() - daysAgo);
94
  timeFilter = gte(productClicks.clickedAt, cutoffDate);
95
  }
96
 
97
- // Query database for top videos
98
- // Note: Since we don't have affiliate_transactions table yet, we'll use clicks as a proxy
99
- // Revenue will be calculated as clicks * estimated commission (mock for now)
100
- const MOCK_COMMISSION_PER_CLICK = TOP_VIDEOS_CONFIG.MOCK_COMMISSION_PER_CLICK;
101
-
102
  const results = await db
103
  .select({
104
  videoId: youtubeVideos.videoId,
@@ -106,17 +89,15 @@ export class TopVideosAnalyticsService {
106
  thumbnailUrl: youtubeVideos.thumbnailUrl,
107
  views: youtubeVideos.viewCount,
108
  uploadDate: youtubeVideos.publishedAt,
109
- clicks: sql<number>`COALESCE(COUNT(DISTINCT ${productClicks.id}), 0)`,
110
  productCount: sql<number>`COUNT(DISTINCT ${detectedObjects.id})`,
111
- // Calculate revenue in the query for proper sorting
112
- revenue: sql<number>`COALESCE(COUNT(DISTINCT ${productClicks.id})::numeric, 0) * ${MOCK_COMMISSION_PER_CLICK}`,
113
  })
114
  .from(youtubeVideos)
115
  .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
116
  .leftJoin(detectedObjects, eq(detectedObjects.videoId, youtubeVideos.id))
117
  .leftJoin(marketplaceMatches, eq(marketplaceMatches.objectId, detectedObjects.id))
118
- .leftJoin(productClicks,
119
- timeFilter
120
  ? and(eq(productClicks.marketplaceMatchId, marketplaceMatches.id), timeFilter)
121
  : eq(productClicks.marketplaceMatchId, marketplaceMatches.id)
122
  )
@@ -129,32 +110,21 @@ export class TopVideosAnalyticsService {
129
  youtubeVideos.viewCount,
130
  youtubeVideos.publishedAt
131
  )
132
- .orderBy(desc(finalConfig.sortBy === 'revenue'
133
- ? sql`COALESCE(COUNT(DISTINCT ${productClicks.id})::numeric, 0) * ${MOCK_COMMISSION_PER_CLICK}`
134
- : sql`COALESCE(COUNT(DISTINCT ${productClicks.id}), 0)`))
135
  .limit(finalConfig.limit);
136
 
137
- // Transform results
138
- // TODO: Replace with actual revenue from affiliate_transactions table when available
139
  const topVideos: TopEvergreenVideo[] = results.map(row => ({
140
  videoId: row.videoId,
141
  videoTitle: row.videoTitle,
142
  thumbnailUrl: row.thumbnailUrl || '',
143
  views: row.views || 0,
144
  uploadDate: row.uploadDate || new Date(),
145
- revenue: Number(row.revenue), // Revenue already calculated in query
146
  clicks: Number(row.clicks),
147
  productCount: Number(row.productCount),
148
  }));
149
 
150
- // Only include videos that have at least one detected product
151
- const filteredVideos = topVideos.filter(
152
- video => video.productCount > 0
153
- );
154
-
155
- // Cache the results
156
  cache.set(cacheKey, { data: filteredVideos, timestamp: Date.now() });
157
-
158
  return filteredVideos;
159
  } catch (error) {
160
  console.error('Error fetching top evergreen videos:', error);
@@ -163,17 +133,13 @@ export class TopVideosAnalyticsService {
163
  }
164
 
165
  /**
166
- * Get all videos with engagement (clicks > 0), sorted by clicks or revenue.
167
  */
168
  static async getEngagedVideos(
169
  creatorId: string,
170
- sortBy: 'clicks' | 'revenue' = 'clicks'
171
  ): Promise<TopEvergreenVideo[]> {
172
- const MOCK_COMMISSION = TOP_VIDEOS_CONFIG.MOCK_COMMISSION_PER_CLICK;
173
-
174
  const clicksExpr = sql<number>`COALESCE(COUNT(DISTINCT ${productClicks.id}), 0)`;
175
- const revenueExpr = sql<number>`COALESCE(COUNT(DISTINCT ${productClicks.id})::numeric, 0) * ${MOCK_COMMISSION}`;
176
- const sortExpr = sortBy === 'revenue' ? revenueExpr : clicksExpr;
177
 
178
  const results = await db
179
  .select({
@@ -184,7 +150,6 @@ export class TopVideosAnalyticsService {
184
  uploadDate: youtubeVideos.publishedAt,
185
  clicks: clicksExpr,
186
  productCount: sql<number>`COUNT(DISTINCT ${detectedObjects.id})`,
187
- revenue: revenueExpr,
188
  })
189
  .from(youtubeVideos)
190
  .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
@@ -201,7 +166,7 @@ export class TopVideosAnalyticsService {
201
  youtubeVideos.publishedAt
202
  )
203
  .having(sql`COALESCE(COUNT(DISTINCT ${productClicks.id}), 0) > 0`)
204
- .orderBy(desc(sortExpr));
205
 
206
  return results.map(row => ({
207
  videoId: row.videoId,
@@ -209,24 +174,19 @@ export class TopVideosAnalyticsService {
209
  thumbnailUrl: row.thumbnailUrl || '',
210
  views: row.views || 0,
211
  uploadDate: row.uploadDate || new Date(),
212
- revenue: Number(row.revenue),
213
  clicks: Number(row.clicks),
214
  productCount: Number(row.productCount),
215
  }));
216
  }
217
 
218
  /**
219
- * Get all products with engagement (clicks > 0), sorted by clicks or revenue.
220
  */
221
  static async getEngagedProducts(
222
  creatorId: string,
223
- sortBy: 'clicks' | 'revenue' = 'clicks'
224
  ): Promise<EngagedProduct[]> {
225
- const MOCK_COMMISSION = TOP_VIDEOS_CONFIG.MOCK_COMMISSION_PER_CLICK;
226
-
227
  const clicksExpr = sql<number>`COALESCE(COUNT(DISTINCT ${productClicks.id}), 0)`;
228
- const revenueExpr = sql<number>`COALESCE(COUNT(DISTINCT ${productClicks.id})::numeric, 0) * ${MOCK_COMMISSION}`;
229
- const sortExpr = sortBy === 'revenue' ? revenueExpr : clicksExpr;
230
 
231
  const results = await db
232
  .select({
@@ -236,11 +196,9 @@ export class TopVideosAnalyticsService {
236
  price: marketplaceMatches.price,
237
  affiliateUrl: marketplaceMatches.affiliateUrl,
238
  clicks: clicksExpr,
239
- revenue: revenueExpr,
240
  videoId: youtubeVideos.videoId,
241
  videoTitle: youtubeVideos.title,
242
  videoThumbnailUrl: youtubeVideos.thumbnailUrl,
243
- // Each match has exactly one detectedObject; MAX satisfies the aggregation
244
  thumbnailUrl: sql<string | null>`MAX(${detectedObjects.thumbnailUrl})`,
245
  })
246
  .from(marketplaceMatches)
@@ -260,7 +218,7 @@ export class TopVideosAnalyticsService {
260
  youtubeVideos.thumbnailUrl
261
  )
262
  .having(sql`COALESCE(COUNT(DISTINCT ${productClicks.id}), 0) > 0`)
263
- .orderBy(desc(sortExpr));
264
 
265
  return results.map(row => ({
266
  matchId: row.matchId,
@@ -269,7 +227,6 @@ export class TopVideosAnalyticsService {
269
  price: row.price,
270
  affiliateUrl: row.affiliateUrl,
271
  clicks: Number(row.clicks),
272
- revenue: Number(row.revenue),
273
  videoId: row.videoId,
274
  videoTitle: row.videoTitle,
275
  thumbnailUrl: row.thumbnailUrl || row.videoThumbnailUrl || '',
@@ -279,36 +236,21 @@ export class TopVideosAnalyticsService {
279
 
280
  /**
281
  * Clear cache for a specific creator or all cache
282
- *
283
- * @param creatorId - Optional creator ID to clear cache for specific creator
284
- *
285
- * @example
286
- * ```typescript
287
- * // Clear cache for specific creator
288
- * TopVideosAnalyticsService.clearCache('user-123');
289
- *
290
- * // Clear all cache
291
- * TopVideosAnalyticsService.clearCache();
292
- * ```
293
  */
294
  static clearCache(creatorId?: string): void {
295
  if (creatorId) {
296
- // Clear cache entries for specific creator
297
  for (const key of cache.keys()) {
298
  if (key.startsWith(creatorId)) {
299
  cache.delete(key);
300
  }
301
  }
302
  } else {
303
- // Clear all cache
304
  cache.clear();
305
  }
306
  }
307
 
308
  /**
309
- * Get cache statistics (useful for monitoring)
310
- *
311
- * @returns Object with cache size and oldest entry age
312
  */
313
  static getCacheStats(): { size: number; oldestEntryAge: number | null } {
314
  if (cache.size === 0) {
 
1
  /**
2
  * Top Videos Analytics Service
3
  *
4
+ * Service for fetching and analyzing top performing videos by clicks.
5
  * Implements caching strategy to reduce database load.
6
  */
7
 
 
9
  import { youtubeVideos, detectedObjects, marketplaceMatches, productClicks, youtubeChannels } from '@/lib/db/schema';
10
  import { eq, and, desc, sql, gte } from 'drizzle-orm';
11
  import { TOP_VIDEOS_CONFIG } from '../config/top-videos.config';
12
+ import type { TopEvergreenVideo, TopVideosSortBy, TopVideosConfig, EngagedProduct } from '../types/analytics.types';
13
 
14
  /**
15
  * In-memory cache with TTL (5 minutes like demand heatmap)
 
44
  * @param creatorId - The creator's user ID
45
  * @param config - Configuration for limit, time period, and sort order
46
  * @returns Array of top performing videos
 
 
 
 
 
 
 
 
 
47
  */
48
  static async getTopEvergreenVideos(
49
  creatorId: string,
50
  config: Partial<TopVideosConfig> = {}
51
  ): Promise<TopEvergreenVideo[]> {
 
52
  if (!creatorId || typeof creatorId !== 'string') {
53
  throw new Error('Invalid creatorId: must be a non-empty string');
54
  }
55
 
 
56
  const finalConfig: TopVideosConfig = {
57
  limit: config.limit ?? TOP_VIDEOS_CONFIG.DEFAULT_LIMIT,
58
  timePeriod: config.timePeriod ?? TOP_VIDEOS_CONFIG.DEFAULT_TIME_PERIOD,
59
+ sortBy: 'clicks', // Only sorting by clicks supported now
60
  };
61
 
 
62
  if (finalConfig.limit < 1 || finalConfig.limit > TOP_VIDEOS_CONFIG.MAX_LIMIT) {
63
  throw new Error(`Invalid limit: must be between 1 and ${TOP_VIDEOS_CONFIG.MAX_LIMIT}`);
64
  }
65
 
 
66
  const cacheKey = `${creatorId}-${finalConfig.timePeriod}-${finalConfig.sortBy}-${finalConfig.limit}`;
67
  const cached = cache.get(cacheKey);
68
  if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
 
70
  }
71
 
72
  try {
 
73
  let timeFilter = undefined;
74
  if (finalConfig.timePeriod !== 'all_time') {
75
+ const daysAgo = finalConfig.timePeriod === 'last_7_days'
76
+ ? TOP_VIDEOS_CONFIG.TIME_PERIODS.LAST_7_DAYS
77
  : TOP_VIDEOS_CONFIG.TIME_PERIODS.LAST_30_DAYS;
78
  const cutoffDate = new Date();
79
  cutoffDate.setDate(cutoffDate.getDate() - daysAgo);
80
  timeFilter = gte(productClicks.clickedAt, cutoffDate);
81
  }
82
 
83
+ const clicksExpr = sql<number>`COALESCE(COUNT(DISTINCT ${productClicks.id}), 0)`;
84
+
 
 
 
85
  const results = await db
86
  .select({
87
  videoId: youtubeVideos.videoId,
 
89
  thumbnailUrl: youtubeVideos.thumbnailUrl,
90
  views: youtubeVideos.viewCount,
91
  uploadDate: youtubeVideos.publishedAt,
92
+ clicks: clicksExpr,
93
  productCount: sql<number>`COUNT(DISTINCT ${detectedObjects.id})`,
 
 
94
  })
95
  .from(youtubeVideos)
96
  .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
97
  .leftJoin(detectedObjects, eq(detectedObjects.videoId, youtubeVideos.id))
98
  .leftJoin(marketplaceMatches, eq(marketplaceMatches.objectId, detectedObjects.id))
99
+ .leftJoin(productClicks,
100
+ timeFilter
101
  ? and(eq(productClicks.marketplaceMatchId, marketplaceMatches.id), timeFilter)
102
  : eq(productClicks.marketplaceMatchId, marketplaceMatches.id)
103
  )
 
110
  youtubeVideos.viewCount,
111
  youtubeVideos.publishedAt
112
  )
113
+ .orderBy(desc(clicksExpr))
 
 
114
  .limit(finalConfig.limit);
115
 
 
 
116
  const topVideos: TopEvergreenVideo[] = results.map(row => ({
117
  videoId: row.videoId,
118
  videoTitle: row.videoTitle,
119
  thumbnailUrl: row.thumbnailUrl || '',
120
  views: row.views || 0,
121
  uploadDate: row.uploadDate || new Date(),
 
122
  clicks: Number(row.clicks),
123
  productCount: Number(row.productCount),
124
  }));
125
 
126
+ const filteredVideos = topVideos.filter(video => video.productCount > 0);
 
 
 
 
 
127
  cache.set(cacheKey, { data: filteredVideos, timestamp: Date.now() });
 
128
  return filteredVideos;
129
  } catch (error) {
130
  console.error('Error fetching top evergreen videos:', error);
 
133
  }
134
 
135
  /**
136
+ * Get all videos with engagement (clicks > 0), sorted by clicks.
137
  */
138
  static async getEngagedVideos(
139
  creatorId: string,
140
+ sortBy: 'clicks' = 'clicks'
141
  ): Promise<TopEvergreenVideo[]> {
 
 
142
  const clicksExpr = sql<number>`COALESCE(COUNT(DISTINCT ${productClicks.id}), 0)`;
 
 
143
 
144
  const results = await db
145
  .select({
 
150
  uploadDate: youtubeVideos.publishedAt,
151
  clicks: clicksExpr,
152
  productCount: sql<number>`COUNT(DISTINCT ${detectedObjects.id})`,
 
153
  })
154
  .from(youtubeVideos)
155
  .innerJoin(youtubeChannels, eq(youtubeVideos.channelId, youtubeChannels.id))
 
166
  youtubeVideos.publishedAt
167
  )
168
  .having(sql`COALESCE(COUNT(DISTINCT ${productClicks.id}), 0) > 0`)
169
+ .orderBy(desc(clicksExpr));
170
 
171
  return results.map(row => ({
172
  videoId: row.videoId,
 
174
  thumbnailUrl: row.thumbnailUrl || '',
175
  views: row.views || 0,
176
  uploadDate: row.uploadDate || new Date(),
 
177
  clicks: Number(row.clicks),
178
  productCount: Number(row.productCount),
179
  }));
180
  }
181
 
182
  /**
183
+ * Get all products with engagement (clicks > 0), sorted by clicks.
184
  */
185
  static async getEngagedProducts(
186
  creatorId: string,
187
+ sortBy: 'clicks' = 'clicks'
188
  ): Promise<EngagedProduct[]> {
 
 
189
  const clicksExpr = sql<number>`COALESCE(COUNT(DISTINCT ${productClicks.id}), 0)`;
 
 
190
 
191
  const results = await db
192
  .select({
 
196
  price: marketplaceMatches.price,
197
  affiliateUrl: marketplaceMatches.affiliateUrl,
198
  clicks: clicksExpr,
 
199
  videoId: youtubeVideos.videoId,
200
  videoTitle: youtubeVideos.title,
201
  videoThumbnailUrl: youtubeVideos.thumbnailUrl,
 
202
  thumbnailUrl: sql<string | null>`MAX(${detectedObjects.thumbnailUrl})`,
203
  })
204
  .from(marketplaceMatches)
 
218
  youtubeVideos.thumbnailUrl
219
  )
220
  .having(sql`COALESCE(COUNT(DISTINCT ${productClicks.id}), 0) > 0`)
221
+ .orderBy(desc(clicksExpr));
222
 
223
  return results.map(row => ({
224
  matchId: row.matchId,
 
227
  price: row.price,
228
  affiliateUrl: row.affiliateUrl,
229
  clicks: Number(row.clicks),
 
230
  videoId: row.videoId,
231
  videoTitle: row.videoTitle,
232
  thumbnailUrl: row.thumbnailUrl || row.videoThumbnailUrl || '',
 
236
 
237
  /**
238
  * Clear cache for a specific creator or all cache
 
 
 
 
 
 
 
 
 
 
 
239
  */
240
  static clearCache(creatorId?: string): void {
241
  if (creatorId) {
 
242
  for (const key of cache.keys()) {
243
  if (key.startsWith(creatorId)) {
244
  cache.delete(key);
245
  }
246
  }
247
  } else {
 
248
  cache.clear();
249
  }
250
  }
251
 
252
  /**
253
+ * Get cache statistics
 
 
254
  */
255
  static getCacheStats(): { size: number; oldestEntryAge: number | null } {
256
  if (cache.size === 0) {
src/features/analytics/types/analytics.types.ts CHANGED
@@ -49,9 +49,13 @@ export interface RevenueRecord {
49
  */
50
  export interface KeyMetricsData {
51
  activeItems: number; // Number of active marketplace items
52
- conversionRate: number; // Conversion rate (clicks → interest pledges) as percentage
53
- totalClicks: number; // Total product clicks
54
- totalInterestPledges: number; // Total interest pledges
 
 
 
 
55
  }
56
 
57
  /**
@@ -79,7 +83,7 @@ export type TimePeriod = 'last_7_days' | 'last_30_days' | 'all_time';
79
  /**
80
  * Sort options for top videos
81
  */
82
- export type TopVideosSortBy = 'revenue' | 'clicks';
83
 
84
  /**
85
  * Configuration for fetching top videos
@@ -99,7 +103,6 @@ export interface TopEvergreenVideo {
99
  thumbnailUrl: string; // Video thumbnail URL
100
  views: number; // View count
101
  uploadDate: Date; // Video upload date
102
- revenue: number; // Total revenue generated (USD)
103
  clicks: number; // Total affiliate clicks
104
  productCount: number; // Number of products in video
105
  }
@@ -114,7 +117,6 @@ export interface EngagedProduct {
114
  price: number;
115
  affiliateUrl: string;
116
  clicks: number;
117
- revenue: number;
118
  videoId: string; // YouTube video ID (for linking back)
119
  videoTitle: string;
120
  thumbnailUrl: string; // detected_objects.thumbnail_url (product crop)
 
49
  */
50
  export interface KeyMetricsData {
51
  activeItems: number; // Number of active marketplace items
52
+ activeItemsTrend: number; // New items this week
53
+ pendingDemand: number; // Product Requests + "I Want This" Clicks
54
+ pendingDemandTrend: number; // New demand signals this week
55
+ productRequests: number; // Requests for products not detected yet
56
+ wantThisClicks: number; // "I Want This" clicks on detected products without links
57
+ totalClicks: number; // Total product link clicks
58
+ totalClicksTrend: number; // Percentage change vs previous period
59
  }
60
 
61
  /**
 
83
  /**
84
  * Sort options for top videos
85
  */
86
+ export type TopVideosSortBy = 'clicks';
87
 
88
  /**
89
  * Configuration for fetching top videos
 
103
  thumbnailUrl: string; // Video thumbnail URL
104
  views: number; // View count
105
  uploadDate: Date; // Video upload date
 
106
  clicks: number; // Total affiliate clicks
107
  productCount: number; // Number of products in video
108
  }
 
117
  price: number;
118
  affiliateUrl: string;
119
  clicks: number;
 
120
  videoId: string; // YouTube video ID (for linking back)
121
  videoTitle: string;
122
  thumbnailUrl: string; // detected_objects.thumbnail_url (product crop)