From 16ada6abea052a5c341191a042f7db45ddab5b02 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 02:46:46 +0000 Subject: [PATCH 1/4] 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 --- .../app/challenges/[id]/dashboard/page.tsx | 5 +- .../challenges/[id]/invite/[code]/page.tsx | 237 ++++++++++++++++++ apps/web/components/dashboard/invite-card.tsx | 138 ++++++++++ .../backend/mutations/challengeInvites.ts | 76 ++++++ packages/backend/mutations/participations.ts | 55 +++- packages/backend/queries/challengeInvites.ts | 80 ++++++ packages/backend/schema.ts | 10 + tasks/2026-02-09-challenge-invite-links.md | 33 +++ 8 files changed, 629 insertions(+), 5 deletions(-) create mode 100644 apps/web/app/challenges/[id]/invite/[code]/page.tsx create mode 100644 apps/web/components/dashboard/invite-card.tsx create mode 100644 packages/backend/mutations/challengeInvites.ts create mode 100644 packages/backend/queries/challengeInvites.ts create mode 100644 tasks/2026-02-09-challenge-invite-links.md diff --git a/apps/web/app/challenges/[id]/dashboard/page.tsx b/apps/web/app/challenges/[id]/dashboard/page.tsx index cd348b6c..73e7b552 100644 --- a/apps/web/app/challenges/[id]/dashboard/page.tsx +++ b/apps/web/app/challenges/[id]/dashboard/page.tsx @@ -4,6 +4,7 @@ import { api } from "@repo/backend"; import type { Id } from "@repo/backend/_generated/dataModel"; import { ActivityFeed } from "@/components/dashboard/activity-feed"; +import { InviteCard } from "@/components/dashboard/invite-card"; import { type ChallengeSummary } from "@/components/dashboard/challenge-realtime-context"; import { getCurrentUser } from "@/lib/auth"; import { isAuthenticated } from "@/lib/server-auth"; @@ -61,6 +62,7 @@ export default async function ChallengeDashboardPage({ 0, Math.ceil((dateOnlyToUtcMs(challenge.endDate) - now.getTime()) / DAY_IN_MS), ); + const isUpcoming = now.getTime() < dateOnlyToUtcMs(challenge.startDate); const initialSummary: ChallengeSummary = { stats: { @@ -89,7 +91,8 @@ export default async function ChallengeDashboardPage({ currentUser={user} initialSummary={initialSummary} > -
+
+ {isUpcoming && }
diff --git a/apps/web/app/challenges/[id]/invite/[code]/page.tsx b/apps/web/app/challenges/[id]/invite/[code]/page.tsx new file mode 100644 index 00000000..782265ed --- /dev/null +++ b/apps/web/app/challenges/[id]/invite/[code]/page.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import { useMutation, useQuery } from "convex/react"; +import { api } from "@repo/backend"; +import type { Id } from "@repo/backend/_generated/dataModel"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { CalendarDays, Loader2, Users, CreditCard } from "lucide-react"; +import { formatDateShortFromDateOnly } from "@/lib/date-only"; + +export default function InviteAcceptPage() { + const params = useParams<{ id: string; code: string }>(); + const router = useRouter(); + const [isJoining, setIsJoining] = useState(false); + const [error, setError] = useState(null); + + const inviteData = useQuery(api.queries.challengeInvites.resolveInviteCode, { + code: params.code, + }); + + const participation = useQuery( + api.queries.participations.getCurrentUserParticipation, + inviteData ? { challengeId: inviteData.challengeId } : "skip" + ); + + const paymentInfo = useQuery( + api.queries.paymentConfig.getPublicPaymentInfo, + inviteData + ? { challengeId: inviteData.challengeId } + : "skip" + ); + + const joinChallenge = useMutation(api.mutations.participations.join); + const createCheckoutSession = useMutation(api.mutations.payments.createCheckoutSession); + + const handleJoin = async () => { + if (!inviteData) return; + + try { + setError(null); + setIsJoining(true); + + const requiresPayment = + paymentInfo?.requiresPayment && paymentInfo.priceInCents > 0; + + if (requiresPayment) { + const result = await createCheckoutSession({ + challengeId: inviteData.challengeId, + successUrl: `${window.location.origin}/challenges/${inviteData.challengeId}/payment-success`, + cancelUrl: window.location.href, + }); + + if (result.url) { + // Store invite code so we can use it after payment + sessionStorage.setItem( + `invite_code_${inviteData.challengeId}`, + params.code + ); + window.location.href = result.url; + } else { + throw new Error("Failed to create checkout session"); + } + return; + } + + await joinChallenge({ + challengeId: inviteData.challengeId, + inviteCode: params.code, + }); + + router.push(`/challenges/${inviteData.challengeId}/dashboard`); + } catch (err) { + console.error("Failed to join challenge", err); + if (err instanceof Error) { + if ( + err.message.includes("Not authenticated") || + err.message.includes("User not found") + ) { + router.push( + `/sign-up?redirect_url=/challenges/${params.id}/invite/${params.code}` + ); + return; + } + if (err.message.includes("Already joined")) { + router.push(`/challenges/${inviteData!.challengeId}/dashboard`); + return; + } + } + setError("Something went wrong while joining. Please try again."); + } finally { + setIsJoining(false); + } + }; + + // Loading state + if (inviteData === undefined) { + return ( +
+
+ + Loading invite... +
+
+ ); + } + + // Invalid invite code + if (inviteData === null) { + return ( +
+ + + Invalid Invite Link + + This invite link is invalid or has expired. Ask your friend for a + new link. + + + + + + +
+ ); + } + + // Already participating + if (participation) { + return ( +
+ + + You're already in! + + You're already participating in {inviteData.challengeName}. + + + + + + +
+ ); + } + + const requiresPayment = + paymentInfo?.requiresPayment && paymentInfo.priceInCents > 0; + + const formatPrice = (cents: number, currency: string = "usd") => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency.toUpperCase(), + }).format(cents / 100); + }; + + return ( +
+ + + {inviteData.challengeName} + {inviteData.challengeDescription && ( + + {inviteData.challengeDescription} + + )} +

+ + {inviteData.inviter.name ?? inviteData.inviter.username} + {" "} + invited you to join this challenge +

+
+ +
+
+ + + {formatDateShortFromDateOnly(inviteData.startDate)} –{" "} + {formatDateShortFromDateOnly(inviteData.endDate)} + +
+
+ + {inviteData.participantCount} participants +
+
+ + {error && ( +

{error}

+ )} + + +
+
+
+ ); +} diff --git a/apps/web/components/dashboard/invite-card.tsx b/apps/web/components/dashboard/invite-card.tsx new file mode 100644 index 00000000..60398e6d --- /dev/null +++ b/apps/web/components/dashboard/invite-card.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery } from "convex/react"; +import { api } from "@repo/backend"; +import type { Id } from "@repo/backend/_generated/dataModel"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Check, Copy, Share2, UserPlus } from "lucide-react"; + +interface InviteCardProps { + challengeId: string; +} + +export function InviteCard({ challengeId }: InviteCardProps) { + const [copied, setCopied] = useState(false); + const [generating, setGenerating] = useState(false); + + const existingCode = useQuery(api.queries.challengeInvites.getMyInviteCode, { + challengeId: challengeId as Id<"challenges">, + }); + + const generateCode = useMutation( + api.mutations.challengeInvites.getOrCreateInviteCode + ); + + const [inviteCode, setInviteCode] = useState(null); + + // Use existing code from query, or the one we just generated + const code = inviteCode ?? existingCode; + + const inviteUrl = code + ? `${typeof window !== "undefined" ? window.location.origin : ""}/challenges/${challengeId}/invite/${code}` + : null; + + const handleGenerateCode = async () => { + try { + setGenerating(true); + const result = await generateCode({ + challengeId: challengeId as Id<"challenges">, + }); + setInviteCode(result); + } catch (error) { + console.error("Failed to generate invite code", error); + } finally { + setGenerating(false); + } + }; + + const handleCopy = async () => { + if (!inviteUrl) return; + try { + await navigator.clipboard.writeText(inviteUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback for older browsers + console.error("Failed to copy to clipboard"); + } + }; + + const handleShare = async () => { + if (!inviteUrl) return; + try { + if (navigator.share) { + await navigator.share({ + title: "Join my fitness challenge!", + url: inviteUrl, + }); + } else { + await handleCopy(); + } + } catch (error) { + // User cancelled share or it failed - that's ok + if ((error as Error)?.name !== "AbortError") { + console.error("Share failed", error); + } + } + }; + + return ( + + +
+ + Invite friends +
+ + Share your personal link to invite friends to this challenge. + +
+ + {code ? ( +
+
+ {inviteUrl} +
+ + +
+ ) : ( + + )} +
+
+ ); +} diff --git a/packages/backend/mutations/challengeInvites.ts b/packages/backend/mutations/challengeInvites.ts new file mode 100644 index 00000000..95ba516f --- /dev/null +++ b/packages/backend/mutations/challengeInvites.ts @@ -0,0 +1,76 @@ +import { mutation } from "../_generated/server"; +import { v } from "convex/values"; +import { getCurrentUser } from "../lib/ids"; + +function generateInviteCode(): string { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"; + let code = ""; + for (let i = 0; i < 8; i++) { + code += chars[Math.floor(Math.random() * chars.length)]; + } + return code; +} + +/** + * Get or create an invite code for the current user in a challenge. + * Returns the existing code if one already exists. + */ +export const getOrCreateInviteCode = mutation({ + args: { + challengeId: v.id("challenges"), + }, + handler: async (ctx, args) => { + const user = await getCurrentUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + // Check user is a participant + const participation = await ctx.db + .query("userChallenges") + .withIndex("userChallengeUnique", (q) => + q.eq("userId", user._id).eq("challengeId", args.challengeId) + ) + .first(); + + if (!participation) { + throw new Error("Not participating in this challenge"); + } + + // Check if invite code already exists + const existing = await ctx.db + .query("challengeInvites") + .withIndex("userChallengeUnique", (q) => + q.eq("userId", user._id).eq("challengeId", args.challengeId) + ) + .first(); + + if (existing) { + return existing.code; + } + + // Generate a unique code + let code = generateInviteCode(); + // Check for collision (very unlikely with 8 chars from 55-char alphabet) + let existingCode = await ctx.db + .query("challengeInvites") + .withIndex("code", (q) => q.eq("code", code)) + .first(); + while (existingCode) { + code = generateInviteCode(); + existingCode = await ctx.db + .query("challengeInvites") + .withIndex("code", (q) => q.eq("code", code)) + .first(); + } + + await ctx.db.insert("challengeInvites", { + challengeId: args.challengeId, + userId: user._id, + code, + createdAt: Date.now(), + }); + + return code; + }, +}); diff --git a/packages/backend/mutations/participations.ts b/packages/backend/mutations/participations.ts index 301f1713..e1313857 100644 --- a/packages/backend/mutations/participations.ts +++ b/packages/backend/mutations/participations.ts @@ -37,6 +37,7 @@ export const join = mutation({ args: { challengeId: v.id("challenges"), invitedByUserId: v.optional(v.id("users")), + inviteCode: v.optional(v.string()), }, handler: async (ctx, args) => { // Get the current user from auth @@ -79,12 +80,12 @@ export const join = mutation({ }); user = await ctx.db.get(userId); - + if (!user) { console.error("Failed to retrieve created user"); throw new Error("Failed to create user record. Please try again."); } - + console.log("Successfully created user via fallback in join mutation:", userId); } @@ -100,12 +101,25 @@ export const join = mutation({ throw new Error("Already joined this challenge"); } + // Resolve inviter from invite code if provided + let invitedByUserId = args.invitedByUserId; + const inviteCode = args.inviteCode; + if (inviteCode && !invitedByUserId) { + const invite = await ctx.db + .query("challengeInvites") + .withIndex("code", (q) => q.eq("code", inviteCode)) + .first(); + if (invite && invite.challengeId === args.challengeId) { + invitedByUserId = invite.userId; + } + } + // Block self-join for private challenges (requires admin invitation) const challenge = await ctx.db.get(args.challengeId); if (!challenge) { throw new Error("Challenge not found"); } - if (challenge.visibility === "private") { + if (challenge.visibility === "private" && !invitedByUserId) { throw new Error("This is a private challenge. You need an invitation to join."); } @@ -121,7 +135,7 @@ export const join = mutation({ const participationId = await ctx.db.insert("userChallenges", { challengeId: args.challengeId, userId: user._id, - invitedByUserId: args.invitedByUserId, + invitedByUserId: invitedByUserId, joinedAt: now, totalPoints: 0, currentStreak: 0, @@ -130,6 +144,39 @@ export const join = mutation({ updatedAt: now, }); + // Auto-create mutual follow relationships if joined via invite + if (invitedByUserId && invitedByUserId !== user._id) { + // Invitee follows inviter + const existingFollow1 = await ctx.db + .query("follows") + .withIndex("followerFollowing", (q) => + q.eq("followerId", user._id).eq("followingId", invitedByUserId) + ) + .first(); + if (!existingFollow1) { + await ctx.db.insert("follows", { + followerId: user._id, + followingId: invitedByUserId, + createdAt: now, + }); + } + + // Inviter follows invitee + const existingFollow2 = await ctx.db + .query("follows") + .withIndex("followerFollowing", (q) => + q.eq("followerId", invitedByUserId).eq("followingId", user._id) + ) + .first(); + if (!existingFollow2) { + await ctx.db.insert("follows", { + followerId: invitedByUserId, + followingId: user._id, + createdAt: now, + }); + } + } + // Trigger on_signup emails const emailSequences = await ctx.db .query("emailSequences") diff --git a/packages/backend/queries/challengeInvites.ts b/packages/backend/queries/challengeInvites.ts new file mode 100644 index 00000000..4aeec6d9 --- /dev/null +++ b/packages/backend/queries/challengeInvites.ts @@ -0,0 +1,80 @@ +import { query } from "../_generated/server"; +import { v } from "convex/values"; +import { getCurrentUser } from "../lib/ids"; +import { coerceDateOnlyToString } from "../lib/dateOnly"; + +/** + * Resolve an invite code to get challenge and inviter info. + * Public query - no auth required (so unauthenticated users can see the invite page). + */ +export const resolveInviteCode = query({ + args: { + code: v.string(), + }, + handler: async (ctx, args) => { + const invite = await ctx.db + .query("challengeInvites") + .withIndex("code", (q) => q.eq("code", args.code)) + .first(); + + if (!invite) { + return null; + } + + const [challenge, inviter] = await Promise.all([ + ctx.db.get(invite.challengeId), + ctx.db.get(invite.userId), + ]); + + if (!challenge || !inviter) { + return null; + } + + // Get participant count + const participations = await ctx.db + .query("userChallenges") + .withIndex("challengeId", (q) => q.eq("challengeId", challenge._id)) + .collect(); + + return { + challengeId: challenge._id, + challengeName: challenge.name, + challengeDescription: challenge.description, + startDate: coerceDateOnlyToString(challenge.startDate), + endDate: coerceDateOnlyToString(challenge.endDate), + durationDays: challenge.durationDays, + participantCount: participations.length, + inviter: { + id: inviter._id, + name: inviter.name, + username: inviter.username, + avatarUrl: inviter.avatarUrl, + }, + }; + }, +}); + +/** + * Get the current user's invite code for a challenge (read-only). + * Returns null if no code has been generated yet. + */ +export const getMyInviteCode = query({ + args: { + challengeId: v.id("challenges"), + }, + handler: async (ctx, args) => { + const user = await getCurrentUser(ctx); + if (!user) { + return null; + } + + const invite = await ctx.db + .query("challengeInvites") + .withIndex("userChallengeUnique", (q) => + q.eq("userId", user._id).eq("challengeId", args.challengeId) + ) + .first(); + + return invite?.code ?? null; + }, +}); diff --git a/packages/backend/schema.ts b/packages/backend/schema.ts index c5f029a5..fc366e05 100644 --- a/packages/backend/schema.ts +++ b/packages/backend/schema.ts @@ -438,6 +438,16 @@ export default defineSchema({ .index("invitedById", ["invitedById"]) .index("invitedUserId", ["invitedUserId"]), + // Challenge Invites - personal invite codes per user per challenge + challengeInvites: defineTable({ + challengeId: v.id("challenges"), + userId: v.id("users"), + code: v.string(), // Short alphanumeric invite code + createdAt: v.number(), + }) + .index("code", ["code"]) + .index("userChallengeUnique", ["userId", "challengeId"]), + // Email Sequences - email templates per challenge emailSequences: defineTable({ challengeId: v.id("challenges"), diff --git a/tasks/2026-02-09-challenge-invite-links.md b/tasks/2026-02-09-challenge-invite-links.md new file mode 100644 index 00000000..50fe0af2 --- /dev/null +++ b/tasks/2026-02-09-challenge-invite-links.md @@ -0,0 +1,33 @@ +# Challenge Invite Links + +**Date:** 2026-02-09 +**Description:** Users get a personal invite link after joining a challenge that they can share with friends. Track who invited whom, auto-create follow relationships, and pin an invite card to the feed until the challenge starts. + +## Requirements + +- [x] Add `challengeInvites` table to persist invite codes per user per challenge +- [x] Backend mutation to generate/get an invite code when user is a participant +- [x] Backend query to resolve an invite code to the inviter and challenge +- [x] Update `join` mutation to accept `inviteCode`, record `invitedByUserId`, and auto-follow (both directions) +- [x] Frontend invite accept page at `/challenges/[id]/invite/[code]` +- [x] `InviteCard` component shown above the feed until challenge starts +- [x] Share via Web Share API or clipboard copy + +## Implementation Notes + +### Schema +- New `challengeInvites` table: `{ challengeId, userId, code }` with index on `code` and composite `(userId, challengeId)`. + +### Invite Code Generation +- Short random alphanumeric code (8 chars) generated on first request per user per challenge. +- Idempotent: returns existing code if one exists. + +### Join Flow with Invite +- `/challenges/[id]/invite/[code]` page resolves the invite and shows challenge info + join button. +- On join: `invitedByUserId` is set from the invite code's owner. +- Auto-create mutual follow (inviter follows invitee and invitee follows inviter). + +### Invite Card in Feed +- Pinned above the activity feed when `now < challenge.startDate`. +- Shows the user's invite link with copy/share buttons. +- Dismissed once challenge starts (no dismiss button needed - it auto-hides). From a7d884632e061736c1ec710ff587a60d6ef14682 Mon Sep 17 00:00:00 2001 From: Paul Razgaitis Date: Mon, 9 Feb 2026 07:40:46 -0600 Subject: [PATCH 2/4] checkpoint --- .claude/napkin.md | 20 ++ .../challenges/[id]/invite/[code]/page.tsx | 177 +++++---- .../users/[userId]/user-profile-content.tsx | 23 +- apps/web/app/layout.tsx | 16 + .../components/layout/conditional-header.tsx | 1 + apps/web/package.json | 2 +- packages/backend/_generated/api.d.ts | 4 + packages/backend/actions/seed.ts | 9 + packages/backend/queries/challengeInvites.ts | 1 + pnpm-lock.yaml | 336 +++++++++++++++++- 10 files changed, 498 insertions(+), 91 deletions(-) create mode 100644 .claude/napkin.md diff --git a/.claude/napkin.md b/.claude/napkin.md new file mode 100644 index 00000000..7d2370cd --- /dev/null +++ b/.claude/napkin.md @@ -0,0 +1,20 @@ +# Napkin + +## Corrections +| Date | Source | What Went Wrong | What To Do Instead | +|------|--------|----------------|-------------------| + +## User Preferences +- Hide navbar on full-screen flow pages (invite, dashboard, admin) via `ConditionalHeader` patterns + remove `page-with-header` class + +## Patterns That Work +- Convex queries can join related data inline (e.g., activity types + categories in one query) +- `conditional-header.tsx` DASHBOARD_LAYOUT_PATTERNS array controls navbar visibility per route + +## Patterns That Don't Work + +## Domain Notes +- Scoring configs have types: distance, duration, count, variant +- `page-with-header` CSS class = `pt-16` to offset fixed navbar +- Seed data lives in `packages/backend/actions/seed.ts` +- Schema changes auto-deploy locally via `pnpm dev` diff --git a/apps/web/app/challenges/[id]/invite/[code]/page.tsx b/apps/web/app/challenges/[id]/invite/[code]/page.tsx index 782265ed..2b012630 100644 --- a/apps/web/app/challenges/[id]/invite/[code]/page.tsx +++ b/apps/web/app/challenges/[id]/invite/[code]/page.tsx @@ -3,19 +3,24 @@ import { useParams, useRouter } from "next/navigation"; import { useMutation, useQuery } from "convex/react"; import { api } from "@repo/backend"; -import type { Id } from "@repo/backend/_generated/dataModel"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, - CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { CalendarDays, Loader2, Users, CreditCard } from "lucide-react"; +import { + CalendarDays, + Loader2, + Users, + CreditCard, + Flame, + ArrowLeft, +} from "lucide-react"; import { formatDateShortFromDateOnly } from "@/lib/date-only"; +import { ActivityTypesList } from "../../activity-types/activity-types-list"; export default function InviteAcceptPage() { const params = useParams<{ id: string; code: string }>(); @@ -39,6 +44,23 @@ export default function InviteAcceptPage() { : "skip" ); + const activityTypes = useQuery( + api.queries.activityTypes.getByChallengeId, + inviteData ? { challengeId: inviteData.challengeId } : "skip" + ); + + const categories = useQuery( + api.queries.categories.getChallengeCategories, + inviteData ? { challengeId: inviteData.challengeId } : "skip" + ); + + const categoryMap = useMemo(() => { + if (!categories) return new Map(); + return new Map( + categories.map((c: { _id: string; name: string }) => [c._id, { _id: c._id, name: c.name }] as const) + ); + }, [categories]); + const joinChallenge = useMutation(api.mutations.participations.join); const createCheckoutSession = useMutation(api.mutations.payments.createCheckoutSession); @@ -60,7 +82,6 @@ export default function InviteAcceptPage() { }); if (result.url) { - // Store invite code so we can use it after payment sessionStorage.setItem( `invite_code_${inviteData.challengeId}`, params.code @@ -104,7 +125,7 @@ export default function InviteAcceptPage() { // Loading state if (inviteData === undefined) { return ( -
+
Loading invite... @@ -116,14 +137,14 @@ export default function InviteAcceptPage() { // Invalid invite code if (inviteData === null) { return ( -
+
Invalid Invite Link - +

This invite link is invalid or has expired. Ask your friend for a new link. - +

+ + {/* Hero section */} +
+

+ {inviteData.challengeName} +

{inviteData.challengeDescription && ( - +

{inviteData.challengeDescription} - +

)} -

+

{inviteData.inviter.name ?? inviteData.inviter.username} {" "} - invited you to join this challenge + invited you to join

- - -
-
- - - {formatDateShortFromDateOnly(inviteData.startDate)} –{" "} - {formatDateShortFromDateOnly(inviteData.endDate)} - -
-
- - {inviteData.participantCount} participants -
+
+ + {/* Stats row */} +
+
+ +

+ {formatDateShortFromDateOnly(inviteData.startDate)} –{" "} + {formatDateShortFromDateOnly(inviteData.endDate)} +

+

{inviteData.durationDays} days

+
+ +

{inviteData.participantCount}

+

participants

+
+
+ +

{inviteData.streakMinPoints}+ pts

+

daily for streak

+
+
- {error && ( -

{error}

- )} + {/* Activity types & scoring - reuse the shared component */} + {activityTypes && activityTypes.length > 0 && ( +
+ +
+ )} - -
- + +
+
+
); } diff --git a/apps/web/app/challenges/[id]/users/[userId]/user-profile-content.tsx b/apps/web/app/challenges/[id]/users/[userId]/user-profile-content.tsx index 752023a5..a9d0607f 100644 --- a/apps/web/app/challenges/[id]/users/[userId]/user-profile-content.tsx +++ b/apps/web/app/challenges/[id]/users/[userId]/user-profile-content.tsx @@ -206,19 +206,16 @@ export function UserProfileContent({ {showStravaCard && ( - - - Connect Strava - - Link your Strava account to auto-import workouts for this challenge. - - - - - - + +
+
+ Connect Strava + + Auto-import workouts for this challenge. + +
+ +
)} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 0a08dd9c..c7128557 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import Script from "next/script"; import { Geist, Geist_Mono } from "next/font/google"; import { ConvexProviderWrapper } from "@/components/providers/convex-provider"; import { ConditionalHeader } from "@/components/layout/conditional-header"; @@ -32,6 +33,21 @@ export default async function RootLayout({ return ( + + {process.env.NODE_ENV === "development" && ( +