From b33e06982247a3b1d65e9403738a34b9c66e7a61 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Thu, 21 May 2026 11:22:40 +0100 Subject: [PATCH] fix(rewards): polish publish-wizard preview to industry-standard layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wizard preview's "3/8 Winners Assigned" line plus the existing WinnerCard layout was confusing — "$300 USDC" double-signed the currency, "0 Comments" was meaningless in a payout preview, and track winners got a "4th Position" ribbon they didn't earn. Redesigned to match the patterns Devpost / Devfolio / ETHGlobal use in their publish flows: WinnersGrid header: - Replace the cryptic "X/Y Winners Assigned" with a callout chip: green check + "All N winners assigned" when complete, amber warning + "X of Y assigned (Z unassigned)" when not. - Show the total prize pool ("1,500 USDC pool") as a sibling chip so the organizer sees the dollar figure they're committing. - Render "Overall Placements" as a sub-header only when track winners also exist (avoids redundant heading on OVERALL_ONLY). WinnerCard: - Drop the dollar sign for non-dollar currencies — `"$300 USDC"` is now `"300 USDC"`, a cleaner industry-standard read. - Track winners get a track-name chip ("Best UI/UX") instead of a synthetic-rank ribbon ("4th Position"). - Drop the "0 Comments" noise; not a meaningful signal at payout time. - Drop the placeholder bitmed.png; surface project name + participant name + category as the primary content. - Cards now have consistent dimensions (no podium scaling for tracks), uniform border + hover treatment, prize chip aligned to the right. Only the publish-wizard preview is touched; the public Winners tab and the rewards podium are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hackathons/rewards/WinnerCard.tsx | 212 ++++++++++-------- .../hackathons/rewards/WinnersGrid.tsx | 106 ++++++--- 2 files changed, 193 insertions(+), 125 deletions(-) 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 ( ); })}