Skip to content

Commit 16ada6a

Browse files
committed
feat: add challenge invite links with tracking and auto-follow
Users who join a challenge get a personal invite link they can share. When someone joins via an invite link, the system records who invited whom and automatically creates mutual follow relationships between the inviter and invitee. An invite card is pinned to the top of the dashboard feed until the challenge starts. - Add challengeInvites table to schema for per-user invite codes - Add getOrCreateInviteCode mutation and resolveInviteCode query - Update join mutation to accept inviteCode, resolve inviter, and create mutual follows - Add /challenges/[id]/invite/[code] accept page - Add InviteCard component with copy/share functionality - Pin InviteCard above activity feed for upcoming challenges https://claude.ai/code/session_01KiAJKNHGNMPzfLtKwVCMQ6
1 parent c626b66 commit 16ada6a

8 files changed

Lines changed: 629 additions & 5 deletions

File tree

apps/web/app/challenges/[id]/dashboard/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { api } from "@repo/backend";
44
import type { Id } from "@repo/backend/_generated/dataModel";
55

66
import { ActivityFeed } from "@/components/dashboard/activity-feed";
7+
import { InviteCard } from "@/components/dashboard/invite-card";
78
import { type ChallengeSummary } from "@/components/dashboard/challenge-realtime-context";
89
import { getCurrentUser } from "@/lib/auth";
910
import { isAuthenticated } from "@/lib/server-auth";
@@ -61,6 +62,7 @@ export default async function ChallengeDashboardPage({
6162
0,
6263
Math.ceil((dateOnlyToUtcMs(challenge.endDate) - now.getTime()) / DAY_IN_MS),
6364
);
65+
const isUpcoming = now.getTime() < dateOnlyToUtcMs(challenge.startDate);
6466

6567
const initialSummary: ChallengeSummary = {
6668
stats: {
@@ -89,7 +91,8 @@ export default async function ChallengeDashboardPage({
8991
currentUser={user}
9092
initialSummary={initialSummary}
9193
>
92-
<div className="mx-auto max-w-2xl px-4 py-6">
94+
<div className="mx-auto max-w-2xl px-4 py-6 space-y-4">
95+
{isUpcoming && <InviteCard challengeId={challenge.id} />}
9396
<ActivityFeed challengeId={challenge.id} />
9497
</div>
9598
</DashboardLayoutWrapper>
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
"use client";
2+
3+
import { useParams, useRouter } from "next/navigation";
4+
import { useMutation, useQuery } from "convex/react";
5+
import { api } from "@repo/backend";
6+
import type { Id } from "@repo/backend/_generated/dataModel";
7+
import { useState } from "react";
8+
import { Button } from "@/components/ui/button";
9+
import {
10+
Card,
11+
CardContent,
12+
CardDescription,
13+
CardHeader,
14+
CardTitle,
15+
} from "@/components/ui/card";
16+
import { Badge } from "@/components/ui/badge";
17+
import { CalendarDays, Loader2, Users, CreditCard } from "lucide-react";
18+
import { formatDateShortFromDateOnly } from "@/lib/date-only";
19+
20+
export default function InviteAcceptPage() {
21+
const params = useParams<{ id: string; code: string }>();
22+
const router = useRouter();
23+
const [isJoining, setIsJoining] = useState(false);
24+
const [error, setError] = useState<string | null>(null);
25+
26+
const inviteData = useQuery(api.queries.challengeInvites.resolveInviteCode, {
27+
code: params.code,
28+
});
29+
30+
const participation = useQuery(
31+
api.queries.participations.getCurrentUserParticipation,
32+
inviteData ? { challengeId: inviteData.challengeId } : "skip"
33+
);
34+
35+
const paymentInfo = useQuery(
36+
api.queries.paymentConfig.getPublicPaymentInfo,
37+
inviteData
38+
? { challengeId: inviteData.challengeId }
39+
: "skip"
40+
);
41+
42+
const joinChallenge = useMutation(api.mutations.participations.join);
43+
const createCheckoutSession = useMutation(api.mutations.payments.createCheckoutSession);
44+
45+
const handleJoin = async () => {
46+
if (!inviteData) return;
47+
48+
try {
49+
setError(null);
50+
setIsJoining(true);
51+
52+
const requiresPayment =
53+
paymentInfo?.requiresPayment && paymentInfo.priceInCents > 0;
54+
55+
if (requiresPayment) {
56+
const result = await createCheckoutSession({
57+
challengeId: inviteData.challengeId,
58+
successUrl: `${window.location.origin}/challenges/${inviteData.challengeId}/payment-success`,
59+
cancelUrl: window.location.href,
60+
});
61+
62+
if (result.url) {
63+
// Store invite code so we can use it after payment
64+
sessionStorage.setItem(
65+
`invite_code_${inviteData.challengeId}`,
66+
params.code
67+
);
68+
window.location.href = result.url;
69+
} else {
70+
throw new Error("Failed to create checkout session");
71+
}
72+
return;
73+
}
74+
75+
await joinChallenge({
76+
challengeId: inviteData.challengeId,
77+
inviteCode: params.code,
78+
});
79+
80+
router.push(`/challenges/${inviteData.challengeId}/dashboard`);
81+
} catch (err) {
82+
console.error("Failed to join challenge", err);
83+
if (err instanceof Error) {
84+
if (
85+
err.message.includes("Not authenticated") ||
86+
err.message.includes("User not found")
87+
) {
88+
router.push(
89+
`/sign-up?redirect_url=/challenges/${params.id}/invite/${params.code}`
90+
);
91+
return;
92+
}
93+
if (err.message.includes("Already joined")) {
94+
router.push(`/challenges/${inviteData!.challengeId}/dashboard`);
95+
return;
96+
}
97+
}
98+
setError("Something went wrong while joining. Please try again.");
99+
} finally {
100+
setIsJoining(false);
101+
}
102+
};
103+
104+
// Loading state
105+
if (inviteData === undefined) {
106+
return (
107+
<div className="flex min-h-screen items-center justify-center page-with-header">
108+
<div className="flex items-center gap-2 text-muted-foreground">
109+
<Loader2 className="h-5 w-5 animate-spin" />
110+
Loading invite...
111+
</div>
112+
</div>
113+
);
114+
}
115+
116+
// Invalid invite code
117+
if (inviteData === null) {
118+
return (
119+
<div className="flex min-h-screen items-center justify-center page-with-header">
120+
<Card className="max-w-md">
121+
<CardHeader>
122+
<CardTitle>Invalid Invite Link</CardTitle>
123+
<CardDescription>
124+
This invite link is invalid or has expired. Ask your friend for a
125+
new link.
126+
</CardDescription>
127+
</CardHeader>
128+
<CardContent>
129+
<Button variant="outline" onClick={() => router.push("/challenges")}>
130+
Browse Challenges
131+
</Button>
132+
</CardContent>
133+
</Card>
134+
</div>
135+
);
136+
}
137+
138+
// Already participating
139+
if (participation) {
140+
return (
141+
<div className="flex min-h-screen items-center justify-center page-with-header">
142+
<Card className="max-w-md">
143+
<CardHeader>
144+
<CardTitle>You&apos;re already in!</CardTitle>
145+
<CardDescription>
146+
You&apos;re already participating in {inviteData.challengeName}.
147+
</CardDescription>
148+
</CardHeader>
149+
<CardContent>
150+
<Button
151+
onClick={() =>
152+
router.push(
153+
`/challenges/${inviteData.challengeId}/dashboard`
154+
)
155+
}
156+
>
157+
Go to Dashboard
158+
</Button>
159+
</CardContent>
160+
</Card>
161+
</div>
162+
);
163+
}
164+
165+
const requiresPayment =
166+
paymentInfo?.requiresPayment && paymentInfo.priceInCents > 0;
167+
168+
const formatPrice = (cents: number, currency: string = "usd") => {
169+
return new Intl.NumberFormat("en-US", {
170+
style: "currency",
171+
currency: currency.toUpperCase(),
172+
}).format(cents / 100);
173+
};
174+
175+
return (
176+
<div className="flex min-h-screen items-center justify-center p-4 page-with-header">
177+
<Card className="max-w-lg w-full">
178+
<CardHeader className="text-center">
179+
<CardTitle className="text-2xl">{inviteData.challengeName}</CardTitle>
180+
{inviteData.challengeDescription && (
181+
<CardDescription className="text-base">
182+
{inviteData.challengeDescription}
183+
</CardDescription>
184+
)}
185+
<p className="text-sm text-muted-foreground mt-2">
186+
<span className="font-medium text-foreground">
187+
{inviteData.inviter.name ?? inviteData.inviter.username}
188+
</span>{" "}
189+
invited you to join this challenge
190+
</p>
191+
</CardHeader>
192+
<CardContent className="space-y-6">
193+
<div className="grid grid-cols-2 gap-4 text-sm">
194+
<div className="flex items-center gap-2 text-muted-foreground">
195+
<CalendarDays className="h-4 w-4" />
196+
<span>
197+
{formatDateShortFromDateOnly(inviteData.startDate)} &ndash;{" "}
198+
{formatDateShortFromDateOnly(inviteData.endDate)}
199+
</span>
200+
</div>
201+
<div className="flex items-center gap-2 text-muted-foreground">
202+
<Users className="h-4 w-4" />
203+
<span>{inviteData.participantCount} participants</span>
204+
</div>
205+
</div>
206+
207+
{error && (
208+
<p className="text-sm text-destructive text-center">{error}</p>
209+
)}
210+
211+
<Button
212+
className="w-full"
213+
size="lg"
214+
onClick={handleJoin}
215+
disabled={isJoining}
216+
>
217+
{isJoining ? (
218+
<>
219+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
220+
{requiresPayment
221+
? "Redirecting to payment..."
222+
: "Joining..."}
223+
</>
224+
) : (
225+
<>
226+
{requiresPayment && <CreditCard className="mr-2 h-4 w-4" />}
227+
{requiresPayment
228+
? `Join for ${formatPrice(paymentInfo!.priceInCents, paymentInfo!.currency)}`
229+
: "Join Challenge"}
230+
</>
231+
)}
232+
</Button>
233+
</CardContent>
234+
</Card>
235+
</div>
236+
);
237+
}

0 commit comments

Comments
 (0)