Skip to content

Commit 8cc9db9

Browse files
authored
Merge pull request #19 from Mra1k3r0/master
feat: invites just dropped, stats finally make sense
2 parents f0bf257 + fb024dd commit 8cc9db9

12 files changed

Lines changed: 498 additions & 37 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,4 @@ yarn-error.log*
4040
# typescript
4141
*.tsbuildinfo
4242
next-env.d.ts
43+

app/(auth)/login/page.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@ export const metadata: Metadata = {
99
"Log in to your DevPulse account to monitor your coding activity and compete on leaderboards.",
1010
};
1111

12-
export default async function Login() {
12+
export default async function Login(props: {
13+
searchParams?: Promise<{ redirect?: string }>;
14+
}) {
15+
const redirectParam = (await props.searchParams)?.redirect;
16+
const redirectTo =
17+
redirectParam && redirectParam.startsWith("/") && !redirectParam.startsWith("//")
18+
? redirectParam
19+
: undefined;
20+
1321
return (
1422
<div className="min-h-screen flex bg-[#0a0a1a] text-white">
1523
{/* Left Side - Visual / Branding */}
@@ -88,7 +96,7 @@ export default async function Login() {
8896
<p className="mt-8 text-center text-sm text-gray-400">
8997
Don&apos;t have an account?{" "}
9098
<Link
91-
href="/signup"
99+
href={redirectTo ? `/signup?redirect=${encodeURIComponent(redirectTo)}` : "/signup"}
92100
className="text-indigo-400 hover:text-indigo-300 font-semibold transition-colors underline-offset-4 hover:underline"
93101
>
94102
Sign up

app/(auth)/signup/page.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@ export const metadata: Metadata = {
99
"Create a DevPulse account to monitor your coding activity and compete on leaderboards.",
1010
};
1111

12-
export default async function Signup() {
12+
export default async function Signup(props: {
13+
searchParams?: Promise<{ redirect?: string }>;
14+
}) {
15+
const redirectParam = (await props.searchParams)?.redirect;
16+
const redirectTo =
17+
redirectParam && redirectParam.startsWith("/") && !redirectParam.startsWith("//")
18+
? redirectParam
19+
: undefined;
20+
1321
return (
1422
<div className="min-h-screen flex bg-[#0a0a1a] text-white">
1523
{/* Left Side - Visual / Branding */}
@@ -90,7 +98,7 @@ export default async function Signup() {
9098
<p className="mt-8 text-center text-sm text-gray-400">
9199
Already have an account?{" "}
92100
<Link
93-
href="/login"
101+
href={redirectTo ? `/login?redirect=${encodeURIComponent(redirectTo)}` : "/login"}
94102
className="text-indigo-400 hover:text-indigo-300 font-semibold transition-colors underline-offset-4 hover:underline"
95103
>
96104
Log in
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { createClient } from "../../../lib/supabase/client";
5+
import { useRouter } from "next/navigation";
6+
import { toast } from "react-toastify";
7+
import Link from "next/link";
8+
9+
export default function JoinButton({
10+
code,
11+
leaderboardSlug,
12+
isLoggedIn,
13+
alreadyMember,
14+
}: {
15+
code: string;
16+
leaderboardSlug: string;
17+
isLoggedIn: boolean;
18+
alreadyMember: boolean;
19+
}) {
20+
const router = useRouter();
21+
const [joining, setJoining] = useState(false);
22+
23+
if (alreadyMember) {
24+
return (
25+
<Link
26+
href={`/leaderboard/${leaderboardSlug}`}
27+
className="btn-primary inline-flex items-center justify-center gap-2 w-full py-4 text-sm font-bold rounded-xl shadow-lg shadow-indigo-500/20"
28+
>
29+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
30+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
31+
</svg>
32+
Already a Member &mdash; View Leaderboard
33+
</Link>
34+
);
35+
}
36+
37+
if (!isLoggedIn) {
38+
return (
39+
<div className="space-y-3">
40+
<Link
41+
href={`/login?redirect=${encodeURIComponent(`/join?id=${code}`)}`}
42+
className="btn-primary inline-flex items-center justify-center gap-2 w-full py-4 text-sm font-bold rounded-xl shadow-lg shadow-indigo-500/20"
43+
>
44+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
45+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
46+
</svg>
47+
Log In to Join
48+
</Link>
49+
<p className="text-xs text-gray-500">
50+
Don&apos;t have an account?{" "}
51+
<Link href={`/signup?redirect=${encodeURIComponent(`/join?id=${code}`)}`} className="text-indigo-400 hover:text-indigo-300 transition-colors">
52+
Sign up free
53+
</Link>
54+
</p>
55+
</div>
56+
);
57+
}
58+
59+
const handleJoin = async () => {
60+
setJoining(true);
61+
const supabase = createClient();
62+
63+
const joinPromise = (async () => {
64+
const { data: userData } = await supabase.auth.getUser();
65+
const user = userData.user;
66+
if (!user) throw new Error("Not authenticated");
67+
68+
const { data: board } = await supabase
69+
.from("leaderboards")
70+
.select("id")
71+
.eq("join_code", code)
72+
.single();
73+
74+
if (!board) throw new Error("Invalid invite code");
75+
76+
const { error } = await supabase.from("leaderboard_members").insert({
77+
leaderboard_id: board.id,
78+
user_id: user.id,
79+
});
80+
81+
if (error) throw error;
82+
return board;
83+
})();
84+
85+
try {
86+
await toast.promise(joinPromise, {
87+
pending: "Joining leaderboard...",
88+
success: "You're in! Welcome to the leaderboard.",
89+
error: {
90+
render({ data }) {
91+
const err = data as Error;
92+
if (err?.code === "23505") {
93+
return "You are already a member of this leaderboard.";
94+
}
95+
return err?.message || "Failed to join. Please try again.";
96+
},
97+
},
98+
});
99+
100+
router.push(`/leaderboard/${leaderboardSlug}`);
101+
} finally {
102+
setJoining(false);
103+
}
104+
};
105+
106+
return (
107+
<button
108+
onClick={handleJoin}
109+
disabled={joining}
110+
className="btn-primary inline-flex items-center justify-center gap-2 w-full py-4 text-sm font-bold rounded-xl shadow-lg shadow-indigo-500/20 hover:shadow-indigo-500/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
111+
>
112+
{joining ? (
113+
<>
114+
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
115+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
116+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
117+
</svg>
118+
Joining...
119+
</>
120+
) : (
121+
<>
122+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
123+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
124+
</svg>
125+
Accept Invite &amp; Join
126+
</>
127+
)}
128+
</button>
129+
);
130+
}

app/(public)/join/[code]/page.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Metadata } from "next";
2+
import { createClient } from "../../../lib/supabase/server";
3+
import { redirect } from "next/navigation";
4+
5+
type Props = {
6+
params: Promise<{ code: string }>;
7+
};
8+
9+
async function getLeaderboard(code: string) {
10+
const supabase = await createClient();
11+
const { data } = await supabase
12+
.from("leaderboards")
13+
.select("id, name, description, slug, owner_id, created_at")
14+
.eq("join_code", code)
15+
.single();
16+
return data;
17+
}
18+
19+
async function getMemberCount(leaderboardId: string) {
20+
const supabase = await createClient();
21+
const { count } = await supabase
22+
.from("leaderboard_members")
23+
.select("*", { count: "exact", head: true })
24+
.eq("leaderboard_id", leaderboardId);
25+
return count ?? 0;
26+
}
27+
28+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
29+
const { code } = await params;
30+
const leaderboard = await getLeaderboard(code);
31+
32+
if (!leaderboard) {
33+
return {
34+
title: "Invite Not Found - DevPulse",
35+
description: "This invite link is invalid or has expired.",
36+
};
37+
}
38+
39+
const title = `You're invited to join ${leaderboard.name}!`;
40+
const description =
41+
leaderboard.description?.length > 0
42+
? leaderboard.description
43+
: `Join the ${leaderboard.name} leaderboard on DevPulse and compete with other developers. Track your coding activity and climb the ranks!`;
44+
45+
return {
46+
title: `${title} - DevPulse`,
47+
description,
48+
openGraph: {
49+
title,
50+
description,
51+
type: "website",
52+
siteName: "DevPulse",
53+
url: `/join?id=${encodeURIComponent(code)}`,
54+
},
55+
twitter: {
56+
card: "summary_large_image",
57+
title,
58+
description,
59+
},
60+
};
61+
}
62+
63+
export default async function JoinPage({ params }: Props) {
64+
const { code } = await params;
65+
redirect(`/join?id=${encodeURIComponent(code)}`);
66+
}

0 commit comments

Comments
 (0)