@@ -10,86 +10,15 @@ import {
1010 toSorobanErrorMessage ,
1111 TOKEN_ADDRESSES
1212} from "@/lib/soroban" ;
13- import { hasValidPrecision , validateAmountInput } from "@/utils/amount" ;
13+ import { hasValidPrecision } from "@/utils/amount" ;
1414import { toast } from "react-hot-toast" ;
1515import { useRouter , useSearchParams } from "next/navigation" ;
1616import Link from "next/link" ;
1717import { ArrowLeft , FileText , X } from "lucide-react" ;
1818import { useWallet } from "@/context/wallet-context" ;
19+ import { useStreamForm } from "@/hooks/useStreamForm" ;
1920
2021const TOKEN_DECIMALS = 7 ;
21- const DRAFT_STORAGE_KEY = "flowfi.create-stream.draft.v1" ;
22-
23- interface StreamDraft {
24- recipient : string ;
25- token : string ;
26- amount : string ;
27- duration : string ;
28- savedAt : number ;
29- }
30-
31- interface FormFields {
32- recipient : string ;
33- token : string ;
34- amount : string ;
35- duration : string ;
36- }
37-
38- const DEFAULT_FORM : FormFields = {
39- recipient : "" ,
40- token : "XLM" ,
41- amount : "" ,
42- duration : "30" ,
43- } ;
44-
45- function isPristineForm ( form : FormFields ) : boolean {
46- return (
47- form . recipient === "" &&
48- form . token === DEFAULT_FORM . token &&
49- form . amount === "" &&
50- form . duration === DEFAULT_FORM . duration
51- ) ;
52- }
53-
54- function saveDraftToSession ( data : StreamDraft ) : void {
55- try {
56- if ( typeof window === "undefined" ) return ;
57- sessionStorage . setItem ( DRAFT_STORAGE_KEY , JSON . stringify ( data ) ) ;
58- } catch {
59- // sessionStorage may be full or unavailable
60- }
61- }
62-
63- function loadDraftFromSession ( ) : StreamDraft | null {
64- try {
65- if ( typeof window === "undefined" ) return null ;
66- const raw = sessionStorage . getItem ( DRAFT_STORAGE_KEY ) ;
67- if ( ! raw ) return null ;
68- const parsed = JSON . parse ( raw ) as StreamDraft ;
69- // Reject empty drafts so a bogus "resumed draft" banner is never shown
70- // over a blank form.
71- if (
72- parsed &&
73- typeof parsed . recipient === "string" &&
74- typeof parsed . amount === "string" &&
75- ( parsed . recipient . trim ( ) !== "" || parsed . amount . trim ( ) !== "" )
76- ) {
77- return parsed ;
78- }
79- return null ;
80- } catch {
81- return null ;
82- }
83- }
84-
85- function clearDraft ( ) : void {
86- try {
87- if ( typeof window === "undefined" ) return ;
88- sessionStorage . removeItem ( DRAFT_STORAGE_KEY ) ;
89- } catch {
90- // ignore
91- }
92- }
9322
9423export default function CreateStreamContent ( ) {
9524 const { status, session } = useWallet ( ) ;
@@ -98,55 +27,40 @@ export default function CreateStreamContent() {
9827 const [ nowTimestamp ] = useState ( ( ) => Date . now ( ) ) ;
9928 const [ loading , setLoading ] = useState ( false ) ;
10029 const [ txState , setTxState ] = useState < "idle" | "signing" | "submitted" | "confirming" > ( "idle" ) ;
101- // Read the draft once at mount so the banner has a stable savedAt value
102- // instead of re-reading sessionStorage on every render.
103- const [ restoredDraft , setRestoredDraft ] = useState < StreamDraft | null > (
104- ( ) => loadDraftFromSession ( )
105- ) ;
10630 const [ dismissedDraftBanner , setDismissedDraftBanner ] = useState ( false ) ;
107- const draftRestored = restoredDraft !== null ;
10831
109- const [ formData , setFormData ] = useState < FormFields > ( ( ) => {
110- if ( restoredDraft ) {
111- return {
112- recipient : restoredDraft . recipient ,
113- token : restoredDraft . token ,
114- amount : restoredDraft . amount ,
115- duration : restoredDraft . duration ,
116- } ;
117- }
118- return { ...DEFAULT_FORM } ;
32+ // ── Shared form hook ────────────────────────────────────────────────────
33+ const {
34+ formData,
35+ errors,
36+ updateFormData,
37+ resetForm : _resetForm ,
38+ validateAll,
39+ walletBalance,
40+ walletBalanceLoading,
41+ walletBalanceError,
42+ hasDraft,
43+ discardDraft,
44+ draftSavedAt,
45+ } = useStreamForm ( {
46+ walletPublicKey : session ?. publicKey ,
47+ enableDraftPersistence : true ,
48+ initialData : { token : "XLM" , duration : "30" } ,
11949 } ) ;
12050
121- // Persist form data to sessionStorage whenever it changes — but never
122- // write an empty draft for a pristine form on first mount.
123- useEffect ( ( ) => {
124- const timer = setTimeout ( ( ) => {
125- if ( isPristineForm ( formData ) ) return ;
126- saveDraftToSession ( {
127- recipient : formData . recipient ,
128- token : formData . token ,
129- amount : formData . amount ,
130- duration : formData . duration ,
131- savedAt : Date . now ( ) ,
132- } ) ;
133- } , 500 ) ; // debounce writes
134- return ( ) => clearTimeout ( timer ) ;
135- } , [ formData ] ) ;
136-
13751 // Handle recipient prefill from search params — but only if no draft is restored
13852 useEffect ( ( ) => {
13953 const recipientParam = searchParams . get ( "recipient" ) ;
140- if ( ! recipientParam || draftRestored ) return ;
54+ if ( ! recipientParam || hasDraft ) return ;
14155
14256 import ( "@stellar/stellar-sdk" ) . then ( ( { StrKey } ) => {
14357 if ( StrKey . isValidEd25519PublicKey ( recipientParam ) ) {
144- setFormData ( ( prev ) => ( { ... prev , recipient : recipientParam } ) ) ;
58+ updateFormData ( { recipient : recipientParam } ) ;
14559 } else {
14660 logger . warn ( "Ignoring malformed recipient query param" , { recipientParam } ) ;
14761 }
14862 } ) ;
149- } , [ searchParams , draftRestored ] ) ;
63+ } , [ searchParams , hasDraft , updateFormData ] ) ;
15064
15165 const handleSubmit = async ( e : React . FormEvent ) => {
15266 e . preventDefault ( ) ;
@@ -155,9 +69,11 @@ export default function CreateStreamContent() {
15569 return ;
15670 }
15771
158- const validationError = validateAmountInput ( formData . amount , TOKEN_DECIMALS ) ;
159- if ( validationError ) {
160- toast . error ( validationError ) ;
72+ // Use the shared validation (checks recipient format, amount, precision, balance)
73+ if ( ! validateAll ( ) ) {
74+ // Show the first error as a toast for flat-form UX
75+ const firstError = Object . values ( errors ) [ 0 ] ;
76+ if ( firstError ) toast . error ( firstError ) ;
16177 return ;
16278 }
16379
@@ -178,7 +94,7 @@ export default function CreateStreamContent() {
17894
17995 if ( result . success ) {
18096 setTxState ( "confirming" ) ;
181- clearDraft ( ) ;
97+ discardDraft ( ) ;
18298 toast . success ( "Stream created successfully!" ) ;
18399 setTimeout ( ( ) => {
184100 setLoading ( false ) ;
@@ -204,16 +120,10 @@ export default function CreateStreamContent() {
204120 }
205121 } ;
206122
207- const amountError = formData . amount
208- ? validateAmountInput ( formData . amount , TOKEN_DECIMALS )
209- : null ;
210-
211123 const handleDismissDraft = useCallback ( ( ) => {
212- clearDraft ( ) ;
213- setRestoredDraft ( null ) ;
124+ discardDraft ( ) ;
214125 setDismissedDraftBanner ( true ) ;
215- setFormData ( { ...DEFAULT_FORM } ) ;
216- } , [ ] ) ;
126+ } , [ discardDraft ] ) ;
217127
218128 return (
219129 < div className = "container mx-auto max-w-2xl px-4 py-12" >
@@ -226,12 +136,12 @@ export default function CreateStreamContent() {
226136 </ Link >
227137
228138 { /* Resume draft banner */ }
229- { restoredDraft && ! dismissedDraftBanner && (
139+ { hasDraft && ! dismissedDraftBanner && draftSavedAt && (
230140 < div className = "mb-6 flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/10 px-5 py-4 text-sm" >
231141 < FileText className = "h-5 w-5 text-accent flex-shrink-0" />
232142 < span className = "flex-1" >
233143 Resumed a saved draft from{ " " }
234- { new Date ( restoredDraft . savedAt ) . toLocaleTimeString ( ) }
144+ { new Date ( draftSavedAt ) . toLocaleTimeString ( ) }
235145 . You can continue editing or start fresh.
236146 </ span >
237147 < button
@@ -261,9 +171,12 @@ export default function CreateStreamContent() {
261171 placeholder = "G..."
262172 className = "w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors"
263173 value = { formData . recipient }
264- onChange = { ( e ) => setFormData ( { ... formData , recipient : e . target . value } ) }
174+ onChange = { ( e ) => updateFormData ( { recipient : e . target . value } ) }
265175 required
266176 />
177+ { errors . recipient && (
178+ < p className = "text-xs text-red-400 mt-1" role = "alert" > { errors . recipient } </ p >
179+ ) }
267180 </ div >
268181
269182 < div className = "grid grid-cols-2 gap-4" >
@@ -275,7 +188,7 @@ export default function CreateStreamContent() {
275188 id = "create-stream-token"
276189 className = "w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors appearance-none"
277190 value = { formData . token }
278- onChange = { ( e ) => setFormData ( { ... formData , token : e . target . value } ) }
191+ onChange = { ( e ) => updateFormData ( { token : e . target . value } ) }
279192 >
280193 { Object . keys ( TOKEN_ADDRESSES ) . map ( ( symbol ) => (
281194 < option key = { symbol } value = { symbol } >
@@ -299,14 +212,25 @@ export default function CreateStreamContent() {
299212 const newValue = e . target . value ;
300213 if ( newValue === '' || / ^ \d * \. ? \d * $ / . test ( newValue ) ) {
301214 if ( hasValidPrecision ( newValue , TOKEN_DECIMALS ) ) {
302- setFormData ( { ... formData , amount : newValue } ) ;
215+ updateFormData ( { amount : newValue } ) ;
303216 }
304217 }
305218 } }
306219 required
307220 />
308- { amountError && (
309- < p className = "text-xs text-red-400 mt-1" > { amountError } </ p >
221+ { errors . amount && (
222+ < p className = "text-xs text-red-400 mt-1" role = "alert" > { errors . amount } </ p >
223+ ) }
224+ { walletBalance && ! errors . amount && (
225+ < p className = "text-xs text-slate-500 mt-1" >
226+ Available: { walletBalance } { formData . token }
227+ </ p >
228+ ) }
229+ { walletBalanceLoading && (
230+ < p className = "text-xs text-slate-500 mt-1" > Loading balance…</ p >
231+ ) }
232+ { walletBalanceError && (
233+ < p className = "text-xs text-yellow-500 mt-1" > { walletBalanceError } </ p >
310234 ) }
311235 </ div >
312236 </ div >
@@ -321,9 +245,12 @@ export default function CreateStreamContent() {
321245 placeholder = "30"
322246 className = "w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors"
323247 value = { formData . duration }
324- onChange = { ( e ) => setFormData ( { ... formData , duration : e . target . value } ) }
248+ onChange = { ( e ) => updateFormData ( { duration : e . target . value } ) }
325249 required
326250 />
251+ { errors . duration && (
252+ < p className = "text-xs text-red-400 mt-1" role = "alert" > { errors . duration } </ p >
253+ ) }
327254 </ div >
328255
329256 < div className = "rounded-2xl bg-accent/5 p-6 space-y-4" >
0 commit comments