13 changed files with 416 additions and 241 deletions
@ -0,0 +1,83 @@
@@ -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 @@
@@ -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 @@
@@ -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