11'use client' ;
22
3- import { useEffect , useState } from 'react' ;
4- import { Flame , Trophy , Users } from 'lucide-react' ;
3+ import { useCallback , useEffect , useState } from 'react' ;
4+ import { Calendar , Flame , Loader2 , Trophy , Users } from 'lucide-react' ;
5+ import { useMutation , useQuery } from 'convex/react' ;
6+ import { api } from '@repo/backend' ;
7+ import type { Id } from '@repo/backend/_generated/dataModel' ;
58
69import { useChallengeSummary } from './challenge-realtime-context' ;
710import { UserAvatar } from '@/components/user-avatar' ;
8- import { Card , CardContent , CardHeader , CardTitle } from '@/components/ui/card' ;
911import { ActiveMiniGames } from '@/components/mini-games' ;
1012import { OnboardingCard } from './onboarding-card' ;
1113import { dateOnlyToUtcMs } from '@/lib/date-only' ;
12- import { formatPoints } from '@/lib/points' ;
13- import { cn } from '@/lib/utils' ;
14+ import { formatPointsCompact } from '@/lib/points' ;
1415
1516interface ChallengeSidebarProps {
1617 challengeId : string ;
@@ -20,7 +21,7 @@ interface ChallengeSidebarProps {
2021
2122export function ChallengeSidebar ( { challengeId, currentUserId, challengeStartDate } : ChallengeSidebarProps ) {
2223 const { summary } = useChallengeSummary ( ) ;
23- const { stats, leaderboard } = summary ;
24+ const { stats } = summary ;
2425
2526 // Compute client-side only to avoid hydration mismatch (Date.now() differs server vs client)
2627 const [ challengeStarted , setChallengeStarted ] = useState ( false ) ;
@@ -30,103 +31,174 @@ export function ChallengeSidebar({ challengeId, currentUserId, challengeStartDat
3031
3132 return (
3233 < div className = "space-y-4" >
33- < Card className = "border-zinc-800 bg-transparent" >
34- < CardHeader className = "flex flex-col space-y-2" >
35- < div className = "text-xs uppercase tracking-wide text-zinc-500" >
36- Status
34+ < SuggestedFollows challengeId = { challengeId } />
35+
36+ < div >
37+ < div className = "mb-4" >
38+ < div className = "text-xs font-medium uppercase tracking-widest text-zinc-500" >
39+ Your points
3740 </ div >
38- < CardTitle className = "text-2xl font-bold text-white" >
39- { formatPoints ( stats . totalPoints ) } total points
40- </ CardTitle >
41- </ CardHeader >
42- < CardContent className = "grid grid-cols-2 gap-3 text-sm" >
43- < div className = "rounded-lg border border-zinc-800 bg-zinc-900/50 p-3" >
44- < div className = "flex items-center justify-between text-zinc-500" >
45- < span > My streak</ span >
46- < Flame className = "h-4 w-4" />
47- </ div >
48- < p className = "mt-2 text-xl font-semibold text-white" > { stats . userStreak } </ p >
41+ < div className = "mt-1 flex items-baseline gap-2" >
42+ < span className = "font-mono text-3xl font-bold text-white" >
43+ { formatPointsCompact ( stats . userPoints ) }
44+ </ span >
45+ { stats . totalParticipants > 0 && ( ( ) => {
46+ const avg = stats . totalPoints / stats . totalParticipants ;
47+ const delta = stats . userPoints - avg ;
48+ const isAbove = delta >= 0 ;
49+ return (
50+ < span className = { `font-mono text-xs ${ isAbove ? 'text-green-400' : 'text-red-400' } ` } >
51+ { isAbove ? '+' : '' } { formatPointsCompact ( delta ) } vs avg
52+ </ span >
53+ ) ;
54+ } ) ( ) }
4955 </ div >
50- < div className = "rounded-lg border border-zinc-800 bg-zinc-900/50 p-3" >
51- < div className = "flex items-center justify-between text-zinc-500" >
52- < span > Participants</ span >
53- < Users className = "h-4 w-4" />
56+ </ div >
57+
58+ { /* Streak & Rank — hero stats */ }
59+ < div className = "mb-3 grid grid-cols-2 gap-3" >
60+ < div className = "rounded-lg border border-orange-500/20 bg-orange-500/5 p-3" >
61+ < div className = "flex items-center gap-1.5 text-xs font-medium uppercase tracking-widest text-orange-400/70" >
62+ < Flame className = "h-3.5 w-3.5" />
63+ Streak
5464 </ div >
55- < p className = "mt-2 text-xl font-semibold text-white" > { stats . totalParticipants } </ p >
56- </ div >
57- < div className = "rounded-lg border border-zinc-800 bg-zinc-900/50 p-3" >
58- < div className = "text-zinc-500" > Days remaining</ div >
59- < p className = "mt-2 text-xl font-semibold text-white" > { stats . daysRemaining } </ p >
65+ < p className = "mt-1 font-mono text-2xl font-bold text-orange-400" >
66+ { stats . userStreak }
67+ </ p >
6068 </ div >
61- < div className = "rounded-lg border border-zinc-800 bg-zinc-900/50 p-3" >
62- < div className = "text-zinc-500" > Your rank</ div >
63- < p className = "mt-2 text-xl font-semibold text-white" >
69+ < div className = "rounded-lg border border-indigo-500/20 bg-indigo-500/5 p-3" >
70+ < div className = "flex items-center gap-1.5 text-xs font-medium uppercase tracking-widest text-indigo-400/70" >
71+ < Trophy className = "h-3.5 w-3.5" />
72+ Rank
73+ </ div >
74+ < p className = "mt-1 font-mono text-2xl font-bold text-indigo-400" >
6475 { stats . userRank ? `#${ stats . userRank } ` : '—' }
6576 </ p >
6677 </ div >
67- </ CardContent >
68- </ Card >
78+ </ div >
79+
80+ { /* Secondary stats — dimmer, smaller */ }
81+ < div className = "grid grid-cols-2 gap-3" >
82+ < div className = "flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 px-3 py-2" >
83+ < Users className = "h-3.5 w-3.5 text-zinc-600" />
84+ < div >
85+ < span className = "font-mono text-sm font-semibold text-zinc-300" > { stats . totalParticipants } </ span >
86+ < span className = "ml-1.5 text-xs text-zinc-600" > players</ span >
87+ </ div >
88+ </ div >
89+ < div className = "flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 px-3 py-2" >
90+ < Calendar className = "h-3.5 w-3.5 text-zinc-600" />
91+ < div >
92+ < span className = "font-mono text-sm font-semibold text-zinc-300" > { stats . daysRemaining } </ span >
93+ < span className = "ml-1.5 text-xs text-zinc-600" > days left</ span >
94+ </ div >
95+ </ div >
96+ </ div >
97+ </ div >
6998
7099 { /* Active Mini-Games */ }
71100 < ActiveMiniGames challengeId = { challengeId } userId = { currentUserId } />
72101
73- < Card className = "border-zinc-800 bg-transparent" >
74- < CardHeader >
75- < CardTitle className = "flex items-center gap-2 text-lg text-white" >
76- < Trophy className = "h-5 w-5 text-amber-500" />
77- Live Leaderboard
78- </ CardTitle >
79- </ CardHeader >
80- < CardContent className = "space-y-2" >
81- { leaderboard . length === 0 && (
82- < p className = "text-sm text-zinc-500" >
83- No participants have logged points yet.
84- </ p >
85- ) }
86- { leaderboard . map ( ( entry , index ) => {
87- const isCurrentUser = entry . participantId === currentUserId ;
88- return (
89- < div
90- key = { entry . participantId }
91- className = { cn (
92- 'flex items-center justify-between rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 transition' ,
93- isCurrentUser && 'border-indigo-500/50 bg-indigo-500/10' ,
94- ) }
95- >
96- < div className = "flex items-center gap-3" >
97- < span className = "text-lg font-semibold text-zinc-500" >
98- #{ index + 1 }
99- </ span >
100- < UserAvatar
101- user = { {
102- id : entry . user . id ,
103- name : entry . user . name ,
104- username : entry . user . username ,
105- avatarUrl : entry . user . avatarUrl ,
106- } }
107- challengeId = { challengeId }
108- size = "md"
109- showName
110- >
111- < p className = "text-xs text-zinc-500" >
112- { formatPoints ( entry . totalPoints ) } pts · streak { entry . currentStreak }
113- </ p >
114- </ UserAvatar >
115- </ div >
116- { isCurrentUser && (
117- < span className = "rounded-full bg-indigo-500 px-3 py-1 text-xs font-semibold text-white" >
118- You
119- </ span >
120- ) }
121- </ div >
122- ) ;
123- } ) }
124- </ CardContent >
125- </ Card >
126-
127102 { challengeStarted && (
128103 < OnboardingCard challengeId = { challengeId } userId = { currentUserId } challengeStartDate = { challengeStartDate } />
129104 ) }
130105 </ div >
131106 ) ;
132107}
108+
109+ function affinityLabel ( score : number , location : string | null ) : string {
110+ if ( score >= 40 ) return 'Active in your feed' ;
111+ if ( score >= 15 ) return "You've interacted" ;
112+ if ( location ) return location ;
113+ return '' ;
114+ }
115+
116+ function SuggestedFollows ( { challengeId } : { challengeId : string } ) {
117+ const suggestions = useQuery ( api . queries . follows . getSuggestions , {
118+ challengeId : challengeId as Id < "challenges" > ,
119+ limit : 5 ,
120+ } ) ;
121+
122+ if ( ! suggestions || suggestions . length === 0 ) return null ;
123+
124+ return (
125+ < div >
126+ < div className = "mb-3 flex items-center justify-between" >
127+ < h3 className = "text-xs font-medium uppercase tracking-widest text-zinc-500" >
128+ People to watch
129+ </ h3 >
130+ </ div >
131+ < div className = "space-y-1.5" >
132+ { suggestions . map ( ( user : { id : string ; name : string | null ; username : string ; avatarUrl : string | null ; location : string | null ; affinityScore : number } ) => (
133+ < SuggestionRow
134+ key = { user . id }
135+ user = { user }
136+ challengeId = { challengeId }
137+ />
138+ ) ) }
139+ </ div >
140+ </ div >
141+ ) ;
142+ }
143+
144+ function SuggestionRow ( {
145+ user,
146+ challengeId,
147+ } : {
148+ user : { id : string ; name : string | null ; username : string ; avatarUrl : string | null ; location : string | null ; affinityScore : number } ;
149+ challengeId : string ;
150+ } ) {
151+ const [ isToggling , setIsToggling ] = useState ( false ) ;
152+ const toggleFollow = useMutation ( api . mutations . follows . toggle ) ;
153+
154+ const handleFollow = useCallback (
155+ async ( e : React . MouseEvent ) => {
156+ e . preventDefault ( ) ;
157+ e . stopPropagation ( ) ;
158+ if ( isToggling ) return ;
159+ setIsToggling ( true ) ;
160+ try {
161+ await toggleFollow ( { userId : user . id as Id < "users" > } ) ;
162+ } catch ( error ) {
163+ console . error ( "Failed to follow:" , error ) ;
164+ } finally {
165+ setIsToggling ( false ) ;
166+ }
167+ } ,
168+ [ isToggling , toggleFollow , user . id ] ,
169+ ) ;
170+
171+ return (
172+ < div className = "group flex items-center gap-3 rounded-lg p-2 transition-colors hover:bg-zinc-900/60" >
173+ < UserAvatar
174+ user = { {
175+ id : user . id ,
176+ name : user . name ,
177+ username : user . username ,
178+ avatarUrl : user . avatarUrl ,
179+ } }
180+ challengeId = { challengeId }
181+ size = "md"
182+ showName
183+ showUsername
184+ >
185+ { affinityLabel ( user . affinityScore , user . location ) && (
186+ < p className = "text-[10px] text-zinc-600" >
187+ { affinityLabel ( user . affinityScore , user . location ) }
188+ </ p >
189+ ) }
190+ </ UserAvatar >
191+ < button
192+ onClick = { handleFollow }
193+ disabled = { isToggling }
194+ className = "ml-auto shrink-0 rounded-full border border-indigo-500/60 px-3 py-1 text-xs font-semibold text-indigo-400 transition-all hover:bg-indigo-500 hover:text-white active:scale-95 disabled:opacity-50"
195+ >
196+ { isToggling ? (
197+ < Loader2 className = "h-3 w-3 animate-spin" />
198+ ) : (
199+ 'Follow'
200+ ) }
201+ </ button >
202+ </ div >
203+ ) ;
204+ }
0 commit comments