diff --git a/app/(protected)/dashboard/urls/meta-chart.tsx b/app/(protected)/dashboard/urls/meta-chart.tsx new file mode 100644 index 0000000..a07c68c --- /dev/null +++ b/app/(protected)/dashboard/urls/meta-chart.tsx @@ -0,0 +1,172 @@ +"use client"; + +import * as React from "react"; +import { UrlMeta } from "@prisma/client"; +import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; + +const chartConfig = { + pv: { + label: "Views", + color: "hsl(var(--chart-1))", + }, + uv: { + label: "Visitors", + color: "hsl(var(--chart-2))", + }, +}; + +function processUrlMeta(urlMetaArray: UrlMeta[]) { + const dailyData: { [key: string]: { clicks: number; ips: Set } } = {}; + + urlMetaArray.forEach((meta) => { + const date = new Date(meta.createdAt).toISOString().split("T")[0]; + + if (!dailyData[date]) { + dailyData[date] = { clicks: 0, ips: new Set() }; + } + + dailyData[date].clicks += meta.click; + dailyData[date].ips.add(meta.ip); + }); + + return Object.entries(dailyData).map(([date, data]) => ({ + date, + clicks: data.clicks, + ips: Array.from(data.ips), + })); +} + +function calculateUVAndPV(logs: UrlMeta[]) { + const uniqueIps = new Set(); + let totalClicks = 0; + + logs.forEach((log) => { + uniqueIps.add(log.ip); + totalClicks += log.click; + }); + + return { + uv: uniqueIps.size, + pv: totalClicks, + }; +} + +export function DailyPVUVChart({ data }: { data: UrlMeta[] }) { + const [activeChart, setActiveChart] = + React.useState("pv"); + + const processedData = processUrlMeta(data).map((entry) => ({ + date: entry.date, + pv: entry.clicks, + uv: new Set(entry.ips).size, + })); + + const dataTotal = calculateUVAndPV(data); + + const latestEntry = data[data.length - 1]; + const latestDate = new Date(latestEntry.updatedAt).toLocaleString("en-US"); + const latestFrom = [ + latestEntry.region ? latestEntry.region : "", + latestEntry.country ? `(${latestEntry.country})` : "", + latestEntry.city ? latestEntry.city : "", + ] + .filter(Boolean) + .join(" "); + + return ( + + +
+ Daily Stats + + Last visitor: {latestDate} from {latestFrom}. + +
+
+ {["pv", "uv"].map((key) => { + const chart = key as keyof typeof chartConfig; + return ( + + ); + })} +
+
+ + + + + { + const date = new Date(value); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + }} + /> + { + return new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }} + /> + } + /> + + + + +
+ ); +} diff --git a/app/(protected)/dashboard/urls/meta.tsx b/app/(protected)/dashboard/urls/meta.tsx new file mode 100644 index 0000000..0ae150d --- /dev/null +++ b/app/(protected)/dashboard/urls/meta.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { UrlMeta, User } from "@prisma/client"; +import useSWR from "swr"; + +import { fetcher } from "@/lib/utils"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; + +import { DailyPVUVChart } from "./meta-chart"; + +export interface UrlMetaProps { + user: Pick; + action: string; + urlId: string; +} + +export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) { + const { data, error, isLoading } = useSWR( + `${action}?id=${urlId}`, + fetcher, + ); + + if (isLoading) + return ( +
+ + +
+ ); + + if (!data) { + return ( + + No Stats + + You don't have any stats yet. + + + ); + } + + return ( +
+ +
+ ); +} diff --git a/app/(protected)/dashboard/urls/url-list.tsx b/app/(protected)/dashboard/urls/url-list.tsx index 31b7a1a..d362fda 100644 --- a/app/(protected)/dashboard/urls/url-list.tsx +++ b/app/(protected)/dashboard/urls/url-list.tsx @@ -3,13 +3,12 @@ import { useState } from "react"; import Link from "next/link"; import { User } from "@prisma/client"; -import { PenLine, RefreshCwIcon } from "lucide-react"; +import { LineChart, PenLine, RefreshCwIcon } from "lucide-react"; import useSWR, { useSWRConfig } from "swr"; import { siteConfig } from "@/config/site"; import { ShortUrlFormData } from "@/lib/dto/short-urls"; import { cn, expirationTime, fetcher, timeAgo } from "@/lib/utils"; -import { useMediaQuery } from "@/hooks/use-media-query"; import { Button } from "@/components/ui/button"; import { Card, @@ -35,6 +34,8 @@ import { UrlForm } from "@/components/forms/url-form"; import { CopyButton } from "@/components/shared/copy-button"; import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; +import UserUrlMetaInfo from "./meta"; + export interface UrlListProps { user: Pick; action: string; @@ -66,7 +67,6 @@ function TableColumnSekleton() { } export default function UserUrlsList({ user, action }: UrlListProps) { - const { isMobile } = useMediaQuery(); const [isShowForm, setShowForm] = useState(false); const [formType, setFormType] = useState("add"); const [currentEditUrl, setCurrentEditUrl] = useState( @@ -74,6 +74,8 @@ export default function UserUrlsList({ user, action }: UrlListProps) { ); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(10); + const [isShowStats, setShowStats] = useState(false); + const [selectedUrlId, setSelectedUrlId] = useState(""); const { mutate } = useSWRConfig(); const { data, error, isLoading } = useSWR<{ @@ -176,65 +178,88 @@ export default function UserUrlsList({ user, action }: UrlListProps) { ) : data && data.list && data.list.length ? ( data.list.map((short) => ( - - - - {short.url} - - + + + + {short.url} + + + + + + {short.target.startsWith("http") + ? short.target.split("//")[1] + : short.target} + + + + + + + {expirationTime(short.expiration, short.updatedAt)} + + + {timeAgo(short.updatedAt as Date)} + + + + + + + {isShowStats && selectedUrlId === short.id && ( + - - - - {short.target.startsWith("http") - ? short.target.split("//")[1] - : short.target} - - - - - - - {expirationTime(short.expiration, short.updatedAt)} - - - {timeAgo(short.updatedAt as Date)} - - - - - + )} + )) ) : ( diff --git a/app/api/url/meta/route.ts b/app/api/url/meta/route.ts new file mode 100644 index 0000000..a7bd167 --- /dev/null +++ b/app/api/url/meta/route.ts @@ -0,0 +1,29 @@ +import { getUserUrlMetaInfo } from "@/lib/dto/short-urls"; +import { checkUserStatus } from "@/lib/dto/user"; +import { getCurrentUser } from "@/lib/session"; + +export async function GET(req: Request) { + try { + const user = checkUserStatus(await getCurrentUser()); + if (user instanceof Response) return user; + + const url = new URL(req.url); + const urlId = url.searchParams.get("id"); + + if (!urlId) { + return Response.json("url id is required", { + status: 400, + statusText: "url id is required", + }); + } + + const data = await getUserUrlMetaInfo(urlId); + + return Response.json(data); + } catch (error) { + return Response.json(error?.statusText || error, { + status: error.status || 500, + statusText: error.statusText || "Server error", + }); + } +} diff --git a/components/charts/interactive-bar-chart.tsx b/components/charts/interactive-bar-chart.tsx index 3550b0b..9320590 100644 --- a/components/charts/interactive-bar-chart.tsx +++ b/components/charts/interactive-bar-chart.tsx @@ -27,15 +27,15 @@ const chartConfig = { }, records: { label: "Records", - color: "hsl(var(--chart-1))", + color: "hsl(var(--chart-2))", }, urls: { label: "URLs", - color: "hsl(var(--chart-2))", + color: "hsl(var(--chart-1))", }, users: { label: "Users", - color: "hsl(var(--chart-2))", + color: "hsl(var(--chart-1))", }, } satisfies ChartConfig; @@ -87,7 +87,7 @@ export function InteractiveBarChart() { { longitude: "", }; const data = await fetch(`https://ip.wr.do/api?ip=${ip}`); // http://ip-api.com/json/42.48.83.141 + const geoInfo = geolocation(req); + console.log("[geoInfo]", geoInfo, geoInfo?.city); + if (data.ok) { const geoData = await data.json(); geo = { diff --git a/package.json b/package.json index 35b265d..5e2d875 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@t3-oss/env-nextjs": "^0.11.0", "@typescript-eslint/parser": "^7.16.1", "@vercel/analytics": "^1.3.1", + "@vercel/functions": "^1.4.0", "@vercel/og": "^0.6.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a996d1d..9db2c89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: '@vercel/analytics': specifier: ^1.3.1 version: 1.3.1(next@14.2.5(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@vercel/functions': + specifier: ^1.4.0 + version: 1.4.0 '@vercel/og': specifier: ^0.6.2 version: 0.6.2 @@ -2706,6 +2709,15 @@ packages: react: optional: true + '@vercel/functions@1.4.0': + resolution: {integrity: sha512-Ln6SpIkms1UJg306X2kbEMyG9ol+mjDr2xx389cvsBxgFyFMI9Bm+LYOG4N3TSik4FI59MECyyc4oz7AIAYmqQ==} + engines: {node: '>= 16'} + peerDependencies: + '@aws-sdk/credential-provider-web-identity': '*' + peerDependenciesMeta: + '@aws-sdk/credential-provider-web-identity': + optional: true + '@vercel/og@0.6.2': resolution: {integrity: sha512-OTe0KE37F5Y2eTys6eMnfopC+P4qr2ooXUTFyFPTplYSPwowmFk/HLD1FXtbKLjqsIH0SgekcJWad+C5uX4nkg==} engines: {node: '>=16'} @@ -8268,7 +8280,7 @@ snapshots: '@react-aria/i18n': 3.11.1(react@18.3.1) '@react-aria/interactions': 3.22.1(react@18.3.1) '@react-aria/ssr': 3.9.5(react@18.3.1) - '@react-aria/utils': 3.24.1(react@18.3.1) + '@react-aria/utils': 3.25.1(react@18.3.1) '@react-aria/visually-hidden': 3.8.14(react@18.3.1) '@react-stately/overlays': 3.6.9(react@18.3.1) '@react-types/button': 3.9.6(react@18.3.1) @@ -8786,6 +8798,8 @@ snapshots: next: 14.2.5(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 + '@vercel/functions@1.4.0': {} + '@vercel/og@0.6.2': dependencies: '@resvg/resvg-wasm': 2.4.0