Skip to content

Commit 6ebc5cc

Browse files
authored
Merge pull request #778 from aetheron06/feat
Feat:Implemented the four requested feature tasks:
2 parents 782ad56 + 50e3678 commit 6ebc5cc

13 files changed

Lines changed: 657 additions & 25 deletions

File tree

app/(dashboard)/analytics/page.tsx

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"use client";
2+
3+
import React, { useEffect, useRef, useState } from "react";
4+
import StatCard from "@/components/dashboard/StatCard";
5+
import Skeleton from "@/components/ui/Skeleton";
6+
import { Download, Eye, Clock, BarChart3 } from "lucide-react";
7+
import analytics from "@/app/lib/analytics";
8+
9+
type AnalyticsResponse = {
10+
totalViews: number;
11+
totalWatchTime: number;
12+
avgEngagement: number;
13+
byPlatform: { platform: string; views: number; engagement: number }[];
14+
top5: { clipId: string; title: string; views: number; platform: string }[];
15+
dateRange: { startDate: string | null; endDate: string | null };
16+
};
17+
18+
export default function AnalyticsPage() {
19+
const [data, setData] = useState<AnalyticsResponse | null>(null);
20+
const [loading, setLoading] = useState(true);
21+
const [error, setError] = useState<string | null>(null);
22+
const [range, setRange] = useState("30d");
23+
const [platform, setPlatform] = useState("all");
24+
const initialized = useRef(false);
25+
26+
useEffect(() => {
27+
if (initialized.current) return;
28+
initialized.current = true;
29+
analytics.trackPageView("/analytics");
30+
}, []);
31+
32+
useEffect(() => {
33+
let cancelled = false;
34+
async function load() {
35+
try {
36+
setLoading(true);
37+
const params = new URLSearchParams();
38+
if (range !== "all") params.set("startDate", new Date(Date.now() - Number(range.replace("d",""))*86400000).toISOString().split("T")[0]);
39+
if (platform !== "all") params.set("platform", platform);
40+
const res = await fetch(`/api/analytics?${params.toString()}`);
41+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
42+
const json = (await res.json()) as AnalyticsResponse;
43+
if (!cancelled) setData(json);
44+
} catch (err) {
45+
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load analytics");
46+
} finally {
47+
if (!cancelled) setLoading(false);
48+
}
49+
}
50+
load();
51+
return () => { cancelled = true; };
52+
}, [range, platform]);
53+
54+
const exportCsv = () => {
55+
if (!data) return;
56+
const header = "clipId,title,views,watchTimeMinutes,engagementRate,platform\n";
57+
const rows = data.top5.map(t => `${t.clipId},"${t.title}",${t.views},,,${t.platform}`).join("\n");
58+
const blob = new Blob([header + rows], { type: "text/csv" });
59+
const url = URL.createObjectURL(blob);
60+
const a = document.createElement("a");
61+
a.href = url;
62+
a.download = `analytics-${range}.csv`;
63+
a.click();
64+
URL.revokeObjectURL(url);
65+
};
66+
67+
return (
68+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-10 py-10">
69+
<div className="space-y-8">
70+
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
71+
<div>
72+
<h1 className="text-[28px] sm:text-[32px] font-extrabold tracking-tight text-white">Clip Analytics</h1>
73+
<p className="text-muted text-[14px] mt-1">Views, watch time, engagement, and platform breakdown.</p>
74+
</div>
75+
<div className="flex items-center gap-3">
76+
<select value={range} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setRange(e.target.value)} className="bg-input text-white text-sm rounded-xl px-3 py-2 border border-white/10">
77+
<option value="7d">Last 7 days</option>
78+
<option value="30d">Last 30 days</option>
79+
<option value="90d">Last 90 days</option>
80+
<option value="all">All time</option>
81+
</select>
82+
<select value={platform} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setPlatform(e.target.value)} className="bg-input text-white text-sm rounded-xl px-3 py-2 border border-white/10">
83+
<option value="all">All platforms</option>
84+
<option value="YouTube">YouTube</option>
85+
<option value="TikTok">TikTok</option>
86+
<option value="Instagram">Instagram</option>
87+
<option value="Twitch">Twitch</option>
88+
</select>
89+
<button onClick={exportCsv} className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-brand text-black font-bold text-sm hover:bg-brand-hover transition-colors">
90+
<Download className="w-4 h-4" /> Export
91+
</button>
92+
</div>
93+
</div>
94+
95+
{loading ? (
96+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
97+
{[1, 2, 3].map((i) => (<div key={i} className="h-32 rounded-2xl bg-white/6 animate-pulse" />))}
98+
</div>
99+
) : error ? (
100+
<div className="rounded-2xl border border-error/30 bg-error/10 p-6">
101+
<p className="text-error text-sm">{error}</p>
102+
</div>
103+
) : data ? (
104+
<>
105+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
106+
<StatCard label="Total Views" value={String(data.totalViews)} icon={Eye} trend={`${data.totalViews.toLocaleString()} views`} />
107+
<StatCard label="Watch Time" value={`${data.totalWatchTime.toLocaleString()}m`} icon={Clock} trend="Total minutes watched" />
108+
<StatCard label="Engagement" value={`${data.avgEngagement.toFixed(2)}%`} icon={BarChart3} trend="Average rate" />
109+
</div>
110+
111+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
112+
<div className="bg-surface border border-white/5 rounded-2xl p-6">
113+
<h3 className="text-white font-bold mb-4">Engagement by Platform</h3>
114+
<div className="space-y-3">
115+
{data.byPlatform.map((p) => (
116+
<div key={p.platform} className="flex items-center justify-between">
117+
<span className="text-sm text-muted">{p.platform}</span>
118+
<div className="flex-1 mx-4">
119+
<div className="h-2 rounded-full bg-white/6 overflow-hidden">
120+
<div className="h-full bg-brand rounded-full" style={{ width: `${Math.min(p.engagement * 10, 100)}%` }} />
121+
</div>
122+
</div>
123+
<span className="text-sm text-white font-mono w-12 text-right">{p.engagement}%</span>
124+
</div>
125+
))}
126+
</div>
127+
</div>
128+
<div className="bg-surface border border-white/5 rounded-2xl p-6">
129+
<h3 className="text-white font-bold mb-4">Top 5 Clips</h3>
130+
<div className="space-y-3">
131+
{data.top5.map((clip, idx) => (
132+
<div key={clip.clipId} className="flex items-center justify-between">
133+
<div className="min-w-0">
134+
<p className="text-sm text-white font-semibold truncate">{clip.title}</p>
135+
<p className="text-xs text-muted">{clip.platform} · {clip.views.toLocaleString()} views</p>
136+
</div>
137+
<span className="text-xs text-muted w-6 text-right">#{idx + 1}</span>
138+
</div>
139+
))}
140+
</div>
141+
</div>
142+
</div>
143+
</>
144+
) : null}
145+
</div>
146+
</div>
147+
);
148+
}

app/(dashboard)/dashboard/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ function StatCardSkeleton() {
6767

6868
export default function DashboardPage() {
6969
const { publicKey } = useAutoStellarWallet();
70-
const { data, loading, error, retry } = useDashboardData();
70+
const { data, loading, error, retry } = useDashboardData({ enableStreaming: true });
7171
const stats = data?.stats;
7272
const recentProjects = data?.recentProjects ?? [];
7373

app/(dashboard)/referral/page.tsx

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"use client";
2+
3+
import React, { useEffect, useRef, useState } from "react";
4+
import StatCard from "@/components/dashboard/StatCard";
5+
import Skeleton from "@/components/ui/Skeleton";
6+
import { Copy, Check, Users, DollarSign, Share2 } from "lucide-react";
7+
import analytics from "@/app/lib/analytics";
8+
9+
type ReferralStats = {
10+
code: string;
11+
link: string;
12+
referralCount: number;
13+
totalEarned: number;
14+
};
15+
16+
export default function ReferralPage() {
17+
const [stats, setStats] = useState<ReferralStats | null>(null);
18+
const [loading, setLoading] = useState(true);
19+
const [error, setError] = useState<string | null>(null);
20+
const [copied, setCopied] = useState(false);
21+
const inputRef = useRef<HTMLInputElement>(null);
22+
23+
useEffect(() => {
24+
let cancelled = false;
25+
async function load() {
26+
try {
27+
const res = await fetch("/api/referral");
28+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
29+
const data = (await res.json()) as ReferralStats;
30+
if (!cancelled) setStats(data);
31+
} catch (err) {
32+
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load referral stats");
33+
} finally {
34+
if (!cancelled) setLoading(false);
35+
}
36+
}
37+
load();
38+
return () => { cancelled = true; };
39+
}, []);
40+
41+
const handleCopy = async () => {
42+
if (!stats) return;
43+
try {
44+
await navigator.clipboard.writeText(stats.link);
45+
setCopied(true);
46+
setTimeout(() => setCopied(false), 1500);
47+
analytics.trackEvent("referral_link_shared", { code: stats.code });
48+
} catch {
49+
inputRef.current?.select();
50+
}
51+
};
52+
53+
const handleShare = () => {
54+
if (!stats) return;
55+
analytics.trackEvent("referral_link_shared", { code: stats.code });
56+
if (navigator.share) {
57+
navigator.share({ title: "Join ClipCash", text: "Use my referral link to sign up", url: stats.link }).catch(() => {});
58+
} else {
59+
handleCopy();
60+
}
61+
};
62+
63+
return (
64+
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-10 py-10">
65+
<div className="space-y-8">
66+
<div>
67+
<h1 className="text-[28px] sm:text-[32px] font-extrabold tracking-tight text-white">Referral Program</h1>
68+
<p className="text-muted text-[14px] mt-1">Share your unique link and earn bonuses when friends join.</p>
69+
</div>
70+
71+
{loading ? (
72+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
73+
{[1, 2, 3].map((i) => (
74+
<div key={i} className="h-32 rounded-2xl bg-white/6 animate-pulse" />
75+
))}
76+
</div>
77+
) : error ? (
78+
<div className="rounded-2xl border border-error/30 bg-error/10 p-6">
79+
<p className="text-error text-sm">{error}</p>
80+
</div>
81+
) : stats ? (
82+
<>
83+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
84+
<StatCard
85+
label="Referrals"
86+
value={String(stats.referralCount)}
87+
trend={`${stats.referralCount} joined`}
88+
icon={Users}
89+
/>
90+
<StatCard
91+
label="Total Earned"
92+
value={`$${stats.totalEarned.toFixed(2)}`}
93+
trend="Referral bonuses"
94+
icon={DollarSign}
95+
/>
96+
<StatCard
97+
label="Your Code"
98+
value={stats.code}
99+
trend="Unique per user"
100+
icon={Share2}
101+
/>
102+
</div>
103+
104+
<div className="bg-surface border border-white/5 rounded-2xl p-6">
105+
<p className="text-[11px] font-bold text-muted-foreground uppercase tracking-widest mb-2">
106+
Your Referral Link
107+
</p>
108+
<div className="flex flex-col sm:flex-row gap-3">
109+
<input
110+
ref={inputRef}
111+
readOnly
112+
value={stats.link}
113+
className="flex-1 bg-input text-white text-sm rounded-xl px-4 py-3 border border-white/10"
114+
/>
115+
<button
116+
onClick={handleCopy}
117+
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand hover:bg-brand-hover text-black font-bold transition-colors"
118+
>
119+
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
120+
{copied ? "Copied" : "Copy"}
121+
</button>
122+
<button
123+
onClick={handleShare}
124+
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl border border-white/10 bg-surface hover:bg-input text-white font-bold transition-colors"
125+
>
126+
<Share2 className="w-4 h-4" />
127+
Share
128+
</button>
129+
</div>
130+
</div>
131+
</>
132+
) : null}
133+
</div>
134+
</div>
135+
);
136+
}

app/api/analytics/route.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
export const dynamic = "force-dynamic";
4+
5+
type Platform = "YouTube" | "TikTok" | "Instagram" | "Twitch";
6+
type ClipMetric = {
7+
clipId: string;
8+
title: string;
9+
views: number;
10+
watchTimeMinutes: number;
11+
engagementRate: number;
12+
platform: Platform;
13+
};
14+
15+
function generateMockMetrics(startDate?: string, endDate?: string, platform?: Platform): ClipMetric[] {
16+
const platforms: Platform[] = ["YouTube", "TikTok", "Instagram", "Twitch"];
17+
const clips: ClipMetric[] = [];
18+
const count = 40;
19+
20+
for (let i = 0; i < count; i++) {
21+
const p = platform ?? platforms[Math.floor(Math.random() * platforms.length)];
22+
const views = Math.floor(Math.random() * 50000) + 1000;
23+
clips.push({
24+
clipId: `CLIP-${String(i + 1).padStart(3, "0")}`,
25+
title: `Clip ${i + 1}`,
26+
views,
27+
watchTimeMinutes: Math.floor(views * (Math.random() * 0.4 + 0.1)),
28+
engagementRate: parseFloat((Math.random() * 8 + 1).toFixed(2)),
29+
platform: p,
30+
});
31+
}
32+
33+
return clips;
34+
}
35+
36+
export async function GET(req: NextRequest) {
37+
const { searchParams } = new URL(req.url);
38+
const startDate = searchParams.get("startDate");
39+
const endDate = searchParams.get("endDate");
40+
const platform = searchParams.get("platform") as Platform | null;
41+
42+
const metrics = generateMockMetrics(startDate || undefined, endDate || undefined, platform || undefined);
43+
44+
const totalViews = metrics.reduce((s, m) => s + m.views, 0);
45+
const totalWatchTime = metrics.reduce((s, m) => s + m.watchTimeMinutes, 0);
46+
const avgEngagement = metrics.length ? metrics.reduce((s, m) => s + m.engagementRate, 0) / metrics.length : 0;
47+
48+
const byPlatform: Record<string, { views: number; engagement: number; count: number }> = {};
49+
metrics.forEach((m) => {
50+
if (!byPlatform[m.platform]) byPlatform[m.platform] = { views: 0, engagement: 0, count: 0 };
51+
byPlatform[m.platform].views += m.views;
52+
byPlatform[m.platform].engagement += m.engagementRate;
53+
byPlatform[m.platform].count += 1;
54+
});
55+
56+
const top5 = [...metrics].sort((a, b) => b.views - a.views).slice(0, 5);
57+
58+
return NextResponse.json({
59+
totalViews,
60+
totalWatchTime,
61+
avgEngagement,
62+
byPlatform: Object.entries(byPlatform).map(([platform, data]) => ({
63+
platform,
64+
views: data.views,
65+
engagement: parseFloat((data.engagement / data.count).toFixed(2)),
66+
})),
67+
top5,
68+
dateRange: { startDate: startDate || null, endDate: endDate || null },
69+
});
70+
}

app/api/clips/[id]/share/route.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
const shareStore = new Map<string, { shareId: string; expiresAt: number; revoked: boolean }>();
4+
5+
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
6+
const body = await req.json().catch(() => ({}));
7+
const days = Number(body?.days || 7);
8+
const expiresAt = Date.now() + days * 24 * 60 * 60 * 1000;
9+
const shareId = `${params.id}-${Math.random().toString(36).slice(2, 10)}`;
10+
shareStore.set(shareId, { shareId, expiresAt, revoked: false });
11+
const base = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
12+
return NextResponse.json({ shareId, shareUrl: `${base}/share/${shareId}`, expiresAt: new Date(expiresAt).toISOString() });
13+
}
14+
15+
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
16+
const shareId = req.nextUrl.searchParams.get("shareId");
17+
if (!shareId) return NextResponse.json({ ok: false, error: "Missing shareId" }, { status: 400 });
18+
const entry = shareStore.get(shareId);
19+
if (!entry) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
20+
entry.revoked = true;
21+
return NextResponse.json({ ok: true });
22+
}

0 commit comments

Comments
 (0)