@@ -3,64 +3,85 @@ import { Button } from '@/components/ui/button';
33import { Dialog , DialogContent , DialogHeader , DialogTitle , DialogFooter } from '@/components/ui/dialog' ;
44import { useFormatXlm } from '@/hooks/useFormatXlm' ;
55import showToast from '@/utils/toast.util' ;
6- import { useBatchBuyMutation } from '@/hooks/useWallet' ;
6+ import { useBatchBuyMutation , type BatchOrder } from '@/hooks/useWallet' ;
77
88export interface BatchOrderRow {
9- creatorId : string ;
10- priceStroops : number ;
9+ address : string ;
10+ creatorId ?: string ;
11+ priceStroops ?: number ;
1112 quantity : number ;
1213}
1314
1415interface Props {
1516 open : boolean ;
1617 onOpenChange : ( open : boolean ) => void ;
18+ liquidBalance ?: number ;
19+ initialRows ?: BatchOrderRow [ ] ;
1720}
1821
1922const MAX_KEYS = 5 ;
20-
21- export default function BatchBuyModal ( { open, onOpenChange } : Props ) {
22- const [ rows , setRows ] = useState < BatchOrderRow [ ] > ( [ ] ) ;
23+ const DEFAULT_LIQUID_BALANCE = 100 ;
24+ const STELLAR_ADDRESS_PATTERN = / ^ G [ A - Z 2 - 7 ] { 55 } $ / ;
25+
26+ export default function BatchBuyModal ( {
27+ open,
28+ onOpenChange,
29+ liquidBalance = DEFAULT_LIQUID_BALANCE ,
30+ initialRows = [ ] ,
31+ } : Props ) {
32+ const [ rows , setRows ] = useState < BatchOrderRow [ ] > ( initialRows ) ;
2333 const [ addInput , setAddInput ] = useState ( '' ) ;
2434 const { format } = useFormatXlm ( ) ;
2535 const mutation = useBatchBuyMutation ( ) ;
2636
27- const totalStroops = useMemo ( ( ) => {
28- return rows . reduce ( ( acc , r ) => acc + ( r . priceStroops * r . quantity ) , 0 ) ;
29- } , [ rows ] ) ;
30-
31- const totalXlm = format ( totalStroops ) ;
37+ const totalQuantity = useMemo (
38+ ( ) => rows . reduce ( ( total , row ) => total + row . quantity , 0 ) ,
39+ [ rows ]
40+ ) ;
41+ const totalStroops = useMemo (
42+ ( ) => rows . reduce ( ( total , row ) => total + ( row . priceStroops ?? 0 ) * row . quantity , 0 ) ,
43+ [ rows ]
44+ ) ;
45+ const invalidAddress = rows . some ( row => ! STELLAR_ADDRESS_PATTERN . test ( row . address ) ) ;
46+ const balanceExceeded = totalQuantity > liquidBalance ;
47+ const validationError = invalidAddress
48+ ? 'Enter valid Stellar addresses for every recipient.'
49+ : balanceExceeded
50+ ? `Total quantity cannot exceed your liquid balance of ${ liquidBalance } keys.`
51+ : undefined ;
52+ const canSubmit = rows . length > 0 && ! validationError ;
3253
3354 const handleAdd = ( ) => {
34- if ( ! addInput ) return ;
55+ const address = addInput . trim ( ) ;
56+ if ( ! address ) return ;
3557 if ( rows . length >= MAX_KEYS ) {
3658 showToast . error ( `Maximum ${ MAX_KEYS } keys per batch` ) ;
3759 return ;
3860 }
3961
40- // For demo: assume price 1 XLM = 10_000_000 stroops
41- const defaultPrice = 10_000_000 ;
42- const newRow : BatchOrderRow = {
43- creatorId : addInput ,
44- priceStroops : defaultPrice ,
45- quantity : 1 ,
46- } ;
47- setRows ( r => [ ...r , newRow ] ) ;
62+ setRows ( rows => [
63+ ...rows ,
64+ { address, creatorId : address , priceStroops : 10_000_000 , quantity : 1 } ,
65+ ] ) ;
4866 setAddInput ( '' ) ;
4967 } ;
5068
5169 const handleRemove = ( idx : number ) => {
52- setRows ( r => r . filter ( ( _ , i ) => i !== idx ) ) ;
70+ setRows ( rows => rows . filter ( ( _ , i ) => i !== idx ) ) ;
5371 } ;
5472
55- const handleQuantityChange = ( idx : number , q : number ) => {
56- setRows ( r => r . map ( ( row , i ) => ( i === idx ? { ...row , quantity : q } : row ) ) ) ;
73+ const handleQuantityChange = ( idx : number , quantity : number ) => {
74+ setRows ( rows =>
75+ rows . map ( ( row , i ) => ( i === idx ? { ...row , quantity : Math . max ( 1 , quantity ) } : row ) )
76+ ) ;
5777 } ;
5878
5979 const handleConfirm = async ( ) => {
60- if ( rows . length === 0 ) return ;
80+ if ( ! canSubmit ) return ;
6181 try {
6282 showToast . loading ( 'Submitting batch buy...' ) ;
63- await mutation . mutateAsync ( { orders : rows } ) ;
83+ const orders : BatchOrder [ ] = rows . map ( ( { address, quantity } ) => ( { address, quantity } ) ) ;
84+ await mutation . mutateAsync ( { orders } ) ;
6485 showToast . transactionSuccess ( 'Batch buy submitted' ) ;
6586 onOpenChange ( false ) ;
6687 setRows ( [ ] ) ;
@@ -81,7 +102,8 @@ export default function BatchBuyModal({ open, onOpenChange }: Props) {
81102 < input
82103 value = { addInput }
83104 onChange = { e => setAddInput ( e . target . value ) }
84- placeholder = "Search or enter creator id"
105+ placeholder = "Enter Stellar recipient address"
106+ aria-label = "Recipient address"
85107 className = "flex-1 rounded-xl bg-white/[0.04] px-3 py-2 text-white"
86108 />
87109 < Button onClick = { handleAdd } > Add</ Button >
@@ -92,35 +114,43 @@ export default function BatchBuyModal({ open, onOpenChange }: Props) {
92114 ) : (
93115 < div className = "space-y-2" >
94116 { rows . map ( ( row , idx ) => (
95- < div key = { row . creatorId } className = "flex items-center gap-2" >
96- < div className = "w-40 text-sm truncate " > { row . creatorId } </ div >
97- < div className = "w-28 text-sm" > { format ( row . priceStroops ) } </ div >
117+ < div key = { ` ${ row . address } - ${ idx } ` } className = "flex items-center gap-2" >
118+ < div className = "w-40 truncate text-sm" > { row . address } </ div >
119+ < div className = "w-28 text-sm" > { format ( row . priceStroops ?? 0 ) } </ div >
98120 < input
99121 type = "number"
100122 min = { 1 }
101123 value = { row . quantity }
102- onChange = { e => handleQuantityChange ( idx , Math . max ( 1 , Number ( e . target . value || 1 ) ) ) }
124+ aria-label = { `Quantity for ${ row . address } ` }
125+ onChange = { e => handleQuantityChange ( idx , Number ( e . target . value || 1 ) ) }
103126 className = "w-24 rounded-xl bg-white/[0.04] px-2 py-1 text-white"
104127 />
105128 < div className = "flex-1 text-sm text-white/60" >
106- Subtotal: { format ( row . priceStroops * row . quantity ) } XLM
129+ Subtotal: { format ( ( row . priceStroops ?? 0 ) * row . quantity ) } XLM
107130 </ div >
108131 < Button variant = "ghost" onClick = { ( ) => handleRemove ( idx ) } > Remove</ Button >
109132 </ div >
110133 ) ) }
111134 </ div >
112135 ) }
113136
114- < div className = "flex items-center justify-between pt-4 border-t border-white/5" >
115- < div className = "text-sm text-white/60" > Total</ div >
116- < div className = "font-bold text-white" > { totalXlm } XLM</ div >
137+ < div className = "flex items-center justify-between border-t border-white/5 pt-4" >
138+ < div className = "text-sm text-white/60" >
139+ Total: { totalQuantity } / { liquidBalance } keys
140+ </ div >
141+ < div className = "font-bold text-white" > { format ( totalStroops ) } XLM</ div >
117142 </ div >
143+ { validationError && (
144+ < p role = "alert" data-testid = "batch-buy-validation-error" className = "text-sm text-red-400" >
145+ { validationError }
146+ </ p >
147+ ) }
118148 </ div >
119149
120150 < DialogFooter >
121151 < div className = "flex justify-end gap-2" >
122152 < Button variant = "ghost" onClick = { ( ) => onOpenChange ( false ) } > Cancel</ Button >
123- < Button onClick = { handleConfirm } disabled = { rows . length === 0 } > Confirm</ Button >
153+ < Button onClick = { handleConfirm } disabled = { ! canSubmit } > Confirm</ Button >
124154 </ div >
125155 </ DialogFooter >
126156 </ DialogContent >
0 commit comments