@@ -4,9 +4,11 @@ import { Text, TextInput, ActivityIndicator } from 'react-native-paper'
44import { walletManager } from '../services/WalletManager'
55import { useWalletStore } from '../store/walletStore'
66import { useBtcPrice } from '../hooks/useBtcPrice'
7- import { satsToThb , formatThb } from '../services/PriceService'
7+ import { satsToThb , thbToSats , formatThb } from '../services/PriceService'
8+ import { isLNURL } from '../services/LNURLService'
9+ import type { LNURLPayResponse } from '../services/LNURLService'
810import QRScanner from './QRScanner'
9- import { Scan , X , ArrowUpRight , Zap , CheckCircle } from 'lucide-react-native'
11+ import { Scan , X , ArrowUpRight , Zap , CheckCircle , ArrowRightLeft } from 'lucide-react-native'
1012import { Colors , Spacing , Radius } from '../theme'
1113import { useSafeAreaInsets } from 'react-native-safe-area-context'
1214
@@ -26,8 +28,11 @@ export default function SendModal({ visible, onDismiss, initialInvoice = '', onP
2628 const [ resolving , setResolving ] = useState ( false )
2729 const [ error , setError ] = useState ( '' )
2830 const [ isLNAddress , setIsLNAddress ] = useState ( false )
31+ const [ isLnurl , setIsLnurl ] = useState ( false )
32+ const [ lnurlMeta , setLnurlMeta ] = useState < LNURLPayResponse | null > ( null )
2933 const [ scannerVisible , setScannerVisible ] = useState ( false )
3034 const [ success , setSuccess ] = useState ( false )
35+ const [ inputUnit , setInputUnit ] = useState < 'sats' | 'thb' > ( 'sats' )
3136 const insets = useSafeAreaInsets ( )
3237 const btcPrice = useBtcPrice ( )
3338
@@ -44,25 +49,59 @@ export default function SendModal({ visible, onDismiss, initialInvoice = '', onP
4449 } , [ visible ] )
4550
4651 useEffect ( ( ) => {
47- const lower = invoice . toLowerCase ( ) . trim ( )
48- setIsLNAddress ( lower . includes ( '@' ) && ! lower . startsWith ( 'lnbc' ) && ! lower . startsWith ( 'lightning:' ) )
52+ const trimmed = invoice . trim ( )
53+ const lower = trimmed . toLowerCase ( )
54+
55+ if ( isLNURL ( trimmed ) ) {
56+ setIsLnurl ( true )
57+ setIsLNAddress ( false )
58+ // Auto-resolve LNURL metadata for min/max hints
59+ setLnurlMeta ( null )
60+ walletManager . resolveLNURL ( trimmed )
61+ . then ( meta => { console . log ( '[asanee-lnurl] Auto-resolved meta:' , meta ?. tag ) ; setLnurlMeta ( meta ) } )
62+ . catch ( ( err ) => { console . error ( '[asanee-lnurl] Auto-resolve failed:' , err ) ; setLnurlMeta ( null ) } )
63+ } else {
64+ setIsLnurl ( false )
65+ setLnurlMeta ( null )
66+ setIsLNAddress ( lower . includes ( '@' ) && ! lower . startsWith ( 'lnbc' ) && ! lower . startsWith ( 'lightning:' ) )
67+ }
4968 } , [ invoice ] )
5069
70+ const needsAmount = isLNAddress || isLnurl
71+
72+ // Compute sats from input (handles THB conversion)
73+ const computedSats = inputUnit === 'thb' && btcPrice && amountSats
74+ ? thbToSats ( Number . parseFloat ( amountSats ) , btcPrice ) . toString ( )
75+ : amountSats
76+
5177 const handlePay = async ( ) => {
5278 if ( ! invoice . trim ( ) ) return
5379 setLoading ( true ) ; setError ( '' )
5480 try {
5581 let finalInvoice = invoice . trim ( )
5682 let finalAmountMsat : number | undefined
5783
58- if ( balance !== null && isLNAddress && amountSats ) {
59- const reqSats = Number . parseInt ( amountSats )
84+ if ( balance !== null && needsAmount && computedSats ) {
85+ const reqSats = Number . parseInt ( computedSats )
6086 if ( reqSats * 1000 > balance ) throw new Error ( `Insufficient funds. Only ${ ( balance / 1000 ) . toLocaleString ( ) } sats available.` )
6187 }
6288
63- if ( isLNAddress ) {
64- if ( ! amountSats ) throw new Error ( 'Please enter an amount' )
65- const amountMsat = Number . parseInt ( amountSats ) * 1000
89+ if ( isLnurl ) {
90+ // LNURL-pay flow
91+ if ( ! computedSats || Number . parseInt ( computedSats ) <= 0 ) throw new Error ( 'Please enter an amount' )
92+ const amountMsat = Number . parseInt ( computedSats ) * 1000
93+ setResolving ( true )
94+ const meta = lnurlMeta || await walletManager . resolveLNURL ( finalInvoice )
95+ if ( amountMsat < meta . minSendable || amountMsat > meta . maxSendable ) {
96+ throw new Error ( `Amount must be between ${ Math . ceil ( meta . minSendable / 1000 ) } and ${ Math . floor ( meta . maxSendable / 1000 ) } sats` )
97+ }
98+ finalInvoice = await walletManager . getInvoiceFromLNURL ( meta . callback , amountMsat )
99+ finalAmountMsat = amountMsat
100+ setResolving ( false )
101+ } else if ( isLNAddress ) {
102+ // Lightning Address flow
103+ if ( ! computedSats || Number . parseInt ( computedSats ) <= 0 ) throw new Error ( 'Please enter an amount' )
104+ const amountMsat = Number . parseInt ( computedSats ) * 1000
66105 setResolving ( true )
67106 const metadata = await walletManager . resolveLightningAddress ( finalInvoice )
68107 finalInvoice = await walletManager . getInvoiceFromLNURL ( metadata . callback , amountMsat )
@@ -81,8 +120,13 @@ export default function SendModal({ visible, onDismiss, initialInvoice = '', onP
81120 }
82121 }
83122
84- const handleScan = ( data : string ) => { setInvoice ( data ) ; setScannerVisible ( false ) }
85- const reset = ( ) => { setInvoice ( '' ) ; setAmountSats ( '' ) ; setError ( '' ) ; setSuccess ( false ) ; onDismiss ( ) }
123+ const handleScan = ( data : string ) => {
124+ // Strip lightning: URI prefix if present (common in QR codes)
125+ const cleaned = data . trim ( ) . toLowerCase ( ) . startsWith ( 'lightning:' ) ? data . trim ( ) . slice ( 'lightning:' . length ) : data . trim ( )
126+ setInvoice ( cleaned )
127+ setScannerVisible ( false )
128+ }
129+ const reset = ( ) => { setInvoice ( '' ) ; setAmountSats ( '' ) ; setError ( '' ) ; setSuccess ( false ) ; setIsLnurl ( false ) ; setLnurlMeta ( null ) ; setInputUnit ( 'sats' ) ; onDismiss ( ) }
86130
87131 if ( scannerVisible ) return < QRScanner onScan = { handleScan } onClose = { ( ) => setScannerVisible ( false ) } />
88132
@@ -146,7 +190,7 @@ export default function SendModal({ visible, onDismiss, initialInvoice = '', onP
146190 multiline = { ! isLNAddress }
147191 numberOfLines = { isLNAddress ? 1 : 2 }
148192 style = { [ styles . input , { flex : 1 } ] }
149- placeholder = "lnbc... or user@domain.com"
193+ placeholder = "lnbc... / user@domain.com / lnurl1... "
150194 outlineColor = { Colors . surfaceBorder }
151195 activeOutlineColor = { Colors . accent }
152196 error = { ! ! error }
@@ -162,10 +206,24 @@ export default function SendModal({ visible, onDismiss, initialInvoice = '', onP
162206 </ Pressable >
163207 </ View >
164208
165- { /* Amount (for LN address) */ }
166- { isLNAddress && (
209+ { /* Amount (for LN address or LNURL ) */ }
210+ { needsAmount && (
167211 < >
168- < Text style = { styles . fieldLabel } > AMOUNT</ Text >
212+ < View style = { styles . fieldLabelRow } >
213+ < Text style = { styles . fieldLabel } > AMOUNT</ Text >
214+ < Pressable
215+ style = { ( { pressed } ) => [ styles . unitToggle , pressed && { opacity : 0.7 } ] }
216+ onPress = { ( ) => {
217+ setAmountSats ( '' )
218+ setInputUnit ( prev => prev === 'sats' ? 'thb' : 'sats' )
219+ } }
220+ >
221+ < ArrowRightLeft size = { 12 } color = { Colors . accent } />
222+ < Text style = { styles . unitToggleText } >
223+ { inputUnit === 'sats' ? '฿ THB' : '⚡ sats' }
224+ </ Text >
225+ </ Pressable >
226+ </ View >
169227 < TextInput
170228 value = { amountSats }
171229 onChangeText = { setAmountSats }
@@ -176,13 +234,21 @@ export default function SendModal({ visible, onDismiss, initialInvoice = '', onP
176234 activeOutlineColor = { Colors . accent }
177235 placeholder = "0"
178236 dense
179- right = { < TextInput . Affix text = " sats" textStyle = { { color : Colors . textSecondary } } /> }
237+ right = { < TextInput . Affix text = { inputUnit === ' sats' ? 'sats' : '฿' } textStyle = { { color : Colors . textSecondary } } /> }
180238 />
181- { amountSats && btcPrice && (
182- < Text style = { styles . fiatAmount } >
183- ≈ { formatThb ( satsToThb ( Number . parseInt ( amountSats ) , btcPrice ) ) }
239+ { /* Min/Max range hint for LNURL */ }
240+ { isLnurl && lnurlMeta && (
241+ < Text style = { styles . lnurlRange } >
242+ { Math . ceil ( lnurlMeta . minSendable / 1000 ) . toLocaleString ( ) } – { Math . floor ( lnurlMeta . maxSendable / 1000 ) . toLocaleString ( ) } sats
184243 </ Text >
185244 ) }
245+ { amountSats && btcPrice ? (
246+ < Text style = { styles . fiatAmount } >
247+ ≈ { inputUnit === 'sats'
248+ ? formatThb ( satsToThb ( Number . parseInt ( amountSats ) , btcPrice ) )
249+ : `${ thbToSats ( Number . parseFloat ( amountSats ) , btcPrice ) . toLocaleString ( ) } sats` }
250+ </ Text >
251+ ) : null }
186252 </ >
187253 ) }
188254
@@ -216,7 +282,7 @@ export default function SendModal({ visible, onDismiss, initialInvoice = '', onP
216282 < >
217283 < ArrowUpRight size = { 18 } color = { Colors . accentText } />
218284 < Text style = { styles . payText } >
219- { isLNAddress ? 'Pay Address' : 'Pay Invoice' }
285+ { isLnurl ? 'Pay LNURL' : isLNAddress ? 'Pay Address' : 'Pay Invoice' }
220286 </ Text >
221287 </ >
222288 ) }
@@ -318,6 +384,26 @@ const styles = StyleSheet.create({
318384 letterSpacing : 1.5 ,
319385 marginBottom : Spacing . xs ,
320386 } ,
387+ fieldLabelRow : {
388+ flexDirection : 'row' ,
389+ alignItems : 'center' ,
390+ justifyContent : 'space-between' ,
391+ marginBottom : Spacing . xs ,
392+ } ,
393+ unitToggle : {
394+ flexDirection : 'row' ,
395+ alignItems : 'center' ,
396+ gap : 4 ,
397+ backgroundColor : Colors . accentDim ,
398+ paddingHorizontal : 8 ,
399+ paddingVertical : 3 ,
400+ borderRadius : Radius . full ,
401+ } ,
402+ unitToggleText : {
403+ color : Colors . accent ,
404+ fontSize : 11 ,
405+ fontWeight : '600' ,
406+ } ,
321407 inputRow : {
322408 flexDirection : 'row' ,
323409 alignItems : 'flex-start' ,
@@ -344,6 +430,13 @@ const styles = StyleSheet.create({
344430 marginTop : Spacing . xs ,
345431 marginLeft : Spacing . xs ,
346432 } ,
433+ lnurlRange : {
434+ color : Colors . textSecondary ,
435+ fontSize : 12 ,
436+ marginTop : - Spacing . sm ,
437+ marginBottom : Spacing . xs ,
438+ marginLeft : Spacing . xs ,
439+ } ,
347440
348441 // Status
349442 statusRow : {
0 commit comments