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 (
-
-
-
- {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.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 || '',