Browse Source

raise the invoice defaults

highlight large amounts
make QR code bigger
imwald
Silberengel 3 weeks ago
parent
commit
7b49160f3d
  1. 47
      src/components/GroupedSatsInput/GroupedSatsInput.test.tsx
  2. 81
      src/components/GroupedSatsInput/index.tsx
  3. 38
      src/components/PaytoDialog/LightningInvoiceSection.tsx
  4. 4
      src/components/PaytoDialog/index.tsx
  5. 60
      src/components/QrCode/index.tsx
  6. 62
      src/lib/lightning-zap-amount.test.ts
  7. 12
      src/lib/lightning.ts
  8. 3
      src/services/local-storage.service.ts

47
src/components/GroupedSatsInput/GroupedSatsInput.test.tsx

@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import GroupedSatsInput from './index'
import {
formatSatsGrouped,
SAT_GROUP_SEPARATOR,
splitSatsGroupedParts
} from '@/lib/lightning'
import { superchatSatsLeadingHighlightClass } from '@/lib/superchat-ui'
describe('GroupedSatsInput', () => {
it('shows the same grouped string in the input as on the overlay', () => {
render(<GroupedSatsInput sats={1_234_567} onSatsChange={() => {}} id="test-sats" />)
const input = screen.getByRole('textbox', { name: '' })
expect(input).toHaveAttribute('id', 'test-sats')
expect(input).toHaveValue(formatSatsGrouped(1_234_567))
})
it('highlights only the leading digit group above 999999 sats', () => {
const { container, rerender } = render(
<GroupedSatsInput sats={420} onSatsChange={() => {}} />
)
expect(container.querySelector(`.${superchatSatsLeadingHighlightClass.split(' ')[0]}`)).toBeNull()
rerender(<GroupedSatsInput sats={1_000_000} onSatsChange={() => {}} />)
const parts = splitSatsGroupedParts(1_000_000)
const highlighted = container.querySelector(`.${superchatSatsLeadingHighlightClass.split(' ')[0]}`)
expect(highlighted).toHaveTextContent(parts[0]!)
expect(highlighted?.textContent).toBe('1')
})
it('renders overlay groups joined with thin spaces (no flex gap)', () => {
const { container } = render(<GroupedSatsInput sats={21_000} onSatsChange={() => {}} />)
const overlay = container.querySelector('[aria-hidden] span.whitespace-nowrap')
expect(overlay?.textContent).toBe(
splitSatsGroupedParts(21_000).join(SAT_GROUP_SEPARATOR)
)
})
it('parses edited grouped input', () => {
const onSatsChange = vi.fn()
render(<GroupedSatsInput sats={420} onSatsChange={onSatsChange} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: `2${SAT_GROUP_SEPARATOR}100` } })
expect(onSatsChange).toHaveBeenLastCalledWith(2100)
})
})

81
src/components/GroupedSatsInput/index.tsx

@ -0,0 +1,81 @@
import { Input } from '@/components/ui/input'
import {
clampZapSats,
formatSatsGrouped,
parseGroupedIntegerInput,
SAT_GROUP_SEPARATOR,
shouldHighlightLeadingSatsGroups,
splitSatsGroupedParts
} from '@/lib/lightning'
import { superchatSatsLeadingHighlightClass } from '@/lib/superchat-ui'
import { cn } from '@/lib/utils'
import { Fragment, type ComponentProps } from 'react'
const defaultTypography =
'tabular-nums font-semibold text-xl sm:text-2xl md:text-2xl'
export default function GroupedSatsInput({
sats,
onSatsChange,
id,
className,
inputClassName,
...inputProps
}: {
sats: number
onSatsChange: (next: number) => void
id?: string
className?: string
inputClassName?: string
} & Omit<ComponentProps<typeof Input>, 'value' | 'onChange' | 'id' | 'className' | 'inputMode'>) {
const clamped = clampZapSats(sats)
const displayValue = sats === 0 ? '' : formatSatsGrouped(clamped)
const highlightLeading = sats > 0 && shouldHighlightLeadingSatsGroups(clamped)
const parts = sats === 0 ? [] : splitSatsGroupedParts(clamped)
const typography = defaultTypography
return (
<div className={cn('relative min-w-0', className)}>
{parts.length > 0 ? (
<div
className="pointer-events-none absolute inset-y-0 left-3 right-3 z-0 flex items-center overflow-hidden"
aria-hidden
>
<span className={cn('whitespace-nowrap', typography)}>
{parts.map((part, index) => (
<Fragment key={`${index}-${part}`}>
{index > 0 ? SAT_GROUP_SEPARATOR : null}
<span
className={cn(
index === 0 && highlightLeading && superchatSatsLeadingHighlightClass
)}
>
{part}
</span>
</Fragment>
))}
</span>
</div>
) : null}
<Input
id={id}
inputMode="numeric"
value={displayValue}
onChange={(e) => onSatsChange(parseGroupedIntegerInput(e.target.value))}
onFocus={(e) => {
requestAnimationFrame(() => {
const val = e.target.value
e.target.setSelectionRange(val.length, val.length)
})
}}
className={cn(
'relative z-10 bg-transparent text-transparent caret-foreground',
'selection:bg-primary/20 selection:text-transparent',
typography,
inputClassName
)}
{...inputProps}
/>
</div>
)
}

38
src/components/PaytoDialog/LightningInvoiceSection.tsx

@ -1,7 +1,7 @@
import GroupedSatsInput from '@/components/GroupedSatsInput'
import QrCode from '@/components/QrCode' import QrCode from '@/components/QrCode'
import SatsAmountEquivalents from '@/components/SatsAmountEquivalents' import SatsAmountEquivalents from '@/components/SatsAmountEquivalents'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
@ -11,7 +11,8 @@ import {
formatAmount, formatAmount,
formatSatsGrouped, formatSatsGrouped,
getAmountFromInvoice, getAmountFromInvoice,
parseGroupedIntegerInput LN_INVOICE_PRESET_SATS,
MIN_ZAP_SATS,
} from '@/lib/lightning' } from '@/lib/lightning'
import { buildPaytoUri, formatPaytoTagValue } from '@/lib/payto' import { buildPaytoUri, formatPaytoTagValue } from '@/lib/payto'
import { superchatLightningAccentClass } from '@/lib/superchat-ui' import { superchatLightningAccentClass } from '@/lib/superchat-ui'
@ -25,7 +26,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
const PRESET_SATS = [21, 210, 420, 1000, 2100, 10_000, 21_000] function normalizeInvoiceZapSats(amount: number): number {
const clamped = clampZapSats(amount)
return clamped > 0 && clamped < MIN_ZAP_SATS ? MIN_ZAP_SATS : clamped
}
function invoiceQrPayload(pr: string): string { function invoiceQrPayload(pr: string): string {
const trimmed = pr.trim() const trimmed = pr.trim()
@ -48,7 +52,7 @@ export default function LightningInvoiceSection({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { defaultZapSats, isWalletConnected } = useZap() const { defaultZapSats, isWalletConnected } = useZap()
const [sats, setSats] = useState(() => clampZapSats(defaultZapSats)) const [sats, setSats] = useState(() => normalizeInvoiceZapSats(defaultZapSats))
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [commentMax, setCommentMax] = useState<number | null>(null) const [commentMax, setCommentMax] = useState<number | null>(null)
const [lnurlMetadataState, setLnurlMetadataState] = useState<'loading' | 'ready' | 'error'>( const [lnurlMetadataState, setLnurlMetadataState] = useState<'loading' | 'ready' | 'error'>(
@ -70,7 +74,7 @@ export default function LightningInvoiceSection({
}, []) }, [])
useEffect(() => { useEffect(() => {
setSats(clampZapSats(defaultZapSats)) setSats(normalizeInvoiceZapSats(defaultZapSats))
setDescription('') setDescription('')
setInvoice(null) setInvoice(null)
setInvoiceDescription(null) setInvoiceDescription(null)
@ -188,32 +192,32 @@ export default function LightningInvoiceSection({
</Label> </Label>
<SatsAmountEquivalents sats={sats} id="ln-invoice-sats-equiv" /> <SatsAmountEquivalents sats={sats} id="ln-invoice-sats-equiv" />
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<Input <GroupedSatsInput
id="ln-invoice-sats" id="ln-invoice-sats"
inputMode="numeric" sats={sats}
value={sats === 0 ? '' : formatSatsGrouped(sats)} onSatsChange={setSats}
onChange={(e) => setSats(parseGroupedIntegerInput(e.target.value))} className="min-w-0 flex-1"
className="h-12 min-w-0 flex-1 text-xl font-semibold tabular-nums sm:h-14 sm:text-2xl" inputClassName="h-12 sm:h-14"
aria-describedby="ln-invoice-sats-equiv ln-invoice-preset-hint" aria-describedby="ln-invoice-sats-equiv ln-invoice-preset-hint"
/> />
<span className="shrink-0 text-base font-medium text-muted-foreground sm:text-lg">{t('sats')}</span> <span className="shrink-0 text-base font-medium text-muted-foreground sm:text-lg">{t('sats')}</span>
</div> </div>
<div <div
id="ln-invoice-preset-hint" id="ln-invoice-preset-hint"
className="grid min-w-0 grid-cols-3 gap-1.5 sm:grid-cols-6" className="grid min-w-0 grid-cols-6 gap-1"
role="group" role="group"
aria-label={t('Amount (sats)')} aria-label={t('Amount (sats)')}
> >
{PRESET_SATS.map((preset) => { {LN_INVOICE_PRESET_SATS.map((preset) => {
const active = sats === preset const active = sats === preset
return ( return (
<Button <Button
key={preset} key={preset}
type="button" type="button"
variant={active ? 'default' : 'outline'} variant={active ? 'default' : 'outline'}
size="default" size="sm"
className={cn( className={cn(
'h-10 min-w-0 px-1.5 text-sm tabular-nums sm:text-base', 'h-8 min-w-0 px-0.5 text-xs tabular-nums sm:h-9 sm:px-1 sm:text-sm',
active && 'ring-1 ring-amber-600/45 dark:ring-yellow-400/50' active && 'ring-1 ring-amber-600/45 dark:ring-yellow-400/50'
)} )}
onClick={() => setSats(preset)} onClick={() => setSats(preset)}
@ -265,7 +269,7 @@ export default function LightningInvoiceSection({
<Button <Button
type="button" type="button"
className="h-12 w-full gap-2 text-base sm:h-14 sm:text-lg" className="h-12 w-full gap-2 text-base sm:h-14 sm:text-lg"
disabled={creating || sats < 1} disabled={creating || sats < MIN_ZAP_SATS}
onClick={() => void handleCreateInvoice()} onClick={() => void handleCreateInvoice()}
> >
{creating ? ( {creating ? (
@ -310,8 +314,8 @@ export default function LightningInvoiceSection({
role="img" role="img"
aria-label={t('Scan to pay with a Lightning wallet')} aria-label={t('Scan to pay with a Lightning wallet')}
> >
<div className="w-full max-w-[min(100%,240px)]"> <div className="w-full max-w-[min(100%,280px)]">
<QrCode value={invoiceQrValue} size={240} /> <QrCode value={invoiceQrValue} fill />
</div> </div>
<p className="text-center text-sm text-muted-foreground sm:text-base"> <p className="text-center text-sm text-muted-foreground sm:text-base">
{t('Scan to pay with a Lightning wallet')} {t('Scan to pay with a Lightning wallet')}

4
src/components/PaytoDialog/index.tsx

@ -238,8 +238,8 @@ export default function PaytoDialog({
role="img" role="img"
aria-label={t('Scan to pay with your wallet')} aria-label={t('Scan to pay with your wallet')}
> >
<div className="w-full max-w-[min(100%,240px)]"> <div className="w-full max-w-[min(100%,280px)]">
<QrCode value={qrPayload} size={240} /> <QrCode value={qrPayload} fill />
</div> </div>
<p className="text-center text-sm text-muted-foreground sm:text-base"> <p className="text-center text-sm text-muted-foreground sm:text-base">
{t('Scan to pay with your wallet')} {t('Scan to pay with your wallet')}

60
src/components/QrCode/index.tsx

@ -1,19 +1,45 @@
import { cn } from '@/lib/utils'
import QRCodeStyling from 'qr-code-styling' import QRCodeStyling from 'qr-code-styling'
import { useEffect, useRef } from 'react' import { useEffect, useRef, useState } from 'react'
/** Standard black-on-white QR (square modules, no logo) for broad wallet/scanner compatibility. */ /** Standard black-on-white QR (square modules, no logo) for broad wallet/scanner compatibility. */
export default function QrCode({ value, size = 180 }: { value: string; size?: number }) { export default function QrCode({
value,
size = 180,
fill = false
}: {
value: string
size?: number
/** Size the QR to fill the white container (responsive). */
fill?: boolean
}) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const shellRef = useRef<HTMLDivElement>(null)
const [measuredSize, setMeasuredSize] = useState(fill ? 0 : size)
const renderSize = fill ? measuredSize : size
useEffect(() => { useEffect(() => {
if (!value.trim()) return if (!fill || !shellRef.current) return
const el = shellRef.current
const measure = () => {
const next = Math.floor(Math.min(el.clientWidth, el.clientHeight))
if (next > 0) setMeasuredSize(next)
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [fill])
useEffect(() => {
if (!value.trim() || renderSize <= 0) return
const pixelRatio = window.devicePixelRatio || 2 const pixelRatio = window.devicePixelRatio || 2
const qrCode = new QRCodeStyling({ const qrCode = new QRCodeStyling({
width: size * pixelRatio, width: renderSize * pixelRatio,
height: size * pixelRatio, height: renderSize * pixelRatio,
data: value, data: value,
margin: 8, margin: fill ? 4 : 8,
qrOptions: { qrOptions: {
errorCorrectionLevel: 'M' errorCorrectionLevel: 'M'
}, },
@ -41,22 +67,34 @@ export default function QrCode({ value, size = 180 }: { value: string; size?: nu
qrCode.append(container) qrCode.append(container)
const canvas = container.querySelector('canvas') const canvas = container.querySelector('canvas')
if (canvas) { if (canvas) {
canvas.style.width = `${size}px` if (fill) {
canvas.style.height = `${size}px` canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.maxWidth = 'none'
} else {
canvas.style.width = `${renderSize}px`
canvas.style.height = `${renderSize}px`
canvas.style.maxWidth = '100%' canvas.style.maxWidth = '100%'
}
canvas.style.display = 'block' canvas.style.display = 'block'
} }
return () => { return () => {
container.innerHTML = '' container.innerHTML = ''
} }
}, [value, size]) }, [value, renderSize, fill])
if (!value.trim()) return null if (!value.trim()) return null
return ( return (
<div className="rounded-lg border border-border/40 bg-white p-3"> <div
<div ref={ref} className="mx-auto w-fit" /> ref={fill ? shellRef : undefined}
className={cn(
'rounded-lg border border-border/40 bg-white',
fill ? 'aspect-square w-full' : 'p-3'
)}
>
<div ref={ref} className={cn(fill ? 'size-full' : 'mx-auto w-fit')} />
</div> </div>
) )
} }

62
src/lib/lightning-zap-amount.test.ts

@ -1,21 +1,77 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
DEFAULT_ZAP_SATS,
LN_INVOICE_PRESET_SATS,
MAX_ZAP_SATS, MAX_ZAP_SATS,
MIN_ZAP_SATS,
SAT_GROUP_SEPARATOR,
ZAP_SATS_HIGHLIGHT_ABOVE,
clampZapSats, clampZapSats,
formatSatsGrouped,
parseGroupedIntegerInput, parseGroupedIntegerInput,
shouldHighlightLeadingSatsGroups, shouldHighlightLeadingSatsGroups,
splitSatsGroupedParts splitSatsGroupedParts
} from './lightning' } from './lightning'
describe('zap sats amount limits', () => { /** Overlay text must match the transparent input value (GroupedSatsInput). */
function groupedDisplayString(amount: number): string {
return splitSatsGroupedParts(amount).join(SAT_GROUP_SEPARATOR)
}
describe('lightning zap amounts', () => {
describe('LN invoice presets and defaults', () => {
it('defines min, default, and six preset buttons', () => {
expect(MIN_ZAP_SATS).toBe(210)
expect(DEFAULT_ZAP_SATS).toBe(420)
expect([...LN_INVOICE_PRESET_SATS]).toEqual([210, 420, 2100, 4200, 21_000, 42_000])
expect(LN_INVOICE_PRESET_SATS[0]).toBe(MIN_ZAP_SATS)
expect(LN_INVOICE_PRESET_SATS).toContain(DEFAULT_ZAP_SATS)
})
})
describe('clamp and parse', () => {
it('clamps to 7 digits', () => { it('clamps to 7 digits', () => {
expect(clampZapSats(99_999_999)).toBe(MAX_ZAP_SATS) expect(clampZapSats(99_999_999)).toBe(MAX_ZAP_SATS)
expect(parseGroupedIntegerInput('12345678')).toBe(1_234_567) expect(parseGroupedIntegerInput('12345678')).toBe(1_234_567)
}) })
it('highlights leading group above 999999 sats', () => { it('strips grouping separators and non-digits when parsing', () => {
expect(parseGroupedIntegerInput(`1${SAT_GROUP_SEPARATOR}234${SAT_GROUP_SEPARATOR}567`)).toBe(
1_234_567
)
expect(parseGroupedIntegerInput('1,234,567')).toBe(1_234_567)
expect(parseGroupedIntegerInput('')).toBe(0)
})
})
describe('grouped display (overlay + input)', () => {
it('formats with thin spaces between digit groups', () => {
expect(formatSatsGrouped(2100)).toBe(`2${SAT_GROUP_SEPARATOR}100`)
expect(formatSatsGrouped(1_000_000)).toBe(`1${SAT_GROUP_SEPARATOR}000${SAT_GROUP_SEPARATOR}000`)
})
it('keeps overlay segments aligned with the input string', () => {
for (const amount of [210, 420, 2100, 4200, 21_000, 42_000, 1_000_000, 9_999_999]) {
expect(groupedDisplayString(amount)).toBe(formatSatsGrouped(amount))
}
})
it('splits into digit groups without separators in parts', () => {
expect(splitSatsGroupedParts(1_000_000)).toEqual(['1', '000', '000'])
expect(splitSatsGroupedParts(420)).toEqual(['420'])
})
})
describe('leading group highlight', () => {
it('is off at or below the threshold', () => {
expect(ZAP_SATS_HIGHLIGHT_ABOVE).toBe(999_999)
expect(shouldHighlightLeadingSatsGroups(999_999)).toBe(false) expect(shouldHighlightLeadingSatsGroups(999_999)).toBe(false)
expect(shouldHighlightLeadingSatsGroups(42_000)).toBe(false)
})
it('is on above 999999 sats', () => {
expect(shouldHighlightLeadingSatsGroups(1_000_000)).toBe(true) expect(shouldHighlightLeadingSatsGroups(1_000_000)).toBe(true)
expect(splitSatsGroupedParts(1_000_000)).toEqual(['1', '000', '000']) expect(shouldHighlightLeadingSatsGroups(MAX_ZAP_SATS)).toBe(true)
})
}) })
}) })

12
src/lib/lightning.ts

@ -13,11 +13,21 @@ export function formatAmount(amount: number) {
return `${Math.round(amount / 100000) / 10}M` return `${Math.round(amount / 100000) / 10}M`
} }
const SAT_GROUP_SEPARATOR = '\u2009' /** Thin space between digit groups in sats display (must match input string). */
export const SAT_GROUP_SEPARATOR = '\u2009'
/** Max sats digits in the zap amount field (9999999). */ /** Max sats digits in the zap amount field (9999999). */
export const MAX_ZAP_SATS = 9_999_999 export const MAX_ZAP_SATS = 9_999_999
/** Minimum sats on the Lightning invoice amount picker (preset floor). */
export const MIN_ZAP_SATS = 210
/** Default zap amount in wallet settings when the user has not set one. */
export const DEFAULT_ZAP_SATS = 420
/** Quick-pick amounts on the Lightning invoice page. */
export const LN_INVOICE_PRESET_SATS = [210, 420, 2100, 4200, 21_000, 42_000] as const
/** Leading digit group + BTC hint styling above this amount (exclusive). */ /** Leading digit group + BTC hint styling above this amount (exclusive). */
export const ZAP_SATS_HIGHLIGHT_ABOVE = 999_999 export const ZAP_SATS_HIGHLIGHT_ABOVE = 999_999

3
src/services/local-storage.service.ts

@ -8,6 +8,7 @@ import {
} from '@/constants' } from '@/constants'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { isSameAccount } from '@/lib/account' import { isSameAccount } from '@/lib/account'
import { DEFAULT_ZAP_SATS } from '@/lib/lightning'
import { setRestrictConnectionsToMetadataRelaysOnly } from '@/lib/read-only-relay-personal' import { setRestrictConnectionsToMetadataRelaysOnly } from '@/lib/read-only-relay-personal'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { import {
@ -97,7 +98,7 @@ class LocalStorageService {
private accounts: TAccount[] = [] private accounts: TAccount[] = []
private currentAccount: TAccount | null = null private currentAccount: TAccount | null = null
private noteListMode: TNoteListMode = 'posts' private noteListMode: TNoteListMode = 'posts'
private defaultZapSats: number = 21 private defaultZapSats: number = DEFAULT_ZAP_SATS
private defaultZapComment: string = 'Zap!' private defaultZapComment: string = 'Zap!'
private quickZap: boolean = false private quickZap: boolean = false
private includePublicZapReceipt: boolean = true private includePublicZapReceipt: boolean = true

Loading…
Cancel
Save