@@ -15,7 +15,8 @@ import BlockIcon from "@mui/icons-material/Block";
1515import ExpandMoreIcon from "@mui/icons-material/ExpandMore" ;
1616import DeleteIcon from "@mui/icons-material/Delete" ;
1717import ContentCopyIcon from "@mui/icons-material/ContentCopy" ;
18- import { useAccount } from "wagmi" ;
18+ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined" ;
19+ import { useAccount , useReadContract } from "wagmi" ;
1920import { useSearchParams } from "next/navigation" ;
2021import { keccak256 } from "viem" ;
2122import Link from "next/link" ;
@@ -30,8 +31,8 @@ import {
3031 useDepositTokens , useTransferToSubMember , useTransferToParent , useWithdraw ,
3132 useTokenBalance ,
3233} from "@/hooks/useRewardsProgram" ;
33- import { MemberRoleLabels , MemberRoleEnum , MemberTypeLabels } from "@/config/contracts" ;
34- import { fromBytes8 , fromBytes12 , fromBytes16 , shortenAddress , formatFula , isValidAddress , formatContractError } from "@/lib/utils" ;
34+ import { MemberRoleLabels , MemberRoleEnum , MemberTypeLabels , CONTRACTS , REWARDS_PROGRAM_ABI } from "@/config/contracts" ;
35+ import { fromBytes8 , fromBytes12 , fromBytes16 , toBytes12 , shortenAddress , formatFula , isValidAddress , formatContractError } from "@/lib/utils" ;
3536import { OnChainDisclaimer } from "@/components/common/OnChainDisclaimer" ;
3637import { QRCodeDisplay } from "@/components/common/QRCodeDisplay" ;
3738
@@ -239,6 +240,16 @@ function ProgramDetail({ programId }: { programId: number }) {
239240 const { data : myBalance } = useMemberBalance ( programId , address ) ;
240241 const { data : transferLimit } = useTransferLimit ( programId ) ;
241242
243+ // My member info (for parent detection)
244+ const { data : myMember } = useReadContract ( {
245+ address : CONTRACTS . rewardsProgram ,
246+ abi : REWARDS_PROGRAM_ABI ,
247+ functionName : "getMember" ,
248+ args : address ? [ programId , address ] : undefined ,
249+ query : { enabled : ! ! address && programId > 0 } ,
250+ } ) ;
251+ const parentAddr = myMember ?. parent && myMember . parent !== "0x0000000000000000000000000000000000000000" ? myMember . parent as string : "" ;
252+
242253 const isPA = role === MemberRoleEnum . ProgramAdmin ;
243254 const canManageProgram = isAdmin || isPA ;
244255 const canManageSubTypes = isAdmin || isPA ;
@@ -295,20 +306,42 @@ function ProgramDetail({ programId }: { programId: number }) {
295306 const [ depAmount , setDepAmount ] = useState ( "" ) ;
296307 const [ depNote , setDepNote ] = useState ( "" ) ;
297308 const [ depDisclaimer , setDepDisclaimer ] = useState ( false ) ;
309+ const [ transMemberCode , setTransMemberCode ] = useState ( "" ) ;
298310 const [ transTo , setTransTo ] = useState ( "" ) ;
299311 const [ transAmount , setTransAmount ] = useState ( "" ) ;
300312 const [ transLocked , setTransLocked ] = useState ( true ) ;
301313 const [ transLockDays , setTransLockDays ] = useState ( "0" ) ;
314+ const [ transNote , setTransNote ] = useState ( "" ) ;
302315 const [ transDisclaimer , setTransDisclaimer ] = useState ( false ) ;
303316 const [ parentTo , setParentTo ] = useState ( "" ) ;
304317 const [ parentAmount , setParentAmount ] = useState ( "" ) ;
318+ const [ parentNote , setParentNote ] = useState ( "" ) ;
305319 const [ parentDisclaimer , setParentDisclaimer ] = useState ( false ) ;
306320 const [ withAmount , setWithAmount ] = useState ( "" ) ;
307321 const [ withDisclaimer , setWithDisclaimer ] = useState ( false ) ;
308322 const [ tokenTab , setTokenTab ] = useState ( 0 ) ;
309323 const canTransferSub = isAdmin || isPA || role === MemberRoleEnum . TeamLeader ;
310324 const isMember = role > 0 || isAdmin ;
311325
326+ // Resolve member code → storage key for transfers
327+ const transMemberCodeBytes = transMemberCode . length > 0 ? toBytes12 ( transMemberCode ) : undefined ;
328+ const { data : transResolvedKey } = useReadContract ( {
329+ address : CONTRACTS . rewardsProgram ,
330+ abi : REWARDS_PROGRAM_ABI ,
331+ functionName : "memberIDLookup" ,
332+ args : transMemberCodeBytes ? [ transMemberCodeBytes , programId ] : undefined ,
333+ query : { enabled : ! ! transMemberCodeBytes && programId > 0 } ,
334+ } ) ;
335+ const { data : transResolvedMember } = useReadContract ( {
336+ address : CONTRACTS . rewardsProgram ,
337+ abi : REWARDS_PROGRAM_ABI ,
338+ functionName : "getMemberByID" ,
339+ args : transMemberCodeBytes ? [ transMemberCodeBytes , programId ] : undefined ,
340+ query : { enabled : ! ! transMemberCodeBytes && programId > 0 } ,
341+ } ) ;
342+ const transResolvedAddr = transResolvedKey && transResolvedKey !== "0x0000000000000000000000000000000000000000" ? transResolvedKey as string : "" ;
343+ const transTarget = transResolvedAddr || transTo ;
344+
312345 const paWalletValid = ! paWallet || isValidAddress ( paWallet ) ;
313346 const mWalletValid = ! mWallet || isValidAddress ( mWallet ) ;
314347
@@ -529,27 +562,57 @@ function ProgramDetail({ programId }: { programId: number }) {
529562 { /* Transfer to Sub-Member */ }
530563 { canTransferSub && tokenTab === 1 && (
531564 < Box sx = { { pt : 2 , maxWidth : 480 } } >
532- < TextField label = "Recipient Wallet" value = { transTo } onChange = { ( e ) => setTransTo ( e . target . value ) }
533- fullWidth size = "small" error = { ! ! transTo && ! isValidAddress ( transTo ) } />
565+ < TextField label = "Recipient Member Code" value = { transMemberCode }
566+ onChange = { ( e ) => setTransMemberCode ( e . target . value . toUpperCase ( ) . slice ( 0 , 12 ) ) }
567+ fullWidth size = "small" placeholder = "e.g. ALICE01"
568+ inputProps = { { maxLength : 12 } } />
569+ { transMemberCode && transResolvedAddr && transResolvedMember && (
570+ < Alert severity = "success" sx = { { mt : 1 } } >
571+ Resolved: < strong > { fromBytes12 ( transResolvedMember . memberID ) } </ strong > — { MemberRoleLabels [ transResolvedMember . role ] || "Unknown" }
572+ { transResolvedMember . wallet && transResolvedMember . wallet !== "0x0000000000000000000000000000000000000000"
573+ ? ` (${ shortenAddress ( transResolvedMember . wallet ) } )`
574+ : " (walletless member)" }
575+ </ Alert >
576+ ) }
577+ { transMemberCode && ! transResolvedAddr && transMemberCodeBytes && (
578+ < Alert severity = "warning" sx = { { mt : 1 } } > Member not found in this program.</ Alert >
579+ ) }
580+ < TextField label = "Override Wallet (optional)" value = { transTo } onChange = { ( e ) => setTransTo ( e . target . value ) }
581+ fullWidth size = "small" sx = { { mt : 1 } } placeholder = "0x... (only if member code is empty)"
582+ error = { ! ! transTo && ! isValidAddress ( transTo ) }
583+ helperText = "Used only when member code is empty"
584+ disabled = { ! ! transResolvedAddr } />
534585 < TextField label = "Amount (FULA)" value = { transAmount } onChange = { ( e ) => setTransAmount ( e . target . value ) }
535586 fullWidth size = "small" type = "number" sx = { { mt : 1 } } />
536- < FormControl fullWidth size = "small" sx = { { mt : 1 } } >
537- < InputLabel > Lock</ InputLabel >
538- < Select value = { transLocked ? "locked" : "unlocked" }
539- onChange = { ( e ) => setTransLocked ( e . target . value === "locked" ) } label = "Lock" >
540- < MenuItem value = "locked" > Permanently Locked</ MenuItem >
541- < MenuItem value = "unlocked" > Unlocked / Time-locked</ MenuItem >
542- </ Select >
543- </ FormControl >
544- { ! transLocked && (
587+ < Box sx = { { display : "flex" , alignItems : "center" , gap : 0.5 , mt : 1 } } >
588+ < FormControl fullWidth size = "small" >
589+ < InputLabel > Lock</ InputLabel >
590+ < Select value = { transLocked ? "locked" : "unlocked" }
591+ onChange = { ( e ) => { setTransLocked ( e . target . value === "locked" ) ; if ( e . target . value === "locked" ) setTransLockDays ( "0" ) ; } } label = "Lock" >
592+ < MenuItem value = "locked" > Permanently Locked</ MenuItem >
593+ < MenuItem value = "unlocked" > Unlocked / Time-locked</ MenuItem >
594+ </ Select >
595+ </ FormControl >
596+ < Tooltip title = "If you check Permanently Locked, the recipient can only transfer the tokens back to sender and cannot withdraw to their wallet" arrow >
597+ < InfoOutlinedIcon sx = { { fontSize : 16 , color : "text.secondary" , cursor : "help" } } />
598+ </ Tooltip >
599+ </ Box >
600+ < Box sx = { { display : "flex" , alignItems : "center" , gap : 0.5 , mt : 1 } } >
545601 < TextField label = "Lock Days (0 = unlocked)" value = { transLockDays }
546602 onChange = { ( e ) => setTransLockDays ( e . target . value ) }
547- fullWidth size = "small" type = "number" sx = { { mt : 1 } } />
548- ) }
603+ fullWidth size = "small" type = "number" disabled = { transLocked } />
604+ < Tooltip title = "If you set a time, the user needs to wait for that number of days before they can withdraw tokens to their wallet. They can still transfer tokens back to sender at any time without waiting" arrow >
605+ < InfoOutlinedIcon sx = { { fontSize : 16 , color : "text.secondary" , cursor : "help" } } />
606+ </ Tooltip >
607+ </ Box >
608+ < TextField label = "Note (optional, max 128)" value = { transNote }
609+ onChange = { ( e ) => setTransNote ( e . target . value . slice ( 0 , 128 ) ) }
610+ fullWidth size = "small" sx = { { mt : 1 } } inputProps = { { maxLength : 128 } }
611+ helperText = { `${ transNote . length } /128` } />
549612 < OnChainDisclaimer accepted = { transDisclaimer } onChange = { setTransDisclaimer } />
550613 < Button variant = "contained" fullWidth sx = { { mt : 1 } }
551- onClick = { ( ) => transfer ( programId , transTo as `0x${string } `, transAmount , transLocked , parseInt ( transLockDays ) || 0 ) }
552- disabled = { isTransPending || isTransConf || ! transTo || ! transAmount || ! isValidAddress ( transTo ) || ! transDisclaimer } >
614+ onClick = { ( ) => transfer ( programId , transTarget as `0x${string } `, transAmount , transLocked , parseInt ( transLockDays ) || 0 , transNote ) }
615+ disabled = { isTransPending || isTransConf || ! transTarget || ! transAmount || ( ! transResolvedAddr && ! ! transTo && ! isValidAddress ( transTo ) ) || ! transDisclaimer } >
553616 { isTransPending || isTransConf ? < CircularProgress size = { 16 } /> : "Transfer" }
554617 </ Button >
555618 { transSuccess && < Alert severity = "success" sx = { { mt : 1 } } > Transferred!</ Alert > }
@@ -560,17 +623,27 @@ function ProgramDetail({ programId }: { programId: number }) {
560623 { /* Transfer to Parent */ }
561624 { tokenTab === ( canTransferSub ? 2 : 1 ) && (
562625 < Box sx = { { pt : 2 , maxWidth : 480 } } >
626+ { parentAddr && (
627+ < Alert severity = "info" sx = { { mb : 1 } } >
628+ Your parent: < strong > { shortenAddress ( parentAddr ) } </ strong > . Leave wallet empty to transfer directly to them.
629+ </ Alert >
630+ ) }
563631 { transferLimit != null && Number ( transferLimit ) > 0 && (
564632 < Alert severity = "info" sx = { { mb : 1 } } > Limit: { String ( transferLimit ) } % of total balance</ Alert >
565633 ) }
566- < TextField label = "Parent Wallet (empty = direct parent )" value = { parentTo }
634+ < TextField label = "Override Parent Wallet (optional )" value = { parentTo }
567635 onChange = { ( e ) => setParentTo ( e . target . value ) }
568- fullWidth size = "small" error = { ! ! parentTo && ! isValidAddress ( parentTo ) } />
636+ fullWidth size = "small" error = { ! ! parentTo && ! isValidAddress ( parentTo ) }
637+ helperText = "Leave empty to transfer to your direct parent" />
569638 < TextField label = "Amount (FULA)" value = { parentAmount } onChange = { ( e ) => setParentAmount ( e . target . value ) }
570639 fullWidth size = "small" type = "number" sx = { { mt : 1 } } />
640+ < TextField label = "Note (optional, max 128)" value = { parentNote }
641+ onChange = { ( e ) => setParentNote ( e . target . value . slice ( 0 , 128 ) ) }
642+ fullWidth size = "small" sx = { { mt : 1 } } inputProps = { { maxLength : 128 } }
643+ helperText = { `${ parentNote . length } /128` } />
571644 < OnChainDisclaimer accepted = { parentDisclaimer } onChange = { setParentDisclaimer } />
572645 < Button variant = "contained" fullWidth sx = { { mt : 1 } }
573- onClick = { ( ) => transferBack ( programId , ( parentTo || "0x0000000000000000000000000000000000000000" ) as `0x${string } `, parentAmount ) }
646+ onClick = { ( ) => transferBack ( programId , ( parentTo || "0x0000000000000000000000000000000000000000" ) as `0x${string } `, parentAmount , parentNote ) }
574647 disabled = { isTransBackPending || isTransBackConf || ! parentAmount || ! parentDisclaimer } >
575648 { isTransBackPending || isTransBackConf ? < CircularProgress size = { 16 } /> : "Transfer to Parent" }
576649 </ Button >
@@ -623,7 +696,7 @@ function ProgramDetail({ programId }: { programId: number }) {
623696 fullWidth margin = "normal" placeholder = "0x..."
624697 error = { ! ! paWallet && ! paWalletValid }
625698 helperText = { paWallet && ! paWalletValid ? "Invalid wallet address" : ! paWallet ? "Walletless: an edit code will be generated for claiming" : "" } />
626- < TextField label = "Member ID" value = { paMemberId } onChange = { ( e ) => setPaMemberId ( e . target . value ) }
699+ < TextField label = "Member ID" value = { paMemberId } onChange = { ( e ) => setPaMemberId ( e . target . value . toUpperCase ( ) ) }
627700 fullWidth margin = "normal" inputProps = { { maxLength : 12 } } />
628701 < FormControl fullWidth margin = "normal" >
629702 < InputLabel > Member Type</ InputLabel >
@@ -660,7 +733,7 @@ function ProgramDetail({ programId }: { programId: number }) {
660733 fullWidth margin = "normal" placeholder = "0x..."
661734 error = { ! ! mWallet && ! mWalletValid }
662735 helperText = { mWallet && ! mWalletValid ? "Invalid wallet address" : ! mWallet ? "Walletless: an edit code will be generated for claiming" : "" } />
663- < TextField label = "Member ID" value = { mMemberId } onChange = { ( e ) => setMMemberId ( e . target . value ) }
736+ < TextField label = "Member ID" value = { mMemberId } onChange = { ( e ) => setMMemberId ( e . target . value . toUpperCase ( ) ) }
664737 fullWidth margin = "normal" inputProps = { { maxLength : 12 } } />
665738 < FormControl fullWidth margin = "normal" >
666739 < InputLabel > Role</ InputLabel >
0 commit comments