Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8,181 changes: 8,181 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,21 @@
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.40.1",
"chart.js": "^4.5.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.1.3",
"framer-motion": "^11.2.10",
"geoip-lite": "^1.4.10",
"input-otp": "^1.2.4",
"lucide-react": "^0.383.0",
"mini-svg-data-uri": "^1.4.4",
"next": "14.2.13",
"next-themes": "^0.3.0",
"react": "^18",
"react-chartjs-2": "^5.3.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18",
"react-hook-form": "^7.51.5",
Expand Down
34 changes: 0 additions & 34 deletions src/app/(main)/dashboard/page.tsx

This file was deleted.

7 changes: 3 additions & 4 deletions src/app/(marketing)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ import MagicBadge from "@/components/ui/magic-badge";
import MagicCard from "@/components/ui/magic-card";
import { COMPANIES, PROCESS } from "@/utils";
import { REVIEWS } from "@/utils/constants/misc";
import { currentUser } from "@clerk/nextjs/server";
import { cookies } from "next/headers";
import { ArrowRightIcon, CreditCardIcon, StarIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";

const HomePage = async () => {

const user = await currentUser();
const token = cookies().get("token")?.value;

return (
<div className="overflow-x-hidden scrollbar-hide size-full">
Expand Down Expand Up @@ -46,7 +45,7 @@ const HomePage = async () => {
</p>
<div className="flex items-center justify-center whitespace-nowrap gap-4 z-50">
<Button asChild>
<Link href={user ? "/dashboard" : "/auth/sign-in"} className="flex items-center">
<Link href={token ? "/dashboard" : "/auth/sign-in"} className="flex items-center">
Start creating for free
<ArrowRightIcon className="w-4 h-4 ml-2" />
</Link>
Expand Down
16 changes: 10 additions & 6 deletions src/app/auth/auth-callback/page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
"use client";

import { getAuthStatus } from "@/actions";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from 'next/navigation';
import { getToken } from "@/lib/auth";
import { apiMe } from "@/lib/api";

const AuthCallbackPage = () => {

const router = useRouter();

const { data } = useQuery({
queryKey: ["auth-status"],
queryFn: async () => await getAuthStatus(),
retry: true,
retryDelay: 500,
queryKey: ["auth-me"],
queryFn: async () => {
const token = getToken();
if (!token) return { error: true } as any;
return apiMe(token);
},
retry: false,
});

if (data?.success) {
if (data?.user) {
router.push("/dashboard");
}

Expand Down
210 changes: 210 additions & 0 deletions src/app/dashboard/DashboardClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"use client";

import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getToken } from "@/lib/auth";
import { apiAnalyticsSummary, apiListLinks } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Copy } from "lucide-react";
import { toast } from "sonner";
import LineChart from "@/components/charts/LineChart";
import BarChart from "@/components/charts/BarChart";
import PieChart from "@/components/charts/PieChart";
import { mockTimeSeries } from "@/lib/mock";

interface LinkItem {
id: string;
short: string;
destination: string;
clicks: number;
createdAt: string;
}

export default function DashboardClient() {
const [selectedLinkId, setSelectedLinkId] = useState<string>("all");

const token = getToken();
const { data } = useQuery({
queryKey: ["links"],
queryFn: async () => token ? apiListLinks(token) : { links: [] as any[] } as any,
});
const links: LinkItem[] = useMemo(() => (
(data?.links || []).map((l: any) => ({
id: l.slug,
short: l.slug,
destination: l.destination,
clicks: l.clicks,
createdAt: new Date(l.createdAt).toLocaleDateString(),
}))
), [data]);

const [granularity, setGranularity] = useState<"hour" | "day" | "month" | "year">("day");
const [days, setDays] = useState<number>(14);
const { data: analytics } = useQuery({
queryKey: ["analytics", selectedLinkId, granularity, days],
queryFn: async () => token ? apiAnalyticsSummary(token, { slug: selectedLinkId === "all" ? undefined : selectedLinkId, days, granularity }) : null,
});

const clicks = mockTimeSeries(14);

const copyToClipboard = async (text: string) => {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
toast.success("Đã copy link vào clipboard");
return;
}
} catch (_) {}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
toast.success("Đã copy link vào clipboard");
} catch (_) {}
document.body.removeChild(textarea);
};

const StatCard = ({ title, value, subtitle }: { title: string; value: string | number; subtitle?: string }) => (
<Card>
<CardHeader>
<CardTitle className="text-base text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-semibold">{value}</div>
{subtitle && <p className="text-sm text-muted-foreground mt-2">{subtitle}</p>}
</CardContent>
</Card>
);

return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Tổng số link" value={analytics?.totals?.totalLinks ?? "—"} subtitle="Số link đã tạo" />
<StatCard title="Tổng lượt click" value={analytics?.totals?.totalClicks ?? "—"} subtitle={`Trong ${days} ngày"`} />
<StatCard title="Người dùng duy nhất" value={analytics?.totals?.uniqueVisitors ?? "—"} subtitle="Unique visitors" />
<StatCard title="CTR trung bình" value={analytics?.totals?.ctr != null ? `${analytics?.totals?.ctr}%` : "—"} subtitle="Click-through rate" />
</div>

<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 py-1">
<div className="text-sm text-muted-foreground">Thống kê theo:</div>
<select
value={selectedLinkId}
onChange={(e) => setSelectedLinkId(e.target.value)}
className="ml-auto h-9 rounded-md border bg-background px-3 text-sm"
>
<option value="all">Tất cả liên kết</option>
{links.map((l) => (
<option key={l.id} value={l.id}>{l.short}</option>
))}
</select>
<select
value={String(days)}
onChange={(e) => setDays(Number(e.target.value))}
className="h-9 rounded-md border bg-background px-3 text-sm"
>
<option value={1}>24 giờ</option>
<option value={7}>7 ngày</option>
<option value={14}>14 ngày</option>
<option value={30}>30 ngày</option>
<option value={90}>90 ngày</option>
<option value={365}>1 năm</option>
</select>
<select
value={granularity}
onChange={(e) => setGranularity(e.target.value as any)}
className="h-9 rounded-md border bg-background px-3 text-sm"
>
<option value="hour">Giờ</option>
<option value="day">Ngày</option>
<option value="month">Tháng</option>
<option value="year">Năm</option>
</select>
</div>

<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-lg">Recent Links</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-muted-foreground">
<tr className="text-left">
<th className="py-2 pr-4">Short</th>
<th className="py-2 pr-4">Destination</th>
<th className="py-2 pr-4">Clicks</th>
<th className="py-2 pr-4">Created</th>
<th className="py-2">Actions</th>
</tr>
</thead>
<tbody>
{links.map((l) => (
<tr key={l.id} className="border-t border-border/40">
<td className="py-2 pr-4">{`${process.env.NEXT_PUBLIC_APP_URL}/${l.short}`}</td>
<td className="py-2 pr-4 truncate max-w-[220px]" title={l.destination}>{l.destination}</td>
<td className="py-2 pr-4">{l.clicks}</td>
<td className="py-2 pr-4">{l.createdAt}</td>
<td className="py-2">
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(`${process.env.NEXT_PUBLIC_APP_URL}/${l.short}`)}
>
Copy
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle className="text-lg">Clicks over time</CardTitle>
</CardHeader>
<CardContent>
<LineChart data={analytics?.clicksOverTime || []} />
</CardContent>
</Card>
</div>

<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Devices</CardTitle>
</CardHeader>
<CardContent>
<BarChart data={analytics?.devices || []} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Countries</CardTitle>
</CardHeader>
<CardContent>
<BarChart data={analytics?.countries || []} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Age</CardTitle>
</CardHeader>
<CardContent>
<PieChart data={[]} />
</CardContent>
</Card>
</div>
</>
);
}


21 changes: 21 additions & 0 deletions src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import { Navbar, Footer } from "@/components";

interface Props {
children: React.ReactNode
}

const DashboardLayout = ({ children }: Props) => {
return (
<>
<Navbar />
<main className="mt-20 mx-auto w-full z-0 relative">
{children}
</main>
<Footer />
</>
);
};

export default DashboardLayout

Loading