13 changed files with 416 additions and 241 deletions
@ -0,0 +1,83 @@ |
|||||||
|
import PaytoLink from '@/components/PaytoLink' |
||||||
|
import type { PaymentMethodGroup } from '@/lib/merge-payment-methods' |
||||||
|
import { Copy } from 'lucide-react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { toast } from 'sonner' |
||||||
|
|
||||||
|
export default function PaymentMethodsSection({ |
||||||
|
groups, |
||||||
|
recipientPubkey, |
||||||
|
onOpenZap, |
||||||
|
title, |
||||||
|
className |
||||||
|
}: { |
||||||
|
groups: PaymentMethodGroup[] |
||||||
|
recipientPubkey?: string |
||||||
|
/** When set, lightning rows can open the zap flow for this profile. */ |
||||||
|
onOpenZap?: () => void |
||||||
|
title?: string |
||||||
|
className?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
|
||||||
|
if (groups.length === 0) return null |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={className}> |
||||||
|
<div className="text-xs font-semibold text-muted-foreground mb-2"> |
||||||
|
{title ?? t('Payment Methods')} |
||||||
|
</div> |
||||||
|
<div className="space-y-3 min-w-0"> |
||||||
|
{groups.map((group, groupIdx) => ( |
||||||
|
<div key={groupIdx} className="text-sm min-w-0"> |
||||||
|
<div className="font-medium">{group.displayType}</div> |
||||||
|
<div className="space-y-1.5 mt-1"> |
||||||
|
{group.methods.map((method, idx) => ( |
||||||
|
<div key={idx} className="min-w-0"> |
||||||
|
{method.authority && ( |
||||||
|
<div className="text-muted-foreground flex items-center gap-1 min-w-0"> |
||||||
|
<PaytoLink |
||||||
|
type={method.type} |
||||||
|
authority={method.authority} |
||||||
|
paytoUri={method.payto} |
||||||
|
pubkey={method.type === 'lightning' ? recipientPubkey : undefined} |
||||||
|
onOpenZap={method.type === 'lightning' ? onOpenZap : undefined} |
||||||
|
className="hover:underline break-all min-w-0 text-primary flex-1" |
||||||
|
> |
||||||
|
{method.authority} |
||||||
|
</PaytoLink> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={(e) => { |
||||||
|
e.preventDefault() |
||||||
|
e.stopPropagation() |
||||||
|
navigator.clipboard.writeText(method.authority) |
||||||
|
toast.success(t('Copied to clipboard')) |
||||||
|
}} |
||||||
|
className="shrink-0 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted" |
||||||
|
title={t('Copy address')} |
||||||
|
> |
||||||
|
<Copy className="size-3.5" /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
{(method.currency || |
||||||
|
(method.minAmount !== undefined && method.maxAmount !== undefined)) && ( |
||||||
|
<div className="text-muted-foreground text-xs mt-0.5"> |
||||||
|
{method.currency && <span>({method.currency})</span>} |
||||||
|
{method.minAmount !== undefined && method.maxAmount !== undefined && ( |
||||||
|
<span className="ml-2"> |
||||||
|
{method.minAmount}-{method.maxAmount} |
||||||
|
</span> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,183 @@ |
|||||||
|
import { getPaymentInfoFromEvent } from '@/lib/event-metadata' |
||||||
|
import { buildPaytoUri, getCanonicalPaytoType, getPaytoEditorTypeLabel, getPaytoTypeInfo } from '@/lib/payto' |
||||||
|
import type { TProfile } from '@/types' |
||||||
|
|
||||||
|
export type MergedPaymentMethod = { |
||||||
|
type: string |
||||||
|
authority: string |
||||||
|
payto?: string |
||||||
|
displayType: string |
||||||
|
currency?: string |
||||||
|
minAmount?: number |
||||||
|
maxAmount?: number |
||||||
|
} |
||||||
|
|
||||||
|
export type PaymentMethodGroup = { |
||||||
|
displayType: string |
||||||
|
methods: MergedPaymentMethod[] |
||||||
|
} |
||||||
|
|
||||||
|
/** Normalize lightning/LUD-16 authority to a canonical form for deduplication. */ |
||||||
|
export function normalizeLightningAuthority(authority: string): string { |
||||||
|
const s = authority.trim().toLowerCase() |
||||||
|
if (!s) return s |
||||||
|
if (s.includes('@')) return s |
||||||
|
const firstDot = s.indexOf('.') |
||||||
|
if (firstDot > 0) return s.slice(0, firstDot) + '@' + s.slice(firstDot + 1) |
||||||
|
return s |
||||||
|
} |
||||||
|
|
||||||
|
export function normalizePaymentAuthority(type: string, authority: string): string { |
||||||
|
const t = type.toLowerCase() |
||||||
|
if (t === 'lightning' && authority) return normalizeLightningAuthority(authority) |
||||||
|
return authority.trim().toLowerCase() |
||||||
|
} |
||||||
|
|
||||||
|
function preferCanonicalLightningAuthority(a: string, b: string): string { |
||||||
|
const hasAt = (s: string) => s.trim().includes('@') |
||||||
|
if (hasAt(a) && !hasAt(b)) return a |
||||||
|
if (hasAt(b) && !hasAt(a)) return b |
||||||
|
return a |
||||||
|
} |
||||||
|
|
||||||
|
/** Bitcoin-layer first, then on-chain Bitcoin family, then everything else. */ |
||||||
|
export function paytoPaymentSortRank(type: string): number { |
||||||
|
const category = getPaytoTypeInfo(type)?.category |
||||||
|
if (category === 'bitcoin-layer') return 0 |
||||||
|
if (category === 'bitcoin') return 1 |
||||||
|
return 2 |
||||||
|
} |
||||||
|
|
||||||
|
/** Merge payment methods from kind 10133 and profile (kind 0: JSON + tags), normalized and deduplicated. */ |
||||||
|
export function mergePaymentMethods( |
||||||
|
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null, |
||||||
|
profile: TProfile | null |
||||||
|
): MergedPaymentMethod[] { |
||||||
|
const seen = new Map<string, MergedPaymentMethod>() |
||||||
|
const out: MergedPaymentMethod[] = [] |
||||||
|
|
||||||
|
const add = ( |
||||||
|
type: string, |
||||||
|
authority: string, |
||||||
|
payto?: string, |
||||||
|
displayType?: string, |
||||||
|
extra?: { currency?: string; minAmount?: number; maxAmount?: number } |
||||||
|
) => { |
||||||
|
if (!authority?.trim()) return |
||||||
|
const normType = getCanonicalPaytoType(type) |
||||||
|
const key = `${normType}:${normalizePaymentAuthority(normType, authority)}` |
||||||
|
const existing = seen.get(key) |
||||||
|
if (existing) { |
||||||
|
if (normType === 'lightning') { |
||||||
|
existing.authority = preferCanonicalLightningAuthority(existing.authority, authority.trim()) |
||||||
|
existing.payto = |
||||||
|
existing.payto || |
||||||
|
payto || |
||||||
|
(normType && authority ? `payto://${normType}/${existing.authority}` : undefined) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
const entry: MergedPaymentMethod = { |
||||||
|
type: normType, |
||||||
|
authority: authority.trim(), |
||||||
|
payto: payto || (normType && authority ? `payto://${normType}/${authority.trim()}` : undefined), |
||||||
|
displayType: displayType || getPaytoEditorTypeLabel(normType), |
||||||
|
...extra |
||||||
|
} |
||||||
|
seen.set(key, entry) |
||||||
|
out.push(entry) |
||||||
|
} |
||||||
|
|
||||||
|
const fromProfile = profile?.lightningAddressList?.length |
||||||
|
? profile.lightningAddressList |
||||||
|
: profile?.lightningAddress |
||||||
|
? [profile.lightningAddress] |
||||||
|
: [] |
||||||
|
fromProfile.forEach((addr) => { |
||||||
|
if (addr) add('lightning', addr, `payto://lightning/${addr}`, 'Lightning Network') |
||||||
|
}) |
||||||
|
|
||||||
|
profile?.wWalletTags?.forEach((w) => { |
||||||
|
const net = w.network.toLowerCase() |
||||||
|
if (net === 'lightning') return |
||||||
|
const addr = w.address?.trim() |
||||||
|
if (!addr) return |
||||||
|
const cur = (w.currency || '').trim().toLowerCase() |
||||||
|
|
||||||
|
if (net === 'bitcoin') { |
||||||
|
add('bitcoin', addr, buildPaytoUri('bitcoin', addr), 'Bitcoin', { currency: w.currency }) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if (cur === 'usdt' || cur === 'usd₮' || cur === 'tether' || net === 'usdt') { |
||||||
|
add('usdt', addr, buildPaytoUri('usdt', addr), 'Tether (USDT)', { currency: w.currency || 'USDT' }) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if (net === 'liquid') { |
||||||
|
if (cur === 'lbtc' || cur === 'l-btc' || cur === 'liquid btc') { |
||||||
|
add('lbtc', addr, buildPaytoUri('lbtc', addr), 'Liquid Bitcoin (LBTC)', { currency: w.currency }) |
||||||
|
} else { |
||||||
|
add('liquid', addr, buildPaytoUri('liquid', addr), cur ? `Liquid (${w.currency})` : 'Liquid', { |
||||||
|
currency: w.currency |
||||||
|
}) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if (cur === 'lbtc' || cur === 'l-btc') { |
||||||
|
add('lbtc', addr, buildPaytoUri('lbtc', addr), 'Liquid Bitcoin (LBTC)', { currency: w.currency }) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
if (paymentInfo?.methods?.length) { |
||||||
|
paymentInfo.methods.forEach((m) => { |
||||||
|
const authority = m.authority || m.address || '' |
||||||
|
add( |
||||||
|
(m.type || 'lightning').toLowerCase(), |
||||||
|
authority, |
||||||
|
m.payto, |
||||||
|
m.displayType, |
||||||
|
{ currency: m.currency, minAmount: m.minAmount, maxAmount: m.maxAmount } |
||||||
|
) |
||||||
|
}) |
||||||
|
} else if (paymentInfo?.payto) { |
||||||
|
const type = (paymentInfo.type || 'lightning').toLowerCase() |
||||||
|
const authority = paymentInfo.authority || paymentInfo.payto.replace(/^payto:\/\/[^/]+\//, '') || '' |
||||||
|
add(type, authority, paymentInfo.payto, type === 'lightning' ? 'Lightning Network' : paymentInfo.type || 'Payment') |
||||||
|
} |
||||||
|
|
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
export function sortMergedPaymentMethods(methods: MergedPaymentMethod[]): MergedPaymentMethod[] { |
||||||
|
return [...methods].sort((a, b) => paytoPaymentSortRank(a.type) - paytoPaymentSortRank(b.type)) |
||||||
|
} |
||||||
|
|
||||||
|
/** Group payment methods by displayType (same headings as profile payment section). */ |
||||||
|
export function groupPaymentMethodsByDisplayType(methods: MergedPaymentMethod[]): PaymentMethodGroup[] { |
||||||
|
const groups = new Map<string, MergedPaymentMethod[]>() |
||||||
|
for (const method of methods) { |
||||||
|
const key = method.displayType || method.type |
||||||
|
if (!groups.has(key)) groups.set(key, []) |
||||||
|
groups.get(key)!.push(method) |
||||||
|
} |
||||||
|
const order = Array.from(groups.keys()).sort((a, b) => { |
||||||
|
const typeA = groups.get(a)?.[0]?.type ?? '' |
||||||
|
const typeB = groups.get(b)?.[0]?.type ?? '' |
||||||
|
return paytoPaymentSortRank(typeA) - paytoPaymentSortRank(typeB) |
||||||
|
}) |
||||||
|
return order.map((key) => ({ displayType: key, methods: groups.get(key) ?? [] })) |
||||||
|
} |
||||||
|
|
||||||
|
/** Payment targets that differ from the lightning address used for zapping. */ |
||||||
|
export function getAlternativePaymentMethods( |
||||||
|
methods: MergedPaymentMethod[], |
||||||
|
zapLightningAddress: string | undefined |
||||||
|
): MergedPaymentMethod[] { |
||||||
|
const zapNorm = zapLightningAddress?.trim() |
||||||
|
? normalizePaymentAuthority('lightning', zapLightningAddress) |
||||||
|
: null |
||||||
|
if (!zapNorm) return methods |
||||||
|
return methods.filter((m) => normalizePaymentAuthority(m.type, m.authority) !== zapNorm) |
||||||
|
} |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
import { Label } from '@/components/ui/label' |
||||||
|
import { Switch } from '@/components/ui/switch' |
||||||
|
import { useZap } from '@/providers/ZapProvider' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function IncludePublicZapReceiptSwitch() { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { includePublicZapReceipt, updateIncludePublicZapReceipt } = useZap() |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="w-full flex justify-between items-center gap-3"> |
||||||
|
<Label htmlFor="include-public-zap-receipt-switch" className="flex-1"> |
||||||
|
<div className="text-base font-medium">{t('Include public zap receipt')}</div> |
||||||
|
<div className="text-muted-foreground text-sm font-normal"> |
||||||
|
{t('When off, your zap may still succeed but a public receipt may not be published to relays')} |
||||||
|
</div> |
||||||
|
</Label> |
||||||
|
<Switch |
||||||
|
id="include-public-zap-receipt-switch" |
||||||
|
checked={includePublicZapReceipt} |
||||||
|
onCheckedChange={updateIncludePublicZapReceipt} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue