Browse Source

alternative payments

imwald
Silberengel 4 weeks ago
parent
commit
80e50063ca
  1. 10
      src/components/NoteStats/ZapButton.tsx
  2. 20
      src/components/Profile/index.tsx
  3. 155
      src/components/ZapDialog/TipPublicMessagePrompt.tsx
  4. 106
      src/components/ZapDialog/index.tsx
  5. 4
      src/data/payto-types.json
  6. 57
      src/hooks/useRecipientAlternativePayments.ts
  7. 9
      src/i18n/locales/de.ts
  8. 9
      src/i18n/locales/en.ts
  9. 4
      src/lib/event-metadata.ts
  10. 7
      src/lib/merge-payment-methods.ts
  11. 69
      src/lib/payto-paypal-url.test.ts
  12. 119
      src/lib/payto-paypal-url.ts
  13. 15
      src/pages/secondary/ProfileEditorPage/index.tsx

10
src/components/NoteStats/ZapButton.tsx

@ -16,6 +16,7 @@ import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 're @@ -16,6 +16,7 @@ import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 're
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import ZapDialog from '../ZapDialog'
import TipPublicMessagePrompt from '../ZapDialog/TipPublicMessagePrompt'
type ZapButtonProps = {
event: Event
@ -29,6 +30,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB @@ -29,6 +30,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
const { defaultZapSats, defaultZapComment, quickZap, includePublicZapReceipt } = useZap()
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
const [openZapDialog, setOpenZapDialog] = useState(false)
const [tipNoticeOpen, setTipNoticeOpen] = useState(false)
const [zapping, setZapping] = useState(false)
const statsLoaded = noteStats?.updatedAt != null
const { zapAmount, hasZapped } = useMemo(() => {
@ -85,6 +87,9 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB @@ -85,6 +87,9 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
defaultZapSats,
defaultZapComment
)
if (event.pubkey !== pubkey && !includePublicZapReceipt) {
setTipNoticeOpen(true)
}
} catch (error) {
toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
} finally {
@ -199,6 +204,11 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB @@ -199,6 +204,11 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
pubkey={event.pubkey}
event={event}
/>
<TipPublicMessagePrompt
open={tipNoticeOpen}
onOpenChange={setTipNoticeOpen}
recipientPubkey={event.pubkey}
/>
</>
)
}

20
src/components/Profile/index.tsx

@ -86,6 +86,7 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' @@ -86,6 +86,7 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { nip66Service } from '@/services/nip66.service'
import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import {
getAlternativePaymentMethods,
groupPaymentMethodsByDisplayType,
mergePaymentMethods,
sortMergedPaymentMethods
@ -134,10 +135,20 @@ export default function Profile({ @@ -134,10 +135,20 @@ export default function Profile({
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
const paymentMethodsByType = useMemo(() => {
const list = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile ?? null))
return groupPaymentMethodsByDisplayType(list)
}, [paymentInfo, profile])
const mergedPaymentMethods = useMemo(
() => sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile ?? null)),
[paymentInfo, profile]
)
const paymentMethodsByType = useMemo(
() => groupPaymentMethodsByDisplayType(mergedPaymentMethods),
[mergedPaymentMethods]
)
const alternativePaymentGroups = useMemo(() => {
const alts = getAlternativePaymentMethods(mergedPaymentMethods, profile?.lightningAddress)
return groupPaymentMethodsByDisplayType(alts)
}, [mergedPaymentMethods, profile?.lightningAddress])
const hasLightningForZap = useMemo(
() => paymentMethodsByType.some((g) => g.methods.some((m) => m.type === 'lightning')),
@ -574,6 +585,7 @@ export default function Profile({ @@ -574,6 +585,7 @@ export default function Profile({
open={openZapDialog}
setOpen={setOpenZapDialog}
pubkey={pubkey}
alternativePaymentGroups={alternativePaymentGroups}
/>
<div className="flex flex-wrap justify-between items-center gap-x-4 gap-y-2 mt-2 text-sm min-w-0">
<div className="flex flex-wrap gap-4 items-center min-w-0">

155
src/components/ZapDialog/TipPublicMessagePrompt.tsx

@ -0,0 +1,155 @@ @@ -0,0 +1,155 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
Drawer,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle
} from '@/components/ui/drawer'
import { createPublicMessageDraftEvent } from '@/lib/draft-event'
import { showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { pubkeyToNpub } from '@/lib/pubkey'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { LoginRequiredError } from '@/lib/nostr-errors'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
const TIP_NOTICE_DEFAULT_KEY = 'I just sent you a tip!'
export default function TipPublicMessagePrompt({
open,
onOpenChange,
recipientPubkey
}: {
open: boolean
onOpenChange: (open: boolean) => void
recipientPubkey: string | null
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { publish, checkLogin, pubkey: selfPubkey } = useNostr()
const [sending, setSending] = useState(false)
const cancelRef = useRef<HTMLButtonElement>(null)
const tipText = t(TIP_NOTICE_DEFAULT_KEY)
const npub = recipientPubkey ? pubkeyToNpub(recipientPubkey) : null
const previewContent = npub ? `nostr:${npub} ${tipText}` : tipText
useEffect(() => {
if (!open) return
const id = requestAnimationFrame(() => {
cancelRef.current?.focus()
})
return () => cancelAnimationFrame(id)
}, [open])
const handleSend = () => {
if (!recipientPubkey) return
checkLogin(async () => {
if (selfPubkey === recipientPubkey) {
onOpenChange(false)
return
}
setSending(true)
try {
const draft = await createPublicMessageDraftEvent(previewContent, [recipientPubkey], {
addClientTag: true
})
await publish(draft, { disableFallbacks: true })
showSimplePublishSuccess(t('Tip notice sent'))
onOpenChange(false)
} catch (error) {
if (error instanceof LoginRequiredError) return
toast.error(
t('Failed to send tip notice', {
error: error instanceof Error ? error.message : String(error)
})
)
} finally {
setSending(false)
}
})
}
const body = (
<>
<p className="text-sm font-medium text-foreground">{t('Tip notice success only note')}</p>
<p className="mt-2 text-sm text-muted-foreground">{t('Tip notice prompt description')}</p>
<p className="mt-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm break-words">{previewContent}</p>
</>
)
const actions = (
<>
<Button
ref={cancelRef}
type="button"
variant="default"
onClick={() => onOpenChange(false)}
disabled={sending}
>
{t('Cancel')}
</Button>
<Button
type="button"
variant="outline"
onClick={handleSend}
disabled={sending || !recipientPubkey}
>
{t('Send')}
</Button>
</>
)
if (!recipientPubkey) return null
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="px-4 pb-6" onOpenAutoFocus={(e) => e.preventDefault()}>
<DrawerHeader>
<DrawerTitle className="flex items-center gap-2">
{t('Tip notice prompt title')}
<UserAvatar size="small" userId={recipientPubkey} />
<Username userId={recipientPubkey} className="truncate" />
</DrawerTitle>
</DrawerHeader>
<div className="px-0 pb-4">{body}</div>
<DrawerFooter className="flex-row justify-end gap-2 pt-2">{actions}</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-md"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{t('Tip notice prompt title')}
<UserAvatar size="small" userId={recipientPubkey} />
<Username userId={recipientPubkey} className="truncate" />
</DialogTitle>
<DialogDescription>{t('Tip notice prompt description')}</DialogDescription>
</DialogHeader>
{body}
<DialogFooter className="gap-2 sm:gap-2">{actions}</DialogFooter>
</DialogContent>
</Dialog>
)
}

106
src/components/ZapDialog/index.tsx

@ -26,6 +26,10 @@ import { NostrEvent } from 'nostr-tools' @@ -26,6 +26,10 @@ import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import type { PaymentMethodGroup } from '@/lib/merge-payment-methods'
import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import { useRecipientAlternativePayments } from '@/hooks/useRecipientAlternativePayments'
import TipPublicMessagePrompt from './TipPublicMessagePrompt'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
@ -35,7 +39,8 @@ export default function ZapDialog({ @@ -35,7 +39,8 @@ export default function ZapDialog({
pubkey,
event,
defaultAmount,
defaultComment
defaultComment,
alternativePaymentGroups
}: {
open: boolean
setOpen: Dispatch<SetStateAction<boolean>>
@ -43,10 +48,34 @@ export default function ZapDialog({ @@ -43,10 +48,34 @@ export default function ZapDialog({
event?: NostrEvent
defaultAmount?: number
defaultComment?: string
/** Non-Lightning (and non-zap-duplicate) payto targets from kind 10133 / profile. */
alternativePaymentGroups?: PaymentMethodGroup[]
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const drawerContentRef = useRef<HTMLDivElement | null>(null)
const { pubkey: selfPubkey } = useNostr()
const fetchedAlternativeGroups = useRecipientAlternativePayments(pubkey, open)
const effectiveAlternativeGroups = alternativePaymentGroups ?? fetchedAlternativeGroups
const [tipNoticeOpen, setTipNoticeOpen] = useState(false)
const skipTipNoticeOnCloseRef = useRef(false)
const maybeOfferTipNoticeOnClose = () => {
if (skipTipNoticeOnCloseRef.current) return
if (selfPubkey && pubkey === selfPubkey) return
setTipNoticeOpen(true)
}
const handleZapDialogOpenChange: Dispatch<SetStateAction<boolean>> = (next) => {
const willOpen = typeof next === 'function' ? next(open) : next
if (!willOpen) {
maybeOfferTipNoticeOnClose()
skipTipNoticeOnCloseRef.current = false
} else {
skipTipNoticeOnCloseRef.current = false
}
setOpen(next)
}
useEffect(() => {
const handleResize = () => {
@ -75,20 +104,19 @@ export default function ZapDialog({ @@ -75,20 +104,19 @@ export default function ZapDialog({
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerOverlay onClick={() => setOpen(false)} />
<Drawer open={open} onOpenChange={handleZapDialogOpenChange}>
<DrawerOverlay onClick={() => handleZapDialogOpenChange(false)} />
<DrawerContent
hideOverlay
onOpenAutoFocus={(e) => e.preventDefault()}
ref={drawerContentRef}
className="flex flex-col h-[80vh]"
className="flex max-h-[80vh] flex-col overflow-y-auto overscroll-contain"
style={{
maxHeight: 'calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 2rem)',
height: '80vh',
paddingBottom: '0' // Remove default padding since we handle it in the button container
}}
>
<DrawerHeader className="px-4">
<DrawerHeader className="shrink-0 px-4">
<DrawerTitle className="flex gap-2 items-center">
<div className="shrink-0">{t('Zap to')}</div>
<UserAvatar size="small" userId={pubkey} />
@ -98,19 +126,29 @@ export default function ZapDialog({ @@ -98,19 +126,29 @@ export default function ZapDialog({
</DrawerHeader>
<ZapDialogContent
open={open}
setOpen={setOpen}
setOpen={handleZapDialogOpenChange}
recipient={pubkey}
event={event}
defaultAmount={defaultAmount}
defaultComment={defaultComment}
alternativePaymentGroups={effectiveAlternativeGroups}
onBeforeZapDialogClose={(withPublicReceipt) => {
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true
}}
/>
</DrawerContent>
<TipPublicMessagePrompt
open={tipNoticeOpen}
onOpenChange={setTipNoticeOpen}
recipientPubkey={pubkey}
/>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<>
<Dialog open={open} onOpenChange={handleZapDialogOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex gap-2 items-center">
@ -122,14 +160,24 @@ export default function ZapDialog({ @@ -122,14 +160,24 @@ export default function ZapDialog({
</DialogHeader>
<ZapDialogContent
open={open}
setOpen={setOpen}
setOpen={handleZapDialogOpenChange}
recipient={pubkey}
event={event}
defaultAmount={defaultAmount}
defaultComment={defaultComment}
alternativePaymentGroups={effectiveAlternativeGroups}
onBeforeZapDialogClose={(withPublicReceipt) => {
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true
}}
/>
</DialogContent>
</Dialog>
<TipPublicMessagePrompt
open={tipNoticeOpen}
onOpenChange={setTipNoticeOpen}
recipientPubkey={pubkey}
/>
</>
)
}
@ -138,7 +186,9 @@ function ZapDialogContent({ @@ -138,7 +186,9 @@ function ZapDialogContent({
recipient,
event,
defaultAmount,
defaultComment
defaultComment,
alternativePaymentGroups,
onBeforeZapDialogClose
}: {
open: boolean
setOpen: Dispatch<SetStateAction<boolean>>
@ -146,6 +196,9 @@ function ZapDialogContent({ @@ -146,6 +196,9 @@ function ZapDialogContent({
event?: NostrEvent
defaultAmount?: number
defaultComment?: string
alternativePaymentGroups?: PaymentMethodGroup[]
/** Runs before the zap dialog closes (e.g. after payment); skip tip notice if a public receipt was sent. */
onBeforeZapDialogClose?: (withPublicReceipt: boolean) => void
}) {
const { t, i18n } = useTranslation()
const { pubkey } = useNostr()
@ -194,12 +247,16 @@ function ZapDialogContent({ @@ -194,12 +247,16 @@ function ZapDialogContent({
throw new Error('You need to be logged in to zap')
}
setZapping(true)
const closeZapDialog = () => {
onBeforeZapDialogClose?.(includePublicZapReceipt)
setOpen(false)
}
const zapResult = await lightning.zap(
pubkey,
event ?? recipient,
sats,
comment,
() => setOpen(false),
closeZapDialog,
includePublicZapReceipt
)
// user canceled
@ -217,9 +274,8 @@ function ZapDialogContent({ @@ -217,9 +274,8 @@ function ZapDialogContent({
}
return (
<div className="flex flex-col h-full">
{/* Scrollable content area */}
<div className="flex-1 overflow-y-auto space-y-4 min-h-0">
<div>
<div className="space-y-4">
{/* Sats slider or input */}
<div className="flex flex-col items-center px-4">
<div className="flex justify-center w-full max-w-xs">
@ -264,8 +320,13 @@ function ZapDialogContent({ @@ -264,8 +320,13 @@ function ZapDialogContent({
<Label htmlFor="comment">{t('zapComment')}</Label>
<Input id="comment" value={comment} onChange={(e) => setComment(e.target.value)} />
</div>
</div>
<div className="px-4 flex items-center justify-between gap-3">
<div
className="space-y-3 border-t border-border bg-background px-4 pt-3"
style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}
>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="zap-include-receipt" className="flex-1 cursor-pointer">
<div className="text-sm font-medium">{t('Include public zap receipt')}</div>
<div className="text-xs text-muted-foreground font-normal">
@ -278,14 +339,23 @@ function ZapDialogContent({ @@ -278,14 +339,23 @@ function ZapDialogContent({
onCheckedChange={updateIncludePublicZapReceipt}
/>
</div>
</div>
{/* Zap button - fixed at bottom */}
<div className="flex-shrink-0 bg-background pt-2 border-t border-border px-4" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<Button onClick={handleZap} className="w-full">
{zapping && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}{' '}
{t('Zap n sats', { n: sats })}
</Button>
{alternativePaymentGroups && alternativePaymentGroups.length > 0 ? (
<div>
<PaymentMethodsSection
groups={alternativePaymentGroups}
recipientPubkey={recipient}
title={t('Other payment methods')}
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0"
/>
<p className="mt-2 text-xs text-muted-foreground">{t('Zap dialog other payment hint')}</p>
</div>
) : null}
</div>
</div>
)

4
src/data/payto-types.json

@ -269,8 +269,8 @@ @@ -269,8 +269,8 @@
"logoAssetPath": "src/assets/payto_logos/paypal.webp",
"profileUrlTemplate": "https://paypal.me/{authority}",
"authority": {
"placeholder": "username",
"hint": "PayPal.me username (without paypal.me/)"
"placeholder": "username (not email)",
"hint": "PayPal.me username (not email), or a full PayPal donation link (Donate button / hosted_button_id URL from paypal.com/donate)."
}
},
"buymeacoffee": {

57
src/hooks/useRecipientAlternativePayments.ts

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
import {
getAlternativePaymentMethods,
groupPaymentMethodsByDisplayType,
mergePaymentMethods,
sortMergedPaymentMethods,
type PaymentMethodGroup
} from '@/lib/merge-payment-methods'
import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata'
import client, { replaceableEventService } from '@/services/client.service'
import { kinds } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import type { TPaymentInfo } from '@/types'
import type { TProfile } from '@/types'
/** Kind 10133 + profile payto targets except the Lightning address used for zapping. */
export function useRecipientAlternativePayments(
recipientPubkey: string | undefined,
enabled: boolean
): PaymentMethodGroup[] {
const [paymentInfo, setPaymentInfo] = useState<TPaymentInfo | null>(null)
const [profile, setProfile] = useState<TProfile | null>(null)
useEffect(() => {
if (!enabled || !recipientPubkey) {
setPaymentInfo(null)
setProfile(null)
return
}
let cancelled = false
void (async () => {
try {
const [paymentEvent, metaEvent] = await Promise.all([
client.fetchPaymentInfoEvent(recipientPubkey),
replaceableEventService.fetchReplaceableEvent(recipientPubkey, kinds.Metadata)
])
if (cancelled) return
setPaymentInfo(paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null)
setProfile(metaEvent ? getProfileFromEvent(metaEvent) : null)
} catch {
if (!cancelled) {
setPaymentInfo(null)
setProfile(null)
}
}
})()
return () => {
cancelled = true
}
}, [recipientPubkey, enabled])
return useMemo(() => {
if (!recipientPubkey) return []
const merged = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile))
const alts = getAlternativePaymentMethods(merged, profile?.lightningAddress)
return groupPaymentMethodsByDisplayType(alts)
}, [recipientPubkey, paymentInfo, profile])
}

9
src/i18n/locales/de.ts

@ -153,6 +153,15 @@ export default { @@ -153,6 +153,15 @@ export default {
"Failed to publish payment info": "Failed to publish payment info",
"Invalid tags JSON": "Invalid tags JSON",
"Payment methods": "Zahlungsmethoden",
"Other payment methods": "Weitere Zahlungsmethoden",
"Zap dialog other payment hint": "Link antippen für PayPal oder Adresse kopieren. Lightning-Tipps über den Button unten.",
"Tip notice prompt title": "Bescheid geben?",
"Tip notice success only note": "Nur wenn du bereits erfolgreich getippt hast (Lightning oder eine andere Zahlungsmethode).",
"Tip notice prompt description": "Öffentliche Nachricht (Kind 24) senden, damit die Person vom Tipp erfährt.",
"I just sent you a tip!": "Ich habe dir gerade einen Tipp geschickt!",
"Tip notice sent": "Nachricht gesendet",
"Failed to send tip notice": "Nachricht konnte nicht gesendet werden: {{error}}",
Send: "Senden",
"Payment type": "Zahlungsart",
"paytoEditor.intro":
"Zahlungsart wählen, dann Adresse oder Benutzername wie in der Hinweiszeile darunter eintragen.",

9
src/i18n/locales/en.ts

@ -158,6 +158,15 @@ export default { @@ -158,6 +158,15 @@ export default {
"Failed to publish payment info": "Failed to publish payment info",
"Invalid tags JSON": "Invalid tags JSON",
"Payment methods": "Payment methods",
"Other payment methods": "Other payment methods",
"Zap dialog other payment hint": "Tap a link to open PayPal or copy an address. Lightning tips use the button below.",
"Tip notice prompt title": "Let them know?",
"Tip notice success only note": "Only if you already sent a tip successfully (Lightning or another payment method).",
"Tip notice prompt description": "Send a public message (kind 24) so they know you tipped.",
"I just sent you a tip!": "I just sent you a tip!",
"Tip notice sent": "Message sent",
"Failed to send tip notice": "Failed to send message: {{error}}",
Send: "Send",
"Payment type": "Payment type",
"paytoEditor.intro":
"Choose a payment type, then enter the address or username shown in the hint below each field.",

4
src/lib/event-metadata.ts

@ -9,6 +9,7 @@ import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromIme @@ -9,6 +9,7 @@ import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromIme
import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeHttpUrl, normalizeUrl } from './url'
import { isTorBrowser } from './utils'
import logger from '@/lib/logger'
import { buildPaytoUri } from '@/lib/payto'
import { getCanonicalPaytoType, getPaytoEditorTypeLabel } from '@/lib/payto-registry'
const emptyHttpRelayListFields = {
@ -391,8 +392,7 @@ export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null { @@ -391,8 +392,7 @@ export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null {
const authority = tag[2] || ''
const extra = tag.slice(3) // Optional extra fields
// Build payto URI: payto://<type>/<authority>
const paytoUri = `payto://${type}/${authority}`
const paytoUri = buildPaytoUri(type, authority)
const method: any = {
type,

7
src/lib/merge-payment-methods.ts

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { getPaymentInfoFromEvent } from '@/lib/event-metadata'
import { buildPaytoUri, getCanonicalPaytoType, getPaytoEditorTypeLabel, getPaytoTypeInfo } from '@/lib/payto'
import { normalizePaypalAuthority } from '@/lib/payto-paypal-url'
import type { TProfile } from '@/types'
export type MergedPaymentMethod = {
@ -82,7 +83,11 @@ export function mergePaymentMethods( @@ -82,7 +83,11 @@ export function mergePaymentMethods(
}
const trimmedAuthority = authority.trim()
const resolvedAuthority =
normType === 'lightning' ? resolveLightningAuthority(trimmedAuthority) : trimmedAuthority
normType === 'lightning'
? resolveLightningAuthority(trimmedAuthority)
: normType === 'paypal'
? normalizePaypalAuthority(trimmedAuthority)
: trimmedAuthority
const entry: MergedPaymentMethod = {
type: normType,
authority: resolvedAuthority,

69
src/lib/payto-paypal-url.test.ts

@ -1,16 +1,32 @@ @@ -1,16 +1,32 @@
import { describe, expect, it } from 'vitest'
import { resolvePaypalPaymentUrl } from './payto-paypal-url'
import { normalizePaypalAuthority, resolvePaypalPaymentUrl } from './payto-paypal-url'
describe('resolvePaypalPaymentUrl', () => {
it('maps paypal.com/paypalme slug to paypal.me', () => {
expect(resolvePaypalPaymentUrl('https://www.paypal.com/paypalme/2rizmo%40gmail.com')).toBe(
'https://paypal.me/2rizmo@gmail.com'
'https://paypal.me/2rizmo%40gmail.com'
)
})
it('passes through donate links', () => {
const donate = 'https://www.paypal.com/donate/?hosted_button_id=T32KCSU8EZTBL'
expect(resolvePaypalPaymentUrl(donate)).toBe(donate)
it('resolves donate links with and without https', () => {
const canonical = 'https://www.paypal.com/donate/?hosted_button_id=T32KCSU8EZTBL'
expect(resolvePaypalPaymentUrl('https://www.paypal.com/donate/?hosted_button_id=T32KCSU8EZTBL')).toBe(
canonical
)
expect(resolvePaypalPaymentUrl('www.paypal.com/donate/?hosted_button_id=T32KCSU8EZTBL')).toBe(
canonical
)
expect(resolvePaypalPaymentUrl('paypal.com/donate/?hosted_button_id=T32KCSU8EZTBL')).toBe(
canonical
)
})
it('maps legacy webscr hosted buttons to donate URLs', () => {
expect(
resolvePaypalPaymentUrl(
'https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=T32KCSU8EZTBL'
)
).toBe('https://www.paypal.com/donate/?hosted_button_id=T32KCSU8EZTBL')
})
it('unwraps YouTube redirect q= PayPal URL', () => {
@ -23,9 +39,52 @@ describe('resolvePaypalPaymentUrl', () => { @@ -23,9 +39,52 @@ describe('resolvePaypalPaymentUrl', () => {
it('builds paypal.me from bare username', () => {
expect(resolvePaypalPaymentUrl('somecreator')).toBe('https://paypal.me/somecreator')
expect(resolvePaypalPaymentUrl('https://www.paypal.com/paypalme/2rizmo@gmail.com')).toBe(
'https://paypal.me/2rizmo%40gmail.com'
)
})
it('normalizes paypal.me path', () => {
expect(resolvePaypalPaymentUrl('https://paypal.me/foo')).toBe('https://paypal.me/foo')
})
it('handles www.paypal.me profile links', () => {
expect(resolvePaypalPaymentUrl('https://www.paypal.me/user38910')).toBe('https://paypal.me/user38910')
expect(resolvePaypalPaymentUrl('www.paypal.me/user38910')).toBe('https://paypal.me/user38910')
})
it('unwraps payto://paypal/… authorities', () => {
expect(resolvePaypalPaymentUrl('payto://paypal/https://www.paypal.me/user38910')).toBe(
'https://paypal.me/user38910'
)
expect(resolvePaypalPaymentUrl('payto://paypal/user38910')).toBe('https://paypal.me/user38910')
expect(
resolvePaypalPaymentUrl('payto://paypal/www.paypal.com/donate/?hosted_button_id=ABC')
).toBe('https://www.paypal.com/donate/?hosted_button_id=ABC')
})
it('passes through pool and campaign donate URLs with https', () => {
const pool = 'https://www.paypal.com/pools/c/abc123'
expect(resolvePaypalPaymentUrl(pool)).toBe(pool)
const campaign = 'https://www.paypal.com/donate/?campaign_id=foo'
expect(resolvePaypalPaymentUrl(campaign)).toBe(campaign)
})
})
describe('normalizePaypalAuthority', () => {
it('extracts username from PayPal.Me URLs', () => {
expect(normalizePaypalAuthority('https://www.paypal.me/user38910')).toBe('user38910')
expect(normalizePaypalAuthority('user38910')).toBe('user38910')
})
it('stores canonical donation URLs', () => {
expect(
normalizePaypalAuthority('www.paypal.com/donate/?hosted_button_id=T32KCSU8EZTBL')
).toBe('https://www.paypal.com/donate/?hosted_button_id=T32KCSU8EZTBL')
expect(
normalizePaypalAuthority(
'https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=T32KCSU8EZTBL'
)
).toBe('https://www.paypal.com/donate/?hosted_button_id=T32KCSU8EZTBL')
})
})

119
src/lib/payto-paypal-url.ts

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
/**
* Resolve PayPal payment targets to a browser-openable https URL.
* Handles PayPal.Me slugs, paypal.com/paypalme/ paths, donate links, and YouTube redirect wrappers.
* Handles PayPal.Me slugs, paypal.com/paypalme/ paths, donation links, and YouTube redirect wrappers.
*/
const PAYPAL_HOSTS = new Set(['paypal.com', 'www.paypal.com', 'paypal.me', 'www.paypal.me'])
@ -18,6 +18,59 @@ function decodeAuthoritySegment(segment: string): string { @@ -18,6 +18,59 @@ function decodeAuthoritySegment(segment: string): string {
}
}
function ensureHttpsPaypalUrl(input: string): string | null {
const s = input.trim()
if (!s) return null
if (/^https?:\/\//i.test(s)) return s
if (/^(www\.)?paypal\.(com|me)(\/.+)?$/i.test(s)) {
return `https://${s}`
}
return null
}
function parsePaypalInputUrl(input: string): URL | null {
const withScheme = ensureHttpsPaypalUrl(input)
if (!withScheme) return null
try {
const u = new URL(withScheme)
if (isPaypalHostname(u.hostname)) return u
} catch {
return null
}
return null
}
export function isPaypalDonationUrl(u: URL): boolean {
const host = u.hostname.toLowerCase().replace(/^www\./, '')
if (host !== 'paypal.com') return false
const path = u.pathname.toLowerCase()
if (path.includes('/donate')) return true
if (path.includes('/cgi-bin/webscr')) return true
if (path.includes('/pools/')) return true
if (path.includes('/fund/')) return true
if (u.searchParams.has('hosted_button_id')) return true
if (u.searchParams.get('cmd') === '_donations' || u.searchParams.get('cmd') === '_xclick') return true
return false
}
/** Canonical https donate / hosted-button URL when possible. */
function normalizePaypalDonationUrl(u: URL): string {
const hostedButtonId = u.searchParams.get('hosted_button_id')
if (hostedButtonId) {
return `https://www.paypal.com/donate/?hosted_button_id=${encodeURIComponent(hostedButtonId)}`
}
const out = new URL(u.toString())
out.protocol = 'https:'
const host = out.hostname.toLowerCase().replace(/^www\./, '')
if (host === 'paypal.com') {
out.hostname = 'www.paypal.com'
}
return out.toString()
}
function extractNestedUrlFromYoutubeRedirect(input: string): string | null {
let u: URL
try {
@ -48,30 +101,42 @@ function normalizePaypalComOrMeUrl(u: URL): string { @@ -48,30 +101,42 @@ function normalizePaypalComOrMeUrl(u: URL): string {
if (host === 'paypal.me') {
const slug = u.pathname.replace(/^\/+/, '').split('/')[0]
if (slug) return `https://paypal.me/${decodeAuthoritySegment(slug)}`
if (slug) return paypalMeUrlFromSlug(decodeAuthoritySegment(slug))
return u.origin
}
const meMatch = u.pathname.match(/\/paypalme\/([^/?#]+)/i)
if (meMatch?.[1]) {
return `https://paypal.me/${decodeAuthoritySegment(meMatch[1])}`
return paypalMeUrlFromSlug(decodeAuthoritySegment(meMatch[1]))
}
if (host === 'paypal.com' && isPaypalDonationUrl(u)) {
return normalizePaypalDonationUrl(u)
}
// Donate / hosted button / payment links — open as published
return u.toString()
}
function paypalMeUrlFromSlug(slug: string): string {
const trimmed = slug.trim()
if (!trimmed) return 'https://paypal.me/'
return `https://paypal.me/${encodeURIComponent(trimmed)}`
}
function extractPaypalMeSlugFromText(input: string): string | null {
let s = input.trim()
if (!s) return null
s = s.replace(/^payto:\/\/paypal\//i, '')
if (/^payto:\/\/paypal\//i.test(s)) {
return extractPaypalMeSlugFromText(s.replace(/^payto:\/\/paypal\//i, ''))
}
if (/^https?:\/\//i.test(s)) return null
s = s
.replace(/^www\./i, '')
.replace(/^paypal\.me\//i, '')
.replace(/^www\.paypal\.me\//i, '')
.replace(/^paypal\.com\/paypalme\//i, '')
if (!s || s.includes('/') || s.includes('?') || s.includes('#')) return null
@ -79,26 +144,50 @@ function extractPaypalMeSlugFromText(input: string): string | null { @@ -79,26 +144,50 @@ function extractPaypalMeSlugFromText(input: string): string | null {
}
/**
* Turn a payto PayPal authority (username, email slug, or full URL) into an https URL for the browser.
* Canonical PayPal.Me handle or donation URL for payto storage/display.
*/
export function normalizePaypalAuthority(authority: string): string {
const trimmed = authority.trim()
if (!trimmed) return trimmed
const resolved = resolvePaypalPaymentUrl(trimmed)
if (!resolved) return trimmed
try {
const u = new URL(resolved)
const host = u.hostname.toLowerCase().replace(/^www\./, '')
if (host === 'paypal.me') {
const slug = u.pathname.replace(/^\/+/, '').split('/')[0]
if (slug) return decodeAuthoritySegment(slug)
}
if (host === 'paypal.com' && isPaypalDonationUrl(u)) {
return resolved
}
} catch {
/* keep trimmed */
}
return trimmed
}
/**
* Turn a payto PayPal authority (username, email slug, donation link, or full URL) into an https URL for the browser.
*/
export function resolvePaypalPaymentUrl(authority: string): string | null {
const trimmed = authority.trim()
if (!trimmed) return null
if (/^payto:\/\/paypal\//i.test(trimmed)) {
return resolvePaypalPaymentUrl(trimmed.replace(/^payto:\/\/paypal\//i, ''))
}
const fromYoutube = extractNestedUrlFromYoutubeRedirect(trimmed)
if (fromYoutube) return resolvePaypalPaymentUrl(fromYoutube)
if (/^https?:\/\//i.test(trimmed)) {
try {
const u = new URL(trimmed)
if (isPaypalHostname(u.hostname)) return normalizePaypalComOrMeUrl(u)
} catch {
return null
}
}
const parsed = parsePaypalInputUrl(trimmed)
if (parsed) return normalizePaypalComOrMeUrl(parsed)
const slug = extractPaypalMeSlugFromText(trimmed)
if (slug) return `https://paypal.me/${slug}`
if (slug) return paypalMeUrlFromSlug(slug)
return null
}

15
src/pages/secondary/ProfileEditorPage/index.tsx

@ -36,6 +36,7 @@ import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build' @@ -36,6 +36,7 @@ import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build'
import { isVideo } from '@/lib/url'
import PaymentMethodRow from '@/components/ProfileEditor/PaymentMethodRow'
import { PAYTO_EDITOR_OTHER_OPTION } from '@/lib/payto'
import { normalizePaypalAuthority } from '@/lib/payto-paypal-url'
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'
@ -243,7 +244,12 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -243,7 +244,12 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const type = m.type.trim()
return m.authority.trim() && type && type !== PAYTO_EDITOR_OTHER_OPTION
})
.map((m) => ['payto', m.type.trim().toLowerCase(), m.authority.trim()])
.map((m) => {
const type = m.type.trim().toLowerCase()
const authority =
type === 'paypal' ? normalizePaypalAuthority(m.authority) : m.authority.trim()
return ['payto', type, authority]
})
savingPaymentInfoRef.current = true
setSavingPaymentInfo(true)
try {
@ -863,7 +869,12 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -863,7 +869,12 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const type = m.type.trim()
return m.authority.trim() && type && type !== PAYTO_EDITOR_OTHER_OPTION
})
.map((m) => ['payto', m.type.trim().toLowerCase(), m.authority.trim()])
.map((m) => {
const type = m.type.trim().toLowerCase()
const authority =
type === 'paypal' ? normalizePaypalAuthority(m.authority) : m.authority.trim()
return ['payto', type, authority]
})
),
null,
2

Loading…
Cancel
Save