-
+
{t("privacyPolicy.title")}
diff --git a/src/app/profile/[playerName]/layout.tsx b/src/app/profile/[playerName]/layout.tsx
new file mode 100644
index 000000000..bf7b9d06d
--- /dev/null
+++ b/src/app/profile/[playerName]/layout.tsx
@@ -0,0 +1,38 @@
+import { DashboardLayout } from "@/components/dashboard-layout";
+import type { Metadata } from "next";
+import { getLocale, getTranslations } from "next-intl/server";
+
+export async function generateMetadata(
+ props: LayoutProps<"/profile/[playerName]">
+): Promise {
+ const params = await props.params;
+ const t = await getTranslations("profilePage.layoutMetadata");
+ const locale = await getLocale();
+ const playerName = decodeURIComponent(params.playerName);
+
+ return {
+ title: t("title", { playerName }),
+ description: t("description", { playerName }),
+ openGraph: {
+ title: t("ogTitle", { playerName }),
+ description: t("ogDescription", { playerName }),
+ url: "https://parsertime.app",
+ type: "website",
+ siteName: "Parsertime",
+ images: [
+ {
+ url: `https://parsertime.app/api/og?title=${t("ogImage", { playerName })}`,
+ width: 1200,
+ height: 630,
+ },
+ ],
+ locale,
+ },
+ };
+}
+
+export default function ProfileLayout({
+ children,
+}: LayoutProps<"/profile/[playerName]">) {
+ return {children};
+}
diff --git a/src/app/profile/[playerName]/page.tsx b/src/app/profile/[playerName]/page.tsx
new file mode 100644
index 000000000..bc9fb152f
--- /dev/null
+++ b/src/app/profile/[playerName]/page.tsx
@@ -0,0 +1,587 @@
+import { Achievements } from "@/components/profile/achievements";
+import { HeroMasteryGrid } from "@/components/profile/hero-mastery-grid";
+import { HeroRating } from "@/components/profile/hero-rating";
+import { PersonalRecords } from "@/components/profile/personal-records";
+import { PlayStyleIndicator } from "@/components/profile/play-style-indicator";
+import { ProfileHeader } from "@/components/profile/profile-header";
+import { RecentActivityCalendar } from "@/components/profile/recent-activity-calendar";
+import { StatFluctuationCards } from "@/components/profile/stat-fluctuation-cards";
+import {
+ RangePicker,
+ type Timeframe,
+} from "@/components/stats/player/range-picker";
+import { Link } from "@/components/ui/link";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ getAllDeathsForPlayer,
+ getAllKillsForPlayer,
+ getAllMapWinratesForPlayer,
+ getAllStatsForPlayer,
+} from "@/data/scrim-dto";
+import { getCompositeSRLeaderboard } from "@/lib/hero-rating";
+import { Permission } from "@/lib/permissions";
+import prisma from "@/lib/prisma";
+import {
+ cn,
+ getHeroRatingBorderColor,
+ toHero,
+ toTimestampWithHours,
+} from "@/lib/utils";
+import { type HeroName, heroRoleMapping } from "@/types/heroes";
+import type { PagePropsWithLocale } from "@/types/next";
+import { $Enums, type Scrim } from "@prisma/client";
+import { getTranslations } from "next-intl/server";
+import Image from "next/image";
+
+// Helper type for hero data
+type HeroData = {
+ player_hero: string;
+ total_time_played: number;
+ hero_rating: number;
+ mapsPlayed: number;
+ percentile: string;
+ rank: number;
+};
+
+export default async function ProfilePage(
+ props: PagePropsWithLocale<"/profile/[playerName]">
+) {
+ const params = await props.params;
+ const name = decodeURIComponent(params.playerName);
+ const t = await getTranslations("heroes");
+
+ // Attempt to fetch user data
+ const user = await prisma.user.findFirst({
+ where: {
+ OR: [
+ { name: { equals: name, mode: "insensitive" } },
+ { battletag: { equals: name, mode: "insensitive" } },
+ ],
+ },
+ });
+
+ const [timeframe1, timeframe2, timeframe3] = await Promise.all([
+ new Permission("stats-timeframe-1").check(),
+ new Permission("stats-timeframe-2").check(),
+ new Permission("stats-timeframe-3").check(),
+ ]);
+
+ const permissions = {
+ "stats-timeframe-1": timeframe1,
+ "stats-timeframe-2": timeframe2,
+ "stats-timeframe-3": timeframe3,
+ };
+
+ let appliedTitle = null;
+ if (user) {
+ appliedTitle = await prisma.appliedTitle.findFirst({
+ where: { userId: user?.id },
+ select: { title: true },
+ });
+ }
+
+ const playerData = {
+ name: user?.name ?? name,
+ image: user?.image ?? null,
+ bannerImage: user?.bannerImage ?? null,
+ title: appliedTitle?.title ?? null,
+ billingPlan: user?.billingPlan ?? $Enums.BillingPlan.FREE,
+ email: user?.email ?? null,
+ };
+
+ // 1. Fetch all heroes played by the user, sorted by time played
+ // We use raw query for speed and aggregation
+ type HeroPlayTime = {
+ player_hero: string;
+ total_time_played: number;
+ };
+
+ const heroesPlayed = await prisma.$queryRaw`
+ WITH final_rows AS (
+ SELECT DISTINCT ON ("MapDataId", player_name, player_hero)
+ player_hero,
+ hero_time_played
+ FROM
+ "PlayerStat"
+ WHERE
+ player_name ILIKE ${name}
+ AND hero_time_played > 0
+ ORDER BY
+ "MapDataId",
+ player_name,
+ player_hero,
+ round_number DESC,
+ id DESC
+ )
+ SELECT
+ player_hero,
+ SUM(hero_time_played) AS total_time_played
+ FROM
+ final_rows
+ GROUP BY
+ player_hero
+ ORDER BY
+ total_time_played DESC
+ `;
+
+ // Uncomment this to rate only the top 10 heroes to avoid too many DB calls
+ // const topHeroesToRate = heroesPlayed.slice(0, 10);
+
+ const heroRatings = await Promise.all(
+ heroesPlayed.map(async (hero) => {
+ const compositeLeaderboard = await getCompositeSRLeaderboard({
+ hero: hero.player_hero as HeroName,
+ player: name,
+ limit: 300,
+ });
+
+ if (!compositeLeaderboard) {
+ const mapsPlayed = await prisma.playerStat.groupBy({
+ by: ["MapDataId"],
+ where: {
+ player_name: { equals: name, mode: "insensitive" },
+ player_hero: hero.player_hero as HeroName,
+ hero_time_played: { gt: 60 },
+ },
+ });
+
+ return {
+ ...hero,
+ hero_rating: 0,
+ mapsPlayed: mapsPlayed.length,
+ percentile: "0",
+ rank: 0,
+ } as unknown as HeroData;
+ }
+
+ return {
+ ...hero,
+ hero_rating: compositeLeaderboard.composite_sr ?? 0,
+ mapsPlayed: compositeLeaderboard.maps ?? 0,
+ percentile: compositeLeaderboard.percentile ?? "0",
+ rank: compositeLeaderboard.rank ?? 0,
+ } as unknown as HeroData;
+ })
+ );
+
+ // Merge back into full list
+ const allHeroesData: HeroData[] = heroesPlayed.map((hero) => {
+ const ratedHero = heroRatings.find(
+ (h) => h.player_hero === hero.player_hero
+ );
+ if (ratedHero) return ratedHero;
+ return {
+ ...hero,
+ hero_rating: 0,
+ mapsPlayed: 0, // We didn't fetch this for non-top heroes to save time
+ percentile: "0",
+ rank: 0,
+ };
+ });
+
+ const top3Heroes = allHeroesData.slice(0, 3);
+
+ // Calculate Role Data
+ const roleData: Record<
+ "Tank" | "Damage" | "Support",
+ { time: number; sr: number }
+ > = {
+ Tank: { time: 0, sr: 0 },
+ Damage: { time: 0, sr: 0 },
+ Support: { time: 0, sr: 0 },
+ };
+
+ allHeroesData.forEach((hero) => {
+ const role = heroRoleMapping[hero.player_hero as HeroName];
+ if (role) {
+ roleData[role].time += hero.total_time_played;
+ if (hero.hero_rating > 0) {
+ // Use max hero SR as the "Role SR" for now, as it represents peak performance
+ roleData[role].sr = Math.max(roleData[role].sr, hero.hero_rating);
+ }
+ }
+ });
+
+ const calculatedStats = await prisma.calculatedStat.findMany({
+ where: { playerName: { equals: name, mode: "insensitive" } },
+ });
+
+ const playerScrims = await prisma.playerStat.findMany({
+ where: { player_name: { equals: name, mode: "insensitive" } },
+ select: { scrimId: true },
+ distinct: ["scrimId"],
+ });
+
+ const scrimIds = playerScrims.map((scrim) => scrim.scrimId);
+
+ const allScrims = await prisma.scrim.findMany({
+ where: { id: { in: scrimIds } },
+ });
+
+ const oneWeek = new Date();
+ oneWeek.setDate(oneWeek.getDate() - 7);
+ const oneWeekScrims = allScrims.filter((scrim) => scrim.date >= oneWeek);
+
+ const twoWeeks = new Date();
+ twoWeeks.setDate(twoWeeks.getDate() - 14);
+ const twoWeeksScrims = allScrims.filter((scrim) => scrim.date >= twoWeeks);
+
+ const oneMonth = new Date();
+ oneMonth.setMonth(oneMonth.getMonth() - 1);
+ const monthScrims = allScrims.filter((scrim) => scrim.date >= oneMonth);
+
+ const threeMonths = new Date();
+ threeMonths.setMonth(threeMonths.getMonth() - 3);
+ const threeMonthsScrims = allScrims.filter(
+ (scrim) => scrim.date >= threeMonths
+ );
+
+ const sixMonths = new Date();
+ sixMonths.setMonth(sixMonths.getMonth() - 6);
+ const sixMonthsScrims = allScrims.filter((scrim) => scrim.date >= sixMonths);
+
+ const year = new Date();
+ year.setFullYear(year.getFullYear() - 1);
+ const yearScrims = allScrims.filter((scrim) => scrim.date >= year);
+
+ const data: Record = {
+ "one-week": oneWeekScrims,
+ "two-weeks": twoWeeksScrims,
+ "one-month": monthScrims,
+ "three-months": threeMonthsScrims,
+ "six-months": sixMonthsScrims,
+ "one-year": yearScrims,
+ "all-time": allScrims,
+ custom: [],
+ };
+
+ const permitted = timeframe3
+ ? "all-time"
+ : timeframe2
+ ? "six-months"
+ : "one-month";
+
+ const permittedScrimIds = data[permitted].map((scrim) => scrim.id);
+
+ const [stats, kills, deaths, mapWinrates] = await Promise.all([
+ getAllStatsForPlayer(permittedScrimIds, name),
+ getAllKillsForPlayer(permittedScrimIds, name),
+ getAllDeathsForPlayer(permittedScrimIds, name),
+ getAllMapWinratesForPlayer(permittedScrimIds, name),
+ ]);
+
+ // Calculate max time for bar chart scaling
+ const maxTimePlayed = Math.max(
+ ...allHeroesData.map((h) => h.total_time_played)
+ );
+
+ return (
+
+
+
+
+
+ Overview
+ Progression
+ Statistics
+ {user && Achievements}
+
+
+
+ {/* Left Column: Most Played Heroes */}
+
+
+
+
+ Most Played Heroes
+
+
+ {top3Heroes.map((hero, index) => (
+
+
+
+
+
+
+
+ {t(toHero(hero.player_hero))}
+
+
+
+ ))}
+ {top3Heroes.length === 0 && (
+
+ No data available
+
+ )}
+
+
+
+
+
+
+
+
+ One Week
+
+
+ Two Weeks
+
+
+ One Month
+
+
+ Three Months
+
+
+ Six Months
+
+
+ One Year
+
+
+ All Time
+
+
+ {!timeframe3 && (
+
+
+ Upgrade to view more timeframes
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Right Column: Comparison / Role Stats */}
+
+
+
+
+ Hero Comparison
+
+
+ Time Played
+
+
+ {/* Comparison Bars */}
+
+ {allHeroesData.slice(0, 5).map((hero) => (
+
+
+
+
+ {t(toHero(hero.player_hero))}
+
+ {hero.hero_rating > 0 ? (
+
+ ) : (
+
+ Unplaced
+
+ )}
+
+
+
+
+ {toTimestampWithHours(hero.total_time_played)}
+
+
+
+
+ ))}
+ {allHeroesData.length === 0 && (
+
+ No data available
+
+ )}
+
+
+
+
+
Role
+
Time Played
+
Peak SR
+
+
+
+
+
+ {roleData.Tank.time > 0
+ ? toTimestampWithHours(roleData.Tank.time)
+ : "-"}
+
+
+ {roleData.Tank.sr > 0 ? (
+
+ ) : (
+ "-"
+ )}
+
+
+
+
+
+ {roleData.Damage.time > 0
+ ? toTimestampWithHours(roleData.Damage.time)
+ : "-"}
+
+
+ {roleData.Damage.sr > 0 ? (
+
+ ) : (
+ "-"
+ )}
+
+
+
+
+
+ {roleData.Support.time > 0
+ ? toTimestampWithHours(roleData.Support.time)
+ : "-"}
+
+
+ {roleData.Support.sr > 0 ? (
+
+ ) : (
+ "-"
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Left Column: Play Style Indicator */}
+
+
+ {/* Right Column: Personal Records */}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
new file mode 100644
index 000000000..da6c0a1a3
--- /dev/null
+++ b/src/app/profile/page.tsx
@@ -0,0 +1,12 @@
+import { getUser } from "@/data/user-dto";
+import { auth } from "@/lib/auth";
+import { notFound, redirect } from "next/navigation";
+
+export default async function BaseProfilePage() {
+ const session = await auth();
+ const user = await getUser(session?.user?.email);
+
+ if (!user) redirect("/sign-in");
+ if (!user.battletag) notFound();
+ redirect(`/profile/${user.battletag}`);
+}
diff --git a/src/app/settings/accounts/page.tsx b/src/app/settings/accounts/page.tsx
index 5ada8d9b7..895202fba 100644
--- a/src/app/settings/accounts/page.tsx
+++ b/src/app/settings/accounts/page.tsx
@@ -10,7 +10,7 @@ export default async function LinkedAccountSettingsPage() {
const t = await getTranslations("settingsPage.linkedAccounts");
const session = await auth();
- if (!session || !session.user) {
+ if (!session?.user) {
redirect("/sign-in");
}
@@ -31,10 +31,10 @@ export default async function LinkedAccountSettingsPage() {
);
return (
-
+
{t("title")}
-
{t("description")}
+
{t("description")}
{discordAccount ?
{t("discord.linked")}
:
}
diff --git a/src/app/settings/admin/analytics/layout.tsx b/src/app/settings/admin/analytics/layout.tsx
new file mode 100644
index 000000000..b7ab0e806
--- /dev/null
+++ b/src/app/settings/admin/analytics/layout.tsx
@@ -0,0 +1,20 @@
+import { NoAuthCard } from "@/components/auth/no-auth";
+import { getUser } from "@/data/user-dto";
+import { auth } from "@/lib/auth";
+import { $Enums } from "@prisma/client";
+
+export default async function AdminAnalyticsLayout({
+ children,
+}: LayoutProps<"/settings/admin/analytics">) {
+ const session = await auth();
+
+ const user = await getUser(session?.user?.email);
+
+ if (user?.role !== $Enums.UserRole.ADMIN) {
+ return NoAuthCard();
+ }
+
+ // Must be wrapped in an element due to Next.js Server Component typing
+ // eslint-disable-next-line react/jsx-no-useless-fragment
+ return <>{children}>;
+}
diff --git a/src/app/settings/admin/analytics/page.tsx b/src/app/settings/admin/analytics/page.tsx
new file mode 100644
index 000000000..44f13747d
--- /dev/null
+++ b/src/app/settings/admin/analytics/page.tsx
@@ -0,0 +1,351 @@
+import { BillingPlanPieChart } from "@/components/admin/billing-plan-pie-chart";
+import { MonthlyUserChart } from "@/components/admin/monthly-user-chart";
+import { ScrimActivityChart } from "@/components/admin/scrim-activity-chart";
+import { SignupMethodPieChart } from "@/components/admin/signup-method-pie-chart";
+import { TeamCreationChart } from "@/components/admin/team-creation-chart";
+import { TeamManagerPieChart } from "@/components/admin/team-manager-pie-chart";
+import { NoAuthCard } from "@/components/auth/no-auth";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import { getUser } from "@/data/user-dto";
+import { auth } from "@/lib/auth";
+import prisma from "@/lib/prisma";
+import { $Enums } from "@prisma/client";
+import { getTranslations } from "next-intl/server";
+import { redirect } from "next/navigation";
+
+async function getMonthlyUserData() {
+ const now = new Date();
+ const monthlyData = [];
+
+ // Get data for the last 12 months
+ for (let i = 11; i >= 0; i--) {
+ const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
+ const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
+
+ const userCount = await prisma.user.count({
+ where: {
+ createdAt: {
+ gte: monthStart,
+ lt: monthEnd,
+ },
+ },
+ });
+
+ const monthName = monthStart.toLocaleDateString("en-US", { month: "long" });
+ monthlyData.push({
+ month: monthName,
+ users: userCount,
+ });
+ }
+
+ return monthlyData;
+}
+
+async function getScrimActivityData() {
+ const now = new Date();
+ const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+
+ // Get scrims created in the last 30 days, grouped by day
+ const scrimData = await prisma.scrim.findMany({
+ where: {
+ createdAt: {
+ gte: thirtyDaysAgo,
+ },
+ },
+ select: {
+ createdAt: true,
+ },
+ orderBy: {
+ createdAt: "asc",
+ },
+ });
+
+ // Group scrims by day
+ const dailyData = new Map
();
+
+ // Initialize all days in the range with 0
+ for (let i = 0; i < 30; i++) {
+ const date = new Date(thirtyDaysAgo.getTime() + i * 24 * 60 * 60 * 1000);
+ const dateKey = date.toISOString().split("T")[0];
+ dailyData.set(dateKey, 0);
+ }
+
+ // Count scrims per day
+ scrimData.forEach((scrim) => {
+ const dateKey = scrim.createdAt.toISOString().split("T")[0];
+ const currentCount = dailyData.get(dateKey) ?? 0;
+ dailyData.set(dateKey, currentCount + 1);
+ });
+
+ // Convert to array
+ const dataArray = Array.from(dailyData.entries()).map(([date, count]) => ({
+ date,
+ scrims: count,
+ }));
+
+ return dataArray;
+}
+
+async function getTeamCreationData() {
+ const now = new Date();
+ const monthlyData = [];
+
+ // Get data for the last 12 months
+ for (let i = 11; i >= 0; i--) {
+ const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
+ const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
+
+ const teamCount = await prisma.team.count({
+ where: {
+ createdAt: {
+ gte: monthStart,
+ lt: monthEnd,
+ },
+ },
+ });
+
+ const monthName = monthStart.toLocaleDateString("en-US", { month: "long" });
+ monthlyData.push({
+ month: monthName,
+ teams: teamCount,
+ });
+ }
+
+ return monthlyData;
+}
+
+async function getTeamManagerData() {
+ const [totalUsers, teamManagers, teamOwners] = await Promise.all([
+ prisma.user.count(),
+ prisma.teamManager.groupBy({
+ by: ["userId"],
+ _count: {
+ userId: true,
+ },
+ }),
+ prisma.team.groupBy({
+ by: ["ownerId"],
+ _count: {
+ ownerId: true,
+ },
+ }),
+ ]);
+
+ // Create sets to avoid double counting users who are both owners and managers
+ const managerUserIds = new Set(teamManagers.map((tm) => tm.userId));
+ const ownerUserIds = new Set(teamOwners.map((to) => to.ownerId));
+
+ // Combine both sets to get unique power users (owners or managers)
+ const powerUserIds = new Set([...managerUserIds, ...ownerUserIds]);
+ const uniquePowerUsers = powerUserIds.size;
+ const regularUsers = totalUsers - uniquePowerUsers;
+
+ return [
+ {
+ role: "Regular Users",
+ count: regularUsers,
+ percentage: Math.round((regularUsers / totalUsers) * 100),
+ },
+ {
+ role: "Power Users",
+ count: uniquePowerUsers,
+ percentage: Math.round((uniquePowerUsers / totalUsers) * 100),
+ },
+ ];
+}
+
+async function getSignupMethodData() {
+ const [totalUsers, oauthAccounts] = await Promise.all([
+ prisma.user.count(),
+ prisma.account.groupBy({
+ by: ["provider"],
+ _count: {
+ userId: true,
+ },
+ }),
+ ]);
+
+ // Create a map of provider counts
+ const providerCounts = new Map();
+ let totalOAuthUsers = 0;
+
+ oauthAccounts.forEach((account) => {
+ const count = account._count.userId;
+ providerCounts.set(account.provider, count);
+ totalOAuthUsers += count;
+ });
+
+ // Users who signed up via email (no OAuth account)
+ const emailUsers = totalUsers - totalOAuthUsers;
+
+ // Build the result array
+ const result = [];
+
+ // Add email users
+ if (emailUsers > 0) {
+ result.push({
+ method: "Email",
+ count: emailUsers,
+ percentage: Math.round((emailUsers / totalUsers) * 100),
+ });
+ }
+
+ // Add OAuth providers
+ const providerNames = {
+ discord: "Discord",
+ google: "Google",
+ github: "GitHub",
+ };
+
+ ["discord", "google", "github"].forEach((provider) => {
+ const count = providerCounts.get(provider) ?? 0;
+ if (count > 0) {
+ result.push({
+ method: providerNames[provider as keyof typeof providerNames],
+ count,
+ percentage: Math.round((count / totalUsers) * 100),
+ });
+ }
+ });
+
+ return result;
+}
+
+async function getBillingPlanData() {
+ const billingPlans = await prisma.user.groupBy({
+ by: ["billingPlan"],
+ _count: {
+ id: true,
+ },
+ });
+
+ const totalUsers = billingPlans.reduce(
+ (sum, plan) => sum + plan._count.id,
+ 0
+ );
+
+ return billingPlans.map((plan) => ({
+ plan: plan.billingPlan,
+ count: plan._count.id,
+ percentage: Math.round((plan._count.id / totalUsers) * 100),
+ }));
+}
+
+export default async function AdminAnalyticsPage() {
+ const session = await auth();
+ if (!session?.user) {
+ redirect("/sign-in");
+ }
+
+ const user = await getUser(session.user.email);
+ if (!user) {
+ redirect("/sign-up");
+ }
+ if (user.role !== $Enums.UserRole.ADMIN) {
+ return NoAuthCard();
+ }
+
+ const t = await getTranslations("settingsPage.admin.analytics");
+ const [
+ monthlyUserData,
+ scrimActivityData,
+ teamCreationData,
+ teamManagerData,
+ signupMethodData,
+ billingPlanData,
+ ] = await Promise.all([
+ getMonthlyUserData(),
+ getScrimActivityData(),
+ getTeamCreationData(),
+ getTeamManagerData(),
+ getSignupMethodData(),
+ getBillingPlanData(),
+ ]);
+
+ return (
+
+
+
{t("title")}
+
{t("description")}
+
+
+
+
+
+
+ {t("userGrowth.title")}
+ {t("userGrowth.description")}
+
+
+
+
+
+
+
+ {t("signupMethods.title")}
+
+ {t("signupMethods.description")}
+
+
+
+
+
+
+
+
+
+
+ {t("billingPlans.title")}
+ {t("billingPlans.description")}
+
+
+
+
+
+
+
+ {t("scrimActivity.title")}
+
+ {t("scrimActivity.description")}
+
+
+
+
+
+
+
+
+
+
+ {t("teamCreations.title")}
+
+ {t("teamCreations.description")}
+
+
+
+
+
+
+
+
+ {t("teamManagerDistribution.title")}
+
+ {t("teamManagerDistribution.description")}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/settings/admin/layout.tsx b/src/app/settings/admin/audit-logs/layout.tsx
similarity index 79%
rename from src/app/settings/admin/layout.tsx
rename to src/app/settings/admin/audit-logs/layout.tsx
index caf493640..3cec51fba 100644
--- a/src/app/settings/admin/layout.tsx
+++ b/src/app/settings/admin/audit-logs/layout.tsx
@@ -1,14 +1,11 @@
-import NoAuthCard from "@/components/auth/no-auth";
+import { NoAuthCard } from "@/components/auth/no-auth";
+import { getUser } from "@/data/user-dto";
import { auth } from "@/lib/auth";
import { $Enums } from "@prisma/client";
-import prisma from "@/lib/prisma";
-import { getUser } from "@/data/user-dto";
export default async function AdminLayout({
children,
-}: {
- children: React.ReactNode;
-}) {
+}: LayoutProps<"/settings/admin/audit-logs">) {
const session = await auth();
const user = await getUser(session?.user?.email);
diff --git a/src/app/settings/admin/audit-logs/page.tsx b/src/app/settings/admin/audit-logs/page.tsx
new file mode 100644
index 000000000..1cbc6ce1a
--- /dev/null
+++ b/src/app/settings/admin/audit-logs/page.tsx
@@ -0,0 +1,29 @@
+import { AuditLog } from "@/components/admin/audit-log";
+import { NoAuthCard } from "@/components/auth/no-auth";
+import { getUser } from "@/data/user-dto";
+import { auth } from "@/lib/auth";
+import { $Enums } from "@prisma/client";
+import { redirect } from "next/navigation";
+
+export default async function AuditLogsPage() {
+ const session = await auth();
+ if (!session?.user) {
+ redirect("/sign-in");
+ }
+
+ const user = await getUser(session.user.email);
+
+ if (!user) {
+ redirect("/sign-up");
+ }
+
+ if (user.role !== $Enums.UserRole.ADMIN) {
+ return NoAuthCard();
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/settings/admin/impersonate-user/layout.tsx b/src/app/settings/admin/impersonate-user/layout.tsx
new file mode 100644
index 000000000..86fdebb99
--- /dev/null
+++ b/src/app/settings/admin/impersonate-user/layout.tsx
@@ -0,0 +1,20 @@
+import { NoAuthCard } from "@/components/auth/no-auth";
+import { getUser } from "@/data/user-dto";
+import { auth } from "@/lib/auth";
+import { $Enums } from "@prisma/client";
+
+export default async function AdminLayout({
+ children,
+}: LayoutProps<"/settings/admin/impersonate-user">) {
+ const session = await auth();
+
+ const user = await getUser(session?.user?.email);
+
+ if (user?.role !== $Enums.UserRole.ADMIN) {
+ return NoAuthCard();
+ }
+
+ // Must be wrapped in an element due to Next.js Server Component typing
+
+ return {children}
;
+}
diff --git a/src/app/settings/admin/impersonate-user/page.tsx b/src/app/settings/admin/impersonate-user/page.tsx
new file mode 100644
index 000000000..f3b701116
--- /dev/null
+++ b/src/app/settings/admin/impersonate-user/page.tsx
@@ -0,0 +1,38 @@
+import { ImpersonateUserForm } from "@/components/admin/impersonate-user";
+import { NoAuthCard } from "@/components/auth/no-auth";
+import { Separator } from "@/components/ui/separator";
+import { getUser } from "@/data/user-dto";
+import { auth } from "@/lib/auth";
+import { $Enums } from "@prisma/client";
+import { getTranslations } from "next-intl/server";
+import { redirect } from "next/navigation";
+
+export default async function AdminSettingsPage() {
+ const t = await getTranslations("settingsPage.admin.impersonateUser");
+
+ const session = await auth();
+ if (!session?.user) {
+ redirect("/sign-in");
+ }
+
+ const user = await getUser(session.user.email);
+
+ if (!user) {
+ redirect("/sign-up");
+ }
+
+ if (user.role !== $Enums.UserRole.ADMIN) {
+ return NoAuthCard();
+ }
+
+ return (
+
+
+
{t("title")}
+
{t("description")}
+
+
+
+
+ );
+}
diff --git a/src/app/settings/admin/page.tsx b/src/app/settings/admin/page.tsx
index cb1c01922..cf2df550c 100644
--- a/src/app/settings/admin/page.tsx
+++ b/src/app/settings/admin/page.tsx
@@ -1,38 +1,76 @@
-import { ImpersonateUserForm } from "@/components/admin/impersonate-user";
-import NoAuthCard from "@/components/auth/no-auth";
-import { Separator } from "@/components/ui/separator";
+import { AuditLog } from "@/components/admin/audit-log";
+import { StatsCards } from "@/components/admin/stats-cards";
+import { UserSearch } from "@/components/admin/user-search";
+import { NoAuthCard } from "@/components/auth/no-auth";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getUser } from "@/data/user-dto";
import { auth } from "@/lib/auth";
import { $Enums } from "@prisma/client";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
-export default async function AdminSettingsPage() {
- const t = await getTranslations("settingsPage.admin");
-
+export default async function AdminDashboard() {
const session = await auth();
- if (!session || !session.user) {
+ if (!session?.user) {
redirect("/sign-in");
}
const user = await getUser(session.user.email);
-
if (!user) {
redirect("/sign-up");
}
-
if (user.role !== $Enums.UserRole.ADMIN) {
return NoAuthCard();
}
+ const t = await getTranslations("settingsPage.admin.dashboard");
+
return (
-
+
-
{t("title")}
-
{t("description")}
+
{t("title")}
+
{t("description")}
-
-
+
+
+
+
+
+
+ {t("user-search.title")}
+
+ {t("audit-log.title")}
+
+
+
+
+ {t("user-search.title")}
+ {t("user-search.description")}
+
+
+
+
+
+
+
+
+
+ {t("audit-log.title")}
+ {t("audit-log.description")}
+
+
+
+
+
+
+
);
}
diff --git a/src/app/settings/billing/page.tsx b/src/app/settings/billing/page.tsx
new file mode 100644
index 000000000..473bda7c0
--- /dev/null
+++ b/src/app/settings/billing/page.tsx
@@ -0,0 +1,65 @@
+import { UsageCard } from "@/components/settings/usage-card";
+import { Separator } from "@/components/ui/separator";
+import { getUser } from "@/data/user-dto";
+import { auth } from "@/lib/auth";
+import prisma from "@/lib/prisma";
+import { getCustomerPortalUrl } from "@/lib/stripe";
+import type { Route } from "next";
+import { getTranslations } from "next-intl/server";
+import { redirect } from "next/navigation";
+
+export default async function SettingsBillingPage() {
+ const t = await getTranslations("settingsPage.billing");
+
+ const session = await auth();
+ if (!session?.user) redirect("/sign-in");
+
+ const user = await getUser(session?.user?.email);
+ if (!user) redirect("/sign-up");
+
+ const billingPortalUrl = (await getCustomerPortalUrl(user)) as Route;
+
+ const teamCount = await prisma.team.count({
+ where: {
+ ownerId: user.id,
+ },
+ });
+
+ const scrims = await prisma.scrim.findMany({
+ where: {
+ creatorId: user.id,
+ },
+ });
+ const scrimCount = scrims.length;
+
+ const teams = await prisma.team.findMany({
+ where: {
+ ownerId: user.id,
+ },
+ select: {
+ users: true,
+ },
+ });
+
+ const teamMemberCount = teams.reduce(
+ (acc, team) => acc + team.users.length,
+ 0
+ );
+
+ return (
+
+
+
{t("title")}
+
{t("description")}
+
+
+
+
+ );
+}
diff --git a/src/app/settings/layout.tsx b/src/app/settings/layout.tsx
index 5233648ae..6854c2022 100644
--- a/src/app/settings/layout.tsx
+++ b/src/app/settings/layout.tsx
@@ -1,18 +1,16 @@
-import { Metadata } from "next";
-
-import DashboardLayout from "@/components/dashboard-layout";
+import { DashboardLayout } from "@/components/dashboard-layout";
import { SidebarNav } from "@/components/settings/sidebar-nav";
import { Separator } from "@/components/ui/separator";
import { getUser } from "@/data/user-dto";
import { auth } from "@/lib/auth";
import { $Enums } from "@prisma/client";
+import type { Metadata, Route } from "next";
import { getTranslations } from "next-intl/server";
-export async function generateMetadata({
- params,
-}: {
- params: { locale: string };
-}): Promise
{
+export async function generateMetadata(
+ props: LayoutProps<"/settings">
+): Promise {
+ const params = (await props.params) as { locale: string };
const t = await getTranslations("settingsPage.metadata");
return {
@@ -36,32 +34,43 @@ export async function generateMetadata({
};
}
-interface SettingsLayoutProps {
- children: React.ReactNode;
-}
-
export default async function SettingsLayout({
children,
-}: SettingsLayoutProps) {
+}: LayoutProps<"/settings">) {
const t = await getTranslations("settingsPage");
- const sidebarNavItems = [
+ const sidebarNavItems: { title: string; href: Route }[] = [
{
title: t("sideNav.profile"),
href: "/settings",
},
+ {
+ title: t("sideNav.billing"),
+ href: "/settings/billing",
+ },
{
title: t("sideNav.linkedAccounts"),
href: "/settings/accounts",
},
];
- const adminNavItems = [
- ...sidebarNavItems,
+ const adminNavItems: { title: string; href: Route }[] = [
{
- title: t("sideNav.admin"),
+ title: t("sideNav.dashboard"),
href: "/settings/admin",
},
+ {
+ title: t("sideNav.analytics"),
+ href: "/settings/admin/analytics",
+ },
+ {
+ title: t("sideNav.impersonateUser"),
+ href: "/settings/admin/impersonate-user",
+ },
+ {
+ title: t("sideNav.auditLogs"),
+ href: "/settings/admin/audit-logs",
+ },
];
const session = await auth();
@@ -78,11 +87,20 @@ export default async function SettingsLayout({
{t("description")}
-
-
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index 5a9a3ce9f..056d65c02 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -1,19 +1,19 @@
import { DangerZone } from "@/components/settings/danger-zone";
import { ProfileForm } from "@/components/settings/profile-form";
import { Separator } from "@/components/ui/separator";
-import { getUser } from "@/data/user-dto";
+import { getAppSettings, getUser } from "@/data/user-dto";
import { auth } from "@/lib/auth";
+import prisma from "@/lib/prisma";
import { getCustomerPortalUrl } from "@/lib/stripe";
-import { ExternalLinkIcon } from "@radix-ui/react-icons";
+import type { Route } from "next";
import { getTranslations } from "next-intl/server";
-import Link from "next/link";
import { redirect } from "next/navigation";
export default async function SettingsProfilePage() {
const t = await getTranslations("settingsPage.profile");
const session = await auth();
- if (!session || !session.user) {
+ if (!session?.user) {
redirect("/sign-in");
}
@@ -23,35 +23,27 @@ export default async function SettingsProfilePage() {
redirect("/sign-up");
}
- const billingPortalUrl = await getCustomerPortalUrl(user);
+ const appSettings = await getAppSettings(session.user.email);
+ const billingPortalUrl = (await getCustomerPortalUrl(user)) as Route;
+
+ const appliedTitle = await prisma.appliedTitle.findFirst({
+ where: {
+ userId: user.id,
+ },
+ });
return (
-
+
{t("title")}
-
{t("description")}
+
{t("description")}
-
- {t("planDescription", {
- billingPlan: t(`billingPlan.${user.billingPlan}`),
- })}
-
-
- {user.billingPlan === "FREE" ? (
- t("planUpgrade")
- ) : (
-
- {t("manageSubscription")}{" "}
-
-
- )}
-
-
-
+
);
diff --git a/src/app/stats/[playerName]/page.tsx b/src/app/stats/[playerName]/page.tsx
index 5c2d6423e..9e99b2679 100644
--- a/src/app/stats/[playerName]/page.tsx
+++ b/src/app/stats/[playerName]/page.tsx
@@ -1,4 +1,7 @@
-import { RangePicker, Timeframe } from "@/components/stats/player/range-picker";
+import {
+ RangePicker,
+ type Timeframe,
+} from "@/components/stats/player/range-picker";
import { Card } from "@/components/ui/card";
import { Link } from "@/components/ui/link";
import {
@@ -11,14 +14,16 @@ import { getUser } from "@/data/user-dto";
import { auth } from "@/lib/auth";
import { Permission } from "@/lib/permissions";
import prisma from "@/lib/prisma";
-import { Kill, PlayerStat, Scrim } from "@prisma/client";
-import { Metadata } from "next";
+import type { PagePropsWithLocale } from "@/types/next";
+import type { Kill, PlayerStat, Scrim } from "@prisma/client";
+import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
-type Props = { params: { playerName: string; locale: string } };
-
-export async function generateMetadata({ params }: Props): Promise
{
+export async function generateMetadata(
+ props: PagePropsWithLocale<"/stats/[playerName]">
+): Promise {
+ const params = await props.params;
const t = await getTranslations("statsPage.playerMetadata");
const playerName = decodeURIComponent(params.playerName);
const suffix = playerName.endsWith("s") ? "'" : "'s";
@@ -47,7 +52,10 @@ export async function generateMetadata({ params }: Props): Promise {
};
}
-export default async function PlayerStats({ params }: Props) {
+export default async function PlayerStats(
+ props: PagePropsWithLocale<"/stats/[playerName]">
+) {
+ const params = await props.params;
const t = await getTranslations("statsPage.playerStats");
const name = decodeURIComponent(params.playerName);
@@ -145,7 +153,7 @@ export default async function PlayerStats({ params }: Props) {
getAllMapWinratesForPlayer(permittedScrimIds, name),
getAllDeathsForPlayer(permittedScrimIds, name),
]);
- } catch (e) {
+ } catch {
return (
@@ -161,7 +169,7 @@ export default async function PlayerStats({ params }: Props) {
← {t("back")}
diff --git a/src/app/stats/compare/page.tsx b/src/app/stats/compare/page.tsx
new file mode 100644
index 000000000..391028014
--- /dev/null
+++ b/src/app/stats/compare/page.tsx
@@ -0,0 +1,214 @@
+"use client";
+
+import { ComparisonView } from "@/components/stats/compare/comparison-view";
+import type { Timeframe } from "@/components/stats/player/range-picker";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import type { Winrate } from "@/data/scrim-dto";
+import type { Kill, PlayerStat, Scrim } from "@prisma/client";
+import { useQuery } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+
+type PlayerStatsResponse = {
+ success: boolean;
+ data?: {
+ playerName: string;
+ scrims: Record
;
+ stats: PlayerStat[];
+ kills: Kill[];
+ mapWinrates: Winrate;
+ deaths: Kill[];
+ permissions: {
+ "stats-timeframe-1": boolean;
+ "stats-timeframe-2": boolean;
+ "stats-timeframe-3": boolean;
+ };
+ };
+ error?: string;
+};
+
+async function fetchPlayerStats(
+ playerName: string
+): Promise {
+ const response = await fetch(
+ `/api/player/stats?playerName=${encodeURIComponent(playerName)}`
+ );
+ if (!response.ok) {
+ throw new Error("Failed to fetch player stats");
+ }
+ const data = (await response.json()) as PlayerStatsResponse;
+
+ if (data.success && data.data) {
+ // Convert date strings back to Date objects for scrims
+ const convertedScrims = Object.fromEntries(
+ Object.entries(data.data.scrims).map(([timeframe, scrims]) => [
+ timeframe,
+ scrims.map((scrim) => ({
+ ...scrim,
+ date: new Date(scrim.date),
+ createdAt: new Date(scrim.createdAt),
+ updatedAt: new Date(scrim.updatedAt),
+ })),
+ ])
+ ) as Record;
+
+ // Convert date strings in mapWinrates
+ const convertedMapWinrates = data.data.mapWinrates.map((winrate) => ({
+ ...winrate,
+ date: new Date(winrate.date),
+ }));
+
+ return {
+ ...data,
+ data: {
+ ...data.data,
+ scrims: convertedScrims,
+ mapWinrates: convertedMapWinrates,
+ },
+ };
+ }
+
+ return data;
+}
+
+export default function ComparePage() {
+ const t = useTranslations("statsPage.compareStats");
+ const [player1Input, setPlayer1Input] = useState("");
+ const [player2Input, setPlayer2Input] = useState("");
+ const [player1Name, setPlayer1Name] = useState(null);
+ const [player2Name, setPlayer2Name] = useState(null);
+
+ const player1Query = useQuery({
+ queryKey: ["playerStats", player1Name],
+ queryFn: () => fetchPlayerStats(player1Name!),
+ enabled: !!player1Name,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ const player2Query = useQuery({
+ queryKey: ["playerStats", player2Name],
+ queryFn: () => fetchPlayerStats(player2Name!),
+ enabled: !!player2Name,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ function handleCompare(e: React.FormEvent) {
+ e.preventDefault();
+ if (player1Input.trim()) {
+ setPlayer1Name(player1Input.trim());
+ }
+ if (player2Input.trim()) {
+ setPlayer2Name(player2Input.trim());
+ }
+ }
+
+ function handleReset() {
+ setPlayer1Input("");
+ setPlayer2Input("");
+ setPlayer1Name(null);
+ setPlayer2Name(null);
+ }
+
+ const bothPlayersLoaded =
+ player1Query.data?.success && player2Query.data?.success;
+
+ return (
+
+
+
{t("title")}
+
+
+
+
+ {t("enterPlayerNames")}
+
+
+
+
+
+
+ {(player1Query.isLoading || player2Query.isLoading) && (
+
+
+
+
+ {t("loading")}
+
+
+
+ )}
+
+ {(player1Query.isError || player2Query.isError) && (
+
+
+
+
{t("errorLoading")}
+
+ {player1Query.isError && t("errorPlayer1")}
+ {player1Query.isError && player2Query.isError
+ ? t("errorBoth")
+ : ""}
+ {player2Query.isError && t("errorPlayer2")}
+
+
+
+
+ )}
+
+ {bothPlayersLoaded &&
+ player1Query.data?.data &&
+ player2Query.data?.data && (
+
+ )}
+
+ {!player1Name && !player2Name && (
+
+
+ {t("enterPlayersPrompt")}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/stats/hero/[heroName]/page.tsx b/src/app/stats/hero/[heroName]/page.tsx
index 6b1e4a176..139efc35a 100644
--- a/src/app/stats/hero/[heroName]/page.tsx
+++ b/src/app/stats/hero/[heroName]/page.tsx
@@ -1,4 +1,7 @@
-import { RangePicker, Timeframe } from "@/components/stats/hero/range-picker";
+import {
+ RangePicker,
+ type Timeframe,
+} from "@/components/stats/hero/range-picker";
import { Card } from "@/components/ui/card";
import { Link } from "@/components/ui/link";
import {
@@ -11,17 +14,17 @@ import { auth } from "@/lib/auth";
import { Permission } from "@/lib/permissions";
import prisma from "@/lib/prisma";
import { translateHeroName } from "@/lib/utils";
-import { HeroName, heroRoleMapping } from "@/types/heroes";
-import { Kill, PlayerStat, Scrim } from "@prisma/client";
-import { Metadata } from "next";
+import { type HeroName, heroRoleMapping } from "@/types/heroes";
+import type { PagePropsWithLocale } from "@/types/next";
+import type { Kill, PlayerStat, Scrim } from "@prisma/client";
+import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
-type Props = {
- params: { heroName: string; locale: string };
-};
-
-export async function generateMetadata({ params }: Props): Promise {
+export async function generateMetadata(
+ props: PagePropsWithLocale<"/stats/hero/[heroName]">
+): Promise {
+ const params = await props.params;
const heroName = decodeURIComponent(params.heroName);
const hero = await translateHeroName(heroName);
const t = await getTranslations("statsPage.heroMetadata");
@@ -47,7 +50,10 @@ export async function generateMetadata({ params }: Props): Promise {
};
}
-export default async function HeroStats({ params }: Props) {
+export default async function HeroStats(
+ props: PagePropsWithLocale<"/stats/hero/[heroName]">
+) {
+ const params = await props.params;
const t = await getTranslations("statsPage.heroStats");
const hero = decodeURIComponent(params.heroName);
@@ -141,7 +147,7 @@ export default async function HeroStats({ params }: Props) {
getAllKillsForHero(allScrimIds, hero),
getAllDeathsForHero(allScrimIds, hero),
]);
- } catch (e) {
+ } catch {
return (
@@ -157,7 +163,7 @@ export default async function HeroStats({ params }: Props) {
← {t("back")}
diff --git a/src/app/stats/hero/page.tsx b/src/app/stats/hero/page.tsx
index 08996bdef..470216667 100644
--- a/src/app/stats/hero/page.tsx
+++ b/src/app/stats/hero/page.tsx
@@ -2,15 +2,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Link } from "@/components/ui/link";
import { getHeroNames, toHero } from "@/lib/utils";
import { roleHeroMapping } from "@/types/heroes";
-import { Metadata } from "next";
+import type { PagePropsWithLocale } from "@/types/next";
+import type { Metadata, Route } from "next";
import { getTranslations } from "next-intl/server";
import Image from "next/image";
-export async function generateMetadata({
- params,
-}: {
- params: { locale: string };
-}): Promise
{
+export async function generateMetadata(
+ props: PagePropsWithLocale<"/stats/hero">
+): Promise {
+ const params = await props.params;
const t = await getTranslations("statsPage.heroStatsMetadata");
return {
title: t("title"),
@@ -59,20 +59,20 @@ export default async function HeroSelect() {
{tankHeroes.map((hero) => (
- {heroNames.get(toHero(hero)) || hero}
+ {heroNames.get(toHero(hero)) ?? hero}
))}
@@ -88,20 +88,20 @@ export default async function HeroSelect() {
{damageHeroes.map((hero) => (
- {heroNames.get(toHero(hero)) || hero}
+ {heroNames.get(toHero(hero)) ?? hero}
))}
@@ -117,20 +117,20 @@ export default async function HeroSelect() {
{supportHeroes.map((hero) => (
- {heroNames.get(toHero(hero)) || hero}
+ {heroNames.get(toHero(hero)) ?? hero}
))}
@@ -138,7 +138,7 @@ export default async function HeroSelect() {
-
+
{t("description")}
diff --git a/src/app/stats/layout.tsx b/src/app/stats/layout.tsx
index 28228dfa2..024a3513b 100644
--- a/src/app/stats/layout.tsx
+++ b/src/app/stats/layout.tsx
@@ -1,12 +1,11 @@
-import DashboardLayout from "@/components/dashboard-layout";
-import { Metadata } from "next";
+import { DashboardLayout } from "@/components/dashboard-layout";
+import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
-export async function generateMetadata({
- params,
-}: {
- params: { locale: string };
-}): Promise
{
+export async function generateMetadata(
+ props: LayoutProps<"/stats">
+): Promise {
+ const params = (await props.params) as { locale: string };
const t = await getTranslations("statsPage.layoutMetadata");
return {
@@ -30,10 +29,6 @@ export async function generateMetadata({
};
}
-export default function StatsLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
+export default function StatsLayout({ children }: LayoutProps<"/stats">) {
return {children};
}
diff --git a/src/app/stats/page.tsx b/src/app/stats/page.tsx
index fc8be2f4a..8463dd060 100644
--- a/src/app/stats/page.tsx
+++ b/src/app/stats/page.tsx
@@ -1,3 +1,4 @@
+import { PlayerHoverCard } from "@/components/player/hover-card";
import { Searchbar } from "@/components/stats/searchbar";
import {
Card,
@@ -6,7 +7,8 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
-import CardIcon from "@/components/ui/card-icon";
+import { CardIcon } from "@/components/ui/card-icon";
+import { Link } from "@/components/ui/link";
import {
Table,
TableBody,
@@ -23,18 +25,21 @@ import {
toTimestampWithDays,
toTimestampWithHours,
} from "@/lib/utils";
+import type { Route } from "next";
import { getTranslations } from "next-intl/server";
export default async function StatsPage() {
const t = await getTranslations("statsPage");
- const [userNum, scrimNum, killNum, statNum, mapNum] = await Promise.all([
- prisma.user.count(),
- prisma.scrim.count(),
- prisma.kill.count(),
- prisma.playerStat.count(),
- prisma.mapData.count(),
- ]);
+ const [userNum, scrimNum, killNum, statNum, mapNum, calculatedStatNum] =
+ await Promise.all([
+ prisma.user.count(),
+ prisma.scrim.count(),
+ prisma.kill.count(),
+ prisma.playerStat.count(),
+ prisma.mapData.count(),
+ prisma.calculatedStat.count(),
+ ]);
type MostPlayedHeroes = {
player_hero: string;
@@ -147,7 +152,7 @@ export default async function StatsPage() {
{format(userNum)}
- {t("users.footer")}
+ {t("users.footer")}
@@ -170,7 +175,7 @@ export default async function StatsPage() {
{format(scrimNum)}
-
+
{t("scrims.footer")}
@@ -192,7 +197,7 @@ export default async function StatsPage() {
{format(killNum)}
- {t("kills.footer")}
+ {t("kills.footer")}
@@ -206,10 +211,12 @@ export default async function StatsPage() {
- {format(statNum)}
+
+ {format(statNum + calculatedStatNum)}
+
-
+
{t("playerStat.footer")}
@@ -283,7 +290,7 @@ export default async function StatsPage() {
-
+
{t("top3MostPlayed.footer", { mapNum })}
@@ -341,7 +348,17 @@ export default async function StatsPage() {
- {row.attacker_name}
+
+
+
+ {row.attacker_name}
+
+
+
{format(row._count.attacker_name)}
))}
@@ -349,7 +366,7 @@ export default async function StatsPage() {
-
+
{t("top3Kills.footer", { mapNum })}
@@ -403,7 +420,17 @@ export default async function StatsPage() {
- {row.player_name}
+
+
+
+ {row.player_name}
+
+
+
{format(round(row._sum.hero_damage_dealt!))}
@@ -413,7 +440,7 @@ export default async function StatsPage() {
-
+
{t("top3Dmg.footer", { mapNum })}
@@ -467,7 +494,17 @@ export default async function StatsPage() {
- {row.player_name}
+
+
+
+ {row.player_name}
+
+
+
{" "}
{format(round(row._sum.healing_dealt!))}
@@ -478,7 +515,7 @@ export default async function StatsPage() {
-
+
{t("top3Healing.footer", { mapNum })}
@@ -532,7 +569,17 @@ export default async function StatsPage() {
- {row.player_name}
+
+
+
+ {row.player_name}
+
+
+
{" "}
{format(round(row._sum.damage_blocked!))}
@@ -543,7 +590,7 @@ export default async function StatsPage() {
-
+
{t("top3DmgBlocked.footer", { mapNum })}
@@ -601,7 +648,17 @@ export default async function StatsPage() {
- {row.victim_name}
+
+
+
+ {row.victim_name}
+
+
+
{" "}
{format(round(row._count.victim_name))}
@@ -612,7 +669,7 @@ export default async function StatsPage() {
-
+
{t("top3Deaths.footer", { mapNum })}
@@ -669,7 +726,17 @@ export default async function StatsPage() {
- {row.player_name}
+
+
+
+ {row.player_name}
+
+
+
{toTimestampWithHours(row._sum.hero_time_played!)}
@@ -679,7 +746,7 @@ export default async function StatsPage() {
-
+
{t("top3TimePlayed.footer", { mapNum })}
@@ -735,7 +802,17 @@ export default async function StatsPage() {
- {row.player_name}
+
+
+
+ {row.player_name}
+
+
+
{row.coincidence_count.toString()}
))}
@@ -743,7 +820,7 @@ export default async function StatsPage() {
-
+
{t("top3Ajax.footer", { mapNum })}
diff --git a/src/app/stats/team/[teamId]/layout.tsx b/src/app/stats/team/[teamId]/layout.tsx
new file mode 100644
index 000000000..9c5513651
--- /dev/null
+++ b/src/app/stats/team/[teamId]/layout.tsx
@@ -0,0 +1,45 @@
+import prisma from "@/lib/prisma";
+import type { Metadata } from "next";
+import { getLocale, getTranslations } from "next-intl/server";
+
+export async function generateMetadata(
+ props: LayoutProps<"/stats/team/[teamId]">
+): Promise {
+ const params = await props.params;
+ const t = await getTranslations("teamStatsPage.layoutMetadata");
+ const locale = await getLocale();
+
+ const teamId = parseInt(params.teamId);
+ const team = await prisma.team.findFirst({
+ where: { id: teamId },
+ select: { name: true },
+ });
+
+ const teamName = team?.name ?? t("defaultTeam");
+
+ return {
+ title: t("title", { teamName }),
+ description: t("description", { teamName }),
+ openGraph: {
+ title: t("ogTitle", { teamName }),
+ description: t("ogDescription", { teamName }),
+ url: "https://parsertime.app",
+ type: "website",
+ siteName: "Parsertime",
+ images: [
+ {
+ url: `https://parsertime.app/api/og?title=${t("ogImage", { teamName })}`,
+ width: 1200,
+ height: 630,
+ },
+ ],
+ locale,
+ },
+ };
+}
+
+export default function TeamStatsLayout({
+ children,
+}: LayoutProps<"/stats/team/[teamId]">) {
+ return children;
+}
diff --git a/src/app/stats/team/[teamId]/loading.tsx b/src/app/stats/team/[teamId]/loading.tsx
new file mode 100644
index 000000000..8040071da
--- /dev/null
+++ b/src/app/stats/team/[teamId]/loading.tsx
@@ -0,0 +1,203 @@
+/* eslint-disable react/no-array-index-key */
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+export default function TeamStatsLoading() {
+ return (
+
+ {/* Header Section Skeleton */}
+
+
+ {/* Tabbed Content Skeleton */}
+
+
+ Overview
+ Performance
+ Heroes
+ Trends
+ Maps
+ Teamfights
+
+
+ {/* Overview Tab Skeleton */}
+
+ {/* Quick Stats Skeleton */}
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+
+
+ {/* Team Roster + Recent Activity Grid Skeleton */}
+
+ {/* Team Roster Skeleton */}
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+
+
+ {/* Recent Activity Calendar Skeleton */}
+
+
+
+
+
+
+
+
+
+
+ {/* Top Maps + Strengths/Weaknesses Grid Skeleton */}
+
+ {/* Top Maps Skeleton */}
+
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+
+
+ {/* Strengths/Weaknesses Skeleton */}
+
+
+
+
+
+
+
+
+
+
+ {/* Role Balance Radar Skeleton */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Other Tabs Skeleton (shown when switching tabs during loading) */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/stats/team/[teamId]/page.tsx b/src/app/stats/team/[teamId]/page.tsx
new file mode 100644
index 000000000..67734db4e
--- /dev/null
+++ b/src/app/stats/team/[teamId]/page.tsx
@@ -0,0 +1,332 @@
+import { RecentActivityCalendar } from "@/components/profile/recent-activity-calendar";
+import { BestRoleTriosCard } from "@/components/stats/team/best-role-trios-card";
+import { HeroPoolContainer } from "@/components/stats/team/hero-pool-container";
+import { MapModePerformanceCard } from "@/components/stats/team/map-mode-performance-card";
+import { MapWinrateGallery } from "@/components/stats/team/map-winrate-gallery";
+import { PlayerMapPerformanceCard } from "@/components/stats/team/player-map-performance-card";
+import { QuickStatsCard } from "@/components/stats/team/quick-stats-card";
+import { RecentFormCard } from "@/components/stats/team/recent-form-card";
+import { RoleBalanceRadar } from "@/components/stats/team/role-balance-radar";
+import { RolePerformanceCard } from "@/components/stats/team/role-performance-card";
+import { StrengthsWeaknessesCard } from "@/components/stats/team/strengths-weaknesses-card";
+import { TeamFightStatsCard } from "@/components/stats/team/team-fight-stats-card";
+import { TeamRosterGrid } from "@/components/stats/team/team-roster-grid";
+import { TopMapsCard } from "@/components/stats/team/top-maps-card";
+import { UltimateEconomyCard } from "@/components/stats/team/ultimate-economy-card";
+import { WinLossStreaksCard } from "@/components/stats/team/win-loss-streaks-card";
+import { WinProbabilityInsights } from "@/components/stats/team/win-probability-insights";
+import { WinrateOverTimeChart } from "@/components/stats/team/winrate-over-time-chart";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ getHeroPickrateRawData,
+ getPlayerMapPerformanceMatrix,
+} from "@/data/team-analytics-dto";
+import { getTeamFightStats } from "@/data/team-fight-stats-dto";
+import {
+ getHeroPoolAnalysis,
+ getHeroPoolRawData,
+} from "@/data/team-hero-pool-dto";
+import { getMapModePerformance } from "@/data/team-map-mode-stats-dto";
+import {
+ getRecentForm,
+ getStreakInfo,
+ getWinrateOverTime,
+} from "@/data/team-performance-trends-dto";
+import { getQuickWinsStats } from "@/data/team-quick-wins-dto";
+import {
+ getBestRoleTrios,
+ getRoleBalanceAnalysis,
+ getRolePerformanceStats,
+} from "@/data/team-role-stats-dto";
+import {
+ getBestMapByWinrate,
+ getBlindSpotMap,
+ getTeamRoster,
+ getTeamWinrates,
+ getTop5MapsByPlaytime,
+ getTopMapsByPlaytime,
+} from "@/data/team-stats-dto";
+import { getUser } from "@/data/user-dto";
+import { auth } from "@/lib/auth";
+import { calculateHeroPickrateMatrix } from "@/lib/hero-pickrate-utils";
+import { Permission } from "@/lib/permissions";
+import prisma from "@/lib/prisma";
+import { getMapNames } from "@/lib/utils";
+import type { PagePropsWithLocale } from "@/types/next";
+import { $Enums } from "@prisma/client";
+import Image from "next/image";
+import { notFound } from "next/navigation";
+
+export default async function TeamStatsPage(
+ props: PagePropsWithLocale<"/stats/team/[teamId]">
+) {
+ const session = await auth();
+ const user = await getUser(session?.user.email);
+ if (!user) notFound();
+
+ const params = await props.params;
+ const teamId = parseInt(params.teamId);
+
+ const team = await prisma.team.findFirst({
+ where: { id: teamId },
+ include: { users: true },
+ });
+ if (!team) notFound();
+
+ // If the user is not a member of the team and is not an admin, do not show the page
+ const userIsMember = team.users.some((teamUser) => teamUser.id === user.id);
+ if (!userIsMember && user.role !== $Enums.UserRole.ADMIN) notFound();
+
+ const [
+ scrims,
+ teamRoster,
+ winrates,
+ top5Maps,
+ allMapsPlaytime,
+ bestMapByWinrate,
+ blindSpotMap,
+ fightStats,
+ mapNames,
+ roleStats,
+ roleBalance,
+ bestTrios,
+ weeklyWinrate,
+ monthlyWinrate,
+ recentForm,
+ streakInfo,
+ mapModePerformance,
+ quickStats,
+ playerMapPerformance,
+ timeframe1,
+ timeframe2,
+ timeframe3,
+ ] = await Promise.all([
+ prisma.scrim.findMany({
+ where: { teamId },
+ }),
+ getTeamRoster(teamId),
+ getTeamWinrates(teamId),
+ getTop5MapsByPlaytime(teamId),
+ getTopMapsByPlaytime(teamId),
+ getBestMapByWinrate(teamId),
+ getBlindSpotMap(teamId),
+ getTeamFightStats(teamId),
+ getMapNames(),
+ getRolePerformanceStats(teamId),
+ getRoleBalanceAnalysis(teamId),
+ getBestRoleTrios(teamId),
+ getWinrateOverTime(teamId, "week"),
+ getWinrateOverTime(teamId, "month"),
+ getRecentForm(teamId),
+ getStreakInfo(teamId),
+ getMapModePerformance(teamId),
+ getQuickWinsStats(teamId),
+ getPlayerMapPerformanceMatrix(teamId),
+ new Permission("stats-timeframe-1").check(),
+ new Permission("stats-timeframe-2").check(),
+ new Permission("stats-timeframe-3").check(),
+ ]);
+
+ // Determine the maximum permitted timeframe based on permissions
+ const permitted = timeframe3
+ ? "all-time"
+ : timeframe2
+ ? "six-months"
+ : "one-month";
+
+ // Calculate date limits based on permitted timeframe
+ let dateFrom: Date | undefined;
+ const dateTo = new Date();
+
+ if (permitted !== "all-time") {
+ dateFrom = new Date();
+ if (permitted === "one-month") {
+ dateFrom.setMonth(dateFrom.getMonth() - 1);
+ } else if (permitted === "six-months") {
+ dateFrom.setMonth(dateFrom.getMonth() - 6);
+ }
+ }
+
+ // Fetch hero data with date restrictions
+ const heroPoolRawData = await getHeroPoolRawData(teamId);
+
+ // Filter raw data client-side for the permitted timeframe
+ let filteredHeroPoolRawData = heroPoolRawData;
+ if (dateFrom) {
+ filteredHeroPoolRawData = {
+ ...heroPoolRawData,
+ mapDataRecords: heroPoolRawData.mapDataRecords.filter(
+ (record) => record.scrimDate >= dateFrom && record.scrimDate <= dateTo
+ ),
+ };
+ }
+
+ // Fetch pickrate data with the same restrictions
+ const heroPickrateRawData = await getHeroPickrateRawData(teamId);
+
+ // Filter pickrate raw data for the permitted timeframe
+ let filteredPickrateRawData = heroPickrateRawData;
+ if (dateFrom) {
+ filteredPickrateRawData = {
+ ...heroPickrateRawData,
+ mapDataRecords: heroPickrateRawData.mapDataRecords.filter(
+ (record) => record.scrimDate >= dateFrom && record.scrimDate <= dateTo
+ ),
+ };
+ }
+
+ // Calculate initial data for one-week view (default)
+ const oneWeekAgo = new Date();
+ oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
+
+ const oneWeekPickrateRawData = {
+ ...heroPickrateRawData,
+ mapDataRecords: heroPickrateRawData.mapDataRecords.filter(
+ (record) => record.scrimDate >= oneWeekAgo && record.scrimDate <= dateTo
+ ),
+ };
+
+ const initialHeroPool = await getHeroPoolAnalysis(teamId, oneWeekAgo, dateTo);
+ const initialHeroPickrateMatrix = calculateHeroPickrateMatrix(
+ oneWeekPickrateRawData
+ );
+
+ // Convert playtime array to Record for gallery
+ const mapPlaytimes: Record = {};
+ allMapsPlaytime.forEach((map) => {
+ mapPlaytimes[map.name] = map.playtime;
+ });
+
+ const totalGames = winrates.overallWins + winrates.overallLosses;
+
+ // Build permissions object for timeframe restrictions
+ const permissions = {
+ "stats-timeframe-1": timeframe1,
+ "stats-timeframe-2": timeframe2,
+ "stats-timeframe-3": timeframe3,
+ };
+
+ return (
+
+ {/* Header Section */}
+
+
+
+
{team.name}
+ {totalGames > 0 && (
+
+
+ Overall Record: {winrates.overallWins}W -{" "}
+ {winrates.overallLosses}L
+
+
+ {winrates.overallWinrate.toFixed(1)}% Win Rate
+
+
+ )}
+
+
+
+ {/* Tabbed Content */}
+
+
+ Overview
+ Performance
+ Heroes
+ Trends
+ Maps
+ Teamfights
+
+
+ {/* Overview Tab */}
+
+ {/* Quick Stats */}
+
+
+
+ {/* Team Roster */}
+
+
+ {/* Recent Activity Calendar */}
+
+
+
+ {/* Two Column Layout: Top Maps + Strengths/Weaknesses */}
+
+
+
+
+
+ {/* Role Balance Overview */}
+
+
+
+
+
+ {/* Performance Tab */}
+
+
+
+
+
+ {/* Heroes Tab */}
+
+
+
+
+ {/* Trends Tab */}
+
+
+
+
+
+
+
+
+ {/* Maps Tab */}
+
+
+
+
+
+
+ {/* Teamfights Tab */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/stats/team/page.tsx b/src/app/stats/team/page.tsx
new file mode 100644
index 000000000..5ec9cc9de
--- /dev/null
+++ b/src/app/stats/team/page.tsx
@@ -0,0 +1,624 @@
+import { TeamSelector } from "@/components/stats/team/selector";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { getUser } from "@/data/user-dto";
+import { auth } from "@/lib/auth";
+import prisma from "@/lib/prisma";
+import { redirect } from "next/navigation";
+
+export default async function TeamStatsPage() {
+ const session = await auth();
+ const user = await getUser(session?.user.email);
+ if (!user) redirect("/sign-in");
+
+ const teams = await prisma.team.findMany({
+ where: {
+ users: {
+ some: { id: user.id },
+ },
+ },
+ orderBy: { createdAt: "asc" },
+ });
+
+ return (
+
+
+
Team Stats
+
+
+
+
+
Team Statistics
+
+ Select a team below to view comprehensive statistics and performance
+ metrics for your team. Team stats are calculated by aggregating
+ individual player performances and analyzing team-wide trends across
+ matches and scrims. Only teams you are a member of will be shown.
+
+
+
+
+
+
+
+
+
+
+
+ Overview: What's
+ Your Team's Overall Performance?
+
+
+
+
+ The Overview tab provides a high-level snapshot of your
+ team's performance and composition.
+
+
+
+
Quick Stats Card
+
+ Displays key performance indicators at a glance:
+
+
+ -
+ Last 10 Games: Win rate and record for
+ your most recent matches
+
+ -
+ Best Day of Week: Identifies which day
+ your team performs best (requires 3+ games per day)
+
+ -
+ Average Fight Duration: Mean length of
+ team fights in seconds
+
+ -
+ First Pick Success Rate: Win rate when
+ your team gets the first pick in a fight
+
+
+
+
+
+
Team Roster Grid
+
+ Visual overview of all team members, showing their roles
+ and basic information.
+
+
+
+
+
Recent Activity Calendar
+
+ Calendar heatmap showing when your team has played scrims
+ and matches, helping identify activity patterns and
+ consistency.
+
+
+
+
+
Top Maps Card
+
+ Shows your top 5 most played maps with win rates, helping
+ identify your team's comfort zones and frequently
+ played maps.
+
+
+
+
+
Strengths & Weaknesses
+
+ Highlights your best map by win rate and your blind spot
+ map (most played map with lowest win rate), providing
+ actionable insights for practice priorities.
+
+
+
+
+
Role Balance Radar
+
+ Interactive radar chart comparing Tank, Damage, and
+ Support roles across four key metrics:
+
+
+ -
+ Eliminations: K/D ratio normalized
+ across roles
+
+ -
+ Survivability: Deaths per minute (lower
+ is better)
+
+ -
+ Ult Usage: Ultimate efficiency and
+ timing
+
+ -
+ Activity: Total playtime across roles
+
+
+
+ Includes balance score, strongest/weakest role
+ identification, and actionable insights for improving role
+ balance.
+
+
+
+
+
+
+
+
+ Performance: Which Roles
+ Are Your Strongest?
+
+
+
+
+ The Performance tab dives deep into role-specific metrics
+ and team composition effectiveness.
+
+
+
+
Role Performance Card
+
+ Detailed statistics for each role (Tank, Damage, Support)
+ including:
+
+
+ - Win rates and match records per role
+ - Per-10-minute normalized statistics
+ - K/D ratios and survivability metrics
+ - Ultimate efficiency and usage rates
+ - Damage, healing, and mitigation statistics
+
+
+
+
+
Best Role Trios Card
+
+ Identifies the most successful 3-player role combinations
+ (e.g., 1 Tank, 2 Damage, 2 Support) with:
+
+
+ - Win rates for each role trio composition
+ - Number of games played with each composition
+ - Ranking of most effective trios
+
+
+ Helps identify which role distributions work best for your
+ team.
+
+
+
+
+
+
+
+
+ Heroes: What Heroes Does
+ Your Team Actually Play?
+
+
+
+
+ The Heroes tab provides comprehensive analysis of your
+ team's hero pool and pick patterns.
+
+
+
+
Timeframe Selection
+
+ Filter hero data by multiple timeframes (subject to
+ permissions):
+
+
+ - Last Week, 2 Weeks, or Month
+ - Last 3 or 6 Months
+ - Last Year or All Time
+ - Custom date range picker
+
+
+ Permissions control access to longer timeframes, allowing
+ teams to unlock extended historical data.
+
+
+
+
+
Hero Pool Overview Card
+
+ Comprehensive analysis of your team's hero usage:
+
+
+ -
+ Hero Diversity: Number of unique heroes
+ played and diversity score
+
+ -
+ Most Played Heroes: Top heroes by
+ playtime with win rates
+
+ -
+ Hero Win Rates: Success rates for each
+ hero your team plays
+
+ -
+ Role Distribution: Breakdown of hero
+ picks by role
+
+ -
+ Pool Depth Analysis: Insights into
+ whether your hero pool is too narrow or well-balanced
+
+
+
+
+
+
Hero Pickrate Heatmap
+
+ Interactive heatmap visualization showing:
+
+
+ -
+ Hero pick rates across different maps and game modes
+
+ - Color-coded intensity showing frequency of picks
+ - Map-specific hero preferences
+ - Patterns in hero selection strategies
+
+
+ Helps identify which heroes your team favors on specific
+ maps and whether you're adapting compositions
+ effectively.
+
+
+
+
+
+
+
+
+ Trends: Is Your Team
+ Getting Better?
+
+
+
+
+ The Trends tab tracks your team's performance over time
+ and identifies patterns.
+
+
+
+
Winrate Over Time Chart
+
Dual-timeline visualization showing:
+
+ -
+ Weekly Trends: Win rate changes week by
+ week
+
+ -
+ Monthly Trends: Longer-term performance
+ patterns
+
+ - Trend lines indicating improvement or decline
+ - Game counts per time period
+
+
+ Helps identify if your team is improving, declining, or
+ maintaining consistent performance.
+
+
+
+
+
Recent Form Card
+
+ Analysis of your team's most recent performance:
+
+
+ - Win rate in recent games
+ -
+ Performance trajectory (improving/declining/stable)
+
+ - Comparison to overall win rate
+ - Form rating and insights
+
+
+
+
+
Win/Loss Streaks Card
+
Tracks consecutive wins and losses:
+
+ - Current win or loss streak
+ - Longest win streak achieved
+ - Longest loss streak experienced
+ - Streak patterns and frequency
+
+
+ Helps identify momentum patterns and consistency in
+ performance.
+
+
+
+
+
+
+
+
+ Maps: Which Maps Are Your
+ Best and Worst?
+
+
+
+
+ The Maps tab provides detailed analysis of your team's
+ performance across different maps and game modes.
+
+
+
+
Map Mode Performance Card
+
+ Breakdown of performance by game mode:
+
+
+ -
+ Control: Win rates and performance on
+ control point maps
+
+ -
+ Escort: Performance on payload escort
+ maps
+
+ -
+ Hybrid: Combined control and escort
+ maps
+
+ -
+ Push: Robot push map performance
+
+ -
+ Flashpoint: Control point cluster maps
+
+
+
+ Helps identify which game modes your team excels at or
+ struggles with.
+
+
+
+
+
Map Winrate Gallery
+
+ Visual gallery showing all maps with:
+
+
+ - Win rate for each individual map
+ - Total playtime on each map
+ - Color-coded performance indicators
+ - Quick visual comparison across all maps
+
+
+ Makes it easy to spot your strongest and weakest maps at a
+ glance.
+
+
+
+
+
+ Player Map Performance Card
+
+
+ Matrix showing individual player performance across
+ different maps:
+
+
+ - Win rates for each player on each map
+ - Performance heatmap by player and map
+ - Identification of map specialists
+ - Team composition planning insights
+
+
+ Helps optimize lineups by identifying which players
+ perform best on specific maps.
+
+
+
+
+
+
+
+
+ Teamfights: How Do You
+ Win Team Fights?
+
+
+
+
+ The Teamfights tab provides deep analysis of team fight
+ performance, ultimate economy, and fight outcomes.
+
+
+
+
Team Fight Stats Card
+
Comprehensive team fight metrics:
+
+ -
+ Overall Fight Winrate: Percentage of
+ team fights won
+
+ -
+ First Pick Winrate: Win rate when your
+ team gets the first elimination
+
+ -
+ First Death Winrate: Win rate when your
+ team loses a player first
+
+ -
+ First Ultimate Winrate: Win rate when
+ your team uses the first ultimate
+
+ -
+ Dry Fights: Percentage of fights with
+ no ultimates used and win rate in those fights
+
+ -
+ Average Ultimates Per Fight: Mean
+ number of ultimates used in non-dry fights
+
+
+
+
+
+
Ultimate Economy Card
+
+ Analysis of ultimate usage and efficiency:
+
+
+ - Ultimate usage rates by role
+ - Ultimate economy efficiency
+ - Coordination and timing metrics
+ - Comparison of ultimate advantage scenarios
+
+
+ Helps identify if your team is maximizing ultimate value
+ and coordinating ultimates effectively.
+
+
+
+
+
Win Probability Insights
+
+ Statistical analysis of fight outcomes:
+
+
+ -
+ Win probability based on fight conditions (first pick,
+ ultimate advantage, etc.)
+
+ - Expected vs. actual win rates
+ - Fight outcome predictions
+ - Key factors influencing fight success
+
+
+ Provides data-driven insights into what conditions lead to
+ successful team fights.
+
+
+
+
+
+
+
+
+ How to Use: How Can I
+ Turn This Data Into Wins?
+
+
+
+
+ Team stats are designed to help you make data-driven
+ decisions about practice priorities, composition choices,
+ and strategic improvements.
+
+
+
+
Getting Started
+
+ -
+ Start with the Overview tab to get a high-level
+ understanding of your team's performance
+
+ -
+ Review Quick Stats to identify immediate strengths and
+ weaknesses
+
+ -
+ Check the Role Balance Radar to see if any roles need
+ more attention
+
+
+
+
+
+
Practice Planning
+
+ -
+ Use the Maps tab to identify maps that need more
+ practice (low win rates with high playtime)
+
+ -
+ Review Player Map Performance to optimize lineups for
+ specific maps
+
+ -
+ Check Map Mode Performance to focus practice on weaker
+ game modes
+
+
+
+
+
+
Composition Strategy
+
+ -
+ Review Best Role Trios to understand which role
+ distributions work best
+
+ -
+ Use the Heroes tab to identify if your hero pool is too
+ narrow or needs expansion
+
+ -
+ Check Hero Pickrate Heatmap to see if you're
+ adapting compositions effectively across maps
+
+
+
+
+
+
Team Fight Improvement
+
+ -
+ Review Team Fight Stats to identify fight win conditions
+ (first pick, ultimate advantage, etc.)
+
+ -
+ Use Ultimate Economy insights to improve ultimate
+ coordination
+
+ -
+ Check Win Probability Insights to understand what
+ factors most influence fight success
+
+
+
+
+
+
Tracking Progress
+
+ -
+ Use the Trends tab to monitor improvement over time
+
+ -
+ Review Recent Form to see if recent changes are having
+ positive effects
+
+ -
+ Track Win/Loss Streaks to identify consistency patterns
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/team/[teamId]/layout.tsx b/src/app/team/[teamId]/layout.tsx
index 01f3733ac..b5c65b0ca 100644
--- a/src/app/team/[teamId]/layout.tsx
+++ b/src/app/team/[teamId]/layout.tsx
@@ -1,13 +1,11 @@
-import NoAuthCard from "@/components/auth/no-auth";
+import { NoAuthCard } from "@/components/auth/no-auth";
import { isAuthedToViewTeam } from "@/lib/auth";
-export default async function TeamLayout({
- children,
- params,
-}: {
- children: React.ReactNode;
- params: { teamId: string };
-}) {
+export default async function TeamLayout(props: LayoutProps<"/team/[teamId]">) {
+ const params = await props.params;
+
+ const { children } = props;
+
const id = parseInt(params.teamId);
const isAuthed = await isAuthedToViewTeam(id);
diff --git a/src/app/team/[teamId]/page.tsx b/src/app/team/[teamId]/page.tsx
index ded586402..4b7fbc0bf 100644
--- a/src/app/team/[teamId]/page.tsx
+++ b/src/app/team/[teamId]/page.tsx
@@ -1,20 +1,28 @@
import { AddMemberCard } from "@/components/team/add-member-card";
import { DangerZone } from "@/components/team/danger-zone";
+import { TeamMemberCard } from "@/components/team/team-member-card";
+import { TeamMemberUsage } from "@/components/team/team-member-usage";
import { TeamSettingsForm } from "@/components/team/team-settings-form";
import { UserCardButtons } from "@/components/team/user-card-buttons";
-import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { getUser } from "@/data/user-dto";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
-import { $Enums, User } from "@prisma/client";
-import { Metadata } from "next";
+import type { PagePropsWithLocale } from "@/types/next";
+import { $Enums } from "@prisma/client";
+import { Lock } from "lucide-react";
+import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
-import Image from "next/image";
-type Props = { params: { teamId: string; locale: string } };
-
-export async function generateMetadata({ params }: Props): Promise {
+export async function generateMetadata(
+ props: PagePropsWithLocale<"/team/[teamId]">
+): Promise {
+ const params = await props.params;
const t = await getTranslations("teamPage.teamMetadata");
const teamId = decodeURIComponent(params.teamId);
@@ -46,7 +54,10 @@ export async function generateMetadata({ params }: Props): Promise {
};
}
-export default async function Team({ params }: { params: { teamId: string } }) {
+export default async function Team(
+ props: PagePropsWithLocale<"/team/[teamId]">
+) {
+ const params = await props.params;
const t = await getTranslations("teamPage");
const session = await auth();
@@ -54,7 +65,27 @@ export default async function Team({ params }: { params: { teamId: string } }) {
const [teamData, teamMembersData, teamManagers] = await Promise.all([
prisma.team.findFirst({ where: { id: teamId } }),
- prisma.team.findFirst({ where: { id: teamId }, select: { users: true } }),
+ prisma.team.findFirst({
+ where: { id: teamId },
+ select: {
+ users: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ bannerImage: true,
+ billingPlan: true,
+ battletag: true,
+ appliedTitles: {
+ select: {
+ title: true,
+ },
+ },
+ },
+ },
+ },
+ }),
prisma.teamManager.findMany({ where: { teamId } }),
]);
@@ -62,7 +93,7 @@ export default async function Team({ params }: { params: { teamId: string } }) {
const user = await getUser(session?.user?.email);
- function userIsManager(user: User) {
+ function userIsManager(user: { id: string }) {
return teamManagers.some((manager) => manager.userId === user.id);
}
@@ -72,11 +103,23 @@ export default async function Team({ params }: { params: { teamId: string } }) {
user?.role === $Enums.UserRole.MANAGER ||
user?.role === $Enums.UserRole.ADMIN;
+ const teamOwner = await prisma.user.findFirst({
+ where: { id: teamData?.ownerId },
+ });
+
return (
-
- {teamData?.name ?? t("defaultName")}
+
+ {teamData?.name ?? t("defaultName")}{" "}
+ {teamData?.readonly && (
+
+
+
+
+ {t("readonly.title")}
+
+ )}
@@ -96,44 +139,33 @@ export default async function Team({ params }: { params: { teamId: string } }) {
{teamMembers?.users.map((user) => (
-
-
-
-
-
- {user.name} {userIsManager(user) && t("manager")}{" "}
- {user.id === teamData?.ownerId && t("owner")}{" "}
- {user.name === session?.user?.name && t("you")}
-
-
-
-
- {user.email}
-
+
{hasPerms &&
user.email !== session?.user?.email &&
user.id !== teamData?.ownerId && (
)}
-
+
))}
{hasPerms &&
}
)}
+
+
+
+ {t("teamMemberUsage.title")}
+
+
+
diff --git a/src/app/team/join/[token]/page.tsx b/src/app/team/join/[token]/page.tsx
index e839e9795..f8afc7db5 100644
--- a/src/app/team/join/[token]/page.tsx
+++ b/src/app/team/join/[token]/page.tsx
@@ -1,17 +1,17 @@
import { auth } from "@/lib/auth";
-import Logger from "@/lib/logger";
+import { Logger } from "@/lib/logger";
import prisma from "@/lib/prisma";
+import type { PagePropsWithLocale } from "@/types/next";
import { redirect } from "next/navigation";
-export default async function TokenPage({
- params,
-}: {
- params: { token: string };
-}) {
+export default async function TokenPage(
+ props: PagePropsWithLocale<"/team/join/[token]">
+) {
+ const params = await props.params;
const session = await auth();
const token = params.token;
- if (!session) redirect("/login");
+ if (!session) redirect("/sign-in");
try {
const teamCreatedAt = new Date(atob(token));
@@ -31,7 +31,7 @@ export default async function TokenPage({
});
Logger.log(`User now belongs to team: ${JSON.stringify(team)}`);
- } catch (e) {
+ } catch {
const teamInviteToken = await prisma.teamInviteToken.findUnique({
where: { token },
});
diff --git a/src/app/team/join/page.tsx b/src/app/team/join/page.tsx
index 0880c300f..ca09e3cc5 100644
--- a/src/app/team/join/page.tsx
+++ b/src/app/team/join/page.tsx
@@ -2,10 +2,10 @@
import { JoinTokenInput } from "@/components/team/join-token-input";
import { Button } from "@/components/ui/button";
-import { useToast } from "@/components/ui/use-toast";
import { useTranslations } from "next-intl";
import { useRouter, useSearchParams } from "next/navigation";
import { startTransition, useState } from "react";
+import { toast } from "sonner";
function toInviteToken(tokenArr: string[]) {
// Join all parts into a single string
@@ -26,18 +26,15 @@ export default function TeamJoinPage() {
const t = useTranslations("teamPage.join");
const [token, setToken] = useState(Array(19).fill("")); // Initialize state with 19 empty strings
- const { toast } = useToast();
const router = useRouter();
const searchParams = useSearchParams();
const error = searchParams.get("error");
if (error === "invalid-token") {
- toast({
- title: t("invalidToken.title"),
+ toast.error(t("invalidToken.title"), {
description: t("invalidToken.description"),
duration: 5000,
- variant: "destructive",
});
router.replace("/team/join");
}
@@ -52,20 +49,17 @@ export default function TeamJoinPage() {
});
if (res.ok) {
- toast({
- title: t("handleSubmit.title"),
+ toast.success(t("handleSubmit.title"), {
description: t("handleSubmit.description"),
duration: 5000,
});
router.push("/dashboard");
} else {
- toast({
- title: t("handleSubmit.errorTitle"),
+ toast.error(t("handleSubmit.errorTitle"), {
description: t("handleSubmit.errorDescription", {
error: `${await res.text()} (${res.status})`,
}),
duration: 5000,
- variant: "destructive",
});
}
}
diff --git a/src/app/team/join/success/page.tsx b/src/app/team/join/success/page.tsx
index 9c9a09d0e..190e02fad 100644
--- a/src/app/team/join/success/page.tsx
+++ b/src/app/team/join/success/page.tsx
@@ -1,20 +1,18 @@
"use client";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
-import { useToast } from "@/components/ui/use-toast";
+import { Link } from "@/components/ui/link";
import { useTranslations } from "next-intl";
-import Link from "next/link";
import { useRouter } from "next/navigation";
+import { toast } from "sonner";
export default function TeamJoinSuccessPage() {
const t = useTranslations("teamPage.join");
- const { toast } = useToast();
const router = useRouter();
// Show a success message if the user has successfully joined the team
- toast({
- title: t("handleSubmit.title"),
+ toast.success(t("handleSubmit.title"), {
description: t("handleSubmit.description"),
duration: 5000,
});
diff --git a/src/app/team/layout.tsx b/src/app/team/layout.tsx
index 841edcca3..78082b382 100644
--- a/src/app/team/layout.tsx
+++ b/src/app/team/layout.tsx
@@ -1,9 +1,5 @@
-import DashboardLayout from "@/components/dashboard-layout";
+import { DashboardLayout } from "@/components/dashboard-layout";
-export default function TeamLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
+export default function TeamLayout({ children }: LayoutProps<"/team">) {
return {children};
}
diff --git a/src/app/team/page.tsx b/src/app/team/page.tsx
index d8157aa60..e1531df70 100644
--- a/src/app/team/page.tsx
+++ b/src/app/team/page.tsx
@@ -1,20 +1,21 @@
import { EmptyTeamView } from "@/components/team/empty-team-view";
-import { Card, CardHeader } from "@/components/ui/card";
+import { Card, CardFooter, CardHeader } from "@/components/ui/card";
+import { Link } from "@/components/ui/link";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getUser } from "@/data/user-dto";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
+import type { PagePropsWithLocale } from "@/types/next";
import { $Enums } from "@prisma/client";
-import { Metadata } from "next";
+import { ChartBarIcon } from "lucide-react";
+import type { Metadata, Route } from "next";
import { getTranslations } from "next-intl/server";
import Image from "next/image";
-import Link from "next/link";
-export async function generateMetadata({
- params,
-}: {
- params: { locale: string };
-}): Promise {
+export async function generateMetadata(
+ props: PagePropsWithLocale<"/team">
+): Promise {
+ const params = await props.params;
const t = await getTranslations("teamPage.metadata");
return {
title: t("title"),
@@ -78,7 +79,7 @@ export default async function TeamPage() {
.map((team) => (
-
+
+
+
+
+ {t("viewStats")} →
+
+
))}
@@ -113,7 +123,7 @@ export default async function TeamPage() {
.map((team) => (
-
+
+
+
+
+ {t("viewStats")} →
+
+
))}
diff --git a/src/app/terms-of-service/layout.tsx b/src/app/terms-of-service/layout.tsx
new file mode 100644
index 000000000..2fa29cbd9
--- /dev/null
+++ b/src/app/terms-of-service/layout.tsx
@@ -0,0 +1,45 @@
+import { Footer } from "@/components/footer";
+import { Header } from "@/components/header";
+import type { Metadata } from "next";
+import { getLocale, getTranslations } from "next-intl/server";
+
+export async function generateMetadata(): Promise {
+ const locale = await getLocale();
+ const t = await getTranslations({
+ locale,
+ namespace: "termsPage.metadata",
+ });
+
+ return {
+ title: t("title"),
+ description: t("description"),
+ metadataBase: new URL("https://parsertime.app"),
+ openGraph: {
+ title: t("ogTitle"),
+ description: t("ogDescription"),
+ url: "https://parsertime.app",
+ type: "website",
+ siteName: "Parsertime",
+ images: [
+ {
+ url: `https://parsertime.app/opengraph-image.png`,
+ width: 1200,
+ height: 630,
+ },
+ ],
+ locale,
+ },
+ };
+}
+
+export default function TermsLayout({
+ children,
+}: LayoutProps<"/terms-of-service">) {
+ return (
+ <>
+
+ {children}
+
+ >
+ );
+}
diff --git a/src/app/terms-of-service/page.tsx b/src/app/terms-of-service/page.tsx
new file mode 100644
index 000000000..e63ab9ada
--- /dev/null
+++ b/src/app/terms-of-service/page.tsx
@@ -0,0 +1,116 @@
+import { Link } from "@/components/ui/link";
+import { getTranslations } from "next-intl/server";
+
+export default async function TermsPage() {
+ const t = await getTranslations("termsPage");
+
+ return (
+
+
+
+ {t("termsOfService.title")}
+
+
+ {t("termsOfService.description")}
+
+
+
+ {t("acceptance.title")}
+
+
{t("acceptance.description")}
+
+
+ {t("openSource.title")}
+
+
{t("openSource.description")}
+
+ - {t("openSource.list1")}
+ - {t("openSource.list2")}
+ - {t("openSource.list3")}
+
+
+
+ {t("useOfService.title")}
+
+
{t("useOfService.description")}
+
+ - {t("useOfService.list1")}
+ - {t("useOfService.list2")}
+ - {t("useOfService.list3")}
+ - {t("useOfService.list4")}
+
+
+
+ {t("userAccounts.title")}
+
+
{t("userAccounts.description")}
+
+ - {t("userAccounts.list1")}
+ - {t("userAccounts.list2")}
+ - {t("userAccounts.list3")}
+
+
+
+ {t("prohibitedUses.title")}
+
+
{t("prohibitedUses.description")}
+
+ - {t("prohibitedUses.list1")}
+ - {t("prohibitedUses.list2")}
+ - {t("prohibitedUses.list3")}
+ - {t("prohibitedUses.list4")}
+ - {t("prohibitedUses.list5")}
+ - {t("prohibitedUses.list6")}
+
+
+
+ {t("abuseAndMisuse.title")}
+
+
{t("abuseAndMisuse.description")}
+
+ - {t("abuseAndMisuse.list1")}
+ - {t("abuseAndMisuse.list2")}
+ - {t("abuseAndMisuse.list3")}
+
+
+
+ {t("disclaimers.title")}
+
+
{t("disclaimers.description")}
+
+
+ {t("limitationOfLiability.title")}
+
+
{t("limitationOfLiability.description")}
+
+
+ {t("termination.title")}
+
+
{t("termination.description")}
+
+
+ {t("governingLaw.title")}
+
+
{t("governingLaw.description")}
+
+
+ {t("changesToTerms.title")}
+
+
{t("changesToTerms.description")}
+
+
+ {t("contactUs.title")}
+
+
+ {t.rich("contactUs.description", {
+ link: (chunks) => (
+
+ {chunks}
+
+ ),
+ })}
+
+
+
+ );
+}
diff --git a/src/components/admin/audit-log.tsx b/src/components/admin/audit-log.tsx
new file mode 100644
index 000000000..3e0488821
--- /dev/null
+++ b/src/components/admin/audit-log.tsx
@@ -0,0 +1,728 @@
+/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
+"use client";
+
+import { DownloadAuditLogs } from "@/components/admin/download-audit-logs";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Calendar } from "@/components/ui/calendar";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { cn } from "@/lib/utils";
+import type { AuditLog } from "@prisma/client";
+import { $Enums } from "@prisma/client";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { addWeeks, format } from "date-fns";
+import {
+ Bot,
+ Bug,
+ CalendarIcon,
+ ChevronDown,
+ Crown,
+ Edit,
+ Filter,
+ ImageIcon,
+ Loader2,
+ Mail,
+ Map,
+ Minus,
+ Plus,
+ Shield,
+ ShieldAlert,
+ Target,
+ Trash2,
+ TrendingDown,
+ TrendingUp,
+ UserCheck,
+ UserMinus,
+ UserPlus,
+ UserX,
+ VenetianMask,
+ X,
+} from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useRef, useState } from "react";
+import type { DateRange } from "react-day-picker";
+import { useDebounce } from "use-debounce";
+
+const actionTypes = {
+ // User Management Actions
+ [$Enums.AuditLogAction.USER_BAN]: {
+ label: "User Ban",
+ className: "border-red-500 text-red-500",
+ iconClassName: "text-red-500",
+ icon: UserX,
+ },
+ [$Enums.AuditLogAction.USER_UNBAN]: {
+ label: "User Unban",
+ className: "border-emerald-500 text-emerald-500",
+ iconClassName: "text-emerald-500",
+ icon: UserCheck,
+ },
+ [$Enums.AuditLogAction.USER_AVATAR_UPDATED]: {
+ label: "User Avatar Updated",
+ className: "border-blue-500 text-blue-500",
+ iconClassName: "text-blue-500",
+ icon: ImageIcon,
+ },
+ [$Enums.AuditLogAction.USER_ACCOUNT_DELETED]: {
+ label: "User Account Deleted",
+ className: "border-red-600 text-red-600",
+ iconClassName: "text-red-600",
+ icon: UserMinus,
+ },
+ [$Enums.AuditLogAction.USER_NAME_UPDATED]: {
+ label: "User Name Updated",
+ className: "border-blue-400 text-blue-400",
+ iconClassName: "text-blue-400",
+ icon: Edit,
+ },
+
+ // Security & Admin Actions
+ [$Enums.AuditLogAction.TRUST_SCORE_ADJUST]: {
+ label: "Trust Score Adjust",
+ className: "border-indigo-500 text-indigo-500",
+ iconClassName: "text-indigo-500",
+ icon: Shield,
+ },
+ [$Enums.AuditLogAction.IMPERSONATE_USER]: {
+ label: "Impersonate User",
+ className: "border-purple-500 text-purple-500",
+ iconClassName: "text-purple-500",
+ icon: VenetianMask,
+ },
+ [$Enums.AuditLogAction.SUSPICIOUS_ACTIVITY_DETECTED]: {
+ label: "Suspicious Activity Detected",
+ className: "border-orange-500 text-orange-500",
+ iconClassName: "text-orange-500",
+ icon: ShieldAlert,
+ },
+
+ // Team Management Actions
+ [$Enums.AuditLogAction.TEAM_CREATED]: {
+ label: "Team Created",
+ className: "border-green-500 text-green-500",
+ iconClassName: "text-green-500",
+ icon: Plus,
+ },
+ [$Enums.AuditLogAction.TEAM_UPDATED]: {
+ label: "Team Updated",
+ className: "border-blue-500 text-blue-500",
+ iconClassName: "text-blue-500",
+ icon: Edit,
+ },
+ [$Enums.AuditLogAction.TEAM_DELETED]: {
+ label: "Team Deleted",
+ className: "border-red-500 text-red-500",
+ iconClassName: "text-red-500",
+ icon: Trash2,
+ },
+ [$Enums.AuditLogAction.TEAM_AVATAR_UPDATED]: {
+ label: "Team Avatar Updated",
+ className: "border-cyan-500 text-cyan-500",
+ iconClassName: "text-cyan-500",
+ icon: ImageIcon,
+ },
+ [$Enums.AuditLogAction.TEAM_INVITE_SENT]: {
+ label: "Team Invite Sent",
+ className: "border-violet-500 text-violet-500",
+ iconClassName: "text-violet-500",
+ icon: Mail,
+ },
+ [$Enums.AuditLogAction.TEAM_JOINED]: {
+ label: "Team Joined",
+ className: "border-green-400 text-green-400",
+ iconClassName: "text-green-400",
+ icon: UserPlus,
+ },
+ [$Enums.AuditLogAction.TEAM_LEFT]: {
+ label: "Team Left",
+ className: "border-yellow-500 text-yellow-500",
+ iconClassName: "text-yellow-500",
+ icon: UserMinus,
+ },
+ [$Enums.AuditLogAction.TEAM_MEMBER_PROMOTED]: {
+ label: "Team Member Promoted",
+ className: "border-emerald-600 text-emerald-600",
+ iconClassName: "text-emerald-600",
+ icon: TrendingUp,
+ },
+ [$Enums.AuditLogAction.TEAM_MEMBER_DEMOTED]: {
+ label: "Team Member Demoted",
+ className: "border-orange-400 text-orange-400",
+ iconClassName: "text-orange-400",
+ icon: TrendingDown,
+ },
+ [$Enums.AuditLogAction.TEAM_MEMBER_REMOVED]: {
+ label: "Team Member Removed",
+ className: "border-red-400 text-red-400",
+ iconClassName: "text-red-400",
+ icon: Minus,
+ },
+ [$Enums.AuditLogAction.TEAM_OWNERSHIP_TRANSFERRED]: {
+ label: "Team Ownership Transferred",
+ className: "border-amber-500 text-amber-500",
+ iconClassName: "text-amber-500",
+ icon: Crown,
+ },
+
+ // Scrim Management Actions
+ [$Enums.AuditLogAction.SCRIM_CREATED]: {
+ label: "Scrim Created",
+ className: "border-teal-500 text-teal-500",
+ iconClassName: "text-teal-500",
+ icon: Target,
+ },
+ [$Enums.AuditLogAction.SCRIM_UPDATED]: {
+ label: "Scrim Updated",
+ className: "border-teal-400 text-teal-400",
+ iconClassName: "text-teal-400",
+ icon: Edit,
+ },
+ [$Enums.AuditLogAction.SCRIM_DELETED]: {
+ label: "Scrim Deleted",
+ className: "border-red-300 text-red-300",
+ iconClassName: "text-red-300",
+ icon: Trash2,
+ },
+
+ // Map Management Actions
+ [$Enums.AuditLogAction.MAP_CREATED]: {
+ label: "Map Created",
+ className: "border-slate-500 text-slate-500",
+ iconClassName: "text-slate-500",
+ icon: Map,
+ },
+ [$Enums.AuditLogAction.MAP_UPDATED]: {
+ label: "Map Updated",
+ className: "border-slate-400 text-slate-400",
+ iconClassName: "text-slate-400",
+ icon: Edit,
+ },
+ [$Enums.AuditLogAction.MAP_DELETED]: {
+ label: "Map Deleted",
+ className: "border-slate-600 text-slate-600",
+ iconClassName: "text-slate-600",
+ icon: Trash2,
+ },
+
+ // System Actions
+ [$Enums.AuditLogAction.BUG_REPORT_SUBMITTED]: {
+ label: "Bug Report Submitted",
+ className: "border-pink-500 text-pink-500",
+ iconClassName: "text-pink-500",
+ icon: Bug,
+ },
+};
+
+function getActionBadge(action: string) {
+ const actionInfo = actionTypes[action as keyof typeof actionTypes];
+
+ if (actionInfo) {
+ const Icon = actionInfo.icon;
+ return (
+
+
+ {actionInfo.label}
+
+ );
+ }
+
+ return (
+
+
+ {action.replace("_", " ")}
+
+ );
+}
+
+export function AuditLog({
+ limit = 10,
+ height = "max-h-[500px]",
+}: {
+ limit?: number;
+ height?: string;
+}) {
+ const t = useTranslations("settingsPage.admin.audit-log");
+ const loadMoreRef = useRef(null);
+
+ const TODAY = new Date();
+
+ const [dateRange, setDateRange] = useState(() => ({
+ from: addWeeks(TODAY, -1),
+ to: TODAY,
+ }));
+
+ const [selectedActions, setSelectedActions] = useState([]);
+ const [userEmailSearchInput, setUserEmailSearchInput] = useState("");
+ const [targetSearchInput, setTargetSearchInput] = useState("");
+ const [debouncedUserEmailSearch] = useDebounce(userEmailSearchInput, 300);
+ const [debouncedTargetSearch] = useDebounce(targetSearchInput, 300);
+
+ const uniqueActionTypes = Object.keys(
+ actionTypes
+ ) as (keyof typeof actionTypes)[];
+
+ const {
+ data,
+ isLoading,
+ isError,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = useInfiniteQuery<
+ { items: AuditLog[]; nextCursor: number | null; hasMore: boolean },
+ Error
+ >({
+ queryKey: [
+ "auditLogs",
+ dateRange,
+ selectedActions,
+ debouncedUserEmailSearch,
+ debouncedTargetSearch,
+ ] as const,
+ initialPageParam: undefined,
+ queryFn: async (context) => {
+ const pageParam = context.pageParam as number | undefined;
+ const params = new URLSearchParams();
+ if (pageParam) params.set("cursor", pageParam.toString());
+ params.set("limit", limit.toString());
+
+ if (dateRange?.from)
+ params.set("startDate", dateRange.from.toISOString());
+
+ if (dateRange?.to) params.set("endDate", dateRange.to.toISOString());
+
+ if (debouncedUserEmailSearch)
+ params.set("userEmail", debouncedUserEmailSearch);
+ if (debouncedTargetSearch) params.set("target", debouncedTargetSearch);
+
+ selectedActions.forEach((action) => params.append("action", action));
+
+ const res = await fetch(`/api/admin/audit-logs?${params.toString()}`);
+ if (!res.ok) throw new Error("Failed to fetch audit logs");
+
+ return res.json() as Promise<{
+ items: AuditLog[];
+ nextCursor: number | null;
+ hasMore: boolean;
+ }>;
+ },
+ getNextPageParam: (lastPage) =>
+ lastPage.hasMore ? lastPage.nextCursor : undefined,
+ });
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.length === 0) return;
+ const first = entries[0];
+ if (first.isIntersecting && hasNextPage && !isFetchingNextPage) {
+ void fetchNextPage();
+ }
+ },
+ { threshold: 0.1 }
+ );
+
+ const currentRef = loadMoreRef.current;
+ if (currentRef) {
+ observer.observe(currentRef);
+ }
+
+ return () => {
+ if (currentRef) {
+ observer.unobserve(currentRef);
+ }
+ };
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
+
+ const logs: AuditLog[] = data ? data.pages.flatMap((page) => page.items) : [];
+
+ function formatDateRange() {
+ if (!dateRange) return "Select date range";
+
+ if (dateRange.from && dateRange.to) {
+ if (dateRange.from.toDateString() === dateRange.to.toDateString()) {
+ return format(dateRange.from, "PPP");
+ }
+ return `${format(dateRange.from, "PP")} - ${format(dateRange.to, "PP")}`;
+ }
+
+ if (dateRange.from) {
+ return `From ${format(dateRange.from, "PP")}`;
+ }
+
+ if (dateRange.to) {
+ return `Until ${format(dateRange.to, "PP")}`;
+ }
+
+ return "Select date range";
+ }
+
+ function toggleActionType(actionType: string) {
+ setSelectedActions((prev) =>
+ prev.includes(actionType)
+ ? prev.filter((a) => a !== actionType)
+ : [...prev, actionType]
+ );
+ }
+
+ function clearFilters() {
+ setSelectedActions([]);
+ setDateRange(undefined);
+ setUserEmailSearchInput("");
+ setTargetSearchInput("");
+ }
+
+ return (
+
+
{t("title")}
+
+
+
setUserEmailSearchInput(e.target.value)}
+ className="w-[200px]"
+ />
+
setTargetSearchInput(e.target.value)}
+ className="w-[200px]"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("filter-label")}
+
+ {uniqueActionTypes.map((actionType) => {
+ const actionInfo = actionTypes[actionType];
+ return (
+ toggleActionType(actionType)}
+ >
+
+ {actionInfo && (
+
+ )}
+ {actionInfo
+ ? actionInfo.label
+ : actionType.replace("_", " ")}
+
+
+ );
+ })}
+
+
+
+ {(selectedActions.length > 0 ||
+ dateRange ||
+ debouncedUserEmailSearch ||
+ debouncedTargetSearch) && (
+
+ )}
+
+
+
+ {/* Active filters display */}
+ {(selectedActions.length > 0 ||
+ dateRange ||
+ debouncedUserEmailSearch ||
+ debouncedTargetSearch) && (
+
+ {debouncedUserEmailSearch && (
+
+ {t("user-email", { userEmail: debouncedUserEmailSearch })}
+ setUserEmailSearchInput("")}
+ />
+
+ )}
+ {debouncedTargetSearch && (
+
+ {t("target", { target: debouncedTargetSearch })}
+ setTargetSearchInput("")}
+ />
+
+ )}
+ {selectedActions.map((action) => {
+ const actionInfo = actionTypes[action as keyof typeof actionTypes];
+ return (
+
+ {actionInfo ? actionInfo.label : action.replace("_", " ")}
+
+ setSelectedActions((prev) =>
+ prev.filter((a) => a !== action)
+ )
+ }
+ />
+
+ );
+ })}
+ {dateRange && (
+
+ {formatDateRange()}
+ setDateRange(undefined)}
+ />
+
+ )}
+ {Boolean(
+ selectedActions.length > 0 ||
+ dateRange ||
+ debouncedUserEmailSearch ||
+ debouncedTargetSearch
+ ) && (
+
+ )}
+
+ )}
+
+
+
+
+
+
+ {t("table.user-email")}
+ {t("table.action")}
+ {t("table.target")}
+ {t("table.details")}
+ {t("table.date-time")}
+
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : isError ? (
+
+
+ {t("table.error-loading-logs")}
+
+
+ ) : logs.length > 0 ? (
+ <>
+ {logs.map((log) => (
+
+ ))}
+ {hasNextPage && (
+
+
+ {isFetchingNextPage && (
+
+ )}
+
+
+ )}
+ >
+ ) : (
+
+
+ {t("table.no-logs-found")}
+
+
+ )}
+
+
+
+
+
+
+ {t("showing-logs", { count: logs.length })}
+
+
+
+
+ );
+}
+
+function AuditLogHoverCard({ log }: { log: AuditLog }) {
+ const t = useTranslations("settingsPage.admin.audit-log");
+
+ return (
+
+
+
+
+ {log.userEmail === "System" && }
+ {log.userEmail}
+
+ {getActionBadge(log.action)}
+ {log.target}
+
+ {log.details}
+
+
+ {t("table.date-time-format", {
+ date: format(new Date(log.createdAt), "PP"),
+ time: format(new Date(log.createdAt), "p"),
+ })}
+
+
+
+
+
+ {/* Header section with admin name */}
+
+
+
+ {t("hover.user-email")}
+
+
+ {log.userEmail === "System" && }
+ {log.userEmail}
+
+
+
+
+ {t("hover.action")}
+
+ {getActionBadge(log.action)}
+
+
+
+ {/* Content section */}
+
+
+
+
+ {t("hover.target")}
+
+
+ {log.target}
+
+
+
+
+
+ {t("hover.details")}
+
+
+ {log.details}
+
+
+
+
+
+ {t("hover.timestamp")}
+
+
+ {format(new Date(log.createdAt), "PPPp")}
+
+
+
+
+
+ {/* Footer with ID */}
+
+
+
+ {t("hover.id", { id: log.id.toString() })}
+
+ {log.userEmail === "System" && (
+
+ {t("hover.auto-generated")}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/billing-plan-pie-chart.tsx b/src/components/admin/billing-plan-pie-chart.tsx
new file mode 100644
index 000000000..22fa5d0d0
--- /dev/null
+++ b/src/components/admin/billing-plan-pie-chart.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import {
+ ChartContainer,
+ ChartLegend,
+ ChartLegendContent,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart";
+import { Cell, Pie, PieChart } from "recharts";
+
+type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record<"light" | "dark", string> }
+ );
+};
+
+type BillingPlanData = {
+ plan: string;
+ count: number;
+ percentage: number;
+};
+
+type BillingPlanPieChartProps = {
+ data: BillingPlanData[];
+};
+
+export function BillingPlanPieChart({ data }: BillingPlanPieChartProps) {
+ const chartConfig: ChartConfig = {
+ FREE: {
+ label: "Free",
+ color: "var(--chart-1)",
+ },
+ BASIC: {
+ label: "Basic",
+ color: "var(--chart-3)",
+ },
+ PREMIUM: {
+ label: "Premium",
+ color: "var(--chart-5)",
+ },
+ };
+
+ const COLORS = [
+ "var(--chart-1)", // FREE
+ "var(--chart-3)", // BASIC
+ "var(--chart-5)", // PREMIUM
+ ];
+
+ return (
+
+
+
+ {data.map((entry) => (
+ |
+ ))}
+
+ }
+ formatter={(value, name) => {
+ const entry = data.find((d) => d.plan === name);
+ return [
+ `${String(value)} (${entry?.percentage ?? 0}%)`,
+ String(name),
+ ];
+ }}
+ />
+ } />
+
+
+ );
+}
diff --git a/src/components/admin/download-audit-logs.tsx b/src/components/admin/download-audit-logs.tsx
new file mode 100644
index 000000000..bbd4f0f45
--- /dev/null
+++ b/src/components/admin/download-audit-logs.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import type { AuditLog } from "@prisma/client";
+import { Download, Loader2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+import { toast } from "sonner";
+
+type DownloadAuditLogsProps = {
+ logs: AuditLog[];
+};
+
+export function DownloadAuditLogs({ logs }: DownloadAuditLogsProps) {
+ const t = useTranslations("settingsPage.admin.audit-log.download");
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ function convertToCSV(logs: AuditLog[]) {
+ // Define CSV headers
+ const headers = [
+ "User Email",
+ "Action",
+ "Target",
+ "Details",
+ "Timestamp (UTC)",
+ ];
+
+ // Convert logs to CSV rows
+ const rows = logs.map((log) => {
+ return [
+ log.userEmail,
+ log.action,
+ log.target,
+ // Escape quotes in details to prevent CSV formatting issues
+ `"${log.details.replace(/"/g, '""')}"`,
+ // Use ISO string for consistent UTC timestamp
+ new Date(log.createdAt).toISOString(),
+ ];
+ });
+
+ // Combine headers and rows
+ return [headers, ...rows].map((row) => row.join(",")).join("\n");
+ }
+
+ function handleDownload() {
+ setIsDownloading(true);
+ try {
+ const csv = convertToCSV(logs);
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.setAttribute("href", url);
+ link.setAttribute(
+ "download",
+ t("filename", { date: new Date().toISOString().split("T")[0] })
+ );
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ } catch (error) {
+ toast.error(
+ t("error", {
+ error: error instanceof Error ? error.message : t("unknown-error"),
+ })
+ );
+ } finally {
+ setIsDownloading(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+ {t("tooltip")}
+
+
+ );
+}
diff --git a/src/components/admin/impersonate-user.tsx b/src/components/admin/impersonate-user.tsx
index 9626f02c3..687ef0a69 100644
--- a/src/components/admin/impersonate-user.tsx
+++ b/src/components/admin/impersonate-user.tsx
@@ -1,9 +1,5 @@
"use client";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-
import { Button } from "@/components/ui/button";
import {
Form,
@@ -16,15 +12,18 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
-import { toast } from "@/components/ui/use-toast";
import { ClientOnly } from "@/lib/client-only";
+import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
const adminFormSchema = z.object({
email: z.string().email({
message: "Please enter a valid email address.",
}),
- isProd: z.boolean().default(true),
+ isProd: z.boolean(),
});
type AdminFormValues = z.infer;
@@ -57,16 +56,13 @@ export function ImpersonateUserForm() {
await navigator.clipboard.writeText(url);
- toast({
- title: t("onSubmit.title"),
+ toast.success(t("onSubmit.title"), {
description: t("onSubmit.description"),
duration: 5000,
});
- } catch (e) {
- toast({
- title: t("onSubmit.errorTitle"),
+ } catch {
+ toast.error(t("onSubmit.errorTitle"), {
description: t("onSubmit.errorDescription"),
- variant: "destructive",
});
}
}
@@ -97,7 +93,7 @@ export function ImpersonateUserForm() {
control={form.control}
name="isProd"
render={({ field }) => (
-
+
}
+ );
+};
+
+type MonthlyUserData = {
+ month: string;
+ users: number;
+};
+
+type MonthlyUserChartProps = {
+ data: MonthlyUserData[];
+};
+
+export function MonthlyUserChart({ data }: MonthlyUserChartProps) {
+ const chartConfig: ChartConfig = {
+ users: {
+ label: "Users",
+ color: "var(--chart-1)",
+ },
+ };
+
+ return (
+
+
+
+ value.slice(0, 3)}
+ />
+ } />
+
+
+
+ );
+}
diff --git a/src/components/admin/scrim-activity-chart.tsx b/src/components/admin/scrim-activity-chart.tsx
new file mode 100644
index 000000000..188921b72
--- /dev/null
+++ b/src/components/admin/scrim-activity-chart.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart";
+import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
+
+type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record<"light" | "dark", string> }
+ );
+};
+
+type ScrimActivityData = {
+ date: string;
+ scrims: number;
+};
+
+type ScrimActivityChartProps = {
+ data: ScrimActivityData[];
+};
+
+export function ScrimActivityChart({ data }: ScrimActivityChartProps) {
+ const chartConfig: ChartConfig = {
+ scrims: {
+ label: "Scrims Created",
+ color: "var(--chart-2)",
+ },
+ };
+
+ // Format date for display (show only day/month)
+ function formatXAxisLabel(value: string) {
+ const date = new Date(value);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ });
+ }
+
+ return (
+
+
+
+
+ }
+ labelFormatter={(value) => {
+ const date = new Date(value as string);
+ return date.toLocaleDateString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ }}
+ />
+
+
+
+ );
+}
diff --git a/src/components/admin/signup-method-pie-chart.tsx b/src/components/admin/signup-method-pie-chart.tsx
new file mode 100644
index 000000000..39f11a500
--- /dev/null
+++ b/src/components/admin/signup-method-pie-chart.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import {
+ ChartContainer,
+ ChartLegend,
+ ChartLegendContent,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart";
+import { Cell, Pie, PieChart } from "recharts";
+
+type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record<"light" | "dark", string> }
+ );
+};
+
+type SignupMethodData = {
+ method: string;
+ count: number;
+ percentage: number;
+};
+
+type SignupMethodPieChartProps = {
+ data: SignupMethodData[];
+};
+
+export function SignupMethodPieChart({ data }: SignupMethodPieChartProps) {
+ const chartConfig: ChartConfig = {
+ Email: {
+ label: "Email",
+ color: "var(--chart-1)",
+ },
+ Discord: {
+ label: "Discord",
+ color: "var(--chart-2)",
+ },
+ Google: {
+ label: "Google",
+ color: "var(--chart-3)",
+ },
+ GitHub: {
+ label: "GitHub",
+ color: "var(--chart-4)",
+ },
+ };
+
+ const COLORS = [
+ "var(--chart-1)", // Email
+ "var(--chart-2)", // Discord
+ "var(--chart-3)", // Google
+ "var(--chart-4)", // GitHub
+ "var(--chart-5)", // Fallback
+ ];
+
+ return (
+
+
+
+ {data.map((entry) => (
+ |
+ ))}
+
+ }
+ formatter={(value, name) => {
+ const entry = data.find((d) => d.method === name);
+ return [
+ `${String(value)} (${entry?.percentage ?? 0}%)`,
+ String(name),
+ ];
+ }}
+ />
+ } />
+
+
+ );
+}
diff --git a/src/components/admin/stats-cards.tsx b/src/components/admin/stats-cards.tsx
new file mode 100644
index 000000000..d47ef5c5b
--- /dev/null
+++ b/src/components/admin/stats-cards.tsx
@@ -0,0 +1,233 @@
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import prisma from "@/lib/prisma";
+import {
+ Activity,
+ CreditCard,
+ TrendingDown,
+ TrendingUp,
+ Users,
+} from "lucide-react";
+import { getTranslations } from "next-intl/server";
+
+function formatDelta(delta: number) {
+ return delta > 0 ? `+${delta}` : delta;
+}
+
+async function getUserStats() {
+ const now = new Date();
+ const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
+ const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+
+ const [totalUsers, usersThisMonth, usersLastMonth] = await Promise.all([
+ prisma.user.count(),
+ prisma.user.count({
+ where: {
+ createdAt: {
+ gte: thisMonthStart,
+ },
+ },
+ }),
+ prisma.user.count({
+ where: {
+ createdAt: {
+ gte: lastMonthStart,
+ lt: thisMonthStart,
+ },
+ },
+ }),
+ ]);
+
+ // Calculate growth comparison
+ const growthComparison = usersThisMonth - usersLastMonth;
+ const growthTrend =
+ growthComparison > 0 ? "up" : growthComparison < 0 ? "down" : "neutral";
+
+ return {
+ totalUsers,
+ monthlyGrowth: usersThisMonth,
+ usersThisMonth,
+ usersLastMonth,
+ growthComparison,
+ growthTrend,
+ };
+}
+
+async function getScrimStats() {
+ const now = new Date();
+ const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
+ const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+
+ const [totalScrims, scrimsThisMonth, scrimsLastMonth] = await Promise.all([
+ prisma.scrim.count(),
+ prisma.scrim.count({
+ where: {
+ createdAt: {
+ gte: thisMonthStart,
+ },
+ },
+ }),
+ prisma.scrim.count({
+ where: {
+ createdAt: {
+ gte: lastMonthStart,
+ lt: thisMonthStart,
+ },
+ },
+ }),
+ ]);
+
+ // Calculate growth comparison
+ const growthComparison = scrimsThisMonth - scrimsLastMonth;
+ const growthTrend =
+ growthComparison > 0 ? "up" : growthComparison < 0 ? "down" : "neutral";
+
+ return {
+ totalScrims,
+ scrimsThisMonth,
+ scrimsLastMonth,
+ growthComparison,
+ growthTrend,
+ };
+}
+
+async function getConversionStats() {
+ const [totalUsers, paidUsers] = await Promise.all([
+ prisma.user.count(),
+ prisma.user.count({
+ where: {
+ billingPlan: {
+ not: "FREE",
+ },
+ },
+ }),
+ ]);
+
+ const conversionRate = totalUsers > 0 ? (paidUsers / totalUsers) * 100 : 0;
+
+ return {
+ totalUsers,
+ paidUsers,
+ conversionRate,
+ };
+}
+
+export async function StatsCards() {
+ const t = await getTranslations("settingsPage.admin.dashboard.stats-cards");
+
+ const [userStats, scrimStats, conversionStats] = await Promise.all([
+ getUserStats(),
+ getScrimStats(),
+ getConversionStats(),
+ ]);
+
+ const {
+ totalUsers,
+ monthlyGrowth,
+ usersThisMonth,
+ usersLastMonth,
+ growthComparison: userGrowthComparison,
+ growthTrend: userGrowthTrend,
+ } = userStats;
+
+ const {
+ scrimsThisMonth,
+ scrimsLastMonth,
+ growthComparison: scrimGrowthComparison,
+ growthTrend: scrimGrowthTrend,
+ } = scrimStats;
+
+ const { paidUsers, conversionRate } = conversionStats;
+
+ return (
+
+
+
+
+ {t("total-users.title")}
+
+
+
+
+
+ {totalUsers.toLocaleString()}
+
+
+ {t("total-users.delta", {
+ delta: formatDelta(monthlyGrowth),
+ })}
+
+
+
+
+
+
+ {t("user-growth.title")}
+
+ {userGrowthTrend === "up" && (
+
+ )}
+ {userGrowthTrend === "down" && (
+
+ )}
+ {userGrowthTrend === "neutral" && (
+
+ )}
+
+
+
+ {usersThisMonth.toLocaleString()}
+
+
+ {t("user-growth.delta", {
+ delta: formatDelta(userGrowthComparison),
+ lastMonth: usersLastMonth.toLocaleString(),
+ })}
+
+
+
+
+
+
+ {t("scrim-activity.title")}
+
+ {scrimGrowthTrend === "up" && (
+
+ )}
+ {scrimGrowthTrend === "down" && (
+
+ )}
+ {scrimGrowthTrend === "neutral" && (
+
+ )}
+
+
+
+ {scrimsThisMonth.toLocaleString()}
+
+
+ {t("scrim-activity.delta", {
+ delta: formatDelta(scrimGrowthComparison),
+ lastMonth: scrimsLastMonth.toLocaleString(),
+ })}
+
+
+
+
+
+
+ {t("conversion-rate.title")}
+
+
+
+
+ {conversionRate.toFixed(1)}%
+
+ {t("conversion-rate.delta", {
+ paidUsers: paidUsers.toLocaleString(),
+ })}
+
+
+
+
+ );
+}
diff --git a/src/components/admin/team-creation-chart.tsx b/src/components/admin/team-creation-chart.tsx
new file mode 100644
index 000000000..3c7687d30
--- /dev/null
+++ b/src/components/admin/team-creation-chart.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart";
+import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
+
+type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record<"light" | "dark", string> }
+ );
+};
+
+type TeamCreationData = {
+ month: string;
+ teams: number;
+};
+
+type TeamCreationChartProps = {
+ data: TeamCreationData[];
+};
+
+export function TeamCreationChart({ data }: TeamCreationChartProps) {
+ const chartConfig: ChartConfig = {
+ teams: {
+ label: "Teams Created",
+ color: "var(--chart-3)",
+ },
+ };
+
+ return (
+
+
+
+ value.slice(0, 3)}
+ />
+ } />
+
+
+
+ );
+}
diff --git a/src/components/admin/team-manager-pie-chart.tsx b/src/components/admin/team-manager-pie-chart.tsx
new file mode 100644
index 000000000..bf8637f2d
--- /dev/null
+++ b/src/components/admin/team-manager-pie-chart.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import {
+ ChartContainer,
+ ChartLegend,
+ ChartLegendContent,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart";
+import { Cell, Pie, PieChart } from "recharts";
+
+type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record<"light" | "dark", string> }
+ );
+};
+
+type TeamManagerData = {
+ role: string;
+ count: number;
+ percentage: number;
+};
+
+type TeamManagerPieChartProps = {
+ data: TeamManagerData[];
+};
+
+export function TeamManagerPieChart({ data }: TeamManagerPieChartProps) {
+ const chartConfig: ChartConfig = {
+ "Regular Users": {
+ label: "Regular Users",
+ color: "var(--chart-1)",
+ },
+ "Power Users": {
+ label: "Power Users",
+ color: "var(--chart-4)",
+ },
+ };
+
+ const COLORS = ["var(--chart-1)", "var(--chart-4)"];
+
+ return (
+
+
+
+ {data.map((entry, index) => (
+ |
+ ))}
+
+ }
+ formatter={(value, name) => [
+ `${String(value)} (${data.find((d) => d.role === name)?.percentage}%)`,
+ String(name),
+ ]}
+ />
+ } />
+
+
+ );
+}
diff --git a/src/components/admin/user-search.tsx b/src/components/admin/user-search.tsx
new file mode 100644
index 000000000..6f63efb73
--- /dev/null
+++ b/src/components/admin/user-search.tsx
@@ -0,0 +1,423 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Calendar } from "@/components/ui/calendar";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Slider } from "@/components/ui/slider";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { cn } from "@/lib/utils";
+import type { User } from "@prisma/client";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { format } from "date-fns";
+import {
+ CalendarIcon,
+ CheckCircle,
+ ChevronDown,
+ Filter,
+ Loader2,
+ Search,
+ X,
+} from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useRef, useState } from "react";
+import type { DateRange } from "react-day-picker";
+import { useDebounce } from "use-debounce";
+
+type UserResponse = {
+ items: User[];
+ nextCursor: string | null;
+ hasMore: boolean;
+};
+
+type UserSearchProps = {
+ limit?: number;
+ height?: string;
+};
+
+export function UserSearch({
+ limit = 10,
+ height = "max-h-[500px]",
+}: UserSearchProps) {
+ const t = useTranslations("settingsPage.admin.user-search");
+ const loadMoreRef = useRef(null);
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [trustRange, setTrustRange] = useState([0, 100]);
+ const [billingPlanFilter, setBillingPlanFilter] = useState("all");
+ const [joinDateRange, setJoinDateRange] = useState();
+ const [showFilters, setShowFilters] = useState(false);
+ const [debouncedSearch] = useDebounce(searchQuery, 300);
+
+ const {
+ data,
+ isLoading,
+ isError,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = useInfiniteQuery({
+ queryKey: [
+ "users",
+ debouncedSearch,
+ trustRange,
+ billingPlanFilter,
+ joinDateRange,
+ limit,
+ ] as const,
+ initialPageParam: null as string | null,
+ queryFn: async ({ pageParam }) => {
+ const params = new URLSearchParams();
+ if (pageParam) params.set("cursor", pageParam);
+ params.set("limit", limit.toString());
+
+ if (debouncedSearch) params.set("search", debouncedSearch);
+ params.set("trustScoreMin", trustRange[0].toString());
+ params.set("trustScoreMax", trustRange[1].toString());
+ if (billingPlanFilter !== "all")
+ params.set("billingPlan", billingPlanFilter);
+
+ if (joinDateRange?.from)
+ params.set("joinedAfter", joinDateRange.from.toISOString());
+ if (joinDateRange?.to)
+ params.set("joinedBefore", joinDateRange.to.toISOString());
+
+ const res = await fetch(`/api/admin/user-search?${params.toString()}`);
+ if (!res.ok) throw new Error("Failed to fetch users");
+
+ return res.json() as Promise;
+ },
+ getNextPageParam: (lastPage) =>
+ lastPage.hasMore ? lastPage.nextCursor : null,
+ });
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.length === 0) return;
+ const first = entries[0];
+ if (first.isIntersecting && hasNextPage && !isFetchingNextPage) {
+ void fetchNextPage();
+ }
+ },
+ { threshold: 0.1 }
+ );
+
+ const currentRef = loadMoreRef.current;
+ if (currentRef) {
+ observer.observe(currentRef);
+ }
+
+ return () => {
+ if (currentRef) {
+ observer.unobserve(currentRef);
+ }
+ };
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
+
+ const users = data ? data.pages.flatMap((page) => page.items) : [];
+
+ function getBillingPlanBadge(score: string) {
+ if (score === "FREE")
+ return (
+ FREE
+ );
+ if (score === "BASIC")
+ return (
+
+ BASIC
+
+ );
+ if (score === "PREMIUM")
+ return (
+
+ PREMIUM
+
+ );
+ return UNKNOWN;
+ }
+
+ function formatJoinDateRange() {
+ if (!joinDateRange) return "Select join date range";
+
+ if (joinDateRange.from && joinDateRange.to) {
+ if (
+ joinDateRange.from.toDateString() === joinDateRange.to.toDateString()
+ ) {
+ return format(joinDateRange.from, "PPP");
+ }
+ return `${format(joinDateRange.from, "PP")} - ${format(joinDateRange.to, "PP")}`;
+ }
+
+ if (joinDateRange.from) {
+ return `From ${format(joinDateRange.from, "PP")}`;
+ }
+
+ if (joinDateRange.to) {
+ return `Until ${format(joinDateRange.to, "PP")}`;
+ }
+
+ return "Select join date range";
+ }
+
+ function clearFilters() {
+ setSearchQuery("");
+ setTrustRange([0, 100]);
+ setBillingPlanFilter("all");
+ setJoinDateRange(undefined);
+ }
+
+ return (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ />
+
+
+
+
+ {showFilters && (
+
+
+
+
+
+
+
+ {trustRange[0]}
+ {trustRange[1]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ {t("table.name")}
+ {t("table.email")}
+ {t("table.billing-plan")}
+ {t("table.role")}
+ {t("table.email-verified")}
+ {t("table.join-date")}
+ {/*
+ {t("table.actions")}
+ */}
+
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : isError ? (
+
+
+ {t("table.error-loading-users")}
+
+
+ ) : users.length > 0 ? (
+ <>
+ {users.map((user: User & { createdAt: Date }) => (
+
+ {user.name}
+ {user.email}
+
+
+ {getBillingPlanBadge(user.billingPlan)}
+
+
+ {user.role}
+
+ {user.emailVerified ? (
+
+
+ {t("table.verified")}
+
+ ) : (
+
+
+ {t("table.unverified")}
+
+ )}
+
+
+
+ {format(new Date(user.createdAt), "PPP")}
+
+
+
+ {/*
+
+
+
+
+
+
+ {t("table.actions")}
+
+ {
+ window.location.href = `/profile/${user.username}`;
+ }}
+ >
+ {t("table.view-profile")}
+
+ {
+ window.location.href = `/profile/${user.username}/edit`;
+ }}
+ >
+ {t("table.edit-user")}
+
+
+
+ */}
+
+ ))}
+ {hasNextPage && (
+
+
+ {isFetchingNextPage && (
+
+ )}
+
+
+ )}
+ >
+ ) : (
+
+
+ {t("table.no-users-found")}
+
+
+ )}
+
+
+
+
+
+
+ {t("showing-users", { count: users.length })}
+
+
+
+ );
+}
diff --git a/src/components/auth/auth-components.tsx b/src/components/auth/auth-components.tsx
index 4e8d09c75..46a9d1fa4 100644
--- a/src/components/auth/auth-components.tsx
+++ b/src/components/auth/auth-components.tsx
@@ -1,5 +1,4 @@
-/* eslint-disable @typescript-eslint/no-misused-promises */
-import { Button } from "@/components/ui/button";
+import type { Button } from "@/components/ui/button";
import { signOut } from "@/lib/auth";
import { useTranslations } from "next-intl";
diff --git a/src/components/auth/no-auth.tsx b/src/components/auth/no-auth.tsx
index acbaa0058..8a2978666 100644
--- a/src/components/auth/no-auth.tsx
+++ b/src/components/auth/no-auth.tsx
@@ -8,7 +8,7 @@ import {
import { getTranslations } from "next-intl/server";
import Link from "next/link";
-export default async function NoAuthCard() {
+export async function NoAuthCard() {
const t = await getTranslations("noAuth");
return (
diff --git a/src/components/auth/user-auth-form.tsx b/src/components/auth/user-auth-form.tsx
index 71de1fd4b..b01b595a1 100644
--- a/src/components/auth/user-auth-form.tsx
+++ b/src/components/auth/user-auth-form.tsx
@@ -1,148 +1,228 @@
"use client";
-import * as React from "react";
-
import { Icons } from "@/components/icons";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { cn } from "@/lib/utils";
-import { signIn } from "next-auth/react";
-
+import { Link } from "@/components/ui/link";
import { ClientOnly } from "@/lib/client-only";
+import { cn } from "@/lib/utils";
import { EnvelopeOpenIcon } from "@radix-ui/react-icons";
import { track } from "@vercel/analytics";
+import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl";
+import { usePathname } from "next/navigation";
+import * as React from "react";
import { z } from "zod";
-interface UserAuthFormProps extends React.HTMLAttributes {}
-
-export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
+export function UserAuthForm({
+ className,
+ ...props
+}: React.HTMLAttributes) {
const [isLoading, setIsLoading] = React.useState(false);
const [email, setEmail] = React.useState("");
+ const lastSignedInUsing = localStorage.getItem("lastSignedInUsing");
+
async function onSubmit(event: React.SyntheticEvent) {
event.preventDefault();
setIsLoading(true);
+ track("Sign In", { location: "Auth form", method: "Email" });
+ localStorage.setItem("lastSignedInUsing", "email");
await signIn("email", { email });
}
+ async function handleProviderSignIn(provider: string) {
+ setIsLoading(true);
+ track("Sign In", { location: "Auth form", method: provider });
+ localStorage.setItem("lastSignedInUsing", provider);
+ await signIn(provider);
+ }
+
const t = useTranslations("signInPage");
+ const pathname = usePathname();
+
return (
-