Skip to content

Commit b2e7150

Browse files
committed
feat: display financial preview in rewards confirmation dialog
1 parent cd82852 commit b2e7150

3 files changed

Lines changed: 323 additions & 74 deletions

File tree

components/organization/hackathons/new/tabs/RewardsTab.tsx

Lines changed: 257 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ import {
2626
import { rewardsSchema, RewardsFormData } from './schemas/rewardsSchema';
2727
import {
2828
getFeeEstimate,
29+
getFinancialPreview,
2930
type FeeEstimateData,
31+
type FinancialPreviewData,
3032
} from '@/lib/api/hackathons/rewards';
3133
import { cn } from '@/lib/utils';
3234
import 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

7277
const 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

Comments
 (0)