diff --git a/components/auth/SignupWrapper.tsx b/components/auth/SignupWrapper.tsx index 0e91e2c8..83564df8 100644 --- a/components/auth/SignupWrapper.tsx +++ b/components/auth/SignupWrapper.tsx @@ -27,11 +27,25 @@ const SignupWrapper = ({ setIsLoading(true); setLoadingState(true); + // Better Auth treats a relative `callbackURL` as relative to the API + // host that handled the OAuth callback (e.g. api.boundlessfi.xyz), + // not the frontend host. The previous default of '/' caused + // successful sign-ups to land on the API host's root, so users saw + // a blank/404 page and thought sign-up had failed — yet the session + // cookie was already set, so a later cache clear silently logged + // them in. Always send an absolute URL pointing at the frontend. + const callbackURL = + typeof window !== 'undefined' + ? window.location.origin + : ( + process.env.NEXT_PUBLIC_APP_URL || 'https://boundlessfi.xyz' + ).replace(/\/$/, ''); + try { await authClient.signIn.social( { provider: 'google', - callbackURL: process.env.NEXT_PUBLIC_APP_URL || '/', + callbackURL, }, { onRequest: () => { diff --git a/components/organization/hackathons/details/ExportButton.tsx b/components/organization/hackathons/details/ExportButton.tsx index dd0a2504..a4242862 100644 --- a/components/organization/hackathons/details/ExportButton.tsx +++ b/components/organization/hackathons/details/ExportButton.tsx @@ -50,6 +50,11 @@ const DATASETS = [ label: 'Winners', description: 'Wallet address, activation & USDC trustline', }, + { + id: 'judging', + label: 'Judging', + description: 'Results, judges, scores & comments', + }, ] as const; export function ExportButton({ diff --git a/components/organization/hackathons/rewards/WinnerCard.tsx b/components/organization/hackathons/rewards/WinnerCard.tsx index 7d22eabb..181c4c76 100644 --- a/components/organization/hackathons/rewards/WinnerCard.tsx +++ b/components/organization/hackathons/rewards/WinnerCard.tsx @@ -1,8 +1,7 @@ 'use client'; import React from 'react'; -import Image from 'next/image'; -import { ArrowUpRight } from 'lucide-react'; +import { Trophy, Layers } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { @@ -12,8 +11,7 @@ import { } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { Submission } from './types'; -import Ribbon from '@/components/svg/Ribbon'; -import { getRibbonColors, getRibbonText } from './winnersUtils'; +import { getRibbonColors } from './winnersUtils'; interface WinnerCardProps { rank: number; @@ -22,8 +20,36 @@ interface WinnerCardProps { currency?: string; prizeLabel?: string; maxRank: number; + /** + * Track name to render as the primary badge instead of the + * rank ribbon. When present, the card switches to track-winner + * styling (no podium scaling, neutral border accent). + */ + trackName?: string; } +const formatPrize = (amount?: string, currency?: string) => { + if (!amount || amount === '0' || amount === '0.00') return null; + const c = currency || 'USDC'; + // Industry-standard format: amount first, single currency suffix. + // The previous "$300 USDC" double-signed the value and read as + // confusing for USDC payouts (USDC is the unit, not USD). + const numeric = Number(amount); + const display = Number.isFinite(numeric) + ? numeric.toLocaleString('en-US') + : amount; + return `${display} ${c}`; +}; + +const ordinalSuffix = (rank: number) => { + const j = rank % 10; + const k = rank % 100; + if (j === 1 && k !== 11) return 'st'; + if (j === 2 && k !== 12) return 'nd'; + if (j === 3 && k !== 13) return 'rd'; + return 'th'; +}; + export default function WinnerCard({ rank, winner, @@ -31,114 +57,104 @@ export default function WinnerCard({ currency, prizeLabel, maxRank, + trackName, }: WinnerCardProps) { - const getScaleClass = () => { - if (maxRank <= 3) { - if (rank === 1) return 'md:scale-110'; - if (rank === 2 || rank === 3) return 'md:scale-95'; - } else { - if (rank === 1) return 'md:scale-105'; - } - return ''; - }; + const isTrack = !!trackName; + const prizeText = formatPrize(prizeAmount, currency) || prizeLabel || null; + const ribbonColors = getRibbonColors(rank); + + // Subtle scale only for overall podium (rank 1-3). Track cards stay + // uniform — they're a flat sibling row, not a podium. + const scaleClass = + !isTrack && rank === 1 && maxRank <= 3 ? 'md:scale-105' : ''; return (
-
- Trophy - - {prizeAmount != null && currency && prizeAmount !== '0' - ? `$${prizeAmount} ${currency}` - : prizeLabel || 'No prize configured'} - -
- -
- - {winner ? ( - <> - - - {winner.name.charAt(0).toUpperCase()} - - - ) : ( - - ? - - )} - -
+ {/* Header: rank/track badge + prize chip */} +
+ {isTrack ? ( + + + {trackName} + + ) : ( + + {rank} + {ordinalSuffix(rank)} + Place + + )} -
- - - {getRibbonText(rank)} - + {prizeText && ( +
+ + + {prizeText} + +
+ )}
-
-

- {winner?.name || '?'} -

-
+ {/* Project block: avatar + name + category */} + {winner ? ( +
+ + + + {winner.name?.charAt(0) || '?'} + + - {winner && ( -
-
-
- {winner.projectName} -
-
- - -

- {winner.projectName} -

-
- -

{winner.projectName}

-
-
- - {winner.category || 'General'} - -
-
- - {winner.averageScore - ? winner.averageScore.toFixed(1) - : winner.score || 0}{' '} - Score - -
- {winner.commentCount || 0} Comments - +
+ + +

+ {winner.projectName} +

+
+ +

{winner.projectName}

+
+
+
+ {winner.name || 'Unknown'} + {winner.category && ( + <> + + {winner.category} + + )}
+ ) : ( +
+ + + ? + + +
No winner assigned
+
)}
); diff --git a/components/organization/hackathons/rewards/WinnersGrid.tsx b/components/organization/hackathons/rewards/WinnersGrid.tsx index c8fa89de..bb1bdbd6 100644 --- a/components/organization/hackathons/rewards/WinnersGrid.tsx +++ b/components/organization/hackathons/rewards/WinnersGrid.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useMemo } from 'react'; +import { CheckCircle2, AlertTriangle } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Submission } from './types'; import WinnerCard from './WinnerCard'; @@ -105,37 +106,87 @@ export default function WinnersGrid({ return sorted; }, [displayPairs.overallPairs]); + // Total prize pool across all tiers (overall + track). Drives the + // summary chip in the header so the organizer sees the dollar figure + // they're about to commit, not just the winner count. + const totalPool = useMemo(() => { + return prizeTiers.reduce((sum, t) => { + const amount = parseFloat(t.prizeAmount || '0'); + return Number.isFinite(amount) ? sum + amount : sum; + }, 0); + }, [prizeTiers]); + const totalPoolCurrency = prizeTiers[0]?.currency || 'USDC'; + + const assignedCount = winners.length; + const isComplete = assignedCount === totalTiers && totalTiers > 0; + return ( -
-
- - {winners.length}/{totalTiers} Winners Assigned - +
+ {/* Summary header: completion state + total pool. Replaces the + minimal "3/8 Winners Assigned" that read as confusing in the + wizard preview. */} +
+
+ {isComplete ? ( + + ) : ( + + )} +
+ + {isComplete + ? `All ${totalTiers} winners assigned` + : `${assignedCount} of ${totalTiers} winners assigned`} + + {!isComplete && totalTiers - assignedCount > 0 && ( + + ({totalTiers - assignedCount} unassigned) + + )} +
+
+ {totalPool > 0 && ( +
+ + {totalPool.toLocaleString('en-US')} {totalPoolCurrency} pool + +
+ )}
{orderedOverall.length > 0 && ( -
- {orderedOverall.map(({ key, tier, winner }) => { - const prize = getPrizeForRank(tier.rank); - return ( - - ); - })} +
+ {displayPairs.trackPairs.length > 0 && ( +
+ Overall Placements +
+ )} +
+ {orderedOverall.map(({ key, tier, winner }) => { + const prize = getPrizeForRank(tier.rank); + return ( + + ); + })} +
)} {displayPairs.trackPairs.length > 0 && ( -
+
Track Winners
@@ -150,15 +201,16 @@ export default function WinnersGrid({ return ( ); })} diff --git a/components/organization/hackathons/rewards/types.ts b/components/organization/hackathons/rewards/types.ts index 0633aaaf..788d1adf 100644 --- a/components/organization/hackathons/rewards/types.ts +++ b/components/organization/hackathons/rewards/types.ts @@ -1,5 +1,12 @@ export interface Submission { id: string; + /** + * The actual HackathonSubmission row ID, distinct from `id` which the + * rewards data mapper sets to the participant ID. Required for + * matching submissions against backend payloads (judging results, + * track winners) that key by submissionId. + */ + submissionId?: string; name: string; projectName: string; avatar?: string; diff --git a/hooks/use-hackathon-rewards.ts b/hooks/use-hackathon-rewards.ts index 2b4a8fe4..ec4240cd 100644 --- a/hooks/use-hackathon-rewards.ts +++ b/hooks/use-hackathon-rewards.ts @@ -472,7 +472,14 @@ export const useHackathonRewards = ( const byId = new Map(fetched.map(tw => [tw.submissionId, tw])); setSubmissions(prev => prev.map(sub => { - const tw = byId.get(sub.id); + // Match against the real submission row ID (now threaded + // through by the mapper). Fall back to `sub.id` for any + // mapper output that predates the `submissionId` field — + // older rows would have `id === submissionId` when + // participant data was missing. + const tw = + (sub.submissionId && byId.get(sub.submissionId)) || + byId.get(sub.id); if (!tw) return sub; return { ...sub, diff --git a/lib/api/hackathons/rewards.ts b/lib/api/hackathons/rewards.ts index 1b383396..58a75aa9 100644 --- a/lib/api/hackathons/rewards.ts +++ b/lib/api/hackathons/rewards.ts @@ -214,6 +214,7 @@ export const exportHackathon = async ( | 'submissions' | 'prize_tiers' | 'winners' + | 'judging' | 'full' = 'full' ): Promise => { const res = await api.get( diff --git a/lib/utils/rewards-data-mapper.ts b/lib/utils/rewards-data-mapper.ts index a423640e..67137675 100644 --- a/lib/utils/rewards-data-mapper.ts +++ b/lib/utils/rewards-data-mapper.ts @@ -63,6 +63,12 @@ export const mapJudgingSubmissionToRewardSubmission = ( return { id: participant.id || sub.id || '', + // The real submission row ID, used by backend payloads (judging + // results, track winners) that key by submissionId. The mapper's + // `id` field stays on the participant ID for compatibility with + // existing rank-assignment and display code that already keys off + // it. + submissionId: submissionData.id || sub.id || sub.submissionId || '', participantId: participant.id || sub.id || '', name, projectName: submissionData.projectName || '',