From 7b49160f3d10b2fe1fed733792e220dd795a01b5 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 24 May 2026 11:29:13 +0200 Subject: [PATCH] raise the invoice defaults highlight large amounts make QR code bigger --- .../GroupedSatsInput.test.tsx | 47 +++++++++++ src/components/GroupedSatsInput/index.tsx | 81 +++++++++++++++++++ .../PaytoDialog/LightningInvoiceSection.tsx | 38 +++++---- src/components/PaytoDialog/index.tsx | 4 +- src/components/QrCode/index.tsx | 62 +++++++++++--- src/lib/lightning-zap-amount.test.ts | 72 +++++++++++++++-- src/lib/lightning.ts | 12 ++- src/services/local-storage.service.ts | 3 +- 8 files changed, 278 insertions(+), 41 deletions(-) create mode 100644 src/components/GroupedSatsInput/GroupedSatsInput.test.tsx create mode 100644 src/components/GroupedSatsInput/index.tsx diff --git a/src/components/GroupedSatsInput/GroupedSatsInput.test.tsx b/src/components/GroupedSatsInput/GroupedSatsInput.test.tsx new file mode 100644 index 00000000..4ffc1b99 --- /dev/null +++ b/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( {}} 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 999 999 sats', () => { + const { container, rerender } = render( + {}} /> + ) + expect(container.querySelector(`.${superchatSatsLeadingHighlightClass.split(' ')[0]}`)).toBeNull() + + rerender( {}} />) + 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( {}} />) + 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() + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: `2${SAT_GROUP_SEPARATOR}100` } }) + expect(onSatsChange).toHaveBeenLastCalledWith(2100) + }) +}) diff --git a/src/components/GroupedSatsInput/index.tsx b/src/components/GroupedSatsInput/index.tsx new file mode 100644 index 00000000..10f2f3de --- /dev/null +++ b/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, '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 ( +
+ {parts.length > 0 ? ( +
+ + {parts.map((part, index) => ( + + {index > 0 ? SAT_GROUP_SEPARATOR : null} + + {part} + + + ))} + +
+ ) : null} + 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} + /> +
+ ) +} diff --git a/src/components/PaytoDialog/LightningInvoiceSection.tsx b/src/components/PaytoDialog/LightningInvoiceSection.tsx index 1aed0d2d..1bb677db 100644 --- a/src/components/PaytoDialog/LightningInvoiceSection.tsx +++ b/src/components/PaytoDialog/LightningInvoiceSection.tsx @@ -1,7 +1,7 @@ +import GroupedSatsInput from '@/components/GroupedSatsInput' import QrCode from '@/components/QrCode' import SatsAmountEquivalents from '@/components/SatsAmountEquivalents' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { Textarea } from '@/components/ui/textarea' @@ -11,7 +11,8 @@ import { formatAmount, formatSatsGrouped, getAmountFromInvoice, - parseGroupedIntegerInput + LN_INVOICE_PRESET_SATS, + MIN_ZAP_SATS, } from '@/lib/lightning' import { buildPaytoUri, formatPaytoTagValue } from '@/lib/payto' import { superchatLightningAccentClass } from '@/lib/superchat-ui' @@ -25,7 +26,10 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' 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 { const trimmed = pr.trim() @@ -48,7 +52,7 @@ export default function LightningInvoiceSection({ }) { const { t } = useTranslation() const { defaultZapSats, isWalletConnected } = useZap() - const [sats, setSats] = useState(() => clampZapSats(defaultZapSats)) + const [sats, setSats] = useState(() => normalizeInvoiceZapSats(defaultZapSats)) const [description, setDescription] = useState('') const [commentMax, setCommentMax] = useState(null) const [lnurlMetadataState, setLnurlMetadataState] = useState<'loading' | 'ready' | 'error'>( @@ -70,7 +74,7 @@ export default function LightningInvoiceSection({ }, []) useEffect(() => { - setSats(clampZapSats(defaultZapSats)) + setSats(normalizeInvoiceZapSats(defaultZapSats)) setDescription('') setInvoice(null) setInvoiceDescription(null) @@ -188,32 +192,32 @@ export default function LightningInvoiceSection({
- setSats(parseGroupedIntegerInput(e.target.value))} - className="h-12 min-w-0 flex-1 text-xl font-semibold tabular-nums sm:h-14 sm:text-2xl" + sats={sats} + onSatsChange={setSats} + className="min-w-0 flex-1" + inputClassName="h-12 sm:h-14" aria-describedby="ln-invoice-sats-equiv ln-invoice-preset-hint" /> {t('sats')}
- {PRESET_SATS.map((preset) => { + {LN_INVOICE_PRESET_SATS.map((preset) => { const active = sats === preset return (