Spaces:
Runtime error
Runtime error
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 +80 -8
- src/app/dashboard/requests/products-awaiting-links.tsx +115 -0
- src/features/analytics/actions/get-top-evergreen-videos.ts +2 -2
- src/features/analytics/components/key-metrics-row.tsx +52 -25
- src/features/analytics/components/top-evergreen-videos.tsx +13 -33
- src/features/analytics/config/top-videos.config.ts +1 -11
- src/features/analytics/services/key-metrics-analytics.service.ts +113 -29
- src/features/analytics/services/top-videos-analytics.service.ts +19 -77
- src/features/analytics/types/analytics.types.ts +8 -6
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 {
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 73 |
</p>
|
| 74 |
</div>
|
| 75 |
|
| 76 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 83 |
<p className="text-muted-foreground max-w-md mt-2 text-sm">
|
| 84 |
-
As viewers explore your vault, their requests
|
| 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'
|
| 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'
|
| 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="
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
</CardContent>
|
| 76 |
</Card>
|
| 77 |
</Link>
|
| 78 |
|
| 79 |
-
{/*
|
| 80 |
-
<
|
| 81 |
-
<
|
| 82 |
-
<
|
| 83 |
-
<
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
{/* Total Clicks */}
|
| 98 |
-
<Card className="
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 134 |
<CardHeader className="pb-3">
|
| 135 |
-
<Skeleton className="h-4 w-32 bg-
|
| 136 |
</CardHeader>
|
| 137 |
<CardContent>
|
| 138 |
-
<Skeleton className="h-10 w-20 bg-
|
| 139 |
-
<Skeleton className="h-3 w-40 mt-2 bg-
|
| 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'
|
| 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
|
| 151 |
-
<p className="text-sm
|
| 152 |
-
<p className="text-
|
| 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 |
-
|
| 191 |
-
|
| 192 |
-
|
| 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
|
| 202 |
-
<p className="text-sm
|
| 203 |
-
<p className="text-
|
| 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: '
|
| 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
|
| 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 {
|
| 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
|
| 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
|
| 90 |
Sentry.captureException(error);
|
| 91 |
throw error;
|
| 92 |
}
|
| 93 |
}
|
| 94 |
|
| 95 |
/**
|
| 96 |
-
*
|
| 97 |
* @param creatorId - The creator's user ID
|
| 98 |
*/
|
| 99 |
-
static async
|
| 100 |
try {
|
| 101 |
-
const
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
|
| 111 |
-
|
| 112 |
-
return Math.round(conversionRate * 10) / 10; // Round to 1 decimal place
|
| 113 |
} catch (error) {
|
| 114 |
-
console.error('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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
this.getActiveItemsCount(creatorId),
|
| 128 |
-
this.
|
| 129 |
-
this.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
]);
|
| 131 |
|
| 132 |
-
// Calculate
|
| 133 |
-
const
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
return {
|
| 138 |
activeItems,
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
totalClicks,
|
| 141 |
-
|
| 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
|
| 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:
|
| 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 |
-
|
| 98 |
-
|
| 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:
|
| 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(
|
| 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 |
-
|
| 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
|
| 167 |
*/
|
| 168 |
static async getEngagedVideos(
|
| 169 |
creatorId: string,
|
| 170 |
-
sortBy: '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(
|
| 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
|
| 220 |
*/
|
| 221 |
static async getEngagedProducts(
|
| 222 |
creatorId: string,
|
| 223 |
-
sortBy: '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(
|
| 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
|
| 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 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = '
|
| 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)
|