Skip to content

Commit 2d8a746

Browse files
authored
Merge pull request #24 from prazgaitis/try-ssr
Server-render invite flow and add SSR dashboard debug route
2 parents 1adeb20 + 358b3f0 commit 2d8a746

4 files changed

Lines changed: 387 additions & 194 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import Link from "next/link";
2+
import { notFound, redirect } from "next/navigation";
3+
import { fetchQuery } from "convex/nextjs";
4+
import { api } from "@repo/backend";
5+
import type { Id } from "@repo/backend/_generated/dataModel";
6+
7+
import { getCurrentUser } from "@/lib/auth";
8+
import { getToken, isAuthenticated } from "@/lib/server-auth";
9+
import { formatDateShortFromDateOnly } from "@/lib/date-only";
10+
11+
interface DashboardSsrDebugPageProps {
12+
params: Promise<{ id: string }>;
13+
}
14+
15+
interface InitialFeedResponse {
16+
page: Array<{
17+
activity: {
18+
_id: string;
19+
pointsEarned: number;
20+
loggedDate: number;
21+
createdAt: number;
22+
notes?: string | null;
23+
metrics?: Record<string, unknown>;
24+
};
25+
user: {
26+
id: string;
27+
username: string;
28+
name: string | null;
29+
} | null;
30+
activityType: {
31+
id: string | null;
32+
name: string | null;
33+
} | null;
34+
likes: number;
35+
comments: number;
36+
likedByUser: boolean;
37+
}>;
38+
}
39+
40+
interface DashboardDataResponse {
41+
challenge: {
42+
_id: string;
43+
creatorId: string;
44+
name: string;
45+
startDate: string;
46+
endDate: string;
47+
};
48+
participation: unknown | null;
49+
leaderboard: Array<{
50+
participantId: string;
51+
totalPoints: number;
52+
currentStreak: number;
53+
user: {
54+
id: string;
55+
name: string | null;
56+
username: string;
57+
avatarUrl: string | null;
58+
};
59+
}>;
60+
stats: {
61+
totalParticipants: number;
62+
userPoints: number;
63+
userRank: number | null;
64+
};
65+
}
66+
67+
function formatPoints(value: number): string {
68+
const normalized = Math.round((value + Number.EPSILON) * 100) / 100;
69+
return normalized.toString();
70+
}
71+
72+
export default async function DashboardSsrDebugPage({
73+
params,
74+
}: DashboardSsrDebugPageProps) {
75+
const [{ id }, user, token] = await Promise.all([
76+
params,
77+
getCurrentUser(),
78+
getToken(),
79+
]);
80+
81+
if (!user) {
82+
const authenticated = await isAuthenticated();
83+
if (authenticated) {
84+
redirect(`/challenges/${id}`);
85+
}
86+
redirect(`/sign-in?redirect_url=/challenges/${id}/dashboard-ssr-debug`);
87+
}
88+
89+
const challengeId = id as Id<"challenges">;
90+
91+
const [dashboardData, feed] = await Promise.all([
92+
fetchQuery(api.queries.challenges.getDashboardData, {
93+
challengeId,
94+
userId: user._id,
95+
}) as Promise<DashboardDataResponse | null>,
96+
fetchQuery(
97+
api.queries.activities.getChallengeFeed,
98+
{
99+
challengeId,
100+
followingOnly: false,
101+
includeEngagementCounts: true,
102+
includeMediaUrls: false,
103+
paginationOpts: {
104+
numItems: 25,
105+
cursor: null,
106+
},
107+
},
108+
token ? { token } : {}
109+
) as Promise<InitialFeedResponse>,
110+
]);
111+
112+
if (!dashboardData) {
113+
notFound();
114+
}
115+
116+
const { challenge, participation, leaderboard, stats } = dashboardData;
117+
const canAccess =
118+
user.role === "admin" ||
119+
challenge.creatorId === user._id ||
120+
Boolean(participation);
121+
122+
if (!canAccess) {
123+
redirect(`/challenges/${id}`);
124+
}
125+
126+
return (
127+
<main className="mx-auto max-w-5xl px-4 py-8">
128+
<div className="mb-6 flex items-center justify-between gap-4">
129+
<div>
130+
<h1 className="text-2xl font-bold">Dashboard SSR Debug</h1>
131+
<p className="text-sm text-muted-foreground">
132+
Fully server-rendered snapshot for challenge{" "}
133+
<span className="font-mono">{challenge._id}</span>
134+
</p>
135+
</div>
136+
<Link
137+
href={`/challenges/${id}/dashboard`}
138+
className="rounded border px-3 py-2 text-sm hover:bg-accent"
139+
>
140+
Back to Dashboard
141+
</Link>
142+
</div>
143+
144+
<section className="mb-6 grid gap-3 sm:grid-cols-4">
145+
<div className="rounded border bg-card p-3">
146+
<p className="text-xs text-muted-foreground">Challenge</p>
147+
<p className="font-medium">{challenge.name}</p>
148+
<p className="text-xs text-muted-foreground">
149+
{formatDateShortFromDateOnly(challenge.startDate)} -{" "}
150+
{formatDateShortFromDateOnly(challenge.endDate)}
151+
</p>
152+
</div>
153+
<div className="rounded border bg-card p-3">
154+
<p className="text-xs text-muted-foreground">Participants</p>
155+
<p className="text-xl font-semibold">{stats.totalParticipants}</p>
156+
</div>
157+
<div className="rounded border bg-card p-3">
158+
<p className="text-xs text-muted-foreground">Your Points</p>
159+
<p className="text-xl font-semibold">{formatPoints(stats.userPoints)}</p>
160+
</div>
161+
<div className="rounded border bg-card p-3">
162+
<p className="text-xs text-muted-foreground">Your Rank</p>
163+
<p className="text-xl font-semibold">{stats.userRank ?? "-"}</p>
164+
</div>
165+
</section>
166+
167+
<section className="mb-6 rounded border bg-card p-4">
168+
<h2 className="mb-3 text-lg font-semibold">Top Leaderboard</h2>
169+
{leaderboard.length === 0 ? (
170+
<p className="text-sm text-muted-foreground">No leaderboard data.</p>
171+
) : (
172+
<ul className="space-y-1 text-sm">
173+
{leaderboard.map((entry: DashboardDataResponse["leaderboard"][number], index: number) => (
174+
<li key={entry.participantId} className="flex items-center justify-between">
175+
<span>
176+
{index + 1}. {entry.user.name ?? entry.user.username}
177+
</span>
178+
<span className="font-medium">{formatPoints(entry.totalPoints)} pts</span>
179+
</li>
180+
))}
181+
</ul>
182+
)}
183+
</section>
184+
185+
<section className="rounded border bg-card p-4">
186+
<h2 className="mb-3 text-lg font-semibold">Recent Feed (SSR)</h2>
187+
{feed.page.length === 0 ? (
188+
<p className="text-sm text-muted-foreground">No activities found.</p>
189+
) : (
190+
<ul className="space-y-2">
191+
{feed.page.map((item: InitialFeedResponse["page"][number]) => (
192+
<li key={item.activity._id} className="rounded border p-3 text-sm">
193+
<div className="flex flex-wrap items-center justify-between gap-2">
194+
<div>
195+
<span className="font-medium">
196+
{item.user?.name ?? item.user?.username ?? "Unknown user"}
197+
</span>{" "}
198+
<span className="text-muted-foreground">
199+
logged {item.activityType?.name ?? "activity"}
200+
</span>
201+
</div>
202+
<span className="font-semibold">
203+
+{formatPoints(item.activity.pointsEarned)} pts
204+
</span>
205+
</div>
206+
<div className="mt-1 text-xs text-muted-foreground">
207+
{new Date(item.activity.createdAt).toLocaleString()} |{" "}
208+
{item.likes} likes | {item.comments} comments |{" "}
209+
likedByYou: {item.likedByUser ? "yes" : "no"}
210+
</div>
211+
</li>
212+
))}
213+
</ul>
214+
)}
215+
</section>
216+
</main>
217+
);
218+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { useMutation } from "convex/react";
5+
import { api } from "@repo/backend";
6+
import type { Id } from "@repo/backend/_generated/dataModel";
7+
import { useRouter } from "next/navigation";
8+
import { CreditCard, Loader2 } from "lucide-react";
9+
10+
import { Button } from "@/components/ui/button";
11+
12+
interface InviteJoinCtaProps {
13+
challengeId: string;
14+
inviteCode: string;
15+
routeChallengeId: string;
16+
requiresPayment: boolean;
17+
priceInCents: number;
18+
currency: string;
19+
}
20+
21+
function formatPrice(cents: number, currency: string = "usd") {
22+
return new Intl.NumberFormat("en-US", {
23+
style: "currency",
24+
currency: currency.toUpperCase(),
25+
}).format(cents / 100);
26+
}
27+
28+
export function InviteJoinCta({
29+
challengeId,
30+
inviteCode,
31+
routeChallengeId,
32+
requiresPayment,
33+
priceInCents,
34+
currency,
35+
}: InviteJoinCtaProps) {
36+
const router = useRouter();
37+
const joinChallenge = useMutation(api.mutations.participations.join);
38+
const createCheckoutSession = useMutation(api.mutations.payments.createCheckoutSession);
39+
40+
const [isJoining, setIsJoining] = useState(false);
41+
const [error, setError] = useState<string | null>(null);
42+
43+
const handleJoin = async () => {
44+
try {
45+
setError(null);
46+
setIsJoining(true);
47+
48+
if (requiresPayment) {
49+
const result = await createCheckoutSession({
50+
challengeId: challengeId as Id<"challenges">,
51+
successUrl: `${window.location.origin}/challenges/${challengeId}/payment-success`,
52+
cancelUrl: window.location.href,
53+
});
54+
55+
if (result.url) {
56+
sessionStorage.setItem(`invite_code_${challengeId}`, inviteCode);
57+
window.location.href = result.url;
58+
return;
59+
}
60+
61+
throw new Error("Failed to create checkout session");
62+
}
63+
64+
await joinChallenge({
65+
challengeId: challengeId as Id<"challenges">,
66+
inviteCode,
67+
});
68+
69+
router.push(`/challenges/${challengeId}/dashboard`);
70+
} catch (err) {
71+
console.error("Failed to join challenge", err);
72+
if (err instanceof Error) {
73+
if (err.message.includes("Not authenticated") || err.message.includes("User not found")) {
74+
router.push(`/sign-up?redirect_url=/challenges/${routeChallengeId}/invite/${inviteCode}`);
75+
return;
76+
}
77+
if (err.message.includes("Already joined")) {
78+
router.push(`/challenges/${challengeId}/dashboard`);
79+
return;
80+
}
81+
}
82+
setError("Something went wrong while joining. Please try again.");
83+
} finally {
84+
setIsJoining(false);
85+
}
86+
};
87+
88+
return (
89+
<div className="sticky bottom-4 z-10">
90+
<div className="rounded-xl border bg-card/95 p-4 shadow-lg backdrop-blur">
91+
{error && <p className="mb-3 text-center text-sm text-destructive">{error}</p>}
92+
<Button className="w-full" size="lg" onClick={handleJoin} disabled={isJoining}>
93+
{isJoining ? (
94+
<>
95+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
96+
{requiresPayment ? "Redirecting to payment..." : "Joining..."}
97+
</>
98+
) : (
99+
<>
100+
{requiresPayment && <CreditCard className="mr-2 h-4 w-4" />}
101+
{requiresPayment
102+
? `Join for ${formatPrice(priceInCents, currency)}`
103+
: "Join Challenge"}
104+
</>
105+
)}
106+
</Button>
107+
</div>
108+
</div>
109+
);
110+
}

0 commit comments

Comments
 (0)