From 56636d0671db9be9989875dd0ed1ee2b7bf5607a Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 19 May 2026 11:39:19 +0200 Subject: [PATCH] make payto more self-explanatory --- src/assets/payto_logos/README.md | 8 +- src/components/Profile/index.tsx | 8 +- .../ProfileEditor/PaymentMethodRow.tsx | 78 ++++ src/components/ProfileList/index.tsx | 7 +- src/data/payto-types.json | 376 ++++++++++++++++++ src/hooks/useProfileReportsEvents.tsx | 93 ++--- src/hooks/useProfileWall.tsx | 34 +- src/i18n/locales/de.ts | 7 +- src/i18n/locales/en.ts | 3 + src/lib/payto-editor-hints.test.ts | 31 ++ src/lib/payto-logos.ts | 88 ++-- src/lib/payto-registry.ts | 111 ++++++ src/lib/payto.ts | 174 +------- .../secondary/ProfileEditorPage/index.tsx | 54 +-- 14 files changed, 749 insertions(+), 323 deletions(-) create mode 100644 src/components/ProfileEditor/PaymentMethodRow.tsx create mode 100644 src/data/payto-types.json create mode 100644 src/lib/payto-editor-hints.test.ts create mode 100644 src/lib/payto-registry.ts diff --git a/src/assets/payto_logos/README.md b/src/assets/payto_logos/README.md index 263a90d9..ebbda146 100644 --- a/src/assets/payto_logos/README.md +++ b/src/assets/payto_logos/README.md @@ -1,9 +1,9 @@ # Payto logos -Icons for payment types (crypto, etc.) used by payto links. +Icons for payment types used by payto links in the app. -**Supported formats:** SVG, GIF, JPG/JPEG, PNG, WebP, etc. — any format the browser can display in `` is fine. SVG scales best at different sizes. +**Supported formats:** SVG, GIF, JPG/JPEG, PNG, WebP, etc. -Filenames are mapped in `src/lib/payto.ts` → `PAYTO_LOGO_FILES`. Use whatever extension the asset has (.svg, .gif, .jpg, .png, …). +**Catalog:** Each type’s `logoAssetPath` in [`src/data/payto-types.json`](../../data/payto-types.json) points at a file here (e.g. `src/assets/payto_logos/ethereum-eth-logo.svg`). Add or change logos by editing that JSON and placing the image in this folder. -Bundled via Vite (`import.meta.glob`) so URLs live under `/assets/` and deploy with the app build. +Bundled via Vite `import.meta.glob` in [`src/lib/payto-logos.ts`](../../lib/payto-logos.ts); runtime URLs are under `/assets/…`. diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index a3a5f404..6ed0df29 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -865,10 +865,14 @@ export default function Profile({ - + {profileFeedTab === 'reports' ? ( + + ) : null} - + {profileFeedTab === 'wall' ? ( + + ) : null} {isSelf && ( diff --git a/src/components/ProfileEditor/PaymentMethodRow.tsx b/src/components/ProfileEditor/PaymentMethodRow.tsx new file mode 100644 index 00000000..165fa373 --- /dev/null +++ b/src/components/ProfileEditor/PaymentMethodRow.tsx @@ -0,0 +1,78 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { + getCanonicalPaytoType, + getPaytoAuthorityFieldHelp, + getPaytoEditorTypeLabel, + paytoEditorSelectTypes +} from '@/lib/payto' +import { Trash2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +export type PaymentMethodRowValue = { type: string; authority: string } + +type PaymentMethodRowProps = { + row: PaymentMethodRowValue + onChange: (row: PaymentMethodRowValue) => void + onRemove: () => void +} + +export default function PaymentMethodRow({ row, onChange, onRemove }: PaymentMethodRowProps) { + const { t } = useTranslation() + const selectTypes = paytoEditorSelectTypes(row.type) + const canonicalType = getCanonicalPaytoType(row.type || 'lightning') + const fieldHelp = getPaytoAuthorityFieldHelp(canonicalType) + + return ( +
+ + +
+ onChange({ ...row, authority: e.target.value })} + placeholder={t(`paytoEditor.placeholder.${canonicalType}`, { + defaultValue: fieldHelp.placeholder + })} + className="font-mono text-sm" + aria-describedby={`payto-hint-${canonicalType}`} + /> +

+ {t(`paytoEditor.hint.${canonicalType}`, { defaultValue: fieldHelp.hint })} +

+
+ + +
+ ) +} diff --git a/src/components/ProfileList/index.tsx b/src/components/ProfileList/index.tsx index 2d288893..c0cca55a 100644 --- a/src/components/ProfileList/index.tsx +++ b/src/components/ProfileList/index.tsx @@ -1,13 +1,14 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import UserItem from '../UserItem' export default function ProfileList({ pubkeys }: { pubkeys: string[] }) { const [visiblePubkeys, setVisiblePubkeys] = useState([]) const bottomRef = useRef(null) + const pubkeysKey = useMemo(() => pubkeys.join('\u0001'), [pubkeys]) useEffect(() => { setVisiblePubkeys(pubkeys.slice(0, 10)) - }, [pubkeys]) + }, [pubkeysKey, pubkeys]) useEffect(() => { const options = { @@ -32,7 +33,7 @@ export default function ProfileList({ pubkeys }: { pubkeys: string[] }) { observerInstance.unobserve(currentBottomRef) } } - }, [visiblePubkeys, pubkeys]) + }, [visiblePubkeys, pubkeysKey, pubkeys]) return (
diff --git a/src/data/payto-types.json b/src/data/payto-types.json new file mode 100644 index 00000000..c1c16a62 --- /dev/null +++ b/src/data/payto-types.json @@ -0,0 +1,376 @@ +{ + "_meta": { + "logoAssetsDir": "src/assets/payto_logos", + "logoAssetPathNote": "Repo-relative path to the logo file. The app bundles these via Vite and exposes them under /assets/ at runtime (see getPaytoLogoPath)." + }, + "editorOrder": [ + "lightning", + "bitcoin", + "liquid", + "lbtc", + "sats", + "ethereum", + "monero", + "litecoin", + "dogecoin", + "bitcoin-cash", + "solana", + "nano", + "usdt", + "usdc", + "dai", + "euroc", + "paypal", + "cashme", + "venmo", + "revolut", + "buymeacoffee", + "ko-fi", + "patreon", + "github", + "geyser", + "gofundme", + "kickstarter", + "apple-pay", + "google-pay" + ], + "genericAuthorityHelp": { + "placeholder": "payment target", + "hint": "Authority segment of payto:/// (address, username, or ID)" + }, + "aliases": { + "btc": "bitcoin", + "doge": "dogecoin", + "eth": "ethereum", + "xmr": "monero", + "ltc": "litecoin", + "xno": "nano", + "sol": "solana", + "bch": "bitcoin-cash" + }, + "types": { + "bitcoin": { + "label": "Bitcoin", + "symbol": "₿", + "category": "bitcoin", + "authority": { + "placeholder": "bc1q…", + "hint": "On-chain Bitcoin address (Bech32 bc1… preferred)" + } + }, + "liquid": { + "label": "Liquid", + "symbol": "⛓", + "category": "bitcoin-layer", + "logoAssetPath": "src/assets/payto_logos/LBTC.svg", + "authority": { + "placeholder": "VJL… or bc1q… on Liquid", + "hint": "Liquid network address (confidential or explicit)" + } + }, + "lbtc": { + "label": "Liquid Bitcoin", + "symbol": "₿", + "category": "bitcoin-layer", + "logoAssetPath": "src/assets/payto_logos/LBTC.svg", + "authority": { + "placeholder": "VJL…", + "hint": "Liquid Bitcoin (L-BTC) receiving address" + } + }, + "sats": { + "label": "Satoshis", + "symbol": "丰", + "category": "bitcoin", + "authority": { + "placeholder": "bc1q… or lightning address", + "hint": "Satoshis payment target (same formats as Bitcoin / Lightning)" + } + }, + "lightning": { + "label": "Lightning Network", + "symbol": "⚡", + "category": "bitcoin-layer", + "authority": { + "placeholder": "user@getalby.com", + "hint": "Lightning address (LUD-16): name@domain — not a BOLT11 invoice" + } + }, + "ethereum": { + "label": "Ethereum", + "symbol": "Ξ", + "category": "crypto", + "logoAssetPath": "src/assets/payto_logos/ethereum-eth-logo.svg", + "authority": { + "placeholder": "0x…", + "hint": "Ethereum address (0x + 40 hex characters)" + } + }, + "monero": { + "label": "Monero", + "symbol": "ɱ", + "category": "crypto", + "logoAssetPath": "src/assets/payto_logos/Monero.png", + "authority": { + "placeholder": "4… or 8…", + "hint": "Monero primary address (starts with 4 or 8)" + } + }, + "nano": { + "label": "Nano", + "symbol": "Ӿ", + "category": "crypto", + "authority": { + "placeholder": "nano_…", + "hint": "Nano account address (nano_ prefix)" + } + }, + "cashme": { + "label": "Cash App", + "symbol": "$", + "category": "fiat", + "logoAssetPath": "src/assets/payto_logos/cashapp.webp", + "profileUrlTemplate": "https://cash.app/{authority}", + "authority": { + "placeholder": "$cashtag", + "hint": "Cash App $cashtag or phone-linked ID" + } + }, + "revolut": { + "label": "Revolut", + "symbol": "💳", + "category": "fiat", + "logoAssetPath": "src/assets/payto_logos/revolut.webp", + "profileUrlTemplate": "https://revolut.me/{authority}", + "authority": { + "placeholder": "username", + "hint": "Revolut.me username" + } + }, + "venmo": { + "label": "Venmo", + "symbol": "$", + "category": "fiat", + "logoAssetPath": "src/assets/payto_logos/venmo.png", + "profileUrlTemplate": "https://venmo.com/{authority}", + "authority": { + "placeholder": "@username", + "hint": "Venmo username (with or without @)" + } + }, + "bitcoin-cash": { + "label": "Bitcoin Cash", + "symbol": "₿", + "category": "crypto", + "logoAssetPath": "src/assets/payto_logos/bitcoin-cash-bch-logo.svg", + "authority": { + "placeholder": "bitcoincash:… or q…", + "hint": "Bitcoin Cash address (CashAddr or legacy)" + } + }, + "dogecoin": { + "label": "Dogecoin", + "symbol": "Ð", + "category": "crypto", + "logoAssetPath": "src/assets/payto_logos/dogecoin-doge-logo.svg", + "authority": { + "placeholder": "D…", + "hint": "Dogecoin address (usually starts with D)" + } + }, + "litecoin": { + "label": "Litecoin", + "symbol": "Ł", + "category": "crypto", + "logoAssetPath": "src/assets/payto_logos/Litecoin.png", + "authority": { + "placeholder": "ltc1q… or L… or M…", + "hint": "Litecoin address" + } + }, + "usdt": { + "label": "Tether", + "symbol": "₮", + "category": "stablecoin", + "logoAssetPath": "src/assets/payto_logos/tether-usdt-logo.svg", + "authority": { + "placeholder": "0x… or T…", + "hint": "USDT address — include network if needed (ERC-20, TRC-20, etc.)" + } + }, + "usdc": { + "label": "USD Coin", + "symbol": "◎", + "category": "stablecoin", + "logoAssetPath": "src/assets/payto_logos/usd-coin-usdc-logo.svg", + "authority": { + "placeholder": "0x…", + "hint": "USDC address — specify chain in notes if not obvious" + } + }, + "dai": { + "label": "Dai", + "symbol": "◈", + "category": "crypto", + "logoAssetPath": "src/assets/payto_logos/multi-collateral-dai-dai-logo.svg", + "authority": { + "placeholder": "0x…", + "hint": "Dai (ERC-20) wallet address" + } + }, + "euroc": { + "label": "Euro Coin", + "symbol": "€", + "category": "stablecoin", + "logoAssetPath": "src/assets/payto_logos/EurC.png", + "authority": { + "placeholder": "0x…", + "hint": "Euro Coin (EURC) wallet address" + } + }, + "solana": { + "label": "Solana", + "symbol": "◎", + "category": "crypto", + "logoAssetPath": "src/assets/payto_logos/solana.png", + "authority": { + "placeholder": "Base58 pubkey…", + "hint": "Solana wallet address (base58, 32–44 characters)" + } + }, + "paypal": { + "label": "PayPal", + "symbol": "💙", + "category": "fiat", + "logoAssetPath": "src/assets/payto_logos/paypal.webp", + "profileUrlTemplate": "https://paypal.me/{authority}", + "authority": { + "placeholder": "username", + "hint": "PayPal.me username (without paypal.me/)" + } + }, + "buymeacoffee": { + "label": "Buy Me a Coffee", + "symbol": "☕", + "category": "tip", + "logoAssetPath": "src/assets/payto_logos/buymeacoffee.png", + "profileUrlTemplate": "https://buymeacoffee.com/{authority}", + "authority": { + "placeholder": "username", + "hint": "Buy Me a Coffee page slug" + } + }, + "ko-fi": { + "label": "Ko-fi", + "symbol": "☕", + "category": "tip", + "logoAssetPath": "src/assets/payto_logos/ko-fi.png", + "profileUrlTemplate": "https://ko-fi.com/{authority}", + "authority": { + "placeholder": "username", + "hint": "Ko-fi profile name" + } + }, + "kofi": { + "label": "Ko-fi", + "symbol": "☕", + "category": "tip", + "logoAssetPath": "src/assets/payto_logos/ko-fi.png", + "profileUrlTemplate": "https://ko-fi.com/{authority}", + "authority": { + "placeholder": "username", + "hint": "Ko-fi profile name" + } + }, + "patreon": { + "label": "Patreon", + "symbol": "🎭", + "category": "tip", + "logoAssetPath": "src/assets/payto_logos/patreon.png", + "profileUrlTemplate": "https://patreon.com/{authority}", + "authority": { + "placeholder": "creator", + "hint": "Patreon creator / page slug" + } + }, + "github": { + "label": "GitHub Sponsors", + "symbol": "🐙", + "category": "tip", + "logoAssetPath": "src/assets/payto_logos/github_sponsors.png", + "profileUrlTemplate": "https://github.com/sponsors/{authority}", + "authority": { + "placeholder": "username", + "hint": "GitHub Sponsors username" + } + }, + "apple-pay": { + "label": "Apple Pay", + "symbol": "🍎", + "category": "fiat", + "logoAssetPath": "src/assets/payto_logos/apple_pay.svg", + "authority": { + "placeholder": "phone or email", + "hint": "Apple Pay contact (phone number or email)" + } + }, + "google-pay": { + "label": "Google Pay", + "symbol": "G", + "category": "fiat", + "logoAssetPath": "src/assets/payto_logos/google_pay.jpeg", + "authority": { + "placeholder": "phone or email", + "hint": "Google Pay contact (phone number or email)" + } + }, + "geyser": { + "label": "Geyser Fund", + "symbol": "⛲", + "category": "tip", + "logoAssetPath": "src/assets/payto_logos/geyser_fund.webp", + "profileUrlTemplate": "https://geyser.fund/project/{authority}", + "authority": { + "placeholder": "project-slug", + "hint": "Geyser Fund project identifier" + } + }, + "gofundme": { + "label": "GoFundMe", + "symbol": "🎯", + "category": "tip", + "logoAssetPath": "src/assets/payto_logos/gofundme.jpeg", + "profileUrlTemplate": "https://www.gofundme.com/f/{authority}", + "authority": { + "placeholder": "campaign-slug", + "hint": "GoFundMe campaign path segment" + } + }, + "kickstarter": { + "label": "Kickstarter", + "symbol": "🚀", + "category": "tip", + "logoAssetPath": "src/assets/payto_logos/kickstarter.webp", + "profileUrlTemplate": "https://www.kickstarter.com/projects/{authority}", + "authority": { + "placeholder": "project-slug", + "hint": "Kickstarter project slug" + } + }, + "bnb": { + "label": "BNB", + "category": "crypto", + "logoAssetPath": "src/assets/payto_logos/BNB.png" + }, + "tron": { + "label": "Tron", + "category": "crypto", + "logoAssetPath": "src/assets/payto_logos/Tron.png" + }, + "xrp": { + "label": "XRP", + "category": "crypto", + "logoAssetPath": "src/assets/payto_logos/XRP.gif" + } + } +} diff --git a/src/hooks/useProfileReportsEvents.tsx b/src/hooks/useProfileReportsEvents.tsx index 3bade308..919f24e5 100644 --- a/src/hooks/useProfileReportsEvents.tsx +++ b/src/hooks/useProfileReportsEvents.tsx @@ -10,7 +10,7 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostrOptional } from '@/providers/nostr-context' import client from '@/services/client.service' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react' import { Event, kinds, type Filter } from 'nostr-tools' const REPORT_KINDS = [kinds.Report, ExtendedKind.REPORT] as const @@ -41,6 +41,14 @@ function mergeReportEvents( return [...dedup.values()].sort((a, b) => b.created_at - a.created_at).slice(0, limit) } +function eventsEqualById(a: Event[], b: Event[]): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i].id !== b[i].id) return false + } + return true +} + type FetchMode = 'received' | 'made' function buildFilter(pubkey: string, mode: FetchMode, limit: number): Filter { @@ -102,6 +110,12 @@ export function useProfileReportsEvents({ const relayUrlsBuilderRef = useRef(relayUrlsBuilder) relayUrlsBuilderRef.current = relayUrlsBuilder + const favoriteRelaysRef = useRef(favoriteRelays) + const blockedRelaysRef = useRef(blockedRelays) + favoriteRelaysRef.current = favoriteRelays + blockedRelaysRef.current = blockedRelays + const useGlobalRelayBootstrapRef = useRef(useGlobalRelayBootstrap) + useGlobalRelayBootstrapRef.current = useGlobalRelayBootstrap const resolveFeedUrls = useCallback( ( @@ -110,19 +124,24 @@ export function useProfileReportsEvents({ ) => { const custom = relayUrlsBuilderRef.current if (custom) { - return custom(favoriteRelays, blockedRelays, authorRelayList, includeAuthorLocal) + return custom( + favoriteRelaysRef.current, + blockedRelaysRef.current, + authorRelayList, + includeAuthorLocal + ) } return buildProfilePageReadRelayUrls( - favoriteRelays, - blockedRelays, + favoriteRelaysRef.current, + blockedRelaysRef.current, authorRelayList, false, includeAuthorLocal, [...REPORT_KINDS], - useGlobalRelayBootstrap + useGlobalRelayBootstrapRef.current ) }, - [favoriteRelays, blockedRelays, useGlobalRelayBootstrap] + [] ) useEffect(() => { @@ -142,12 +161,11 @@ export function useProfileReportsEvents({ useEffect(() => { let cancelled = false - const closers: (() => void)[] = [] const loadMode = async ( mode: FetchMode, cacheKey: string, - setEvents: (events: Event[]) => void + setEvents: Dispatch> ) => { const mem = memoryByKey.get(cacheKey) const cacheAge = mem ? Date.now() - mem.lastUpdated : Infinity @@ -166,7 +184,7 @@ export function useProfileReportsEvents({ postFilter(pubkey, mode) ) memoryByKey.set(cacheKey, { events: processed, lastUpdated: Date.now() }) - setEvents(processed) + setEvents((prev) => (eventsEqualById(prev, processed) ? prev : processed)) } let pkNorm = pubkey @@ -207,28 +225,6 @@ export function useProfileReportsEvents({ /* ignore */ } - try { - const { closer } = await client.subscribeTimeline( - subRequests, - { - onEvents: (rows) => { - if (cancelled) return - for (const e of rows as Event[]) pool.set(e.id, e) - flush() - }, - onNew: (evt) => { - if (cancelled) return - pool.set((evt as Event).id, evt as Event) - flush() - } - }, - { needSort: true } - ) - closers.push(closer) - } catch { - /* ignore */ - } - const authorRl = await client.fetchRelayList(pubkey).catch(() => emptyAuthor) if (cancelled) return const fullUrls = resolveFeedUrls(authorRl, includeAuthorLocalRelays) @@ -246,23 +242,15 @@ export function useProfileReportsEvents({ /* ignore */ } try { - const { closer } = await client.subscribeTimeline( - deltaRequests, - { - onEvents: (rows) => { - if (cancelled) return - for (const e of rows as Event[]) pool.set(e.id, e) - flush() - }, - onNew: (evt) => { - if (cancelled) return - pool.set((evt as Event).id, evt as Event) - flush() - } - }, - { needSort: true } - ) - closers.push(closer) + const fetchedDelta = await client.fetchEvents(deltaUrls, filter, { + cache: true, + eoseTimeout: 4500, + globalTimeout: 14_000 + }) + if (!cancelled) { + for (const e of fetchedDelta) pool.set(e.id, e) + flush() + } } catch { /* ignore */ } @@ -280,13 +268,13 @@ export function useProfileReportsEvents({ if (madeFresh && madeMem) { setMade(madeMem.events) } - if (recvFresh && madeFresh) { + if (recvFresh && madeFresh && refreshToken === 0) { setIsLoading(false) - if (refreshToken === 0) return - } else { - setIsLoading(true) + return } + setIsLoading(true) + await Promise.all([ loadMode('received', receivedCacheKey, setReceived), loadMode('made', madeCacheKey, setMade) @@ -299,7 +287,6 @@ export function useProfileReportsEvents({ return () => { cancelled = true - closers.forEach((c) => c()) } }, [ pubkey, diff --git a/src/hooks/useProfileWall.tsx b/src/hooks/useProfileWall.tsx index f8166df8..9e829286 100644 --- a/src/hooks/useProfileWall.tsx +++ b/src/hooks/useProfileWall.tsx @@ -12,6 +12,7 @@ import { } from '@/lib/nip58-profile-badges' import { isDirectProfileWallComment } from '@/lib/profile-wall-comments' import { normalizeHexPubkey } from '@/lib/pubkey' +import { normalizeAnyRelayUrl } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import client, { replaceableEventService } from '@/services/client.service' @@ -21,6 +22,12 @@ import { Event, kinds, type Filter } from 'nostr-tools' const CACHE_DURATION = 5 * 60 * 1000 const wallCacheByKey = new Map() +function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string { + const fav = [...favoriteRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001') + const blk = [...blockedRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001') + return `${fav}\u0000${blk}` +} + export function useProfileWall(pubkey: string, profileEventId: string | undefined) { const { favoriteRelays, blockedRelays } = useFavoriteRelays() const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() @@ -36,6 +43,17 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine const [isLoading, setIsLoading] = useState(!cached) const [refreshToken, setRefreshToken] = useState(0) + const relayListsKey = useMemo( + () => relayListsContentKey(favoriteRelays, blockedRelays), + [favoriteRelays, blockedRelays] + ) + const favoriteRelaysRef = useRef(favoriteRelays) + const blockedRelaysRef = useRef(blockedRelays) + favoriteRelaysRef.current = favoriteRelays + blockedRelaysRef.current = blockedRelays + const useGlobalRelayBootstrapRef = useRef(useGlobalRelayBootstrap) + useGlobalRelayBootstrapRef.current = useGlobalRelayBootstrap + useEffect(() => { let cancelled = false @@ -62,13 +80,13 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine if (cancelled) return const relayUrls = buildProfilePageReadRelayUrls( - favoriteRelays, - blockedRelays, + favoriteRelaysRef.current, + blockedRelaysRef.current, authorRl, false, false, [ExtendedKind.COMMENT, ExtendedKind.PROFILE_BADGES_LIST, ExtendedKind.BADGE_DEFINITION], - useGlobalRelayBootstrap + useGlobalRelayBootstrapRef.current ) // --- Badges (NIP-58) --- @@ -159,15 +177,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine return () => { cancelled = true } - }, [ - pubkey, - profileEventId, - cacheKey, - refreshToken, - favoriteRelays, - blockedRelays, - useGlobalRelayBootstrap - ]) + }, [pubkey, profileEventId, cacheKey, refreshToken, relayListsKey]) const refresh = useCallback(() => { wallCacheByKey.delete(cacheKey) diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 6d24a1be..2816d555 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -152,8 +152,11 @@ export default { "Payment info updated": "Payment info updated", "Failed to publish payment info": "Failed to publish payment info", "Invalid tags JSON": "Invalid tags JSON", - "Payment methods": "Payment methods", - "NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).": "NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).", + "Payment methods": "Zahlungsmethoden", + "Payment type": "Zahlungsart", + "paytoEditor.intro": + "Zahlungsart wählen, dann Adresse oder Benutzername wie in der Hinweiszeile darunter eintragen.", + "NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).": "NIP-A3 payto-Tags: Typ (z. B. lightning) und Authority (z. B. user@domain.com).", "Type (e.g. lightning)": "Type (e.g. lightning)", "Authority (e.g. user@domain.com)": "Authority (e.g. user@domain.com)", "Add payment method": "Add payment method", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 6c8c47c4..d653fdfc 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -158,6 +158,9 @@ export default { "Failed to publish payment info": "Failed to publish payment info", "Invalid tags JSON": "Invalid tags JSON", "Payment methods": "Payment methods", + "Payment type": "Payment type", + "paytoEditor.intro": + "Choose a payment type, then enter the address or username shown in the hint below each field.", "NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).": "NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).", "Type (e.g. lightning)": "Type (e.g. lightning)", "Authority (e.g. user@domain.com)": "Authority (e.g. user@domain.com)", diff --git a/src/lib/payto-editor-hints.test.ts b/src/lib/payto-editor-hints.test.ts new file mode 100644 index 00000000..5fd602b9 --- /dev/null +++ b/src/lib/payto-editor-hints.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { getPaytoAuthorityFieldHelp, getPaytoLogoPath, paytoEditorSelectTypes } from './payto-registry' + +describe('getPaytoAuthorityFieldHelp', () => { + it('returns lightning-specific hint', () => { + const help = getPaytoAuthorityFieldHelp('lightning') + expect(help.placeholder).toContain('@') + expect(help.hint.toLowerCase()).toContain('lud') + }) + + it('falls back for unknown types', () => { + const help = getPaytoAuthorityFieldHelp('custom-coin') + expect(help.hint).toContain('payto://') + }) +}) + +describe('paytoEditorSelectTypes', () => { + it('appends custom type not in curated list', () => { + const types = paytoEditorSelectTypes('custom-coin') + expect(types[0]).toBe('lightning') + expect(types).toContain('custom-coin') + }) +}) + +describe('getPaytoLogoPath', () => { + it('resolves ethereum logo from catalog asset path', () => { + const url = getPaytoLogoPath('ethereum') + expect(url).toBeTruthy() + expect(url!.length).toBeGreaterThan(10) + }) +}) diff --git a/src/lib/payto-logos.ts b/src/lib/payto-logos.ts index 18329bd4..df1b397a 100644 --- a/src/lib/payto-logos.ts +++ b/src/lib/payto-logos.ts @@ -1,61 +1,35 @@ /** - * Explicit Vite `?url` imports so every payto logo is emitted under `/assets/` in production. - * Keep files in `src/assets/payto_logos/` (not `public/`). + * Resolves payto logo paths from {@link ../data/payto-types.json} `logoAssetPath` values. + * All files under `src/assets/payto_logos/` are bundled via Vite `import.meta.glob`. */ -import applePaySvg from '../assets/payto_logos/apple_pay.svg?url' -import bitcoinCashLogo from '../assets/payto_logos/bitcoin-cash-bch-logo.svg?url' -import bnbPng from '../assets/payto_logos/BNB.png?url' -import buyMeACoffeePng from '../assets/payto_logos/buymeacoffee.png?url' -import cashappWebp from '../assets/payto_logos/cashapp.webp?url' -import daiLogo from '../assets/payto_logos/multi-collateral-dai-dai-logo.svg?url' -import dogecoinLogo from '../assets/payto_logos/dogecoin-doge-logo.svg?url' -import ethLogo from '../assets/payto_logos/ethereum-eth-logo.svg?url' -import eurocPng from '../assets/payto_logos/EurC.png?url' -import geyserWebp from '../assets/payto_logos/geyser_fund.webp?url' -import githubSponsorsPng from '../assets/payto_logos/github_sponsors.png?url' -import gofundmeJpeg from '../assets/payto_logos/gofundme.jpeg?url' -import googlePayJpeg from '../assets/payto_logos/google_pay.jpeg?url' -import kickstarterWebp from '../assets/payto_logos/kickstarter.webp?url' -import kofiPng from '../assets/payto_logos/ko-fi.png?url' -import lbtcSvg from '../assets/payto_logos/LBTC.svg?url' -import litecoinPng from '../assets/payto_logos/Litecoin.png?url' -import moneroPng from '../assets/payto_logos/Monero.png?url' -import patreonPng from '../assets/payto_logos/patreon.png?url' -import paypalWebp from '../assets/payto_logos/paypal.webp?url' -import revolutWebp from '../assets/payto_logos/revolut.webp?url' -import solanaPng from '../assets/payto_logos/solana.png?url' -import tetherLogo from '../assets/payto_logos/tether-usdt-logo.svg?url' -import tronPng from '../assets/payto_logos/Tron.png?url' -import usdcLogo from '../assets/payto_logos/usd-coin-usdc-logo.svg?url' -import venmoPng from '../assets/payto_logos/venmo.png?url' -import xrpGif from '../assets/payto_logos/XRP.gif?url' -export const PAYTO_LOGO_URL_BY_FILENAME: Record = { - 'apple_pay.svg': applePaySvg, - 'bitcoin-cash-bch-logo.svg': bitcoinCashLogo, - 'BNB.png': bnbPng, - 'buymeacoffee.png': buyMeACoffeePng, - 'cashapp.webp': cashappWebp, - 'multi-collateral-dai-dai-logo.svg': daiLogo, - 'dogecoin-doge-logo.svg': dogecoinLogo, - 'ethereum-eth-logo.svg': ethLogo, - 'EurC.png': eurocPng, - 'geyser_fund.webp': geyserWebp, - 'github_sponsors.png': githubSponsorsPng, - 'gofundme.jpeg': gofundmeJpeg, - 'google_pay.jpeg': googlePayJpeg, - 'kickstarter.webp': kickstarterWebp, - 'ko-fi.png': kofiPng, - 'LBTC.svg': lbtcSvg, - 'Litecoin.png': litecoinPng, - 'Monero.png': moneroPng, - 'patreon.png': patreonPng, - 'paypal.webp': paypalWebp, - 'revolut.webp': revolutWebp, - 'solana.png': solanaPng, - 'tether-usdt-logo.svg': tetherLogo, - 'Tron.png': tronPng, - 'usd-coin-usdc-logo.svg': usdcLogo, - 'venmo.png': venmoPng, - 'XRP.gif': xrpGif +const logoModules = import.meta.glob('../assets/payto_logos/*', { + eager: true, + query: '?url', + import: 'default' +}) + +/** Repo-relative path or basename → bundled URL (e.g. `/assets/…`). */ +const URL_BY_ASSET_PATH = new Map() + +for (const [modulePath, url] of Object.entries(logoModules)) { + const filename = modulePath.split('/payto_logos/')[1] + if (!filename || !url) continue + const assetPath = `src/assets/payto_logos/${filename}` + URL_BY_ASSET_PATH.set(assetPath, url) + URL_BY_ASSET_PATH.set(filename, url) } + +/** + * Resolve a catalog `logoAssetPath` (or legacy basename) to the app asset URL. + */ +export function resolvePaytoLogoAssetPath(assetPathOrFilename: string | undefined): string | null { + if (!assetPathOrFilename?.trim()) return null + const key = assetPathOrFilename.trim() + return URL_BY_ASSET_PATH.get(key) ?? URL_BY_ASSET_PATH.get(key.split('/').pop() ?? '') ?? null +} + +/** @deprecated Use {@link resolvePaytoLogoAssetPath} with catalog `logoAssetPath`. */ +export const PAYTO_LOGO_URL_BY_FILENAME: Record = Object.fromEntries( + [...URL_BY_ASSET_PATH.entries()].filter(([k]) => !k.startsWith('src/')) +) diff --git a/src/lib/payto-registry.ts b/src/lib/payto-registry.ts new file mode 100644 index 00000000..ce4f3812 --- /dev/null +++ b/src/lib/payto-registry.ts @@ -0,0 +1,111 @@ +/** + * Loads payto type metadata from {@link ../data/payto-types.json}. + * Edit that JSON to add types, editor order, hints, logos, and profile URL templates. + */ + +import paytoTypesCatalog from '@/data/payto-types.json' +import { resolvePaytoLogoAssetPath } from '@/lib/payto-logos' + +export type PaytoCategory = 'bitcoin' | 'bitcoin-layer' | 'crypto' | 'stablecoin' | 'fiat' | 'tip' + +export type PaytoAuthorityHelp = { + placeholder: string + hint: string +} + +export type PaytoTypeRecord = { + label: string + symbol?: string + category: PaytoCategory + /** Repo-relative path, e.g. `src/assets/payto_logos/ethereum-eth-logo.svg`. */ + logoAssetPath?: string + profileUrlTemplate?: string + authority?: PaytoAuthorityHelp +} + +type PaytoTypesCatalogJson = { + editorOrder: string[] + genericAuthorityHelp: PaytoAuthorityHelp + aliases: Record + types: Record +} + +const catalog = paytoTypesCatalog as PaytoTypesCatalogJson + +export const PAYTO_EDITOR_TYPE_ORDER: readonly string[] = catalog.editorOrder + +const GENERIC_AUTHORITY_HELP: PaytoAuthorityHelp = catalog.genericAuthorityHelp + +const PAYTO_TYPE_ALIASES: Record = catalog.aliases + +const PAYTO_TYPES: Record = catalog.types + +/** UI summary per canonical type (label, symbol, category). */ +export const PAYTO_KNOWN_TYPES: Record< + string, + { label: string; symbol?: string; category: PaytoCategory } +> = Object.fromEntries( + Object.entries(PAYTO_TYPES).map(([id, row]) => [ + id, + { label: row.label, symbol: row.symbol, category: row.category } + ]) +) + +export function getCanonicalPaytoType(type: string): string { + const key = type.toLowerCase().trim() + return PAYTO_TYPE_ALIASES[key] ?? key +} + +export function getPaytoTypeRecord(type: string): PaytoTypeRecord | undefined { + return PAYTO_TYPES[getCanonicalPaytoType(type)] +} + +export function getPaytoTypeInfo(type: string): (typeof PAYTO_KNOWN_TYPES)[string] | undefined { + return PAYTO_KNOWN_TYPES[getCanonicalPaytoType(type)] +} + +export function isKnownPaytoType(type: string): boolean { + return getCanonicalPaytoType(type) in PAYTO_KNOWN_TYPES +} + +export function getPaytoAuthorityFieldHelp(type: string): PaytoAuthorityHelp { + const row = getPaytoTypeRecord(type) + return row?.authority ?? GENERIC_AUTHORITY_HELP +} + +export function getPaytoEditorTypeLabel(type: string): string { + return getPaytoTypeInfo(type)?.label ?? getCanonicalPaytoType(type) +} + +/** Dropdown options: catalog order plus the row's type when not listed. */ +export function paytoEditorSelectTypes(currentType: string): string[] { + const key = getCanonicalPaytoType(currentType) + const ordered = new Set(PAYTO_EDITOR_TYPE_ORDER) + const out = [...PAYTO_EDITOR_TYPE_ORDER] + if (key && !ordered.has(key)) out.push(key) + return out +} + +/** Bundled asset URL for `` (resolved from catalog `logoAssetPath`). */ +export function getPaytoLogoPath(type: string): string | null { + return resolvePaytoLogoAssetPath(getPaytoTypeRecord(type)?.logoAssetPath) +} + +/** Same as {@link getPaytoLogoPath}; alias for callers that expect a URL field name. */ +export function getPaytoLogoUrl(type: string): string | null { + return getPaytoLogoPath(type) +} + +export function getPaytoProfileUrl(type: string, authority: string): string | null { + const template = getPaytoTypeRecord(type)?.profileUrlTemplate + if (!template || !authority.trim()) return null + return template.replace('{authority}', encodeURIComponent(authority.trim())) +} + +export function getPaytoIconChar(type: string): string | null { + return getPaytoTypeRecord(type)?.symbol ?? null +} + +export function isLightningPaytoType(type: string): boolean { + return getCanonicalPaytoType(type) === 'lightning' +} diff --git a/src/lib/payto.ts b/src/lib/payto.ts index af935fb2..49c744c1 100644 --- a/src/lib/payto.ts +++ b/src/lib/payto.ts @@ -1,9 +1,27 @@ /** * payto: URI handling (RFC-8905 / NIP-A3) - * Parse and normalize payto:/// URIs; known types for UI (icons, labels, dialogs). + * Type metadata lives in {@link ../data/payto-types.json} via {@link ./payto-registry}. */ -import { PAYTO_LOGO_URL_BY_FILENAME } from '@/lib/payto-logos' +import { getCanonicalPaytoType } from '@/lib/payto-registry' + +export { + getCanonicalPaytoType, + getPaytoAuthorityFieldHelp, + getPaytoEditorTypeLabel, + getPaytoIconChar, + getPaytoLogoPath, + getPaytoLogoUrl, + getPaytoProfileUrl, + getPaytoTypeInfo, + isKnownPaytoType, + isLightningPaytoType, + paytoEditorSelectTypes, + PAYTO_EDITOR_TYPE_ORDER, + PAYTO_KNOWN_TYPES, + type PaytoAuthorityHelp, + type PaytoCategory +} from '@/lib/payto-registry' export const PAYTO_URI_REGEX = /payto:\/\/([a-z0-9-]+)\/([^\s\]\)\<\"']+)/gi @@ -35,155 +53,3 @@ export function buildPaytoUri(type: string, authority: string): string { const a = encodeURIComponent(authority.trim()) return `payto://${t}/${a}` } - -/** Known payment types: NIP-A3 recommended + common extras (crypto, fiat, tipping) */ -export const PAYTO_KNOWN_TYPES: Record< - string, - { label: string; symbol?: string; category: 'bitcoin' | 'bitcoin-layer' | 'crypto' | 'stablecoin' | 'fiat' | 'tip' } -> = { - bitcoin: { label: 'Bitcoin', symbol: '₿', category: 'bitcoin' }, - /** - * Liquid sidechain — Bitcoin L3 (settlement layer), analogous in role to Lightning (L2) as a - * Bitcoin-native extension; not an alt-L1 “crypto” bucket. - */ - liquid: { label: 'Liquid', symbol: '⛓', category: 'bitcoin-layer' }, - /** Confidential Bitcoin on Liquid (L-BTC). */ - lbtc: { label: 'Liquid Bitcoin', symbol: '₿', category: 'bitcoin-layer' }, - sats: { label: 'Satoshis', symbol: '丰', category: 'bitcoin' }, - lightning: { label: 'Lightning Network', symbol: '⚡', category: 'bitcoin-layer' }, - ethereum: { label: 'Ethereum', symbol: 'Ξ', category: 'crypto' }, - monero: { label: 'Monero', symbol: 'ɱ', category: 'crypto' }, - nano: { label: 'Nano', symbol: 'Ӿ', category: 'crypto' }, - cashme: { label: 'Cash App', symbol: '$', category: 'fiat' }, - revolut: { label: 'Revolut', symbol: '💳', category: 'fiat' }, - venmo: { label: 'Venmo', symbol: '$', category: 'fiat' }, - - // Common crypto - 'bitcoin-cash': { label: 'Bitcoin Cash', symbol: '₿', category: 'crypto' }, - dogecoin: { label: 'Dogecoin', symbol: 'Ð', category: 'crypto' }, - litecoin: { label: 'Litecoin', symbol: 'Ł', category: 'crypto' }, - usdt: { label: 'Tether', symbol: '₮', category: 'stablecoin' }, - usdc: { label: 'USD Coin', symbol: '◎', category: 'stablecoin' }, - dai: { label: 'Dai', symbol: '◈', category: 'crypto' }, - euroc: { label: 'Euro Coin', symbol: '€', category: 'stablecoin' }, - solana: { label: 'Solana', symbol: '◎', category: 'crypto' }, - - // Tipping / donation - paypal: { label: 'PayPal', symbol: '💙', category: 'fiat' }, - buymeacoffee: { label: 'Buy Me a Coffee', symbol: '☕', category: 'tip' }, - 'ko-fi': { label: 'Ko-fi', symbol: '☕', category: 'tip' }, - kofi: { label: 'Ko-fi', symbol: '☕', category: 'tip' }, - patreon: { label: 'Patreon', symbol: '🎭', category: 'tip' }, - github: { label: 'GitHub Sponsors', symbol: '🐙', category: 'tip' }, - - // Fiat / wallets - 'apple-pay': { label: 'Apple Pay', symbol: '🍎', category: 'fiat' }, - 'google-pay': { label: 'Google Pay', symbol: 'G', category: 'fiat' }, - - // Crowdfunding / fundraising - geyser: { label: 'Geyser Fund', symbol: '⛲', category: 'tip' }, - gofundme: { label: 'GoFundMe', symbol: '🎯', category: 'tip' }, - kickstarter: { label: 'Kickstarter', symbol: '🚀', category: 'tip' } -} - -/** - * Short labels accepted after payto:// that map to a canonical type. - * e.g. payto://BTC/... maps to bitcoin; payto://LBTC/... maps to Liquid Bitcoin (not Lightning). - */ -const PAYTO_TYPE_ALIASES: Record = { - btc: 'bitcoin', - doge: 'dogecoin', - eth: 'ethereum', - xmr: 'monero', - ltc: 'litecoin', - xno: 'nano', - sol: 'solana', - bch: 'bitcoin-cash' -} - -export function getCanonicalPaytoType(type: string): string { - const key = type.toLowerCase().trim() - return PAYTO_TYPE_ALIASES[key] ?? key -} - -/** Icon character/symbol for known types; null for unknown (render HelpCircle or ?) */ -export function getPaytoIconChar(type: string): string | null { - const info = getPaytoTypeInfo(type) - return info?.symbol ?? null -} - -/** Logo filename in `src/assets/payto_logos/` for types that have an asset. Any image format works: .svg, .gif, .jpg, .png, .webp, etc. */ -const PAYTO_LOGO_FILES: Record = { - liquid: 'LBTC.svg', - lbtc: 'LBTC.svg', - ethereum: 'ethereum-eth-logo.svg', - monero: 'Monero.png', - litecoin: 'Litecoin.png', - dogecoin: 'dogecoin-doge-logo.svg', - usdt: 'tether-usdt-logo.svg', - usdc: 'usd-coin-usdc-logo.svg', - dai: 'multi-collateral-dai-dai-logo.svg', - euroc: 'EurC.png', - solana: 'solana.png', - bnb: 'BNB.png', - tron: 'Tron.png', - xrp: 'XRP.gif', - 'bitcoin-cash': 'bitcoin-cash-bch-logo.svg', - cashme: 'cashapp.webp', - venmo: 'venmo.png', - paypal: 'paypal.webp', - revolut: 'revolut.webp', - buymeacoffee: 'buymeacoffee.png', - 'ko-fi': 'ko-fi.png', - kofi: 'ko-fi.png', - patreon: 'patreon.png', - github: 'github_sponsors.png', - 'apple-pay': 'apple_pay.svg', - 'google-pay': 'google_pay.jpeg', - geyser: 'geyser_fund.webp', - gofundme: 'gofundme.jpeg', - kickstarter: 'kickstarter.webp' -} - -/** Profile/page URL template for types that have a web profile. Use {authority} as placeholder. Null = no direct link. */ -const PAYTO_PROFILE_URL_TEMPLATES: Record = { - paypal: 'https://paypal.me/{authority}', - venmo: 'https://venmo.com/{authority}', - revolut: 'https://revolut.me/{authority}', - buymeacoffee: 'https://buymeacoffee.com/{authority}', - 'ko-fi': 'https://ko-fi.com/{authority}', - kofi: 'https://ko-fi.com/{authority}', - patreon: 'https://patreon.com/{authority}', - github: 'https://github.com/sponsors/{authority}', - geyser: 'https://geyser.fund/project/{authority}', - gofundme: 'https://www.gofundme.com/f/{authority}', - kickstarter: 'https://www.kickstarter.com/projects/{authority}', - cashme: 'https://cash.app/{authority}' -} - -export function getPaytoProfileUrl(type: string, authority: string): string | null { - const key = type.toLowerCase() - const template = PAYTO_PROFILE_URL_TEMPLATES[key] - if (!template || !authority) return null - return template.replace('{authority}', encodeURIComponent(authority.trim())) -} - -export function getPaytoLogoPath(type: string): string | null { - const key = getCanonicalPaytoType(type) - const file = PAYTO_LOGO_FILES[key] - if (!file) return null - return PAYTO_LOGO_URL_BY_FILENAME[file] ?? null -} - -export function getPaytoTypeInfo(type: string): (typeof PAYTO_KNOWN_TYPES)[string] | undefined { - return PAYTO_KNOWN_TYPES[getCanonicalPaytoType(type)] -} - -export function isKnownPaytoType(type: string): boolean { - return getCanonicalPaytoType(type) in PAYTO_KNOWN_TYPES -} - -/** Check if type is lightning (opens Zap flow when pubkey available) */ -export function isLightningPaytoType(type: string): boolean { - return getCanonicalPaytoType(type) === 'lightning' -} diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index 06af2006..fc63e524 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -34,6 +34,7 @@ import { } from '@/components/ui/select' import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build' import { isVideo } from '@/lib/url' +import PaymentMethodRow from '@/components/ProfileEditor/PaymentMethodRow' import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react' import type { Event } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -816,44 +817,25 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {

- {t('NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).')} + {t('paytoEditor.intro', { + defaultValue: + 'Choose a payment type, then enter the address or username shown in the hint below each field.' + })}

-
+
{paymentInfoEditMethods.map((row, idx) => ( -
- { - const next = [...paymentInfoEditMethods] - next[idx] = { ...next[idx], type: e.target.value } - setPaymentInfoEditMethods(next) - }} - className="flex-1 max-w-[140px] font-mono text-sm" - /> - { - const next = [...paymentInfoEditMethods] - next[idx] = { ...next[idx], authority: e.target.value } - setPaymentInfoEditMethods(next) - }} - className="flex-1 font-mono text-sm" - /> - -
+ { + const methods = [...paymentInfoEditMethods] + methods[idx] = next + setPaymentInfoEditMethods(methods) + }} + onRemove={() => + setPaymentInfoEditMethods(paymentInfoEditMethods.filter((_, i) => i !== idx)) + } + /> ))}