From 80e50063cae0a6ced64d4907b7de1e41b0ebd7c9 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 19 May 2026 14:30:49 +0200 Subject: [PATCH] alternative payments --- src/components/NoteStats/ZapButton.tsx | 10 ++ src/components/Profile/index.tsx | 20 ++- .../ZapDialog/TipPublicMessagePrompt.tsx | 155 ++++++++++++++++++ src/components/ZapDialog/index.tsx | 106 ++++++++++-- src/data/payto-types.json | 4 +- src/hooks/useRecipientAlternativePayments.ts | 57 +++++++ src/i18n/locales/de.ts | 9 + src/i18n/locales/en.ts | 9 + src/lib/event-metadata.ts | 4 +- src/lib/merge-payment-methods.ts | 7 +- src/lib/payto-paypal-url.test.ts | 69 +++++++- src/lib/payto-paypal-url.ts | 119 ++++++++++++-- .../secondary/ProfileEditorPage/index.tsx | 15 +- 13 files changed, 535 insertions(+), 49 deletions(-) create mode 100644 src/components/ZapDialog/TipPublicMessagePrompt.tsx create mode 100644 src/hooks/useRecipientAlternativePayments.ts diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx index 1ed40cb0..089af2e7 100644 --- a/src/components/NoteStats/ZapButton.tsx +++ b/src/components/NoteStats/ZapButton.tsx @@ -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 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 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 pubkey={event.pubkey} event={event} /> + ) } diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 05dc79f9..ecc3bc2e 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -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({ 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({ open={openZapDialog} setOpen={setOpenZapDialog} pubkey={pubkey} + alternativePaymentGroups={alternativePaymentGroups} />
diff --git a/src/components/ZapDialog/TipPublicMessagePrompt.tsx b/src/components/ZapDialog/TipPublicMessagePrompt.tsx new file mode 100644 index 00000000..6fa7f88a --- /dev/null +++ b/src/components/ZapDialog/TipPublicMessagePrompt.tsx @@ -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(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 = ( + <> +

{t('Tip notice success only note')}

+

{t('Tip notice prompt description')}

+

{previewContent}

+ + ) + + const actions = ( + <> + + + + ) + + if (!recipientPubkey) return null + + if (isSmallScreen) { + return ( + + e.preventDefault()}> + + + {t('Tip notice prompt title')} + + + + +
{body}
+ {actions} +
+
+ ) + } + + return ( + + e.preventDefault()} + > + + + {t('Tip notice prompt title')} + + + + {t('Tip notice prompt description')} + + {body} + {actions} + + + ) +} diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx index 44cc2379..3adafcee 100644 --- a/src/components/ZapDialog/index.tsx +++ b/src/components/ZapDialog/index.tsx @@ -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({ pubkey, event, defaultAmount, - defaultComment + defaultComment, + alternativePaymentGroups }: { open: boolean setOpen: Dispatch> @@ -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(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> = (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({ if (isSmallScreen) { return ( - - setOpen(false)} /> + + handleZapDialogOpenChange(false)} /> 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 }} > - +
{t('Zap to')}
@@ -98,19 +126,29 @@ export default function ZapDialog({
{ + if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true + }} />
+
) } return ( - + <> + @@ -122,14 +160,24 @@ export default function ZapDialog({ { + if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true + }} /> + + ) } @@ -138,7 +186,9 @@ function ZapDialogContent({ recipient, event, defaultAmount, - defaultComment + defaultComment, + alternativePaymentGroups, + onBeforeZapDialogClose }: { open: boolean setOpen: Dispatch> @@ -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({ 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({ } return ( -
- {/* Scrollable content area */} -
+
+
{/* Sats slider or input */}
@@ -264,8 +320,13 @@ function ZapDialogContent({ setComment(e.target.value)} />
+
-
+
+
- {/* Zap button - fixed at bottom */} -
+ + {alternativePaymentGroups && alternativePaymentGroups.length > 0 ? ( +
+ +

{t('Zap dialog other payment hint')}

+
+ ) : null}
) diff --git a/src/data/payto-types.json b/src/data/payto-types.json index d7261f3a..4e5e09d7 100644 --- a/src/data/payto-types.json +++ b/src/data/payto-types.json @@ -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": { diff --git a/src/hooks/useRecipientAlternativePayments.ts b/src/hooks/useRecipientAlternativePayments.ts new file mode 100644 index 00000000..effb5e68 --- /dev/null +++ b/src/hooks/useRecipientAlternativePayments.ts @@ -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(null) + const [profile, setProfile] = useState(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]) +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 855b25bd..338b99bb 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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.", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 72f90426..dea9a79e 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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.", diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index a2cfea88..47023273 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -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 { const authority = tag[2] || '' const extra = tag.slice(3) // Optional extra fields - // Build payto URI: payto:/// - const paytoUri = `payto://${type}/${authority}` + const paytoUri = buildPaytoUri(type, authority) const method: any = { type, diff --git a/src/lib/merge-payment-methods.ts b/src/lib/merge-payment-methods.ts index 3782f31e..c9dd9eec 100644 --- a/src/lib/merge-payment-methods.ts +++ b/src/lib/merge-payment-methods.ts @@ -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( } 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, diff --git a/src/lib/payto-paypal-url.test.ts b/src/lib/payto-paypal-url.test.ts index aeb5296f..9766bc99 100644 --- a/src/lib/payto-paypal-url.test.ts +++ b/src/lib/payto-paypal-url.test.ts @@ -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', () => { 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') + }) }) diff --git a/src/lib/payto-paypal-url.ts b/src/lib/payto-paypal-url.ts index 0673c884..88f440f3 100644 --- a/src/lib/payto-paypal-url.ts +++ b/src/lib/payto-paypal-url.ts @@ -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 { } } +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 { 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 { } /** - * 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 } diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index 0799286b..42ee4812 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -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) => { 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) => { 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