Skip to content

Commit 1e6f540

Browse files
Krittanai PeanjaroenKrittanai Peanjaroen
authored andcommitted
add thb
1 parent 163eb0a commit 1e6f540

5 files changed

Lines changed: 276 additions & 41 deletions

File tree

components/ReceiveModal.tsx

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import React, { useState } from 'react'
22
import { View, StyleSheet, Share, Pressable, Platform, Modal, ScrollView, KeyboardAvoidingView } from 'react-native'
33
import { Text, TextInput, ActivityIndicator } from 'react-native-paper'
44
import QRCode from 'react-native-qrcode-svg'
5-
import { Copy, Share2, ArrowDownLeft, CheckCircle, RotateCcw, X } from 'lucide-react-native'
5+
import { Copy, Share2, ArrowDownLeft, CheckCircle, RotateCcw, X, ArrowRightLeft } from 'lucide-react-native'
66
import { walletManager } from '../services/WalletManager'
77
import { useBtcPrice } from '../hooks/useBtcPrice'
8-
import { satsToThb, formatThb } from '../services/PriceService'
8+
import { satsToThb, thbToSats, formatThb } from '../services/PriceService'
99
import * as Clipboard from 'expo-clipboard'
1010
import { Colors, Spacing, Radius } from '../theme'
1111
import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -25,12 +25,16 @@ export default function ReceiveModal({ visible, onDismiss, walletName, walletId
2525
const [copied, setCopied] = useState(false)
2626
const btcPrice = useBtcPrice()
2727
const insets = useSafeAreaInsets()
28+
const [inputUnit, setInputUnit] = useState<'sats' | 'thb'>('sats')
2829

2930
const handleGenerate = async () => {
30-
if (!amount) return
31+
const satsAmount = inputUnit === 'thb' && btcPrice
32+
? thbToSats(Number.parseFloat(amount), btcPrice)
33+
: Number.parseInt(amount)
34+
if (!satsAmount || satsAmount <= 0) return
3135
setLoading(true)
3236
try {
33-
const amountMsat = Number.parseInt(amount) * 1000
37+
const amountMsat = satsAmount * 1000
3438
const response = await walletManager.makeInvoice(amountMsat, description || `Funding ${walletName}`, walletId)
3539
setInvoice(response.invoice)
3640
} catch (error) {
@@ -53,7 +57,7 @@ export default function ReceiveModal({ visible, onDismiss, walletName, walletId
5357
}
5458

5559
const reset = () => {
56-
setAmount(''); setDescription(''); setInvoice(null); setCopied(false); onDismiss()
60+
setAmount(''); setDescription(''); setInvoice(null); setCopied(false); setInputUnit('sats'); onDismiss()
5761
}
5862

5963
return (
@@ -90,26 +94,39 @@ export default function ReceiveModal({ visible, onDismiss, walletName, walletId
9094
<Text style={styles.amountValue}>
9195
{amount || '0'}
9296
</Text>
93-
<Text style={styles.amountUnit}>sats</Text>
97+
<Text style={styles.amountUnit}>{inputUnit === 'sats' ? 'sats' : '฿ THB'}</Text>
9498
</View>
95-
{amount && btcPrice && (
99+
{amount && btcPrice ? (
96100
<Text style={styles.fiatAmount}>
97-
{formatThb(satsToThb(Number.parseInt(amount), btcPrice))}
101+
{inputUnit === 'sats'
102+
? formatThb(satsToThb(Number.parseInt(amount), btcPrice))
103+
: `${thbToSats(Number.parseFloat(amount), btcPrice).toLocaleString()} sats`}
98104
</Text>
99-
)}
105+
) : null}
100106

101-
<TextInput
102-
value={amount}
103-
onChangeText={setAmount}
104-
keyboardType="numeric"
105-
mode="outlined"
106-
style={styles.input}
107-
outlineColor={Colors.surfaceBorder}
108-
activeOutlineColor={Colors.accent}
109-
placeholder="Enter amount"
110-
dense
111-
right={<TextInput.Affix text="sats" textStyle={{ color: Colors.textSecondary }} />}
112-
/>
107+
<View style={styles.amountInputRow}>
108+
<TextInput
109+
value={amount}
110+
onChangeText={setAmount}
111+
keyboardType="numeric"
112+
mode="outlined"
113+
style={[styles.input, { flex: 1 }]}
114+
outlineColor={Colors.surfaceBorder}
115+
activeOutlineColor={Colors.accent}
116+
placeholder="Enter amount"
117+
dense
118+
right={<TextInput.Affix text={inputUnit === 'sats' ? 'sats' : '฿'} textStyle={{ color: Colors.textSecondary }} />}
119+
/>
120+
<Pressable
121+
style={({ pressed }) => [styles.unitToggleBtn, pressed && { opacity: 0.7 }]}
122+
onPress={() => {
123+
setAmount('')
124+
setInputUnit(prev => prev === 'sats' ? 'thb' : 'sats')
125+
}}
126+
>
127+
<ArrowRightLeft size={16} color={Colors.accent} />
128+
</Pressable>
129+
</View>
113130

114131
<TextInput
115132
value={description}
@@ -296,6 +313,22 @@ const styles = StyleSheet.create({
296313
marginBottom: Spacing.md,
297314
backgroundColor: Colors.surfaceElevated,
298315
},
316+
amountInputRow: {
317+
flexDirection: 'row',
318+
alignItems: 'flex-start',
319+
gap: Spacing.sm,
320+
marginBottom: Spacing.md,
321+
},
322+
unitToggleBtn: {
323+
width: 48,
324+
height: 48,
325+
borderRadius: Radius.md,
326+
borderWidth: 1,
327+
borderColor: Colors.surfaceBorder,
328+
alignItems: 'center',
329+
justifyContent: 'center',
330+
marginTop: 2,
331+
},
299332
generateBtn: {
300333
backgroundColor: Colors.accent,
301334
borderRadius: Radius.md,

components/SendModal.tsx

Lines changed: 113 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { Text, TextInput, ActivityIndicator } from 'react-native-paper'
44
import { walletManager } from '../services/WalletManager'
55
import { useWalletStore } from '../store/walletStore'
66
import { 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'
810
import 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'
1012
import { Colors, Spacing, Radius } from '../theme'
1113
import { 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

Comments
 (0)