perf: optimize URL shorten latency to <100ms & add single URL analytics page

This commit is contained in:
Tien Tran committed 2026-09-28 22:55:48 -07:00
1 parent 8ed1562d01
commit 41786f2f51
6 files changed
+633 -31

No files matched your search

+24 -10
View File
@@ -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() {
</div>
</td>
<td className="p-4 text-center font-bold text-slate-800 dark:text-slate-200">
<span className="inline-flex items-center gap-1">
<BarChart2 className="w-3.5 h-3.5 text-indigo-500" />
<Link
href={`/dashboard/urls/${u.id}/analytics`}
className="inline-flex items-center gap-1 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors group"
title="Xem thống kê chi tiết"
>
<BarChart2 className="w-3.5 h-3.5 text-indigo-500 group-hover:scale-110 transition-transform" />
{u.clicks_count}
</span>
</Link>
</td>
<td className="p-4 text-right">
<button
onClick={() => showQr(u.short_url)}
className="p-2 text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 rounded-lg"
title="Xem mã QR"
>
<QrCode className="w-4 h-4" />
</button>
<div className="flex items-center justify-end gap-1">
<Link
href={`/dashboard/urls/${u.id}/analytics`}
className="p-2 text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 rounded-lg transition-colors"
title="Xem thống kê chi tiết"
>
<BarChart2 className="w-4 h-4" />
</Link>
<button
onClick={() => showQr(u.short_url)}
className="p-2 text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 rounded-lg"
title="Xem mã QR"
>
<QrCode className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
+18 -10
View File
@@ -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}`;
+49 -7
View File
@@ -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<string, number> = {};
// Breakdown by OS
const osCounts: Record<string, number> = {};
// Breakdown by Country
const countryCounts: Record<string, number> = {};
const browserCounts: Record<string, number> = {};
const countryCounts: Record<string, { count: number; code?: string }> = {};
const referrerCounts: Record<string, number> = {};
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,
})),
},
});
}
@@ -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<AnalyticsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="max-w-7xl mx-auto px-4 py-16 text-center">
<div className="inline-block w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mb-4" />
<p className="text-slate-500 text-sm">Đang tải phân tích thống kê...</p>
</div>
);
}
if (error || !data) {
return (
<div className="max-w-4xl mx-auto px-4 py-16 text-center">
<div className="p-8 rounded-3xl bg-red-500/10 border border-red-500/20 text-red-600 max-w-md mx-auto">
<p className="font-semibold mb-4">{error || "Không tìm thấy dữ liệu liên kết"}</p>
<Link
href="/dashboard/urls"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-red-600 text-white text-xs font-semibold"
>
<ArrowLeft className="w-4 h-4" />
Quay lại danh sách
</Link>
</div>
</div>
);
}
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 (
<div className="max-w-7xl mx-auto px-4 py-8 space-y-8 animate-in fade-in duration-300">
{/* Top Navigation & Info Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-6 border-b border-slate-200/80 dark:border-slate-800">
<div>
<Link
href="/dashboard/urls"
className="inline-flex items-center gap-1.5 text-xs font-semibold text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 transition-colors mb-3 group"
>
<ArrowLeft className="w-3.5 h-3.5 group-hover:-translate-x-1 transition-transform" />
Quay lại danh sách liên kết
</Link>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
Thống kê:{" "}
<span className="font-mono bg-gradient-to-r from-indigo-500 to-purple-600 bg-clip-text text-transparent">
/{url.short_code}
</span>
</h1>
{url.is_password_protected && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-semibold border border-amber-500/20">
<Shield className="w-3.5 h-3.5" /> Có mật khẩu
</span>
)}
{url.display_on_portal && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-xs font-semibold border border-emerald-500/20">
<Sparkles className="w-3.5 h-3.5" /> Trên Bio Link
</span>
)}
</div>
<p className="text-xs sm:text-sm text-slate-500 dark:text-slate-400 mt-1 max-w-2xl truncate">
Đích đến:{" "}
<a
href={url.original_url}
target="_blank"
rel="noopener noreferrer"
className="text-slate-700 dark:text-slate-300 hover:text-indigo-500 underline"
>
{url.original_url}
</a>
</p>
</div>
{/* Quick action buttons */}
<div className="flex items-center gap-2">
<button
onClick={copyShortUrl}
className="px-4 py-2.5 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-xs font-semibold text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2 shadow-sm transition-all"
>
{copied ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
{copied ? "Đã sao chép!" : "Sao chép link"}
</button>
<a
href={url.short_url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2.5 rounded-xl bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-xs font-semibold shadow-md shadow-indigo-500/20 flex items-center gap-2 transition-all hover:scale-105 active:scale-95"
>
Mở link rút gọn
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
{/* 4 Summary Stat Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6">
<div className="p-6 rounded-3xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm relative overflow-hidden group">
<div className="w-12 h-12 rounded-2xl bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<BarChart2 className="w-6 h-6" />
</div>
<div className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Tổng Lượt Click
</div>
<div className="text-3xl font-black text-slate-900 dark:text-white mt-1">
{analytics.total_clicks}
</div>
</div>
<div className="p-6 rounded-3xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm relative overflow-hidden group">
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 text-purple-600 dark:text-purple-400 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<Clock className="w-6 h-6" />
</div>
<div className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Click Hôm Nay
</div>
<div className="text-3xl font-black text-slate-900 dark:text-white mt-1">
{analytics.today_clicks}
</div>
</div>
<div className="p-6 rounded-3xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm relative overflow-hidden group">
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<Smartphone className="w-6 h-6" />
</div>
<div className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Thiết Bị Hàng Đầu
</div>
<div className="text-xl font-bold text-slate-900 dark:text-white mt-1 capitalize truncate">
{topDevice}
</div>
</div>
<div className="p-6 rounded-3xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm relative overflow-hidden group">
<div className="w-12 h-12 rounded-2xl bg-blue-500/10 text-blue-600 dark:text-blue-400 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<Globe className="w-6 h-6" />
</div>
<div className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Quốc Gia Hàng Đầu
</div>
<div className="text-xl font-bold text-slate-900 dark:text-white mt-1 truncate">
{topCountry}
</div>
</div>
</div>
{/* 7-Day Clicks Timeline Chart */}
<div className="p-6 sm:p-8 rounded-3xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-8">
<div>
<h3 className="text-lg font-bold text-slate-900 dark:text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-indigo-500" />
Lưu lượng 7 ngày gần nhất
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Số lượt nhấp chuột được ghi nhận theo từng ngày
</p>
</div>
<div className="text-xs font-semibold text-slate-500 bg-slate-100 dark:bg-slate-800 px-3 py-1.5 rounded-xl self-start sm:self-auto">
7 ngày qua:{" "}
<span className="text-indigo-600 dark:text-indigo-400 font-bold">
{analytics.seven_days_stats.reduce((a, b) => a + b.count, 0)} clicks
</span>
</div>
</div>
{/* Visual Bar Chart */}
<div className="h-56 flex items-end justify-between gap-2 sm:gap-6 pt-6 pb-2 border-b border-slate-200 dark:border-slate-800">
{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 (
<div key={idx} className="flex-1 flex flex-col items-center h-full justify-end group">
{/* Count tooltip on hover */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity text-[11px] font-bold text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-950/80 px-2 py-0.5 rounded shadow-sm mb-1.5 pointer-events-none">
{item.count} clicks
</div>
{/* Animated bar */}
<div
style={{ height: `${heightPercent}%` }}
className={`w-full max-w-[48px] rounded-t-xl transition-all duration-500 ${
item.count > 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 */}
<div className="text-[11px] font-medium text-slate-500 dark:text-slate-400 mt-3 whitespace-nowrap">
{dayLabel}
</div>
</div>
);
})}
</div>
</div>
{/* Grid 2x2: Devices, OS, Browsers, Countries */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Device Breakdown */}
<div className="p-6 rounded-3xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm">
<h3 className="text-base font-bold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
<Monitor className="w-5 h-5 text-indigo-500" />
Thiết bị truy cập (Device Types)
</h3>
{analytics.devices.length === 0 ? (
<div className="py-8 text-center text-xs text-slate-400">Chưa có dữ liệu thiết bị</div>
) : (
<div className="space-y-4">
{analytics.devices.map((d) => {
const total = analytics.total_clicks || 1;
const percent = Math.round((d.count / total) * 100);
return (
<div key={d.name} className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-slate-700 dark:text-slate-200 capitalize flex items-center gap-2">
{d.name === "mobile" && <Smartphone className="w-4 h-4 text-emerald-500" />}
{d.name === "desktop" && <Monitor className="w-4 h-4 text-indigo-500" />}
{d.name === "tablet" && <Tablet className="w-4 h-4 text-amber-500" />}
{d.name}
</span>
<span className="text-slate-500 font-mono">
{d.count} ({percent}%)
</span>
</div>
<div className="h-2 rounded-full bg-slate-100 dark:bg-slate-800 overflow-hidden">
<div
style={{ width: `${percent}%` }}
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-purple-500"
/>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Countries Breakdown */}
<div className="p-6 rounded-3xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm">
<h3 className="text-base font-bold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
<Globe className="w-5 h-5 text-blue-500" />
Quốc gia truy cập (Top Countries)
</h3>
{analytics.countries.length === 0 ? (
<div className="py-8 text-center text-xs text-slate-400">Chưa có dữ liệu vị trí</div>
) : (
<div className="space-y-4">
{analytics.countries.map((c) => {
const total = analytics.total_clicks || 1;
const percent = Math.round((c.count / total) * 100);
return (
<div key={c.name} className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-slate-700 dark:text-slate-200 flex items-center gap-2 truncate">
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-500">
{c.code || "VN"}
</span>
{c.name}
</span>
<span className="text-slate-500 font-mono">
{c.count} ({percent}%)
</span>
</div>
<div className="h-2 rounded-full bg-slate-100 dark:bg-slate-800 overflow-hidden">
<div
style={{ width: `${percent}%` }}
className="h-full rounded-full bg-gradient-to-r from-blue-500 to-cyan-500"
/>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Operating Systems */}
<div className="p-6 rounded-3xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm">
<h3 className="text-base font-bold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
<Cpu className="w-5 h-5 text-purple-500" />
Hệ điều hành (Operating Systems)
</h3>
{analytics.os.length === 0 ? (
<div className="py-8 text-center text-xs text-slate-400">Chưa có dữ liệu hệ điều hành</div>
) : (
<div className="space-y-3">
{analytics.os.map((item) => {
const total = analytics.total_clicks || 1;
const percent = Math.round((item.count / total) * 100);
return (
<div key={item.name} className="flex items-center justify-between text-xs">
<span className="font-medium text-slate-700 dark:text-slate-300">
{item.name}
</span>
<div className="flex items-center gap-2">
<span className="text-slate-400 font-mono">{percent}%</span>
<span className="font-bold text-slate-900 dark:text-white px-2 py-0.5 rounded-lg bg-slate-100 dark:bg-slate-800 font-mono">
{item.count}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Browsers & Referrers */}
<div className="p-6 rounded-3xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm">
<h3 className="text-base font-bold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
<Compass className="w-5 h-5 text-amber-500" />
Trình duyệt & Nguồn giới thiệu (Browsers & Referrers)
</h3>
<div className="space-y-4">
<div>
<div className="text-[11px] font-bold uppercase tracking-wider text-slate-400 mb-2">
Trình duyệt phổ biến
</div>
<div className="flex flex-wrap gap-2">
{analytics.browsers.map((b) => (
<span
key={b.name}
className="px-3 py-1.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-xs font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1.5"
>
{b.name}: <span className="text-indigo-600 dark:text-indigo-400 font-bold">{b.count}</span>
</span>
))}
</div>
</div>
<div className="pt-2 border-t border-slate-100 dark:border-slate-800">
<div className="text-[11px] font-bold uppercase tracking-wider text-slate-400 mb-2">
Nguồn giới thiệu (Referrer)
</div>
<div className="flex flex-wrap gap-2">
{analytics.referrers.map((r) => (
<span
key={r.name}
className="px-3 py-1.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-xs font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1.5"
>
<Share2 className="w-3.5 h-3.5 text-slate-400" />
{r.name}: <span className="text-purple-600 dark:text-purple-400 font-bold">{r.count}</span>
</span>
))}
</div>
</div>
</div>
</div>
</div>
{/* Recent Clicks Log Table */}
<div className="p-6 sm:p-8 rounded-3xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm">
<h3 className="text-base font-bold text-slate-900 dark:text-white mb-2 flex items-center gap-2">
<Layers className="w-5 h-5 text-indigo-500" />
Nhật ký lượt click gần nhất (Recent Click Logs)
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-6">
Chi tiết 25 lượt truy cập liên kết mới nhất
</p>
{analytics.recent_clicks.length === 0 ? (
<div className="py-8 text-center text-xs text-slate-400">
Chưa có lượt click nào được ghi nhận cho liên kết này.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-slate-50 dark:bg-slate-800/50 text-[11px] font-semibold uppercase text-slate-400 border-b border-slate-200 dark:border-slate-800">
<tr>
<th className="p-3">Thời gian</th>
<th className="p-3">Thiết bị</th>
<th className="p-3">Hệ điều hành / Trình duyệt</th>
<th className="p-3">Vị trí</th>
<th className="p-3">Nguồn</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{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 (
<tr key={click.id} className="hover:bg-slate-50/50 dark:hover:bg-slate-800/50">
<td className="p-3 font-mono text-slate-600 dark:text-slate-400">
{timeFormatted}
</td>
<td className="p-3">
<span className="capitalize font-semibold text-slate-800 dark:text-slate-200">
{click.device_type}
</span>
</td>
<td className="p-3 text-slate-600 dark:text-slate-300">
<div>{click.os}</div>
<div className="text-[10px] text-slate-400">{click.browser}</div>
</td>
<td className="p-3 text-slate-700 dark:text-slate-200">
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] px-1 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-500">
{click.country_code}
</span>
<span>{click.city ? `${click.city}, ` : ""}{click.country_name}</span>
</div>
</td>
<td className="p-3 text-slate-500 truncate max-w-xs">
{click.referrer || "Trực tiếp"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+15 -3
View File
@@ -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() {
</div>
</td>
<td className="p-4 text-center">
<span className="inline-flex items-center gap-1 font-bold text-slate-800 dark:text-slate-200">
<BarChart2 className="w-3.5 h-3.5 text-indigo-500" />
<Link
href={`/dashboard/urls/${u.id}/analytics`}
className="inline-flex items-center gap-1 font-bold text-slate-800 dark:text-slate-200 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors group"
title="Xem thống kê chi tiết"
>
<BarChart2 className="w-3.5 h-3.5 text-indigo-500 group-hover:scale-110 transition-transform" />
{u.clicks_count}
</span>
</Link>
</td>
<td className="p-4 text-center">
<button
@@ -351,6 +356,13 @@ export default function UserUrlsPage() {
</td>
<td className="p-4 text-right">
<div className="flex items-center justify-end gap-1">
<Link
href={`/dashboard/urls/${u.id}/analytics`}
className="p-2 text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 rounded-lg transition-colors"
title="Thống kê chi tiết"
>
<BarChart2 className="w-4 h-4" />
</Link>
<button
onClick={() => showQr(u.short_url)}
className="p-2 text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 rounded-lg transition-colors"
+1 -1
View File
@@ -6,7 +6,7 @@ import * as cheerio from "cheerio";
export async function fetchUrlTitle(targetUrl: string): Promise<string | null> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 6000);
const timeout = setTimeout(() => controller.abort(), 2500);
const res = await fetch(targetUrl, {
signal: controller.signal,