From ae39425055be9e1f73b2df6aa53ab471920a4c0f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 16 Mar 2026 16:11:10 +0100 Subject: [PATCH] expand payto:// handling --- docker-entrypoint.sh | 2 +- public/payto_logos/apple_pay.svg | 3 + public/payto_logos/google_pay.jpeg | Bin 0 -> 5340 bytes src/components/PaytoLink/index.tsx | 12 +++- src/lib/payto.ts | 92 ++++++++++++++++++----------- 5 files changed, 71 insertions(+), 38 deletions(-) create mode 100644 public/payto_logos/apple_pay.svg create mode 100644 public/payto_logos/google_pay.jpeg diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 86ce5a04..587f6ec3 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -2,7 +2,7 @@ # Runtime config for the SPA. NIP-66 monitor runs in a separate cron container; nsec is never sent to the client. # Optional: NIP66_MONITOR_NPUB (npub of the monitor) can be exposed so the relay info page shows who runs the monitor. if [ -n "$NIP66_MONITOR_NPUB" ]; then - echo "$NIP66_MONITOR_NPUB" | jq -n -R '{NIP66_MONITOR_NPUB: .}' > /usr/share/nginx/html/config.json + jq -n --arg npub "$NIP66_MONITOR_NPUB" '{NIP66_MONITOR_NPUB: $npub}' > /usr/share/nginx/html/config.json else echo '{}' > /usr/share/nginx/html/config.json fi diff --git a/public/payto_logos/apple_pay.svg b/public/payto_logos/apple_pay.svg new file mode 100644 index 00000000..82b0cddc --- /dev/null +++ b/public/payto_logos/apple_pay.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/payto_logos/google_pay.jpeg b/public/payto_logos/google_pay.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..348e6839dea2fd1c9d0f7e40acccd7df21e2f1a3 GIT binary patch literal 5340 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 literal 0 HcmV?d00001 diff --git a/src/components/PaytoLink/index.tsx b/src/components/PaytoLink/index.tsx index 9fa960ac..622bac2b 100644 --- a/src/components/PaytoLink/index.tsx +++ b/src/components/PaytoLink/index.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { parsePaytoUri, buildPaytoUri, + getCanonicalPaytoType, getPaytoTypeInfo, getPaytoIconChar, getPaytoLogoPath, @@ -39,7 +40,11 @@ export default function PaytoLink({ const parsed = paytoUri ? parsePaytoUri(paytoUri) : typeProp && authorityProp - ? { type: typeProp.toLowerCase(), authority: authorityProp, raw: buildPaytoUri(typeProp, authorityProp) } + ? { + type: getCanonicalPaytoType(typeProp), + authority: authorityProp, + raw: buildPaytoUri(typeProp, authorityProp) + } : null if (!parsed) { @@ -68,6 +73,7 @@ export default function PaytoLink({ } const displayLabel = info?.label ?? type + const categoryLabel = info?.category ? info.category.charAt(0).toUpperCase() + info.category.slice(1) : '' const logoPath = getPaytoLogoPath(type) const iconChar = getPaytoIconChar(type) const profileUrl = getPaytoProfileUrl(type, authority) @@ -100,7 +106,7 @@ export default function PaytoLink({ 'text-primary hover:underline cursor-pointer text-left break-words inline-flex items-center gap-1.5', className )} - title={`${displayLabel}: ${t('Open on website')}`} + title={categoryLabel ? `${displayLabel} (${categoryLabel}): ${t('Open on website')}` : `${displayLabel}: ${t('Open on website')}`} onClick={(e) => e.stopPropagation()} > {iconEl} @@ -118,7 +124,7 @@ export default function PaytoLink({ 'text-primary hover:underline cursor-pointer text-left break-words inline-flex items-center gap-1.5', className )} - title={known ? `${displayLabel}: ${t('Click to open payment options')}` : t('Click to copy address')} + title={known && categoryLabel ? `${displayLabel} (${categoryLabel}): ${t('Click to open payment options')}` : known ? `${displayLabel}: ${t('Click to open payment options')}` : t('Click to copy address')} > {iconEl} {content} diff --git a/src/lib/payto.ts b/src/lib/payto.ts index a1400b72..2ba751f4 100644 --- a/src/lib/payto.ts +++ b/src/lib/payto.ts @@ -18,9 +18,10 @@ export function parsePaytoUri(uri: string): ParsedPayto | null { const trimmed = uri.trim() const m = /^payto:\/\/([a-z0-9-]+)\/(.+)$/i.exec(trimmed) if (!m) return null - const type = m[1].toLowerCase() + const typeRaw = m[1].toLowerCase() const authority = decodeURIComponent(m[2].replace(/\+/g, ' ')) - if (!type || !authority) return null + if (!typeRaw || !authority) return null + const type = getCanonicalPaytoType(typeRaw) return { type, authority, raw: trimmed } } @@ -36,42 +37,65 @@ export function buildPaytoUri(type: string, authority: string): string { /** Known payment types: NIP-A3 recommended + common extras (crypto, fiat, tipping) */ export const PAYTO_KNOWN_TYPES: Record< string, - { label: string; shortLabel?: string; symbol?: string; category: 'crypto' | 'fiat' | 'lightning' | 'tip' } + { label: string; symbol?: string; category: 'bitcoin' | 'crypto' | 'stablecoin' | 'fiat' | 'lightning' | 'tip' } > = { - bitcoin: { label: 'Bitcoin', shortLabel: 'BTC', symbol: '₿', category: 'crypto' }, - lightning: { label: 'Lightning Network', shortLabel: 'LBTC', symbol: '⚡', category: 'lightning' }, - ethereum: { label: 'Ethereum', shortLabel: 'ETH', symbol: 'Ξ', category: 'crypto' }, - monero: { label: 'Monero', shortLabel: 'XMR', symbol: 'ɱ', category: 'crypto' }, - nano: { label: 'Nano', shortLabel: 'XNO', symbol: 'Ӿ', category: 'crypto' }, - cashme: { label: 'Cash App', shortLabel: 'Cash App', symbol: '$', category: 'fiat' }, - revolut: { label: 'Revolut', shortLabel: 'Revolut', symbol: '💳', category: 'fiat' }, - venmo: { label: 'Venmo', shortLabel: 'Venmo', symbol: '$', category: 'fiat' }, + bitcoin: { label: 'Bitcoin', symbol: '₿', category: 'bitcoin' }, + sats: { label: 'Satoshis', symbol: '丰', category: 'bitcoin' }, + lightning: { label: 'Lightning Network', symbol: '⚡', category: 'lightning' }, + 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 - dogecoin: { label: 'Dogecoin', shortLabel: 'DOGE', symbol: 'Ð', category: 'crypto' }, - litecoin: { label: 'Litecoin', shortLabel: 'LTC', symbol: 'Ł', category: 'crypto' }, - usdt: { label: 'Tether', shortLabel: 'USDT', symbol: '₮', category: 'crypto' }, - usdc: { label: 'USD Coin', shortLabel: 'USDC', symbol: '◎', category: 'crypto' }, - dai: { label: 'Dai', shortLabel: 'DAI', symbol: '◈', category: 'crypto' }, - euroc: { label: 'Euro Coin', shortLabel: 'EUROC', symbol: '€', category: 'crypto' }, - solana: { label: 'Solana', shortLabel: 'SOL', symbol: '◎', category: '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', shortLabel: 'PayPal', symbol: '💙', category: 'fiat' }, - buymeacoffee: { label: 'Buy Me a Coffee', shortLabel: 'Buy Me a Coffee', symbol: '☕', category: 'tip' }, - 'ko-fi': { label: 'Ko-fi', shortLabel: 'Ko-fi', symbol: '☕', category: 'tip' }, - kofi: { label: 'Ko-fi', shortLabel: 'Ko-fi', symbol: '☕', category: 'tip' }, - patreon: { label: 'Patreon', shortLabel: 'Patreon', symbol: '🎭', category: 'tip' }, - github: { label: 'GitHub Sponsors', shortLabel: 'GitHub', symbol: '🐙', category: 'tip' }, + 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', shortLabel: 'Apple Pay', symbol: '🍎', category: 'fiat' }, - 'google-pay': { label: 'Google Pay', shortLabel: 'Google Pay', symbol: 'G', category: 'fiat' }, + 'apple-pay': { label: 'Apple Pay', symbol: '🍎', category: 'fiat' }, + 'google-pay': { label: 'Google Pay', symbol: 'G', category: 'fiat' }, // Crowdfunding / fundraising - geyser: { label: 'Geyser Fund', shortLabel: 'Geyser', symbol: '⛲', category: 'tip' }, - gofundme: { label: 'GoFundMe', shortLabel: 'GoFundMe', symbol: '🎯', category: 'tip' }, - kickstarter: { label: 'Kickstarter', shortLabel: 'Kickstarter', symbol: '🚀', category: 'tip' } + 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/..., payto://LBTC/..., payto://DOGE/... are recognized as bitcoin, lightning, dogecoin. + */ +export const PAYTO_TYPE_ALIASES: Record = { + btc: 'bitcoin', + lbtc: 'lightning', + 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 ?) */ @@ -104,8 +128,8 @@ export const PAYTO_LOGO_FILES: Record = { kofi: 'ko-fi.png', patreon: 'patreon.png', github: 'github_sponsors.png', - 'apple-pay': 'apple_pay.webp', - 'google-pay': 'google_pay.png', + 'apple-pay': 'apple_pay.svg', + 'google-pay': 'google_pay.jpeg', geyser: 'geyser_fund.webp', gofundme: 'gofundme.jpeg', kickstarter: 'kickstarter.webp' @@ -142,14 +166,14 @@ export function getPaytoLogoPath(type: string): string | null { } export function getPaytoTypeInfo(type: string): (typeof PAYTO_KNOWN_TYPES)[string] | undefined { - return PAYTO_KNOWN_TYPES[type.toLowerCase()] + return PAYTO_KNOWN_TYPES[getCanonicalPaytoType(type)] } export function isKnownPaytoType(type: string): boolean { - return type.toLowerCase() in PAYTO_KNOWN_TYPES + 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 type.toLowerCase() === 'lightning' + return getCanonicalPaytoType(type) === 'lightning' }