Skip to content

Commit 2439885

Browse files
authoredMar 21, 2025··
Feature/leaderboard leagues (#526)
2 parents fee7535 + 084ff6b commit 2439885

File tree

21 files changed

+1854
-7
lines changed

21 files changed

+1854
-7
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
-- CreateEnum
2+
CREATE TYPE "LeagueName" AS ENUM ('BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND');
3+
4+
-- CreateEnum
5+
CREATE TYPE "LeagueColor" AS ENUM ('CD7F32', 'C0C0C0', 'FFD700', 'E5E4E2', 'b9f2ff', 'FF4D4D');
6+
7+
-- CreateEnum
8+
CREATE TYPE "LeagueAchievementType" AS ENUM ('LEAGUE_WINNER', 'TOP_THREE', 'PROMOTION', 'PERFECT_WEEK', 'SURVIVAL', 'COMEBACK_KING', 'CONSISTENCY', 'SPEED_DEMON');
9+
10+
-- CreateEnum
11+
CREATE TYPE "LeaguePowerUp" AS ENUM ('DOUBLE_XP', 'SHIELD', 'STREAK_SAVER', 'TIME_FREEZE', 'BONUS_POINTS');
12+
13+
-- CreateTable
14+
CREATE TABLE "IndividualLeagueData" (
15+
"uid" TEXT NOT NULL,
16+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
17+
"updatedAt" TIMESTAMP(3) NOT NULL,
18+
"name" "LeagueName" NOT NULL,
19+
"color" "LeagueColor" NOT NULL,
20+
"description" TEXT,
21+
"xpRequirement" INTEGER NOT NULL,
22+
"resetDate" TIMESTAMP(3) NOT NULL,
23+
"canBeRelegated" BOOLEAN NOT NULL DEFAULT false,
24+
"icon" TEXT,
25+
"inactivityThresholdDays" INTEGER DEFAULT 7,
26+
"maxPowerUpsPerWeek" INTEGER NOT NULL DEFAULT 3,
27+
"xpMultiplier" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
28+
29+
CONSTRAINT "IndividualLeagueData_pkey" PRIMARY KEY ("uid")
30+
);
31+
32+
-- CreateTable
33+
CREATE TABLE "Leagues" (
34+
"uid" TEXT NOT NULL,
35+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
36+
"updatedAt" TIMESTAMP(3) NOT NULL,
37+
"leagueDataUid" TEXT NOT NULL,
38+
"maxUsers" INTEGER NOT NULL DEFAULT 30,
39+
"currentUsers" INTEGER NOT NULL DEFAULT 0,
40+
"startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
41+
"endDate" TIMESTAMP(3) NOT NULL,
42+
"promotionCount" INTEGER NOT NULL DEFAULT 3,
43+
"relegationCount" INTEGER NOT NULL DEFAULT 5,
44+
"weeklyChallenge" TEXT,
45+
"weeklyChallengeXP" INTEGER,
46+
47+
CONSTRAINT "Leagues_pkey" PRIMARY KEY ("uid")
48+
);
49+
50+
-- CreateTable
51+
CREATE TABLE "UserLeague" (
52+
"uid" TEXT NOT NULL,
53+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
54+
"updatedAt" TIMESTAMP(3) NOT NULL,
55+
"userUid" TEXT NOT NULL,
56+
"leagueUid" TEXT NOT NULL,
57+
"position" INTEGER,
58+
"weeklyXp" INTEGER NOT NULL DEFAULT 0,
59+
"promoted" BOOLEAN NOT NULL DEFAULT false,
60+
"relegated" BOOLEAN NOT NULL DEFAULT false,
61+
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
62+
"currentStreak" INTEGER NOT NULL DEFAULT 0,
63+
"bestPosition" INTEGER,
64+
"activePowerUps" "LeaguePowerUp"[],
65+
"powerUpExpiryTime" TIMESTAMP(3),
66+
"challengeProgress" INTEGER NOT NULL DEFAULT 0,
67+
"challengeCompleted" BOOLEAN NOT NULL DEFAULT false,
68+
69+
CONSTRAINT "UserLeague_pkey" PRIMARY KEY ("uid")
70+
);
71+
72+
-- CreateTable
73+
CREATE TABLE "LeagueAchievement" (
74+
"uid" TEXT NOT NULL,
75+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
76+
"userUid" TEXT NOT NULL,
77+
"leagueDataUid" TEXT NOT NULL,
78+
"type" "LeagueAchievementType" NOT NULL,
79+
"earnedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
80+
"metadata" JSONB,
81+
"xpBonus" INTEGER NOT NULL DEFAULT 0,
82+
83+
CONSTRAINT "LeagueAchievement_pkey" PRIMARY KEY ("uid")
84+
);
85+
86+
-- CreateTable
87+
CREATE TABLE "LeagueHistory" (
88+
"uid" TEXT NOT NULL,
89+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
90+
"userUid" TEXT NOT NULL,
91+
"leagueDataUid" TEXT NOT NULL,
92+
"finalPosition" INTEGER NOT NULL,
93+
"finalXp" INTEGER NOT NULL,
94+
"wasPromoted" BOOLEAN NOT NULL,
95+
"wasRelegated" BOOLEAN NOT NULL,
96+
"weekStartDate" TIMESTAMP(3) NOT NULL,
97+
"weekEndDate" TIMESTAMP(3) NOT NULL,
98+
"averageXpPerDay" DOUBLE PRECISION,
99+
"powerUpsUsed" INTEGER NOT NULL DEFAULT 0,
100+
"challengesCompleted" INTEGER NOT NULL DEFAULT 0,
101+
102+
CONSTRAINT "LeagueHistory_pkey" PRIMARY KEY ("uid")
103+
);
104+
105+
-- CreateIndex
106+
CREATE UNIQUE INDEX "UserLeague_userUid_leagueUid_key" ON "UserLeague"("userUid", "leagueUid");
107+
108+
-- AddForeignKey
109+
ALTER TABLE "Leagues" ADD CONSTRAINT "Leagues_leagueDataUid_fkey" FOREIGN KEY ("leagueDataUid") REFERENCES "IndividualLeagueData"("uid") ON DELETE RESTRICT ON UPDATE CASCADE;
110+
111+
-- AddForeignKey
112+
ALTER TABLE "UserLeague" ADD CONSTRAINT "UserLeague_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "Users"("uid") ON DELETE RESTRICT ON UPDATE CASCADE;
113+
114+
-- AddForeignKey
115+
ALTER TABLE "UserLeague" ADD CONSTRAINT "UserLeague_leagueUid_fkey" FOREIGN KEY ("leagueUid") REFERENCES "Leagues"("uid") ON DELETE RESTRICT ON UPDATE CASCADE;
116+
117+
-- AddForeignKey
118+
ALTER TABLE "LeagueAchievement" ADD CONSTRAINT "LeagueAchievement_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "Users"("uid") ON DELETE RESTRICT ON UPDATE CASCADE;
119+
120+
-- AddForeignKey
121+
ALTER TABLE "LeagueAchievement" ADD CONSTRAINT "LeagueAchievement_leagueDataUid_fkey" FOREIGN KEY ("leagueDataUid") REFERENCES "IndividualLeagueData"("uid") ON DELETE RESTRICT ON UPDATE CASCADE;
122+
123+
-- AddForeignKey
124+
ALTER TABLE "LeagueHistory" ADD CONSTRAINT "LeagueHistory_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "Users"("uid") ON DELETE RESTRICT ON UPDATE CASCADE;
125+
126+
-- AddForeignKey
127+
ALTER TABLE "LeagueHistory" ADD CONSTRAINT "LeagueHistory_leagueDataUid_fkey" FOREIGN KEY ("leagueDataUid") REFERENCES "IndividualLeagueData"("uid") ON DELETE RESTRICT ON UPDATE CASCADE;

‎prisma/schema/leagues.prisma

+218
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
enum LeagueName {
2+
BRONZE
3+
SILVER
4+
GOLD
5+
PLATINUM
6+
DIAMOND
7+
}
8+
9+
enum LeagueColor {
10+
CD7F32
11+
C0C0C0
12+
FFD700
13+
E5E4E2
14+
b9f2ff
15+
FF4D4D
16+
}
17+
18+
enum LeagueAchievementType {
19+
LEAGUE_WINNER // did the user win their league last week
20+
TOP_THREE // did the user finish in the top 3 of their league last week
21+
PROMOTION // did the user get promoted last week
22+
PERFECT_WEEK // did the user get 100% of the bonus xp last week
23+
SURVIVAL // did the user avoid relegation last week
24+
COMEBACK_KING // did the user come back from the bottom of the league last week
25+
CONSISTENCY // did the user finish in the top 3 of their league for 3 weeks in a row
26+
SPEED_DEMON // did the user get to the top of the league in the shortest time possible
27+
}
28+
29+
enum LeaguePowerUp {
30+
DOUBLE_XP // double the users xp for the week
31+
SHIELD // prevent the user= from being relegated for the week
32+
STREAK_SAVER // prevent the user from being relegated for the next 3 weeks
33+
TIME_FREEZE // freeze the users league for the week
34+
BONUS_POINTS // give the user bonus xp for the week
35+
}
36+
37+
// schema for a league, no relation to a users as
38+
// we have multiple instances of the same league
39+
model IndividualLeagueData {
40+
// standard prisma fields
41+
uid String @id @default(uuid())
42+
createdAt DateTime @default(now())
43+
updatedAt DateTime @updatedAt
44+
45+
// the name of the league
46+
name LeagueName
47+
48+
// the league's color (used for styling)
49+
color LeagueColor
50+
51+
// a brief description of the league (may or may not be used)
52+
description String?
53+
54+
// the amount of xp required to be in the league
55+
xpRequirement Int
56+
57+
// when the league resets (usually weekly)
58+
resetDate DateTime
59+
60+
// leagues such as the bronze league cannot be relegated
61+
// as it's the lowest league
62+
canBeRelegated Boolean @default(false)
63+
64+
// the league's icon
65+
icon String?
66+
67+
// league rules (may or may not be used)
68+
inactivityThresholdDays Int? @default(7)
69+
70+
// Maximum number of power-ups allowed per week
71+
maxPowerUpsPerWeek Int @default(3)
72+
73+
// bonus XP multiplier for this league
74+
xpMultiplier Float @default(1.0)
75+
76+
// relations
77+
leagues Leagues[]
78+
achievements LeagueAchievement[]
79+
history LeagueHistory[]
80+
}
81+
82+
// we can have multiple instances of the same league
83+
// e.g. bronze league, silver league, gold league, etc.
84+
// this is so the same league can be used for different groups of users
85+
model Leagues {
86+
// standard prisma fields
87+
uid String @id @default(uuid())
88+
createdAt DateTime @default(now())
89+
updatedAt DateTime @updatedAt
90+
91+
// connect to the league data
92+
leagueData IndividualLeagueData @relation(fields: [leagueDataUid], references: [uid])
93+
leagueDataUid String
94+
95+
// maximum number of users in the league
96+
maxUsers Int @default(30)
97+
98+
// current number of users in the league
99+
currentUsers Int @default(0)
100+
101+
// when this league group started
102+
startDate DateTime @default(now())
103+
104+
// When this league group ends
105+
endDate DateTime
106+
107+
// how many users will be promoted
108+
promotionCount Int @default(3)
109+
110+
// how many users will be relegated
111+
relegationCount Int @default(5)
112+
113+
// weekly challenges for bonus xp
114+
weeklyChallenge String?
115+
weeklyChallengeXP Int?
116+
117+
// relations
118+
users UserLeague[]
119+
}
120+
121+
// the data tied to the user in the league
122+
model UserLeague {
123+
uid String @id @default(uuid())
124+
createdAt DateTime @default(now())
125+
updatedAt DateTime @updatedAt
126+
127+
// The user in this league
128+
user Users @relation(fields: [userUid], references: [uid])
129+
userUid String
130+
131+
// The league instance they're in
132+
league Leagues @relation(fields: [leagueUid], references: [uid])
133+
leagueUid String
134+
135+
// users position in the league
136+
position Int?
137+
138+
// users weekly xp in this league
139+
weeklyXp Int @default(0)
140+
141+
// Whether they've been promoted/relegated
142+
promoted Boolean @default(false)
143+
relegated Boolean @default(false)
144+
145+
// When they joined this league
146+
joinedAt DateTime @default(now())
147+
148+
currentStreak Int @default(0)
149+
bestPosition Int?
150+
activePowerUps LeaguePowerUp[]
151+
powerUpExpiryTime DateTime?
152+
153+
// Weekly challenge progress
154+
challengeProgress Int @default(0)
155+
challengeCompleted Boolean @default(false)
156+
157+
// Unique constraint
158+
@@unique([userUid, leagueUid])
159+
}
160+
161+
// FUTURE PROOFING - may not be used initially
162+
model LeagueAchievement {
163+
uid String @id @default(uuid())
164+
createdAt DateTime @default(now())
165+
166+
// The user who earned it
167+
user Users @relation(fields: [userUid], references: [uid])
168+
userUid String
169+
170+
// The league it was earned in
171+
league IndividualLeagueData @relation(fields: [leagueDataUid], references: [uid])
172+
leagueDataUid String
173+
174+
// Type of achievement
175+
type LeagueAchievementType
176+
177+
// When it was earned
178+
earnedAt DateTime @default(now())
179+
180+
// Additional data (e.g., position, score)
181+
metadata Json?
182+
183+
// XP bonus awarded
184+
xpBonus Int @default(0)
185+
}
186+
187+
// FUTURE PROOFING - may not be used initially
188+
model LeagueHistory {
189+
uid String @id @default(uuid())
190+
createdAt DateTime @default(now())
191+
192+
// The user
193+
user Users @relation(fields: [userUid], references: [uid])
194+
userUid String
195+
196+
// The league
197+
league IndividualLeagueData @relation(fields: [leagueDataUid], references: [uid])
198+
leagueDataUid String
199+
200+
// Their final position
201+
finalPosition Int
202+
203+
// Their final XP
204+
finalXp Int
205+
206+
// Whether they were promoted/relegated
207+
wasPromoted Boolean
208+
wasRelegated Boolean
209+
210+
// The week this history is for
211+
weekStartDate DateTime
212+
weekEndDate DateTime
213+
214+
// Performance stats
215+
averageXpPerDay Float?
216+
powerUpsUsed Int @default(0)
217+
challengesCompleted Int @default(0)
218+
}

‎prisma/schema/users.prisma

+6
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ model Users {
7373
roadmaps UserRoadmaps[]
7474
studyPathEnrollments UserStudyPath[]
7575
userMissions UserMission[]
76+
77+
LeagueHistory LeagueHistory[]
78+
79+
LeagueAchievement LeagueAchievement[]
80+
81+
UserLeague UserLeague[]
7682
}
7783

7884
model Streaks {

‎src/actions/leagues/create-league.ts

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use server';
2+
3+
import { z } from 'zod';
4+
import { prisma } from '@/lib/prisma';
5+
import { revalidatePath } from 'next/cache';
6+
7+
const leagueSchema = z.object({
8+
name: z.enum(['BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND']),
9+
color: z.enum(['CD7F32', 'C0C0C0', 'FFD700', 'E5E4E2', 'b9f2ff']),
10+
description: z.string().min(10).max(500),
11+
xpRequirement: z.number().min(0),
12+
resetDate: z.string(),
13+
canBeRelegated: z.boolean(),
14+
maxPowerUpsPerWeek: z.number().min(1).max(10),
15+
xpMultiplier: z.number().min(1).max(5),
16+
maxUsers: z.number().min(10).max(100),
17+
promotionCount: z.number().min(1),
18+
relegationCount: z.number().min(1),
19+
weeklyChallenge: z.string().optional(),
20+
weeklyChallengeXP: z.number().optional(),
21+
});
22+
23+
export async function createLeague(data: z.infer<typeof leagueSchema>) {
24+
const validatedData = leagueSchema.parse(data);
25+
26+
// Create the league data first
27+
const leagueData = await prisma.individualLeagueData.create({
28+
data: {
29+
name: validatedData.name,
30+
color: validatedData.color,
31+
description: validatedData.description,
32+
xpRequirement: validatedData.xpRequirement,
33+
resetDate: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000),
34+
canBeRelegated: validatedData.canBeRelegated,
35+
maxPowerUpsPerWeek: validatedData.maxPowerUpsPerWeek,
36+
xpMultiplier: validatedData.xpMultiplier,
37+
},
38+
});
39+
40+
revalidatePath('/admin/leagues');
41+
revalidatePath('/admin/leagues/list');
42+
43+
return { leagueData };
44+
}

‎src/actions/leagues/update-league.ts

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
'use server';
2+
3+
import { z } from 'zod';
4+
import { prisma } from '@/lib/prisma';
5+
import { revalidatePath } from 'next/cache';
6+
7+
const leagueSchema = z.object({
8+
name: z.enum(['BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND']),
9+
color: z.enum(['CD7F32', 'C0C0C0', 'FFD700', 'E5E4E2', 'b9f2ff']),
10+
description: z.string().min(10).max(500),
11+
xpRequirement: z.number().min(0),
12+
resetDate: z.string(),
13+
canBeRelegated: z.boolean(),
14+
maxPowerUpsPerWeek: z.number().min(1).max(10),
15+
xpMultiplier: z.number().min(1).max(5),
16+
maxUsers: z.number().min(10).max(100),
17+
promotionCount: z.number().min(1),
18+
relegationCount: z.number().min(1),
19+
weeklyChallenge: z.string().optional(),
20+
weeklyChallengeXP: z.number().optional(),
21+
});
22+
23+
export async function updateLeague(uid: string, data: z.infer<typeof leagueSchema>) {
24+
const validatedData = leagueSchema.parse(data);
25+
26+
const league = await prisma.leagues.findUnique({
27+
where: { uid },
28+
include: { leagueData: true },
29+
});
30+
31+
if (!league) {
32+
throw new Error('League not found');
33+
}
34+
35+
// Update league data
36+
await prisma.individualLeagueData.update({
37+
where: { uid: league.leagueData.uid },
38+
data: {
39+
name: validatedData.name,
40+
color: validatedData.color,
41+
description: validatedData.description,
42+
xpRequirement: validatedData.xpRequirement,
43+
resetDate: new Date(validatedData.resetDate),
44+
canBeRelegated: validatedData.canBeRelegated,
45+
maxPowerUpsPerWeek: validatedData.maxPowerUpsPerWeek,
46+
xpMultiplier: validatedData.xpMultiplier,
47+
},
48+
});
49+
50+
// Update league instance
51+
await prisma.leagues.update({
52+
where: { uid },
53+
data: {
54+
maxUsers: validatedData.maxUsers,
55+
promotionCount: validatedData.promotionCount,
56+
relegationCount: validatedData.relegationCount,
57+
weeklyChallenge: validatedData.weeklyChallenge,
58+
weeklyChallengeXP: validatedData.weeklyChallengeXP,
59+
},
60+
});
61+
62+
revalidatePath('/admin/leagues');
63+
revalidatePath('/admin/leagues/list');
64+
revalidatePath(`/admin/leagues/${uid}`);
65+
}

‎src/app/(app)/(default_layout)/layout.tsx

-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import SidebarLayoutTrigger from '@/components/app/navigation/sidebar-layout-trigger';
22
import CurrentStreak from '@/components/ui/current-streak';
3-
import { Separator } from '@/components/ui/separator';
43
import UpgradeModal from '@/components/app/shared/upgrade/upgrade-modal';
54
import UserXp from '@/components/ui/user-xp';
65

@@ -19,7 +18,6 @@ export default function StatisticsLayout({ children }: Readonly<{ children: Reac
1918
</div>
2019
</div>
2120
</div>
22-
<Separator className="bg-black-50" />
2321
<div className="container">{children}</div>
2422
</div>
2523
);

‎src/app/(app)/admin/layout.tsx

+30
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,36 @@ export default async function AdminLayout({ children }: { children: React.ReactN
8585
</div>
8686
</div>
8787
</li>
88+
<li className="relative group">
89+
<Link
90+
href="/admin/leagues"
91+
className="text-gray-300 hover:text-white transition-colors"
92+
>
93+
Leagues
94+
</Link>
95+
<div className="absolute left-0 mt-2 w-48 bg-black-75 border border-black-50 rounded-md shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
96+
<div className="py-1">
97+
<Link
98+
href="/admin/leagues"
99+
className="block px-4 py-2 text-sm text-gray-300 hover:bg-black-50 hover:text-white"
100+
>
101+
Dashboard
102+
</Link>
103+
<Link
104+
href="/admin/leagues/list"
105+
className="block px-4 py-2 text-sm text-gray-300 hover:bg-black-50 hover:text-white"
106+
>
107+
All Leagues
108+
</Link>
109+
<Link
110+
href="/admin/leagues/achievements"
111+
className="block px-4 py-2 text-sm text-gray-300 hover:bg-black-50 hover:text-white"
112+
>
113+
Achievements
114+
</Link>
115+
</div>
116+
</div>
117+
</li>
88118
<li>
89119
<Link
90120
href="/dashboard"
+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Metadata } from 'next';
2+
import Link from 'next/link';
3+
import { ArrowLeft } from 'lucide-react';
4+
import AdminContainer from '@/components/app/admin/admin-container';
5+
import { prisma } from '@/lib/prisma';
6+
import EditLeagueForm from '@/components/app/admin/leagues/edit-league-form';
7+
8+
export const metadata: Metadata = {
9+
title: 'TechBlitz | Edit League',
10+
description: 'View and modify league settings',
11+
};
12+
13+
async function getLeague(uid: string) {
14+
const league = await prisma.individualLeagueData.findUnique({
15+
where: { uid },
16+
});
17+
18+
return league;
19+
}
20+
21+
export default async function LeaguePage({ params }: { params: { uid: string } }) {
22+
const league = await getLeague(params.uid);
23+
24+
return (
25+
<AdminContainer>
26+
<div className="mb-8 flex items-center justify-between">
27+
<div className="flex flex-col items-start space-y-4">
28+
<Link
29+
href="/admin/leagues/list"
30+
className="flex items-center text-sm text-muted-foreground hover:text-primary"
31+
>
32+
<ArrowLeft className="mr-2 h-4 w-4" />
33+
Back to League List
34+
</Link>
35+
<h1 className="text-2xl font-bold text-white">Edit League: {league?.name}</h1>
36+
</div>
37+
</div>
38+
39+
<div className="grid gap-8">
40+
<EditLeagueForm league={league} />
41+
</div>
42+
</AdminContainer>
43+
);
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { Metadata } from 'next';
2+
import Link from 'next/link';
3+
import AdminContainer from '@/components/app/admin/admin-container';
4+
import { ArrowLeft, Award, Trophy, Clock, Star, Shield, Zap } from 'lucide-react';
5+
import { Card } from '@/components/ui/card';
6+
7+
export const metadata: Metadata = {
8+
title: 'TechBlitz | League Achievements',
9+
description: 'Manage league achievements and rewards',
10+
};
11+
12+
const MOCK_ACHIEVEMENTS = [
13+
{
14+
id: 'league-winner',
15+
name: 'League Winner',
16+
description: 'Finish first in your league',
17+
icon: Trophy,
18+
xpBonus: 1000,
19+
type: 'LEAGUE_WINNER',
20+
timesAwarded: 25,
21+
},
22+
{
23+
id: 'top-three',
24+
name: 'Top Three',
25+
description: 'Finish in the top 3 of your league',
26+
icon: Star,
27+
xpBonus: 500,
28+
type: 'TOP_THREE',
29+
timesAwarded: 75,
30+
},
31+
{
32+
id: 'promotion',
33+
name: 'Promotion',
34+
description: 'Get promoted to a higher league',
35+
icon: Award,
36+
xpBonus: 750,
37+
type: 'PROMOTION',
38+
timesAwarded: 100,
39+
},
40+
{
41+
id: 'perfect-week',
42+
name: 'Perfect Week',
43+
description: 'Complete all daily challenges for a week',
44+
icon: Star,
45+
xpBonus: 1500,
46+
type: 'PERFECT_WEEK',
47+
timesAwarded: 50,
48+
},
49+
{
50+
id: 'survival',
51+
name: 'Survival',
52+
description: 'Avoid relegation when in bottom 5',
53+
icon: Shield,
54+
xpBonus: 300,
55+
type: 'SURVIVAL',
56+
timesAwarded: 150,
57+
},
58+
{
59+
id: 'comeback-king',
60+
name: 'Comeback King',
61+
description: 'Rise from bottom 3 to top 3',
62+
icon: Zap,
63+
xpBonus: 1000,
64+
type: 'COMEBACK_KING',
65+
timesAwarded: 15,
66+
},
67+
{
68+
id: 'consistency',
69+
name: 'Consistency',
70+
description: 'Stay in top 3 for three weeks',
71+
icon: Clock,
72+
xpBonus: 2000,
73+
type: 'CONSISTENCY',
74+
timesAwarded: 10,
75+
},
76+
{
77+
id: 'speed-demon',
78+
name: 'Speed Demon',
79+
description: 'Reach top position in record time',
80+
icon: Zap,
81+
xpBonus: 1500,
82+
type: 'SPEED_DEMON',
83+
timesAwarded: 5,
84+
},
85+
];
86+
87+
export default async function LeagueAchievementsPage() {
88+
return (
89+
<AdminContainer>
90+
<div className="max-w-7xl mx-auto">
91+
<div className="flex justify-between items-center mb-6">
92+
<h1 className="text-2xl font-bold text-white">League Achievements</h1>
93+
<div className="flex space-x-3">
94+
<Link
95+
href="/admin/leagues"
96+
className="text-sm px-4 py-2 bg-black-75 hover:bg-black-50 text-white rounded-md transition-colors flex items-center gap-2"
97+
>
98+
<ArrowLeft className="h-4 w-4" /> Back to Dashboard
99+
</Link>
100+
</div>
101+
</div>
102+
103+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
104+
{MOCK_ACHIEVEMENTS.map((achievement) => {
105+
const Icon = achievement.icon;
106+
return (
107+
<Card key={achievement.id} className="bg-black-75 border-black-50 p-6">
108+
<div className="flex items-start gap-4">
109+
<div className="p-3 bg-black-50 rounded-lg">
110+
<Icon className="h-6 w-6 text-accent" />
111+
</div>
112+
<div className="flex-1">
113+
<div className="flex items-center justify-between">
114+
<h3 className="text-lg font-semibold text-white">{achievement.name}</h3>
115+
<span className="text-accent">+{achievement.xpBonus} XP</span>
116+
</div>
117+
<p className="text-gray-400 mt-1">{achievement.description}</p>
118+
<div className="flex items-center gap-4 mt-4">
119+
<div>
120+
<p className="text-sm text-gray-400">Times Awarded</p>
121+
<p className="text-white">{achievement.timesAwarded}</p>
122+
</div>
123+
<div>
124+
<p className="text-sm text-gray-400">Type</p>
125+
<p className="text-white">{achievement.type}</p>
126+
</div>
127+
</div>
128+
</div>
129+
</div>
130+
<div className="mt-4 pt-4 border-t border-black-50 flex justify-end">
131+
<Link
132+
href={`/admin/leagues/achievements/${achievement.id}`}
133+
className="text-sm text-accent hover:text-accent/80"
134+
>
135+
View Details
136+
</Link>
137+
</div>
138+
</Card>
139+
);
140+
})}
141+
</div>
142+
</div>
143+
</AdminContainer>
144+
);
145+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Metadata } from 'next';
2+
import Link from 'next/link';
3+
import AdminContainer from '@/components/app/admin/admin-container';
4+
import { ArrowLeft } from 'lucide-react';
5+
import { Card } from '@/components/ui/card';
6+
import CreateLeagueForm from '@/components/app/admin/leagues/create-league-form';
7+
8+
export const metadata: Metadata = {
9+
title: 'TechBlitz | Create League',
10+
description: 'Create a new league and configure its settings',
11+
};
12+
13+
export default async function CreateLeaguePage() {
14+
return (
15+
<AdminContainer>
16+
<div className="max-w-4xl mx-auto">
17+
<div className="flex justify-between items-center mb-6">
18+
<h1 className="text-2xl font-bold text-white">Create New League</h1>
19+
<div className="flex space-x-3">
20+
<Link
21+
href="/admin/leagues/list"
22+
className="text-sm px-4 py-2 bg-black-75 hover:bg-black-50 text-white rounded-md transition-colors flex items-center gap-2"
23+
>
24+
<ArrowLeft className="h-4 w-4" /> Back to Leagues
25+
</Link>
26+
</div>
27+
</div>
28+
29+
<Card className="bg-black-75 border-black-50 p-6">
30+
<CreateLeagueForm />
31+
</Card>
32+
</div>
33+
</AdminContainer>
34+
);
35+
}
+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { Metadata } from 'next';
2+
import Link from 'next/link';
3+
import AdminContainer from '@/components/app/admin/admin-container';
4+
import { ArrowLeft, Plus } from 'lucide-react';
5+
import { Card } from '@/components/ui/card';
6+
7+
import { Button } from '@/components/ui/button';
8+
import { getLeagues } from '@/utils/data/leagues/get';
9+
10+
export const metadata: Metadata = {
11+
title: 'TechBlitz | League List',
12+
description: 'View and manage all leagues',
13+
};
14+
15+
export default async function LeagueListPage() {
16+
const leagues = await getLeagues();
17+
return (
18+
<AdminContainer>
19+
<div className="max-w-7xl mx-auto">
20+
<div className="flex justify-between items-center mb-6">
21+
<h1 className="text-2xl font-bold text-white">League List</h1>
22+
<div className="flex space-x-3">
23+
<Link
24+
href="/admin/leagues"
25+
className="text-sm px-4 py-2 bg-black-75 hover:bg-black-50 text-white rounded-md transition-colors flex items-center gap-2"
26+
>
27+
<ArrowLeft className="h-4 w-4" /> Back to Dashboard
28+
</Link>
29+
<Link href="/admin/leagues/create">
30+
<Button className="bg-accent hover:bg-accent/80 text-white">
31+
<Plus className="h-4 w-4 mr-2" /> Create League
32+
</Button>
33+
</Link>
34+
</div>
35+
</div>
36+
37+
<div className="grid gap-6">
38+
{leagues.length === 0 && <div className="text-white">No leagues found</div>}
39+
{leagues.map((league) => (
40+
<Card key={league.uid} className="bg-black-75 border-black-50 p-6">
41+
<div className="flex items-start justify-between">
42+
<div className="flex items-start gap-4">
43+
<div
44+
className="w-3 h-3 rounded-full mt-2"
45+
style={{ backgroundColor: `#${league.color}` }}
46+
/>
47+
<div>
48+
<h3 className="text-xl font-semibold text-white">{league.name}</h3>
49+
<p className="text-gray-400 mt-1">{league.description}</p>
50+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
51+
<div>
52+
<p className="text-sm text-gray-400">XP Requirement</p>
53+
<p className="text-white">{league.xpRequirement.toLocaleString()}</p>
54+
</div>
55+
<div>
56+
<p className="text-sm text-gray-400">XP Multiplier</p>
57+
<p className="text-white">{league.xpMultiplier}x</p>
58+
</div>
59+
<div>
60+
<p className="text-sm text-gray-400">Power-ups/Week</p>
61+
<p className="text-white">{league.maxPowerUpsPerWeek}</p>
62+
</div>
63+
</div>
64+
</div>
65+
</div>
66+
</div>
67+
<div className="mt-6 flex justify-between">
68+
<span className="text-sm text-gray-400">
69+
{league.resetDate.toLocaleDateString()} (
70+
{Math.ceil(
71+
(league.resetDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)
72+
)}{' '}
73+
days away)
74+
</span>
75+
<Link
76+
href={`/admin/leagues/${league.uid}`}
77+
className="text-sm text-accent hover:text-accent/80"
78+
>
79+
View Details
80+
</Link>
81+
</div>
82+
</Card>
83+
))}
84+
</div>
85+
</div>
86+
</AdminContainer>
87+
);
88+
}

‎src/app/(app)/admin/leagues/page.tsx

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { Metadata } from 'next';
2+
import Link from 'next/link';
3+
import AdminContainer from '@/components/app/admin/admin-container';
4+
import { Trophy, Users, Award, ArrowUpRight } from 'lucide-react';
5+
import { Card } from '@/components/ui/card';
6+
7+
export const metadata: Metadata = {
8+
title: 'TechBlitz | League Management',
9+
description: 'Manage leagues, achievements, and user rankings',
10+
};
11+
12+
export default async function LeaguesAdminPage() {
13+
return (
14+
<AdminContainer>
15+
<div className="max-w-7xl mx-auto">
16+
<div className="flex justify-between items-center mb-6">
17+
<h1 className="text-2xl font-bold text-white">League Management</h1>
18+
<div className="flex space-x-3">
19+
<Link
20+
href="/admin"
21+
className="text-sm px-4 py-2 bg-black-75 hover:bg-black-50 text-white rounded-md transition-colors"
22+
>
23+
Back to Dashboard
24+
</Link>
25+
</div>
26+
</div>
27+
28+
{/* Statistics Cards */}
29+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
30+
<Card className="bg-black-75 border-black-50 p-6">
31+
<div className="flex items-center justify-between">
32+
<div>
33+
<p className="text-sm text-gray-400">Total Leagues</p>
34+
<h3 className="text-2xl font-bold text-white mt-1">5</h3>
35+
</div>
36+
<Trophy className="h-8 w-8 text-accent" />
37+
</div>
38+
</Card>
39+
40+
<Card className="bg-black-75 border-black-50 p-6">
41+
<div className="flex items-center justify-between">
42+
<div>
43+
<p className="text-sm text-gray-400">Active Users</p>
44+
<h3 className="text-2xl font-bold text-white mt-1">150</h3>
45+
</div>
46+
<Users className="h-8 w-8 text-primary" />
47+
</div>
48+
</Card>
49+
50+
<Card className="bg-black-75 border-black-50 p-6">
51+
<div className="flex items-center justify-between">
52+
<div>
53+
<p className="text-sm text-gray-400">Achievements Earned</p>
54+
<h3 className="text-2xl font-bold text-white mt-1">324</h3>
55+
</div>
56+
<Award className="h-8 w-8 text-green-400" />
57+
</div>
58+
</Card>
59+
</div>
60+
61+
{/* Quick Actions */}
62+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
63+
<Card className="bg-black-75 border-black-50 p-6">
64+
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
65+
<div className="space-y-4">
66+
<Link
67+
href="/admin/leagues/list"
68+
className="flex items-center justify-between p-3 bg-black-50 rounded-md hover:bg-black-25 transition-colors"
69+
>
70+
<div className="flex items-center gap-3">
71+
<Trophy className="h-5 w-5 text-accent" />
72+
<div>
73+
<h3 className="text-white font-medium">Manage Leagues</h3>
74+
<p className="text-sm text-gray-400">View and edit league settings</p>
75+
</div>
76+
</div>
77+
<ArrowUpRight className="h-5 w-5 text-gray-400" />
78+
</Link>
79+
80+
<Link
81+
href="/admin/leagues/achievements"
82+
className="flex items-center justify-between p-3 bg-black-50 rounded-md hover:bg-black-25 transition-colors"
83+
>
84+
<div className="flex items-center gap-3">
85+
<Award className="h-5 w-5 text-green-400" />
86+
<div>
87+
<h3 className="text-white font-medium">Achievement Settings</h3>
88+
<p className="text-sm text-gray-400">Configure league achievements</p>
89+
</div>
90+
</div>
91+
<ArrowUpRight className="h-5 w-5 text-gray-400" />
92+
</Link>
93+
</div>
94+
</Card>
95+
96+
<Card className="bg-black-75 border-black-50 p-6">
97+
<h2 className="text-xl font-semibold text-white mb-4">Recent Activity</h2>
98+
<div className="space-y-4">
99+
<div className="flex items-center justify-between py-2 border-b border-black-50">
100+
<div className="flex items-center gap-3">
101+
<Trophy className="h-4 w-4 text-accent" />
102+
<div>
103+
<p className="text-white">New user promoted to Gold League</p>
104+
<p className="text-sm text-gray-400">2 minutes ago</p>
105+
</div>
106+
</div>
107+
</div>
108+
<div className="flex items-center justify-between py-2 border-b border-black-50">
109+
<div className="flex items-center gap-3">
110+
<Award className="h-4 w-4 text-green-400" />
111+
<div>
112+
<p className="text-white">Achievement unlocked: Perfect Week</p>
113+
<p className="text-sm text-gray-400">15 minutes ago</p>
114+
</div>
115+
</div>
116+
</div>
117+
<div className="flex items-center justify-between py-2">
118+
<div className="flex items-center gap-3">
119+
<Users className="h-4 w-4 text-primary" />
120+
<div>
121+
<p className="text-white">5 users joined Silver League</p>
122+
<p className="text-sm text-gray-400">1 hour ago</p>
123+
</div>
124+
</div>
125+
</div>
126+
</div>
127+
</Card>
128+
</div>
129+
</div>
130+
</AdminContainer>
131+
);
132+
}

‎src/app/(app)/admin/page.tsx

+20
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,26 @@ export default function AdminDashboardPage() {
8888
</Link>
8989
</div>
9090
</div>
91+
<div className="bg-[#000000] rounded-lg shadow-sm border border-black-50 overflow-hidden">
92+
<div className="p-6">
93+
<h2 className="text-xl font-semibold mb-2 text-white">Leagues</h2>
94+
<p className="text-gray-400 mb-4">Create and manage leagues.</p>
95+
</div>
96+
<div className="bg-secondary px-6 py-3 flex justify-between">
97+
<Link
98+
href="/admin/leagues"
99+
className="text-primary hover:text-primary/90 transition-colors"
100+
>
101+
Create New
102+
</Link>
103+
<Link
104+
href="/admin/leagues/list"
105+
className="text-primary hover:text-primary/90 transition-colors"
106+
>
107+
View All
108+
</Link>
109+
</div>
110+
</div>
91111
</div>
92112

93113
{/* Tools Section */}

‎src/app/(app)/dashboard/page.tsx

-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Separator } from '@/components/ui/separator';
21
import ClientPage from './page.client';
32
import DashboardBentoGrid from '@/components/app/dashboard/dashboard-bento-grid';
43
import DashboardHeader from '@/components/app/dashboard/dashboard-header';
@@ -24,7 +23,6 @@ export default async function Dashboard({ searchParams }: DashboardProps) {
2423
>
2524
<div className="text-white flex flex-col gap-y-4 h-full">
2625
<DashboardHeader />
27-
<Separator className="bg-black-50" />
2826
<div className="h-full mt-1 max-w-7xl px-6 mx-auto flex flex-col gap-5">
2927
<DashboardBentoGrid />
3028
</div>
+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import dynamic from 'next/dynamic';
2+
import { Suspense } from 'react';
3+
import { createMetadata } from '@/utils/seo';
4+
import { getMostQuestionsAnswered } from '@/utils/data/leaderboard/get-most-questions-answered';
5+
import GlobalPagination from '@/components/app/shared/pagination';
6+
import { useUserServer } from '@/hooks/use-user-server';
7+
import AnswerQuestionModal from '@/components/app/leaderboard/answer-question-modal';
8+
import { getSuggestions } from '@/utils/data/questions/get-suggestions';
9+
import { getUserXp } from '@/utils/data/user/authed/get-user-xp';
10+
import LeaguesShowcase from '@/components/app/leaderboard/leagues/leagues-showcase';
11+
import UpgradeCard from '@/components/app/shared/upgrade/upgrade-card';
12+
import { getLeagues } from '@/utils/data/leagues/get';
13+
14+
const LeaderboardMostQuestionsAnswered = dynamic(
15+
() => import('@/components/app/leaderboard/leaderboard-most-questions-answered'),
16+
{ loading: () => <div>Loading leaderboard...</div> }
17+
);
18+
19+
export async function generateMetadata() {
20+
return createMetadata({
21+
title: 'Leaderboard | TechBlitz',
22+
description: 'See how you stack up against the rest of the community.',
23+
canonicalUrl: '/leaderboard',
24+
image: {
25+
text: 'Leaderboard | TechBlitz',
26+
bgColor: '#000000',
27+
textColor: '#ffffff',
28+
},
29+
});
30+
}
31+
32+
export default async function Page({
33+
searchParams,
34+
}: {
35+
searchParams: { page: string; postsPerPage: string };
36+
}) {
37+
const currentPage = Number.parseInt(searchParams.page as string) || 1;
38+
const postsPerPage = Number.parseInt(searchParams.postsPerPage as string) || 10;
39+
40+
// run in parallel
41+
const [topThreeUsersData, hasAnsweredMoreThan3Questions, user] = await Promise.all([
42+
getMostQuestionsAnswered(3, 1),
43+
getUserXp().then(({ userXp }) => userXp > 0),
44+
useUserServer(),
45+
]);
46+
47+
const leagues = await getLeagues();
48+
49+
// users logged in must answer more than three questions to view the leaderboard
50+
if (!hasAnsweredMoreThan3Questions && user) {
51+
// get the user a recommended question
52+
const recommendedQuestion = await getSuggestions({
53+
userUid: user.uid,
54+
limit: 1,
55+
});
56+
57+
return <AnswerQuestionModal recommendedQuestion={recommendedQuestion[0]} />;
58+
}
59+
60+
return (
61+
<div className="mx-auto px-4 sm:px-6 lg:px-8 py-12 group flex flex-col xl:flex-row gap-12">
62+
<div className="w-full lg:min-w-[75%] space-y-6">
63+
<LeaguesShowcase leagues={leagues} />
64+
{/**
65+
*
66+
<Suspense fallback={<LoadingSpinner />}>
67+
{/** @ts-ignore - this is the valid type
68+
<LeaderboardHero topThreeUsers={topThreeUsers} />
69+
</Suspense>
70+
*/}
71+
<div className="flex flex-col gap-10 mt-24">
72+
<Suspense fallback={<div>Loading leaderboard...</div>}>
73+
<LeaderboardMostQuestionsAnswered page={currentPage} postsPerPage={postsPerPage} />
74+
<div className="w-full flex justify-center gap-x-2">
75+
<GlobalPagination
76+
currentPage={currentPage}
77+
totalPages={Math.ceil(topThreeUsersData.totalCount / postsPerPage)}
78+
href={'/leaderboard'}
79+
paramName="page"
80+
postsPerPage={postsPerPage}
81+
/>
82+
</div>
83+
</Suspense>
84+
</div>
85+
</div>
86+
<aside className="w-full xl:w-1/4">
87+
<div className="sticky top-10 space-y-5 w-full">
88+
<UpgradeCard
89+
title="Try TechBlitz premium"
90+
description="Premium questions, personalized roadmaps, and unlimited AI credits!"
91+
/>
92+
</div>
93+
</aside>
94+
</div>
95+
);
96+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { useRouter } from 'next/navigation';
5+
import { useForm } from 'react-hook-form';
6+
import { zodResolver } from '@hookform/resolvers/zod';
7+
import { z } from 'zod';
8+
import { Input } from '@/components/ui/input';
9+
import { Button } from '@/components/ui/button';
10+
import { Label } from '@/components/ui/label';
11+
import { Textarea } from '@/components/ui/textarea';
12+
import { createLeague } from '@/actions/leagues/create-league';
13+
import { toast } from 'sonner';
14+
15+
const leagueSchema = z.object({
16+
name: z.enum(['BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND']),
17+
color: z.enum(['CD7F32', 'C0C0C0', 'FFD700', 'E5E4E2', 'b9f2ff']),
18+
description: z.string().min(10).max(500),
19+
xpRequirement: z.number().min(0),
20+
resetDate: z.string(),
21+
canBeRelegated: z.boolean(),
22+
maxPowerUpsPerWeek: z.number().min(1).max(10),
23+
xpMultiplier: z.number().min(1).max(5),
24+
maxUsers: z.number().min(10).max(100),
25+
promotionCount: z.number().min(1),
26+
relegationCount: z.number().min(1),
27+
weeklyChallenge: z.string().optional(),
28+
weeklyChallengeXP: z.number().optional(),
29+
});
30+
31+
type LeagueFormData = z.infer<typeof leagueSchema>;
32+
33+
export default function CreateLeagueForm() {
34+
const router = useRouter();
35+
const [isSubmitting, setIsSubmitting] = useState(false);
36+
37+
const {
38+
register,
39+
handleSubmit,
40+
formState: { errors },
41+
} = useForm<LeagueFormData>({
42+
resolver: zodResolver(leagueSchema),
43+
defaultValues: {
44+
canBeRelegated: true,
45+
maxPowerUpsPerWeek: 3,
46+
xpMultiplier: 1,
47+
maxUsers: 30,
48+
promotionCount: 3,
49+
relegationCount: 5,
50+
},
51+
});
52+
53+
const onSubmit = async (data: LeagueFormData) => {
54+
try {
55+
setIsSubmitting(true);
56+
await createLeague(data);
57+
toast.success('League created successfully');
58+
router.push('/admin/leagues/list');
59+
} catch (error) {
60+
toast.error('Failed to create league');
61+
console.error('Failed to create league:', error);
62+
} finally {
63+
setIsSubmitting(false);
64+
}
65+
};
66+
67+
return (
68+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
69+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
70+
<div>
71+
<Label htmlFor="name" className="text-white">
72+
League Name
73+
</Label>
74+
<select
75+
id="name"
76+
{...register('name')}
77+
className="w-full bg-black-50 text-white border border-black-25 rounded-md p-2"
78+
>
79+
<option value="BRONZE">Bronze League</option>
80+
<option value="SILVER">Silver League</option>
81+
<option value="GOLD">Gold League</option>
82+
<option value="PLATINUM">Platinum League</option>
83+
<option value="DIAMOND">Diamond League</option>
84+
</select>
85+
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name.message}</p>}
86+
</div>
87+
88+
<div>
89+
<Label htmlFor="color" className="text-white">
90+
League Color
91+
</Label>
92+
<select
93+
id="color"
94+
{...register('color')}
95+
className="w-full bg-black-50 text-white border border-black-25 rounded-md p-2"
96+
>
97+
<option value="CD7F32">Bronze</option>
98+
<option value="C0C0C0">Silver</option>
99+
<option value="FFD700">Gold</option>
100+
<option value="E5E4E2">Platinum</option>
101+
<option value="b9f2ff">Diamond</option>
102+
</select>
103+
{errors.color && <p className="text-red-500 text-sm mt-1">{errors.color.message}</p>}
104+
</div>
105+
</div>
106+
107+
<div>
108+
<Label htmlFor="description" className="text-white">
109+
Description
110+
</Label>
111+
<Textarea
112+
id="description"
113+
{...register('description')}
114+
className="bg-black-50 text-white border border-black-25"
115+
placeholder="Enter league description..."
116+
/>
117+
{errors.description && (
118+
<p className="text-red-500 text-sm mt-1">{errors.description.message}</p>
119+
)}
120+
</div>
121+
122+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
123+
<div>
124+
<Label htmlFor="xpRequirement" className="text-white">
125+
XP Requirement
126+
</Label>
127+
<Input
128+
id="xpRequirement"
129+
type="number"
130+
{...register('xpRequirement', { valueAsNumber: true })}
131+
className="bg-black-50 text-white border border-black-25"
132+
/>
133+
{errors.xpRequirement && (
134+
<p className="text-red-500 text-sm mt-1">{errors.xpRequirement.message}</p>
135+
)}
136+
</div>
137+
138+
<div>
139+
<Label htmlFor="resetDate" className="text-white">
140+
Reset Date
141+
</Label>
142+
<Input
143+
id="resetDate"
144+
type="datetime-local"
145+
{...register('resetDate')}
146+
className="bg-black-50 text-white border border-black-25"
147+
/>
148+
{errors.resetDate && (
149+
<p className="text-red-500 text-sm mt-1">{errors.resetDate.message}</p>
150+
)}
151+
</div>
152+
</div>
153+
154+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
155+
<div>
156+
<Label htmlFor="maxPowerUpsPerWeek" className="text-white">
157+
Max Power-ups Per Week
158+
</Label>
159+
<Input
160+
id="maxPowerUpsPerWeek"
161+
type="number"
162+
{...register('maxPowerUpsPerWeek', { valueAsNumber: true })}
163+
className="bg-black-50 text-white border border-black-25"
164+
/>
165+
{errors.maxPowerUpsPerWeek && (
166+
<p className="text-red-500 text-sm mt-1">{errors.maxPowerUpsPerWeek.message}</p>
167+
)}
168+
</div>
169+
170+
<div>
171+
<Label htmlFor="xpMultiplier" className="text-white">
172+
XP Multiplier
173+
</Label>
174+
<Input
175+
id="xpMultiplier"
176+
type="number"
177+
step="0.1"
178+
{...register('xpMultiplier', { valueAsNumber: true })}
179+
className="bg-black-50 text-white border border-black-25"
180+
/>
181+
{errors.xpMultiplier && (
182+
<p className="text-red-500 text-sm mt-1">{errors.xpMultiplier.message}</p>
183+
)}
184+
</div>
185+
186+
<div>
187+
<Label htmlFor="maxUsers" className="text-white">
188+
Max Users
189+
</Label>
190+
<Input
191+
id="maxUsers"
192+
type="number"
193+
{...register('maxUsers', { valueAsNumber: true })}
194+
className="bg-black-50 text-white border border-black-25"
195+
/>
196+
{errors.maxUsers && (
197+
<p className="text-red-500 text-sm mt-1">{errors.maxUsers.message}</p>
198+
)}
199+
</div>
200+
</div>
201+
202+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
203+
<div>
204+
<Label htmlFor="promotionCount" className="text-white">
205+
Promotion Count
206+
</Label>
207+
<Input
208+
id="promotionCount"
209+
type="number"
210+
{...register('promotionCount', { valueAsNumber: true })}
211+
className="bg-black-50 text-white border border-black-25"
212+
/>
213+
{errors.promotionCount && (
214+
<p className="text-red-500 text-sm mt-1">{errors.promotionCount.message}</p>
215+
)}
216+
</div>
217+
218+
<div>
219+
<Label htmlFor="relegationCount" className="text-white">
220+
Relegation Count
221+
</Label>
222+
<Input
223+
id="relegationCount"
224+
type="number"
225+
{...register('relegationCount', { valueAsNumber: true })}
226+
className="bg-black-50 text-white border border-black-25"
227+
/>
228+
{errors.relegationCount && (
229+
<p className="text-red-500 text-sm mt-1">{errors.relegationCount.message}</p>
230+
)}
231+
</div>
232+
</div>
233+
234+
<div>
235+
<div className="flex items-center gap-2 mb-4">
236+
<input
237+
type="checkbox"
238+
id="canBeRelegated"
239+
{...register('canBeRelegated')}
240+
className="bg-black-50 border border-black-25"
241+
/>
242+
<Label htmlFor="canBeRelegated" className="text-white">
243+
Can be relegated
244+
</Label>
245+
</div>
246+
</div>
247+
248+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
249+
<div>
250+
<Label htmlFor="weeklyChallenge" className="text-white">
251+
Weekly Challenge
252+
</Label>
253+
<Input
254+
id="weeklyChallenge"
255+
{...register('weeklyChallenge')}
256+
className="bg-black-50 text-white border border-black-25"
257+
placeholder="Optional weekly challenge"
258+
/>
259+
{errors.weeklyChallenge && (
260+
<p className="text-red-500 text-sm mt-1">{errors.weeklyChallenge.message}</p>
261+
)}
262+
</div>
263+
264+
<div>
265+
<Label htmlFor="weeklyChallengeXP" className="text-white">
266+
Weekly Challenge XP
267+
</Label>
268+
<Input
269+
id="weeklyChallengeXP"
270+
type="number"
271+
{...register('weeklyChallengeXP', { valueAsNumber: true })}
272+
className="bg-black-50 text-white border border-black-25"
273+
placeholder="Optional XP reward"
274+
/>
275+
{errors.weeklyChallengeXP && (
276+
<p className="text-red-500 text-sm mt-1">{errors.weeklyChallengeXP.message}</p>
277+
)}
278+
</div>
279+
</div>
280+
281+
<div className="flex justify-end">
282+
<Button
283+
type="submit"
284+
disabled={isSubmitting}
285+
className="bg-accent hover:bg-accent/80 text-white"
286+
>
287+
{isSubmitting ? 'Creating...' : 'Create League'}
288+
</Button>
289+
</div>
290+
</form>
291+
);
292+
}

‎src/components/app/admin/leagues/edit-league-form.tsx

+384
Large diffs are not rendered by default.

‎src/components/app/leaderboard/leaderboard-most-questions-answered.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ export default async function LeaderboardXPRankings({
2828
const userPromise = useUserServer();
2929

3030
return (
31-
<Card className="border-none">
32-
<CardHeader className="p-0 md:p-6 w-full flex gap-2 justify-between">
31+
<Card className="border-none flex flex-col gap-6">
32+
<CardHeader className="p-0 w-full flex gap-2 justify-between">
3333
<div className="flex flex-wrap items-center justify-between gap-4">
3434
<div className="order-last md:order-first flex items-center gap-x-2">
3535
<Sparkles className="size-5 text-accent" />
@@ -43,7 +43,7 @@ export default async function LeaderboardXPRankings({
4343
<ShowTimeTakenToggle userPromise={userPromise} />
4444
</div>
4545
</CardHeader>
46-
<CardContent className="p-0 pt-6 md:p-6 md:pt-0">
46+
<CardContent className="p-0">
4747
<Table>
4848
<TableHeader>
4949
<TableRow className="border-white/10">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
'use client';
2+
3+
import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel';
4+
import { Card, CardContent } from '@/components/ui/card';
5+
import { useState } from 'react';
6+
import { capitalise } from '@/utils';
7+
8+
// types
9+
import type { IndividualLeagueData } from '@prisma/client';
10+
11+
function LeagueIcon({
12+
league,
13+
isActive,
14+
onClick,
15+
}: {
16+
league: IndividualLeagueData;
17+
isActive: boolean;
18+
onClick: () => void;
19+
}) {
20+
return (
21+
<CarouselItem className="basis-[15%] pl-0 overflow-visible">
22+
<Card
23+
className={`border-none p-3 transition-all duration-300 ${
24+
isActive ? 'scale-100 opacity-100' : 'scale-75 opacity-50'
25+
}`}
26+
onClick={onClick}
27+
>
28+
<CardContent className="flex items-center justify-center p-6">
29+
{/** DUMMY ICON / SHIELD UNTIL I DESIGN ONE */}
30+
<div style={{ backgroundColor: `#${league.color}` }} className="size-20 rounded-lg"></div>
31+
</CardContent>
32+
</Card>
33+
</CarouselItem>
34+
);
35+
}
36+
37+
export default function LeaguesShowcase({ leagues }: { leagues: IndividualLeagueData[] }) {
38+
const [currentLeagueIndex, setCurrentLeagueIndex] = useState(0);
39+
const [carouselApi, setCarouselApi] = useState<any>(null);
40+
41+
const handleLeagueClick = (index: number) => {
42+
setCurrentLeagueIndex(index);
43+
if (carouselApi) {
44+
carouselApi.scrollTo(index);
45+
}
46+
};
47+
48+
return (
49+
<section className="space-y-6">
50+
{/* Carousel section */}
51+
<Carousel
52+
opts={{
53+
loop: true,
54+
align: 'center',
55+
containScroll: false,
56+
dragFree: true,
57+
}}
58+
className="w-full px-4 md:px-12"
59+
setApi={(api) => {
60+
setCarouselApi(api);
61+
api?.on('select', () => {
62+
setCurrentLeagueIndex(api.selectedScrollSnap());
63+
});
64+
}}
65+
>
66+
<CarouselContent className="cursor-pointer">
67+
{leagues.map((league, index) => (
68+
<LeagueIcon
69+
key={league.uid}
70+
league={league}
71+
isActive={index === currentLeagueIndex}
72+
onClick={() => handleLeagueClick(index)}
73+
/>
74+
))}
75+
</CarouselContent>
76+
</Carousel>
77+
{/* Detached text section */}
78+
<div className="text-center mb-8 min-h-[50px] flex flex-col items-center justify-center">
79+
<h3 className="text-3xl font-semibold text-gradient from-white/55 to-white transition-all duration-300">
80+
{leagues[currentLeagueIndex]?.name
81+
? `${capitalise(leagues[currentLeagueIndex].name)} League`
82+
: 'League'}
83+
</h3>
84+
</div>
85+
</section>
86+
);
87+
}

‎src/components/ui/icons/icons/map.tsx

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from 'react';
2+
3+
type iconProps = {
4+
fill?: string,
5+
secondaryfill?: string,
6+
strokewidth?: number,
7+
width?: string,
8+
height?: string,
9+
title?: string
10+
}
11+
12+
function Map(props: iconProps) {
13+
const fill = props.fill || 'currentColor';
14+
const secondaryfill = props.secondaryfill || fill;
15+
const strokewidth = props.strokewidth || 1;
16+
const width = props.width || '1em';
17+
const height = props.height || '1em';
18+
const title = props.title || "map";
19+
20+
return (
21+
<svg height={height} width={width} viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
22+
<title>{title}</title>
23+
<g fill={fill}>
24+
<line fill="none" stroke={secondaryfill} strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokewidth} x1="6.25" x2="6.25" y1="2.792" y2="13.292"/>
25+
<line fill="none" stroke={secondaryfill} strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokewidth} x1="11.75" x2="11.75" y1="4.708" y2="15.208"/>
26+
<path d="M2.533,3.576l3.432-.763c.186-.041,.38-.029,.559,.036l4.952,1.801c.179,.065,.373,.078,.559,.036l2.998-.666c.625-.139,1.217,.336,1.217,.976V13.448c0,.469-.326,.875-.783,.976l-3.432,.763c-.186,.041-.38,.029-.559-.036l-4.952-1.801c-.179-.065-.373-.078-.559-.036l-2.998,.666c-.625,.139-1.217-.336-1.217-.976V4.552c0-.469,.326-.875,.783-.976Z" fill="none" stroke={fill} strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokewidth}/>
27+
</g>
28+
</svg>
29+
);
30+
};
31+
32+
export default Map;

‎src/utils/data/leagues/get.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { prisma } from '@/lib/prisma';
2+
3+
export async function getLeagues() {
4+
const leagues = await prisma.individualLeagueData.findMany();
5+
return leagues;
6+
}

0 commit comments

Comments
 (0)
Please sign in to comment.