@@ -26,7 +26,9 @@ import {
2626import { rewardsSchema , RewardsFormData } from './schemas/rewardsSchema' ;
2727import {
2828 getFeeEstimate ,
29+ getFinancialPreview ,
2930 type FeeEstimateData ,
31+ type FinancialPreviewData ,
3032} from '@/lib/api/hackathons/rewards' ;
3133import { cn } from '@/lib/utils' ;
3234import type { Control } from 'react-hook-form' ;
@@ -67,6 +69,9 @@ interface RewardsTabProps {
6769 onSave ?: ( data : RewardsFormData ) => Promise < void > ;
6870 initialData ?: RewardsFormData ;
6971 isLoading ?: boolean ;
72+ /** Required to call the financial preview dry-run endpoint */
73+ organizationId ?: string ;
74+ hackathonId ?: string ;
7075}
7176
7277const PRIZE_PRESETS = {
@@ -595,12 +600,19 @@ export default function RewardsTab({
595600 onContinue,
596601 initialData,
597602 isLoading = false ,
603+ organizationId,
604+ hackathonId,
598605} : RewardsTabProps ) {
599606 const [ showPresets , setShowPresets ] = useState ( false ) ;
600607 const [ pendingPreset , setPendingPreset ] = useState <
601608 keyof typeof PRIZE_PRESETS | null
602609 > ( null ) ;
603610 const [ confirmOpen , setConfirmOpen ] = useState ( false ) ;
611+ const [ previewData , setPreviewData ] = useState < FinancialPreviewData | null > (
612+ null
613+ ) ;
614+ const [ previewLoading , setPreviewLoading ] = useState ( false ) ;
615+ const [ previewError , setPreviewError ] = useState < string | null > ( null ) ;
604616
605617 /** The amount that was already funded when this form was opened (for top-up delta). */
606618 const initialFundedAmount = useMemo (
@@ -835,14 +847,37 @@ export default function RewardsTab({
835847 } ;
836848
837849 /**
838- * Called when Continue is clicked.
839- * Runs form validation; if it passes, opens the confirm dialog.
840- * If validation fails, react-hook-form will display inline errors as usual.
850+ * Called when the action button is clicked.
851+ * 1. Runs Zod validation — shows inline errors if invalid.
852+ * 2. If valid and org/hackathon IDs are present, calls the dry-run preview endpoint.
853+ * 3. Opens the confirm dialog (with live preview data if available).
841854 */
842855 const handleContinueClick = async ( ) => {
843856 const isValid = await form . trigger ( ) ;
844- if ( isValid ) {
845- setConfirmOpen ( true ) ;
857+ if ( ! isValid ) return ;
858+
859+ // Reset preview state for this dialog open
860+ setPreviewData ( null ) ;
861+ setPreviewError ( null ) ;
862+ setConfirmOpen ( true ) ;
863+
864+ if ( organizationId && hackathonId ) {
865+ setPreviewLoading ( true ) ;
866+ try {
867+ const tiers = form . getValues ( 'prizeTiers' ) ;
868+ const data = await getFinancialPreview ( organizationId , hackathonId , {
869+ prizeTiers : tiers ,
870+ } ) ;
871+ setPreviewData ( data ) ;
872+ } catch ( err : any ) {
873+ setPreviewError (
874+ err ?. response ?. data ?. message ||
875+ err ?. message ||
876+ 'Could not load cost preview. You can still proceed.'
877+ ) ;
878+ } finally {
879+ setPreviewLoading ( false ) ;
880+ }
846881 }
847882 } ;
848883
@@ -1041,7 +1076,7 @@ export default function RewardsTab({
10411076
10421077 { /* ── Confirmation AlertDialog ── */ }
10431078 < AlertDialog open = { confirmOpen } onOpenChange = { setConfirmOpen } >
1044- < AlertDialogContent className = 'border-zinc-800 bg-zinc-950 text-white' >
1079+ < AlertDialogContent className = 'border-zinc-800 bg-zinc-950 text-white sm:max-w-lg ' >
10451080 < AlertDialogHeader >
10461081 < div className = 'mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-orange-500/15' >
10471082 < Wallet className = 'h-6 w-6 text-orange-400' />
@@ -1051,75 +1086,217 @@ export default function RewardsTab({
10511086 </ AlertDialogTitle >
10521087 < AlertDialogDescription asChild >
10531088 < div className = 'space-y-3 text-zinc-400' >
1054- < p >
1055- You are about to lock{ ' ' }
1056- < strong className = 'text-white' >
1057- { ( ( ) => {
1058- const hasExisting = initialFundedAmount > 0 ;
1059- const delta = totalPool - initialFundedAmount ;
1060- const amt = hasExisting
1061- ? delta
1062- : ( feeEstimate ?. totalFunds ?? totalPool ) ;
1063- return `$${ amt . toLocaleString ( 'en-US' , { minimumFractionDigits : 0 , maximumFractionDigits : 2 } ) } USDC` ;
1064- } ) ( ) }
1065- </ strong > { ' ' }
1066- { initialFundedAmount > 0 ? '(top-up amount) ' : '' } into an
1067- escrow smart contract for this hackathon's prize pool.
1068- </ p >
1069- < div className = 'rounded-lg border border-orange-500/20 bg-orange-500/10 p-3 text-sm text-orange-300' >
1070- < p className = 'font-semibold' >
1071- ⚠️ This action is irreversible
1072- </ p >
1073- < p className = 'mt-1 text-orange-400/80' >
1074- Once confirmed, funds cannot be withdrawn without contacting
1075- support. Ensure your wallet has sufficient balance.
1076- </ p >
1077- </ div >
1078- < div className = 'rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 text-sm' >
1079- < div className = 'flex items-center justify-between' >
1080- < span className = 'text-zinc-500' > Prize tiers</ span >
1081- < span className = 'text-zinc-300' > { fields . length } </ span >
1089+ { /* Loading state */ }
1090+ { previewLoading && (
1091+ < div className = 'flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-4 text-sm text-zinc-400' >
1092+ < Loader2 className = 'h-4 w-4 animate-spin text-zinc-500' />
1093+ Calculating exact cost…
10821094 </ div >
1083- < div className = 'mt-1.5 flex items-center justify-between' >
1084- < span className = 'text-zinc-500' > Total prize pool</ span >
1085- < span className = 'text-zinc-300' >
1086- $
1087- { totalPool . toLocaleString ( 'en-US' , {
1095+ ) }
1096+
1097+ { /* Error fallback — still lets user proceed */ }
1098+ { previewError && ! previewLoading && (
1099+ < div className = 'rounded-lg border border-amber-500/20 bg-amber-500/10 p-3 text-sm text-amber-300' >
1100+ < p className = 'font-semibold' > Preview unavailable</ p >
1101+ < p className = 'mt-0.5 text-xs text-amber-400/80' >
1102+ { previewError }
1103+ </ p >
1104+ </ div >
1105+ ) }
1106+
1107+ { /* Rich preview from dry-run endpoint */ }
1108+ { previewData &&
1109+ ! previewLoading &&
1110+ ( ( ) => {
1111+ const fmt = ( n : number ) =>
1112+ n . toLocaleString ( 'en-US' , {
10881113 minimumFractionDigits : 0 ,
10891114 maximumFractionDigits : 2 ,
1090- } ) } { ' ' }
1091- USDC
1092- </ span >
1093- </ div >
1094- { feeEstimate && (
1095- < div className = 'mt-1.5 flex items-center justify-between' >
1096- < span className = 'text-zinc-500' >
1097- { feeEstimate . feeLabel ?? 'Platform fee' }
1098- </ span >
1099- < span className = 'text-zinc-300' >
1100- $
1101- { feeEstimate . feeAmount . toLocaleString ( 'en-US' , {
1102- minimumFractionDigits : 0 ,
1103- maximumFractionDigits : 2 ,
1104- } ) } { ' ' }
1105- USDC
1106- </ span >
1115+ } ) ;
1116+ return (
1117+ < >
1118+ < p >
1119+ You are about to{ ' ' }
1120+ { previewData . additionalFundingRequired > 0
1121+ ? 'top up'
1122+ : 'update' } { ' ' }
1123+ your escrow by{ ' ' }
1124+ < strong className = 'text-white' >
1125+ ${ fmt ( previewData . additionalFundingRequired ) } USDC
1126+ </ strong >
1127+ .
1128+ </ p >
1129+
1130+ { /* Wallet sufficiency */ }
1131+ < div
1132+ className = { cn (
1133+ 'rounded-lg border p-3 text-sm' ,
1134+ previewData . sufficient
1135+ ? 'border-green-500/20 bg-green-500/10 text-green-300'
1136+ : 'border-red-500/20 bg-red-500/10 text-red-300'
1137+ ) }
1138+ >
1139+ < div className = 'flex items-center justify-between' >
1140+ < span > Wallet balance</ span >
1141+ < span className = 'font-semibold' >
1142+ ${ fmt ( previewData . walletBalance ) } USDC
1143+ </ span >
1144+ </ div >
1145+ { ! previewData . sufficient && (
1146+ < p className = 'mt-1 text-xs text-red-400' >
1147+ ⚠️ Shortfall of ${ fmt ( previewData . shortfall ) } USDC
1148+ — please top up your wallet before proceeding.
1149+ </ p >
1150+ ) }
1151+ </ div >
1152+
1153+ { /* Irreversibility warning */ }
1154+ < div className = 'rounded-lg border border-orange-500/20 bg-orange-500/10 p-3 text-sm text-orange-300' >
1155+ < p className = 'font-semibold' >
1156+ ⚠️ This action is irreversible
1157+ </ p >
1158+ < p className = 'mt-1 text-xs text-orange-400/80' >
1159+ Once confirmed, funds move on-chain into escrow and
1160+ cannot be withdrawn without contacting support.
1161+ </ p >
1162+ </ div >
1163+
1164+ { /* Cost summary */ }
1165+ < div className = 'space-y-1.5 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 text-sm' >
1166+ < div className = 'flex justify-between' >
1167+ < span className = 'text-zinc-500' > Current pool</ span >
1168+ < span className = 'text-zinc-300' >
1169+ ${ fmt ( previewData . currentPrizePool ) } USDC
1170+ </ span >
1171+ </ div >
1172+ < div className = 'flex justify-between' >
1173+ < span className = 'text-zinc-500' > New pool</ span >
1174+ < span className = 'text-zinc-300' >
1175+ ${ fmt ( previewData . newPrizePool ) } USDC
1176+ </ span >
1177+ </ div >
1178+ < div className = 'flex justify-between' >
1179+ < span className = 'text-zinc-500' >
1180+ New platform fee
1181+ </ span >
1182+ < span className = 'text-zinc-300' >
1183+ ${ fmt ( previewData . newPlatformFee ) } USDC
1184+ </ span >
1185+ </ div >
1186+ < div className = 'flex justify-between border-t border-zinc-800 pt-1.5 font-semibold' >
1187+ < span className = 'text-zinc-400' >
1188+ You'll pay now
1189+ </ span >
1190+ < span className = 'text-white' >
1191+ ${ fmt ( previewData . additionalFundingRequired ) } USDC
1192+ </ span >
1193+ </ div >
1194+ </ div >
1195+
1196+ { /* Per-tier breakdown */ }
1197+ { previewData . breakdown . length > 0 && (
1198+ < div className = 'rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 text-sm' >
1199+ < p className = 'mb-2 text-xs font-semibold tracking-wide text-zinc-500 uppercase' >
1200+ Per-tier breakdown
1201+ </ p >
1202+ < div className = 'space-y-1' >
1203+ { previewData . breakdown . map ( ( tier , i ) => (
1204+ < div
1205+ key = { i }
1206+ className = 'flex items-center justify-between text-xs'
1207+ >
1208+ < span className = 'text-zinc-400' >
1209+ { tier . place }
1210+ </ span >
1211+ < span className = 'text-zinc-300' >
1212+ ${ fmt ( tier . amount ) } + ${ fmt ( tier . fee ) } fee ={ ' ' }
1213+ < strong > ${ fmt ( tier . total ) } </ strong >
1214+ </ span >
1215+ </ div >
1216+ ) ) }
1217+ </ div >
1218+ </ div >
1219+ ) }
1220+ </ >
1221+ ) ;
1222+ } ) ( ) }
1223+
1224+ { /* Fallback when no org/hackathon IDs (creation flow) */ }
1225+ { ! organizationId && ! previewLoading && ! previewData && (
1226+ < >
1227+ < p >
1228+ You are about to lock{ ' ' }
1229+ < strong className = 'text-white' >
1230+ { ( ( ) => {
1231+ const hasExisting = initialFundedAmount > 0 ;
1232+ const delta = totalPool - initialFundedAmount ;
1233+ const amt = hasExisting
1234+ ? delta
1235+ : ( feeEstimate ?. totalFunds ?? totalPool ) ;
1236+ return `$${ amt . toLocaleString ( 'en-US' , { minimumFractionDigits : 0 , maximumFractionDigits : 2 } ) } USDC` ;
1237+ } ) ( ) }
1238+ </ strong > { ' ' }
1239+ { initialFundedAmount > 0 ? '(top-up) ' : '' } into escrow.
1240+ </ p >
1241+ < div className = 'rounded-lg border border-orange-500/20 bg-orange-500/10 p-3 text-sm text-orange-300' >
1242+ < p className = 'font-semibold' >
1243+ ⚠️ This action is irreversible
1244+ </ p >
1245+ < p className = 'mt-1 text-xs text-orange-400/80' >
1246+ Once confirmed, funds move on-chain and cannot be
1247+ withdrawn without contacting support. Ensure your wallet
1248+ has sufficient balance.
1249+ </ p >
11071250 </ div >
1108- ) }
1109- { initialFundedAmount > 0 && (
1110- < div className = 'mt-1.5 flex items-center justify-between border-t border-zinc-800 pt-1.5' >
1111- < span className = 'text-zinc-500' > Already in escrow</ span >
1112- < span className = 'text-zinc-300' >
1113- $
1114- { initialFundedAmount . toLocaleString ( 'en-US' , {
1115- minimumFractionDigits : 0 ,
1116- maximumFractionDigits : 2 ,
1117- } ) } { ' ' }
1118- USDC
1119- </ span >
1251+ < div className = 'space-y-1.5 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 text-sm' >
1252+ < div className = 'flex justify-between' >
1253+ < span className = 'text-zinc-500' > Prize tiers</ span >
1254+ < span className = 'text-zinc-300' > { fields . length } </ span >
1255+ </ div >
1256+ < div className = 'flex justify-between' >
1257+ < span className = 'text-zinc-500' > Total prize pool</ span >
1258+ < span className = 'text-zinc-300' >
1259+ $
1260+ { totalPool . toLocaleString ( 'en-US' , {
1261+ minimumFractionDigits : 0 ,
1262+ maximumFractionDigits : 2 ,
1263+ } ) } { ' ' }
1264+ USDC
1265+ </ span >
1266+ </ div >
1267+ { feeEstimate && (
1268+ < div className = 'flex justify-between' >
1269+ < span className = 'text-zinc-500' >
1270+ { feeEstimate . feeLabel ?? 'Platform fee' }
1271+ </ span >
1272+ < span className = 'text-zinc-300' >
1273+ $
1274+ { feeEstimate . feeAmount . toLocaleString ( 'en-US' , {
1275+ minimumFractionDigits : 0 ,
1276+ maximumFractionDigits : 2 ,
1277+ } ) } { ' ' }
1278+ USDC
1279+ </ span >
1280+ </ div >
1281+ ) }
1282+ { initialFundedAmount > 0 && (
1283+ < div className = 'flex justify-between border-t border-zinc-800 pt-1.5' >
1284+ < span className = 'text-zinc-500' >
1285+ Already in escrow
1286+ </ span >
1287+ < span className = 'text-zinc-300' >
1288+ $
1289+ { initialFundedAmount . toLocaleString ( 'en-US' , {
1290+ minimumFractionDigits : 0 ,
1291+ maximumFractionDigits : 2 ,
1292+ } ) } { ' ' }
1293+ USDC
1294+ </ span >
1295+ </ div >
1296+ ) }
11201297 </ div >
1121- ) }
1122- </ div >
1298+ </ >
1299+ ) }
11231300 </ div >
11241301 </ AlertDialogDescription >
11251302 </ AlertDialogHeader >
@@ -1128,14 +1305,22 @@ export default function RewardsTab({
11281305 Cancel
11291306 </ AlertDialogCancel >
11301307 < AlertDialogAction
1308+ disabled = { previewData ? ! previewData . sufficient : false }
11311309 onClick = { e => {
11321310 e . preventDefault ( ) ;
11331311 setConfirmOpen ( false ) ;
11341312 handleConfirmedSubmit ( ) ;
11351313 } }
1136- className = 'bg-orange-500 text-white hover:bg-orange-600'
1314+ className = { cn (
1315+ 'text-white' ,
1316+ previewData && ! previewData . sufficient
1317+ ? 'cursor-not-allowed bg-zinc-600 opacity-50'
1318+ : 'bg-orange-500 hover:bg-orange-600'
1319+ ) }
11371320 >
1138- Yes, lock funds
1321+ { previewData && ! previewData . sufficient
1322+ ? 'Insufficient balance'
1323+ : 'Yes, lock funds' }
11391324 </ AlertDialogAction >
11401325 </ AlertDialogFooter >
11411326 </ AlertDialogContent >
0 commit comments