|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@ -0,0 +1,7 @@ |
|||||||
|
# Payto logos |
||||||
|
|
||||||
|
Icons for payment types (crypto, etc.) used by payto links. |
||||||
|
|
||||||
|
**Supported formats:** SVG, GIF, JPG/JPEG, PNG, WebP, etc. — any format the browser can display in `<img>` is fine. SVG scales best at different sizes. |
||||||
|
|
||||||
|
Filenames are mapped in `src/lib/payto.ts` → `PAYTO_LOGO_FILES`. Use whatever extension the asset has (.svg, .gif, .jpg, .png, …). |
||||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
@ -0,0 +1,81 @@ |
|||||||
|
import { |
||||||
|
Dialog, |
||||||
|
DialogContent, |
||||||
|
DialogDescription, |
||||||
|
DialogHeader, |
||||||
|
DialogTitle |
||||||
|
} from '@/components/ui/dialog' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { Copy } from 'lucide-react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { toast } from 'sonner' |
||||||
|
import { getPaytoTypeInfo } from '@/lib/payto' |
||||||
|
import { Zap } from 'lucide-react' |
||||||
|
|
||||||
|
export default function PaytoDialog({ |
||||||
|
open, |
||||||
|
onOpenChange, |
||||||
|
type, |
||||||
|
authority, |
||||||
|
paytoUri |
||||||
|
}: { |
||||||
|
open: boolean |
||||||
|
onOpenChange: (open: boolean) => void |
||||||
|
type: string |
||||||
|
authority: string |
||||||
|
paytoUri: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const info = getPaytoTypeInfo(type) |
||||||
|
const label = info?.label ?? type |
||||||
|
const isLightning = type.toLowerCase() === 'lightning' |
||||||
|
|
||||||
|
const handleCopy = (text: string, label?: string) => { |
||||||
|
navigator.clipboard.writeText(text) |
||||||
|
toast.success(label ? t('Copied {{label}} address', { label }) : t('Copied to clipboard')) |
||||||
|
onOpenChange(false) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}> |
||||||
|
<DialogContent className="sm:max-w-md"> |
||||||
|
<DialogHeader> |
||||||
|
<DialogTitle className="flex items-center gap-2"> |
||||||
|
{isLightning && <Zap className="size-5 text-yellow-400" />} |
||||||
|
<span>{label}</span> |
||||||
|
</DialogTitle> |
||||||
|
<DialogDescription> |
||||||
|
{isLightning |
||||||
|
? t('Lightning payment address – copy to pay via your wallet') |
||||||
|
: t('Payment address – copy to use in your wallet or app')} |
||||||
|
</DialogDescription> |
||||||
|
</DialogHeader> |
||||||
|
<div className="space-y-3"> |
||||||
|
<div className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all select-text"> |
||||||
|
{authority} |
||||||
|
</div> |
||||||
|
<div className="flex flex-wrap gap-2"> |
||||||
|
<Button |
||||||
|
variant="secondary" |
||||||
|
size="sm" |
||||||
|
onClick={() => handleCopy(authority, label)} |
||||||
|
className="gap-2" |
||||||
|
> |
||||||
|
<Copy className="size-4" /> |
||||||
|
{t('Copy address')} |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
variant="outline" |
||||||
|
size="sm" |
||||||
|
onClick={() => handleCopy(paytoUri)} |
||||||
|
className="gap-2" |
||||||
|
> |
||||||
|
<Copy className="size-4" /> |
||||||
|
{t('Copy payto URI')} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</DialogContent> |
||||||
|
</Dialog> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,137 @@ |
|||||||
|
import { useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { toast } from 'sonner' |
||||||
|
import { |
||||||
|
parsePaytoUri, |
||||||
|
buildPaytoUri, |
||||||
|
getPaytoTypeInfo, |
||||||
|
getPaytoIconChar, |
||||||
|
getPaytoLogoPath, |
||||||
|
getPaytoProfileUrl, |
||||||
|
isKnownPaytoType, |
||||||
|
isLightningPaytoType |
||||||
|
} from '@/lib/payto' |
||||||
|
import PaytoDialog from '@/components/PaytoDialog' |
||||||
|
import { HelpCircle } from 'lucide-react' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
|
||||||
|
export default function PaytoLink({ |
||||||
|
paytoUri, |
||||||
|
type: typeProp, |
||||||
|
authority: authorityProp, |
||||||
|
pubkey, |
||||||
|
onOpenZap, |
||||||
|
className, |
||||||
|
children |
||||||
|
}: { |
||||||
|
paytoUri?: string |
||||||
|
type?: string |
||||||
|
authority?: string |
||||||
|
/** When set with lightning type, clicking can open Zap dialog via onOpenZap */ |
||||||
|
pubkey?: string |
||||||
|
onOpenZap?: (pubkey: string) => void |
||||||
|
className?: string |
||||||
|
children?: React.ReactNode |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const [dialogOpen, setDialogOpen] = useState(false) |
||||||
|
|
||||||
|
const parsed = paytoUri |
||||||
|
? parsePaytoUri(paytoUri) |
||||||
|
: typeProp && authorityProp |
||||||
|
? { type: typeProp.toLowerCase(), authority: authorityProp, raw: buildPaytoUri(typeProp, authorityProp) } |
||||||
|
: null |
||||||
|
|
||||||
|
if (!parsed) { |
||||||
|
return children ? <span className={className}>{children}</span> : null |
||||||
|
} |
||||||
|
|
||||||
|
const { type, authority, raw } = parsed |
||||||
|
const info = getPaytoTypeInfo(type) |
||||||
|
const known = isKnownPaytoType(type) |
||||||
|
const isLightning = isLightningPaytoType(type) |
||||||
|
const canZap = isLightning && !!pubkey && !!onOpenZap |
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => { |
||||||
|
e.preventDefault() |
||||||
|
e.stopPropagation() |
||||||
|
if (canZap) { |
||||||
|
onOpenZap(pubkey!) |
||||||
|
return |
||||||
|
} |
||||||
|
if (!known) { |
||||||
|
navigator.clipboard.writeText(raw) |
||||||
|
toast.success(t('Copied payto address')) |
||||||
|
return |
||||||
|
} |
||||||
|
setDialogOpen(true) |
||||||
|
} |
||||||
|
|
||||||
|
const displayLabel = info?.label ?? type |
||||||
|
const logoPath = getPaytoLogoPath(type) |
||||||
|
const iconChar = getPaytoIconChar(type) |
||||||
|
const profileUrl = getPaytoProfileUrl(type, authority) |
||||||
|
const content = children ?? <span className="break-all">{authority}</span> |
||||||
|
|
||||||
|
const iconEl = ( |
||||||
|
<span className="shrink-0 flex items-center justify-center w-4 h-4 text-[1rem] leading-none" aria-hidden> |
||||||
|
{logoPath ? ( |
||||||
|
<img src={logoPath} alt="" className="size-4 object-contain" /> |
||||||
|
) : iconChar != null ? ( |
||||||
|
<span className={cn( |
||||||
|
'inline-flex items-center justify-center', |
||||||
|
isLightning && 'text-yellow-400' |
||||||
|
)}> |
||||||
|
{iconChar} |
||||||
|
</span> |
||||||
|
) : ( |
||||||
|
<HelpCircle className="size-3.5 text-muted-foreground" /> |
||||||
|
)} |
||||||
|
</span> |
||||||
|
) |
||||||
|
|
||||||
|
if (profileUrl) { |
||||||
|
return ( |
||||||
|
<a |
||||||
|
href={profileUrl} |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
className={cn( |
||||||
|
'text-primary hover:underline cursor-pointer text-left break-words inline-flex items-center gap-1.5', |
||||||
|
className |
||||||
|
)} |
||||||
|
title={`${displayLabel}: ${t('Open on website')}`} |
||||||
|
onClick={(e) => e.stopPropagation()} |
||||||
|
> |
||||||
|
{iconEl} |
||||||
|
{content} |
||||||
|
</a> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={handleClick} |
||||||
|
className={cn( |
||||||
|
'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')} |
||||||
|
> |
||||||
|
{iconEl} |
||||||
|
{content} |
||||||
|
</button> |
||||||
|
{known && ( |
||||||
|
<PaytoDialog |
||||||
|
open={dialogOpen} |
||||||
|
onOpenChange={setDialogOpen} |
||||||
|
type={type} |
||||||
|
authority={authority} |
||||||
|
paytoUri={raw} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,155 @@ |
|||||||
|
/** |
||||||
|
* payto: URI handling (RFC-8905 / NIP-A3) |
||||||
|
* Parse and normalize payto://<type>/<authority> URIs; known types for UI (icons, labels, dialogs).
|
||||||
|
*/ |
||||||
|
|
||||||
|
export const PAYTO_URI_REGEX = /payto:\/\/([a-z0-9-]+)\/([^\s\]\)\<\"']+)/gi |
||||||
|
|
||||||
|
export interface ParsedPayto { |
||||||
|
type: string |
||||||
|
authority: string |
||||||
|
raw: string |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse a payto URI into type and authority. Returns null if invalid. |
||||||
|
*/ |
||||||
|
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 authority = decodeURIComponent(m[2].replace(/\+/g, ' ')) |
||||||
|
if (!type || !authority) return null |
||||||
|
return { type, authority, raw: trimmed } |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Build payto URI from type and authority. |
||||||
|
*/ |
||||||
|
export function buildPaytoUri(type: string, authority: string): string { |
||||||
|
const t = type.toLowerCase().replace(/[^a-z0-9-]/g, '') |
||||||
|
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; shortLabel?: string; symbol?: string; category: 'crypto' | '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' }, |
||||||
|
|
||||||
|
// 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' }, |
||||||
|
|
||||||
|
// 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' }, |
||||||
|
|
||||||
|
// 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' }, |
||||||
|
|
||||||
|
// 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' } |
||||||
|
} |
||||||
|
|
||||||
|
/** 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 /payto_logos/ for types that have an asset. Any image format works: .svg, .gif, .jpg, .png, .webp, etc. */ |
||||||
|
export const PAYTO_LOGO_FILES: Record<string, string> = { |
||||||
|
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.webp', |
||||||
|
'google-pay': 'google_pay.png', |
||||||
|
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. */ |
||||||
|
export const PAYTO_PROFILE_URL_TEMPLATES: Record<string, string> = { |
||||||
|
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 = type.toLowerCase() |
||||||
|
const file = PAYTO_LOGO_FILES[key] |
||||||
|
if (!file) return null |
||||||
|
return `/payto_logos/${file}` |
||||||
|
} |
||||||
|
|
||||||
|
export function getPaytoTypeInfo(type: string): (typeof PAYTO_KNOWN_TYPES)[string] | undefined { |
||||||
|
return PAYTO_KNOWN_TYPES[type.toLowerCase()] |
||||||
|
} |
||||||
|
|
||||||
|
export function isKnownPaytoType(type: string): boolean { |
||||||
|
return type.toLowerCase() 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' |
||||||
|
} |
||||||