From 41786f2f51eed0dcd94e83a5e331377813c6a10b Mon Sep 17 00:00:00 2001 From: Tien Tran Date: Mon, 28 Sep 2026 22:55:48 -0700 Subject: [PATCH] perf: optimize URL shorten latency to <100ms & add single URL analytics page --- src/app/admin/urls/page.tsx | 34 +- src/app/api/shorten/route.ts | 28 +- src/app/api/user/urls/[id]/route.ts | 56 +- .../dashboard/urls/[id]/analytics/page.tsx | 526 ++++++++++++++++++ src/app/dashboard/urls/page.tsx | 18 +- src/lib/url-metadata.ts | 2 +- 6 files changed, 633 insertions(+), 31 deletions(-) create mode 100644 src/app/dashboard/urls/[id]/analytics/page.tsx diff --git a/src/app/admin/urls/page.tsx b/src/app/admin/urls/page.tsx index efa185c..bb7d9b7 100644 --- a/src/app/admin/urls/page.tsx +++ b/src/app/admin/urls/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import Link from "next/link"; import { Link2, Search, @@ -219,19 +220,32 @@ export default function AdminUrlsPage() { - - + + {u.clicks_count} - + - +
+ + + + +
)) diff --git a/src/app/api/shorten/route.ts b/src/app/api/shorten/route.ts index bd858d1..e16aa8b 100644 --- a/src/app/api/shorten/route.ts +++ b/src/app/api/shorten/route.ts @@ -91,20 +91,12 @@ export async function POST(req: Request) { } } - // Attempt fast title fetch - let pageTitle: string | null = null; - try { - pageTitle = await fetchUrlTitle(url); - } catch { - // Title fetch is non-blocking - } - - // Create ShortUrl in DB + // Create ShortUrl in DB immediately (instant response) const createdUrl = await prisma.shortUrl.create({ data: { originalUrl: url.trim(), shortCode, - title: pageTitle, + title: null, password: hashedPassword, expiresAt: expirationDate, displayOnPortal: Boolean(display_on_portal), @@ -121,6 +113,22 @@ export async function POST(req: Request) { }, }); + // Truly non-blocking title scrape in background (does not block HTTP response) + fetchUrlTitle(url.trim()) + .then(async (fetchedTitle) => { + if (fetchedTitle) { + try { + await prisma.shortUrl.update({ + where: { id: createdUrl.id }, + data: { title: fetchedTitle }, + }); + } catch { + // Background task: ignore error + } + } + }) + .catch(() => {}); + // Build absolute short URL const appUrl = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000").replace(/\/$/, ""); const fullShortUrl = `${appUrl}/${shortCode}`; diff --git a/src/app/api/user/urls/[id]/route.ts b/src/app/api/user/urls/[id]/route.ts index 20f7eb3..c446a0f 100644 --- a/src/app/api/user/urls/[id]/route.ts +++ b/src/app/api/user/urls/[id]/route.ts @@ -57,12 +57,12 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri }); const sevenDaysStats = Object.entries(clicksByDateMap).map(([date, count]) => ({ date, count })); - // Breakdown by Device + // Breakdown by Device, OS, Browser, Country, Referrer const deviceCounts: Record = {}; - // Breakdown by OS const osCounts: Record = {}; - // Breakdown by Country - const countryCounts: Record = {}; + const browserCounts: Record = {}; + const countryCounts: Record = {}; + const referrerCounts: Record = {}; url.clicks.forEach((c) => { const dev = c.deviceType || "desktop"; @@ -71,10 +71,31 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri const os = c.operatingSystem || "Unknown"; osCounts[os] = (osCounts[os] || 0) + 1; + const browser = c.browser || "Unknown"; + browserCounts[browser] = (browserCounts[browser] || 0) + 1; + const country = c.countryName || "Unknown"; - countryCounts[country] = (countryCounts[country] || 0) + 1; + if (!countryCounts[country]) { + countryCounts[country] = { count: 0, code: c.countryCode || undefined }; + } + countryCounts[country].count++; + + let ref = "Trực tiếp (Direct)"; + if (c.referrer) { + try { + const refUrl = new URL(c.referrer); + ref = refUrl.hostname.replace(/^www\./, ""); + } catch { + ref = c.referrer.slice(0, 30); + } + } + referrerCounts[ref] = (referrerCounts[ref] || 0) + 1; }); + // Today clicks calculation + const todayStr = new Date().toISOString().split("T")[0]; + const todayClicks = clicksByDateMap[todayStr] || 0; + const appUrl = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000").replace(/\/$/, ""); return NextResponse.json({ @@ -92,14 +113,35 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri created_at: url.createdAt, }, analytics: { + total_clicks: url.clicksCount, + today_clicks: todayClicks, seven_days_stats: sevenDaysStats, devices: Object.entries(deviceCounts).map(([name, count]) => ({ name, count })), - os: Object.entries(osCounts).map(([name, count]) => ({ name, count })), + os: Object.entries(osCounts) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count), + browsers: Object.entries(browserCounts) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count), countries: Object.entries(countryCounts) + .map(([name, val]) => ({ name, count: val.count, code: val.code })) + .sort((a, b) => b.count - a.count) + .slice(0, 10), + referrers: Object.entries(referrerCounts) .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count) .slice(0, 10), - total_clicks: url.clicksCount, + recent_clicks: url.clicks.slice(0, 25).map((c) => ({ + id: c.id, + created_at: c.createdAt, + device_type: c.deviceType || "desktop", + os: c.operatingSystem || "Unknown", + browser: c.browser || "Unknown", + country_name: c.countryName || "Unknown", + country_code: c.countryCode || "XX", + city: c.city || null, + referrer: c.referrer || null, + })), }, }); } diff --git a/src/app/dashboard/urls/[id]/analytics/page.tsx b/src/app/dashboard/urls/[id]/analytics/page.tsx new file mode 100644 index 0000000..a79088a --- /dev/null +++ b/src/app/dashboard/urls/[id]/analytics/page.tsx @@ -0,0 +1,526 @@ +"use client"; + +import { useEffect, useState, use } from "react"; +import Link from "next/link"; +import { + ArrowLeft, + BarChart2, + Calendar, + ExternalLink, + Copy, + Check, + Smartphone, + Monitor, + Tablet, + Globe, + Compass, + Cpu, + Share2, + Clock, + Shield, + Layers, + Sparkles, +} from "lucide-react"; + +interface AnalyticsData { + url: { + id: string; + original_url: string; + short_code: string; + short_url: string; + title: string | null; + clicks_count: number; + display_on_portal: boolean; + is_password_protected: boolean; + expires_at: string | null; + tags: string[]; + created_at: string; + }; + analytics: { + total_clicks: number; + today_clicks: number; + seven_days_stats: { date: string; count: number }[]; + devices: { name: string; count: number }[]; + os: { name: string; count: number }[]; + browsers: { name: string; count: number }[]; + countries: { name: string; count: number; code?: string }[]; + referrers: { name: string; count: number }[]; + recent_clicks: { + id: string; + created_at: string; + device_type: string; + os: string; + browser: string; + country_name: string; + country_code: string; + city: string | null; + referrer: string | null; + }[]; + }; +} + +export default function UrlAnalyticsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + async function fetchAnalytics() { + try { + setLoading(true); + const res = await fetch(`/api/user/urls/${id}`); + const json = await res.json(); + if (!res.ok) { + throw new Error(json.error || "Không thể tải dữ liệu thống kê"); + } + setData(json); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + } + fetchAnalytics(); + }, [id]); + + function copyShortUrl() { + if (!data) return; + navigator.clipboard.writeText(data.url.short_url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + if (loading) { + return ( +
+
+

Đang tải phân tích thống kê...

+
+ ); + } + + if (error || !data) { + return ( +
+
+

{error || "Không tìm thấy dữ liệu liên kết"}

+ + + Quay lại danh sách + +
+
+ ); + } + + const { url, analytics } = data; + + // Max count for 7-day chart scaling + const maxSevenDays = Math.max(...analytics.seven_days_stats.map((s) => s.count), 1); + + // Top calculations + const topDevice = analytics.devices[0]?.name || "Chưa có"; + const topCountry = analytics.countries[0]?.name || "Chưa có"; + + return ( +
+ {/* Top Navigation & Info Header */} +
+
+ + + Quay lại danh sách liên kết + +
+

+ Thống kê:{" "} + + /{url.short_code} + +

+ {url.is_password_protected && ( + + Có mật khẩu + + )} + {url.display_on_portal && ( + + Trên Bio Link + + )} +
+

+ Đích đến:{" "} + + {url.original_url} + +

+
+ + {/* Quick action buttons */} +
+ + + Mở link rút gọn + + +
+
+ + {/* 4 Summary Stat Cards */} +
+
+
+ +
+
+ Tổng Lượt Click +
+
+ {analytics.total_clicks} +
+
+ +
+
+ +
+
+ Click Hôm Nay +
+
+ {analytics.today_clicks} +
+
+ +
+
+ +
+
+ Thiết Bị Hàng Đầu +
+
+ {topDevice} +
+
+ +
+
+ +
+
+ Quốc Gia Hàng Đầu +
+
+ {topCountry} +
+
+
+ + {/* 7-Day Clicks Timeline Chart */} +
+
+
+

+ + Lưu lượng 7 ngày gần nhất +

+

+ Số lượt nhấp chuột được ghi nhận theo từng ngày +

+
+
+ 7 ngày qua:{" "} + + {analytics.seven_days_stats.reduce((a, b) => a + b.count, 0)} clicks + +
+
+ + {/* Visual Bar Chart */} +
+ {analytics.seven_days_stats.map((item, idx) => { + const heightPercent = Math.max((item.count / maxSevenDays) * 100, item.count > 0 ? 8 : 2); + const dateObj = new Date(item.date); + const dayLabel = `${dateObj.getDate()}/${dateObj.getMonth() + 1}`; + + return ( +
+ {/* Count tooltip on hover */} +
+ {item.count} clicks +
+ + {/* Animated bar */} +
0 + ? "bg-gradient-to-t from-indigo-600 via-indigo-500 to-purple-500 shadow-md shadow-indigo-500/20 group-hover:brightness-110 group-hover:scale-y-105" + : "bg-slate-100 dark:bg-slate-800" + }`} + /> + + {/* Date Label */} +
+ {dayLabel} +
+
+ ); + })} +
+
+ + {/* Grid 2x2: Devices, OS, Browsers, Countries */} +
+ {/* Device Breakdown */} +
+

+ + Thiết bị truy cập (Device Types) +

+ {analytics.devices.length === 0 ? ( +
Chưa có dữ liệu thiết bị
+ ) : ( +
+ {analytics.devices.map((d) => { + const total = analytics.total_clicks || 1; + const percent = Math.round((d.count / total) * 100); + return ( +
+
+ + {d.name === "mobile" && } + {d.name === "desktop" && } + {d.name === "tablet" && } + {d.name} + + + {d.count} ({percent}%) + +
+
+
+
+
+ ); + })} +
+ )} +
+ + {/* Countries Breakdown */} +
+

+ + Quốc gia truy cập (Top Countries) +

+ {analytics.countries.length === 0 ? ( +
Chưa có dữ liệu vị trí
+ ) : ( +
+ {analytics.countries.map((c) => { + const total = analytics.total_clicks || 1; + const percent = Math.round((c.count / total) * 100); + return ( +
+
+ + + {c.code || "VN"} + + {c.name} + + + {c.count} ({percent}%) + +
+
+
+
+
+ ); + })} +
+ )} +
+ + {/* Operating Systems */} +
+

+ + Hệ điều hành (Operating Systems) +

+ {analytics.os.length === 0 ? ( +
Chưa có dữ liệu hệ điều hành
+ ) : ( +
+ {analytics.os.map((item) => { + const total = analytics.total_clicks || 1; + const percent = Math.round((item.count / total) * 100); + return ( +
+ + {item.name} + +
+ {percent}% + + {item.count} + +
+
+ ); + })} +
+ )} +
+ + {/* Browsers & Referrers */} +
+

+ + Trình duyệt & Nguồn giới thiệu (Browsers & Referrers) +

+
+
+
+ Trình duyệt phổ biến +
+
+ {analytics.browsers.map((b) => ( + + {b.name}: {b.count} + + ))} +
+
+ +
+
+ Nguồn giới thiệu (Referrer) +
+
+ {analytics.referrers.map((r) => ( + + + {r.name}: {r.count} + + ))} +
+
+
+
+
+ + {/* Recent Clicks Log Table */} +
+

+ + Nhật ký lượt click gần nhất (Recent Click Logs) +

+

+ Chi tiết 25 lượt truy cập liên kết mới nhất +

+ + {analytics.recent_clicks.length === 0 ? ( +
+ Chưa có lượt click nào được ghi nhận cho liên kết này. +
+ ) : ( +
+ + + + + + + + + + + + {analytics.recent_clicks.map((click) => { + const d = new Date(click.created_at); + const timeFormatted = d.toLocaleString("vi-VN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + + return ( + + + + + + + + ); + })} + +
Thời gianThiết bịHệ điều hành / Trình duyệtVị tríNguồn
+ {timeFormatted} + + + {click.device_type} + + +
{click.os}
+
{click.browser}
+
+
+ + {click.country_code} + + {click.city ? `${click.city}, ` : ""}{click.country_name} +
+
+ {click.referrer || "Trực tiếp"} +
+
+ )} +
+
+ ); +} diff --git a/src/app/dashboard/urls/page.tsx b/src/app/dashboard/urls/page.tsx index cbbae23..47966c0 100644 --- a/src/app/dashboard/urls/page.tsx +++ b/src/app/dashboard/urls/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import Link from "next/link"; import { Link2, Plus, @@ -331,10 +332,14 @@ export default function UserUrlsPage() {
- - + + {u.clicks_count} - +