perf: optimize URL shorten latency to <100ms & add single URL analytics page
This commit is contained in:
1 parent
8ed1562d01
commit
41786f2f51
6 files changed
+626
-24
No files matched your search
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Link2,
|
||||
Search,
|
||||
@@ -219,12 +220,24 @@ 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">
|
||||
<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"
|
||||
@@ -232,6 +245,7 @@ export default function AdminUrlsPage() {
|
||||
>
|
||||
<QrCode className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user