Browse Source

add a preferred payment setting

imwald
Silberengel 3 weeks ago
parent
commit
2c7a14c0a2
  1. 97
      src/components/PaymentMethodsSection/index.tsx
  2. 2
      src/constants.ts
  3. 26
      src/hooks/usePreferredPaytoCategory.ts
  4. 12
      src/i18n/locales/cs.ts
  5. 12
      src/i18n/locales/de.ts
  6. 12
      src/i18n/locales/en.ts
  7. 12
      src/i18n/locales/es.ts
  8. 12
      src/i18n/locales/fr.ts
  9. 12
      src/i18n/locales/nl.ts
  10. 12
      src/i18n/locales/pl.ts
  11. 12
      src/i18n/locales/ru.ts
  12. 12
      src/i18n/locales/tr.ts
  13. 12
      src/i18n/locales/zh.ts
  14. 69
      src/lib/payto-category-display.test.ts
  15. 63
      src/lib/payto-category-display.ts
  16. 9
      src/lib/payto-registry.ts
  17. 52
      src/pages/secondary/WalletPage/PreferredPaytoCategorySelect.tsx
  18. 2
      src/pages/secondary/WalletPage/index.tsx
  19. 30
      src/services/local-storage.service.ts

97
src/components/PaymentMethodsSection/index.tsx

@ -1,43 +1,39 @@
import PaytoLink from '@/components/PaytoLink' import PaytoLink from '@/components/PaytoLink'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@/components/ui/collapsible'
import { usePreferredPaytoCategory } from '@/hooks/usePreferredPaytoCategory'
import type { PaymentMethodGroup } from '@/lib/merge-payment-methods' import type { PaymentMethodGroup } from '@/lib/merge-payment-methods'
import { partitionPaymentGroupsByPreferredCategory } from '@/lib/payto-category-display'
import { PRIMARY_LINK_HOVER_CLASS } from '@/lib/link-styles' import { PRIMARY_LINK_HOVER_CLASS } from '@/lib/link-styles'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Copy } from 'lucide-react' import { ChevronDown, Copy } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import type { PostPaymentContext } from '@/lib/post-payment-context' import type { PostPaymentContext } from '@/lib/post-payment-context'
export default function PaymentMethodsSection({ function PaymentMethodGroupsList({
groups, groups,
recipientPubkey, recipientPubkey,
referencedEvent, referencedEvent,
offerTipNoticeOnClose = true, offerTipNoticeOnClose,
onPostPaymentRequest, onPostPaymentRequest
title,
className
}: { }: {
groups: PaymentMethodGroup[] groups: PaymentMethodGroup[]
recipientPubkey?: string recipientPubkey?: string
/** Thread context passed to PaytoDialog for superchat requests. */
referencedEvent?: NostrEvent referencedEvent?: NostrEvent
/** When false, PaytoDialog defer post-payment prompt to parent. */ offerTipNoticeOnClose: boolean
offerTipNoticeOnClose?: boolean
onPostPaymentRequest?: (context: PostPaymentContext) => void onPostPaymentRequest?: (context: PostPaymentContext) => void
title?: string
className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
if (groups.length === 0) return null
return ( 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) => ( {groups.map((group, groupIdx) => (
<div key={groupIdx} className="text-sm min-w-0"> <div key={groupIdx} className="text-sm min-w-0">
<div className="font-medium">{group.displayType}</div> <div className="font-medium">{group.displayType}</div>
@ -90,6 +86,71 @@ export default function PaymentMethodsSection({
</div> </div>
</div> </div>
))} ))}
</>
)
}
export default function PaymentMethodsSection({
groups,
recipientPubkey,
referencedEvent,
offerTipNoticeOnClose = true,
onPostPaymentRequest,
title,
className
}: {
groups: PaymentMethodGroup[]
recipientPubkey?: string
/** Thread context passed to PaytoDialog for superchat requests. */
referencedEvent?: NostrEvent
/** When false, PaytoDialog defer post-payment prompt to parent. */
offerTipNoticeOnClose?: boolean
onPostPaymentRequest?: (context: PostPaymentContext) => void
title?: string
className?: string
}) {
const { t } = useTranslation()
const { preferredPaytoCategory } = usePreferredPaytoCategory()
const [otherCategoriesOpen, setOtherCategoriesOpen] = useState(false)
const { preferredGroups, otherGroups } = useMemo(
() => partitionPaymentGroupsByPreferredCategory(groups, preferredPaytoCategory),
[groups, preferredPaytoCategory]
)
if (groups.length === 0) return null
const listProps = {
recipientPubkey,
referencedEvent,
offerTipNoticeOnClose,
onPostPaymentRequest
}
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">
<PaymentMethodGroupsList groups={preferredGroups} {...listProps} />
{otherGroups.length > 0 && (
<Collapsible
open={otherCategoriesOpen}
onOpenChange={setOtherCategoriesOpen}
className="rounded-lg border border-border/80 bg-muted/20"
>
<CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm font-medium hover:bg-muted/50 rounded-lg">
<ChevronDown className="h-4 w-4 shrink-0 transition-transform [[data-state=open]_&]:rotate-180" />
<span className="flex-1">
{t('Other payment categories ({{count}})', { count: otherGroups.length })}
</span>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 px-3 pb-3 pt-0">
<PaymentMethodGroupsList groups={otherGroups} {...listProps} />
</CollapsibleContent>
</Collapsible>
)}
</div> </div>
</div> </div>
) )

2
src/constants.ts

@ -334,6 +334,8 @@ export const StorageKey = {
NOTIFICATION_TYPE: 'notificationType', NOTIFICATION_TYPE: 'notificationType',
DEFAULT_ZAP_SATS: 'defaultZapSats', DEFAULT_ZAP_SATS: 'defaultZapSats',
DEFAULT_ZAP_COMMENT: 'defaultZapComment', DEFAULT_ZAP_COMMENT: 'defaultZapComment',
/** Expanded payto category on payment method lists; empty = show all. */
PREFERRED_PAYTO_CATEGORY: 'preferredPaytoCategory',
QUICK_ZAP: 'quickZap', QUICK_ZAP: 'quickZap',
INCLUDE_PUBLIC_ZAP_RECEIPT: 'includePublicZapReceipt', INCLUDE_PUBLIC_ZAP_RECEIPT: 'includePublicZapReceipt',
/** Per-pubkey ms timestamps: last full network hydrate (see ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS). */ /** Per-pubkey ms timestamps: last full network hydrate (see ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS). */

26
src/hooks/usePreferredPaytoCategory.ts

@ -0,0 +1,26 @@
import { useCallback, useEffect, useState } from 'react'
import type { PaytoCategory } from '@/lib/payto-registry'
import storage from '@/services/local-storage.service'
export const PREFERRED_PAYTO_CATEGORY_CHANGED_EVENT = 'preferredPaytoCategoryChanged'
export function usePreferredPaytoCategory() {
const [preferredPaytoCategory, setPreferredPaytoCategoryState] = useState<PaytoCategory | null>(
() => storage.getPreferredPaytoCategory()
)
useEffect(() => {
const sync = () => setPreferredPaytoCategoryState(storage.getPreferredPaytoCategory())
window.addEventListener(PREFERRED_PAYTO_CATEGORY_CHANGED_EVENT, sync)
return () => window.removeEventListener(PREFERRED_PAYTO_CATEGORY_CHANGED_EVENT, sync)
}, [])
const setPreferredPaytoCategory = useCallback((category: PaytoCategory | null) => {
storage.setPreferredPaytoCategory(category)
setPreferredPaytoCategoryState(category)
window.dispatchEvent(new Event(PREFERRED_PAYTO_CATEGORY_CHANGED_EVENT))
}, [])
return { preferredPaytoCategory, setPreferredPaytoCategory }
}

12
src/i18n/locales/cs.ts

@ -669,6 +669,18 @@ export default {
'Default zap comment': 'Default zap comment', 'Default zap comment': 'Default zap comment',
'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)', 'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)',
'Quick zap': 'Quick zap', 'Quick zap': 'Quick zap',
'Preferred payto category': 'Preferred payto category',
'Show this category expanded on payment method lists; other categories collapse behind an accordion.':
'Show this category expanded on payment method lists; other categories collapse behind an accordion.',
'Show all categories': 'Show all categories',
'Other payment categories ({{count}})': 'Other payment categories ({{count}})',
'paytoCategory.bitcoin': 'Bitcoin',
'paytoCategory.bitcoin-layer': 'Bitcoin layer',
'paytoCategory.monero': 'Monero',
'paytoCategory.crypto': 'Crypto',
'paytoCategory.stablecoin': 'Stablecoin',
'paytoCategory.fiat': 'Fiat',
'paytoCategory.tip': 'Tip',
'If enabled, you can zap with a single click. Click and hold for custom amounts': 'If enabled, you can zap with a single click. Click and hold for custom amounts':
'If enabled, you can zap with a single click. Click and hold for custom amounts', 'If enabled, you can zap with a single click. Click and hold for custom amounts',
'Include public zap receipt': 'Include public zap receipt', 'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/de.ts

@ -677,6 +677,18 @@ export default {
'Default zap comment': 'Standard-Zap-Kommentar', 'Default zap comment': 'Standard-Zap-Kommentar',
'Lightning Address (or LNURL)': 'Lightning-Adresse (oder LNURL)', 'Lightning Address (or LNURL)': 'Lightning-Adresse (oder LNURL)',
'Quick zap': 'Schneller Zap', 'Quick zap': 'Schneller Zap',
'Preferred payto category': 'Bevorzugte Payto-Kategorie',
'Show this category expanded on payment method lists; other categories collapse behind an accordion.':
'Diese Kategorie in Zahlungsmethodenlisten ausgeklappt anzeigen; andere Kategorien hinter einem Akkordeon einklappen.',
'Show all categories': 'Alle Kategorien anzeigen',
'Other payment categories ({{count}})': 'Weitere Zahlungskategorien ({{count}})',
'paytoCategory.bitcoin': 'Bitcoin',
'paytoCategory.bitcoin-layer': 'Bitcoin-Layer',
'paytoCategory.monero': 'Monero',
'paytoCategory.crypto': 'Krypto',
'paytoCategory.stablecoin': 'Stablecoin',
'paytoCategory.fiat': 'Fiat',
'paytoCategory.tip': 'Trinkgeld',
'If enabled, you can zap with a single click. Click and hold for custom amounts': 'If enabled, you can zap with a single click. Click and hold for custom amounts':
'Wenn aktiviert, kannst du mit einem Klick zapen. Klicke und halte für individuelle Beträge', 'Wenn aktiviert, kannst du mit einem Klick zapen. Klicke und halte für individuelle Beträge',
'Include public zap receipt': 'Include public zap receipt', 'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/en.ts

@ -667,6 +667,18 @@ export default {
'Default zap comment': 'Default zap comment', 'Default zap comment': 'Default zap comment',
'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)', 'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)',
'Quick zap': 'Quick zap', 'Quick zap': 'Quick zap',
'Preferred payto category': 'Preferred payto category',
'Show this category expanded on payment method lists; other categories collapse behind an accordion.':
'Show this category expanded on payment method lists; other categories collapse behind an accordion.',
'Show all categories': 'Show all categories',
'Other payment categories ({{count}})': 'Other payment categories ({{count}})',
'paytoCategory.bitcoin': 'Bitcoin',
'paytoCategory.bitcoin-layer': 'Bitcoin layer',
'paytoCategory.monero': 'Monero',
'paytoCategory.crypto': 'Crypto',
'paytoCategory.stablecoin': 'Stablecoin',
'paytoCategory.fiat': 'Fiat',
'paytoCategory.tip': 'Tip',
'If enabled, you can zap with a single click. Click and hold for custom amounts': 'If enabled, you can zap with a single click. Click and hold for custom amounts':
'If enabled, you can zap with a single click. Click and hold for custom amounts', 'If enabled, you can zap with a single click. Click and hold for custom amounts',
'Include public zap receipt': 'Include public zap receipt', 'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/es.ts

@ -671,6 +671,18 @@ export default {
'Default zap comment': 'Comentario predeterminado de zap', 'Default zap comment': 'Comentario predeterminado de zap',
'Lightning Address (or LNURL)': 'Dirección Lightning (o LNURL)', 'Lightning Address (or LNURL)': 'Dirección Lightning (o LNURL)',
'Quick zap': 'Zap rápido', 'Quick zap': 'Zap rápido',
'Preferred payto category': 'Categoría payto preferida',
'Show this category expanded on payment method lists; other categories collapse behind an accordion.':
'Muestra esta categoría expandida en las listas de métodos de pago; las demás se pliegan en un acordeón.',
'Show all categories': 'Mostrar todas las categorías',
'Other payment categories ({{count}})': 'Otras categorías de pago ({{count}})',
'paytoCategory.bitcoin': 'Bitcoin',
'paytoCategory.bitcoin-layer': 'Capa Bitcoin',
'paytoCategory.monero': 'Monero',
'paytoCategory.crypto': 'Cripto',
'paytoCategory.stablecoin': 'Stablecoin',
'paytoCategory.fiat': 'Fiat',
'paytoCategory.tip': 'Propina',
'If enabled, you can zap with a single click. Click and hold for custom amounts': 'If enabled, you can zap with a single click. Click and hold for custom amounts':
'Si está habilitado, puedes enviar un zap con un solo clic. Haz clic y mantén pulsado para cantidades personalizadas', 'Si está habilitado, puedes enviar un zap con un solo clic. Haz clic y mantén pulsado para cantidades personalizadas',
'Include public zap receipt': 'Include public zap receipt', 'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/fr.ts

@ -670,6 +670,18 @@ export default {
'Default zap comment': 'Commentaire de zap par défaut', 'Default zap comment': 'Commentaire de zap par défaut',
'Lightning Address (or LNURL)': 'Adresse Lightning (ou LNURL)', 'Lightning Address (or LNURL)': 'Adresse Lightning (ou LNURL)',
'Quick zap': 'Zap rapide', 'Quick zap': 'Zap rapide',
'Preferred payto category': 'Catégorie payto préférée',
'Show this category expanded on payment method lists; other categories collapse behind an accordion.':
'Afficher cette catégorie dépliée dans les listes de paiement ; les autres sont repliées dans un accordéon.',
'Show all categories': 'Afficher toutes les catégories',
'Other payment categories ({{count}})': 'Autres catégories de paiement ({{count}})',
'paytoCategory.bitcoin': 'Bitcoin',
'paytoCategory.bitcoin-layer': 'Couche Bitcoin',
'paytoCategory.monero': 'Monero',
'paytoCategory.crypto': 'Crypto',
'paytoCategory.stablecoin': 'Stablecoin',
'paytoCategory.fiat': 'Fiat',
'paytoCategory.tip': 'Pourboire',
'If enabled, you can zap with a single click. Click and hold for custom amounts': 'If enabled, you can zap with a single click. Click and hold for custom amounts':
'Si activé, vous pouvez zap avec un seul clic. Cliquez et maintenez pour des montants personnalisés', 'Si activé, vous pouvez zap avec un seul clic. Cliquez et maintenez pour des montants personnalisés',
'Include public zap receipt': 'Include public zap receipt', 'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/nl.ts

@ -669,6 +669,18 @@ export default {
'Default zap comment': 'Default zap comment', 'Default zap comment': 'Default zap comment',
'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)', 'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)',
'Quick zap': 'Quick zap', 'Quick zap': 'Quick zap',
'Preferred payto category': 'Preferred payto category',
'Show this category expanded on payment method lists; other categories collapse behind an accordion.':
'Show this category expanded on payment method lists; other categories collapse behind an accordion.',
'Show all categories': 'Show all categories',
'Other payment categories ({{count}})': 'Other payment categories ({{count}})',
'paytoCategory.bitcoin': 'Bitcoin',
'paytoCategory.bitcoin-layer': 'Bitcoin layer',
'paytoCategory.monero': 'Monero',
'paytoCategory.crypto': 'Crypto',
'paytoCategory.stablecoin': 'Stablecoin',
'paytoCategory.fiat': 'Fiat',
'paytoCategory.tip': 'Tip',
'If enabled, you can zap with a single click. Click and hold for custom amounts': 'If enabled, you can zap with a single click. Click and hold for custom amounts':
'If enabled, you can zap with a single click. Click and hold for custom amounts', 'If enabled, you can zap with a single click. Click and hold for custom amounts',
'Include public zap receipt': 'Include public zap receipt', 'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/pl.ts

@ -668,6 +668,18 @@ export default {
'Default zap comment': 'Standardowy komentarz do zapa', 'Default zap comment': 'Standardowy komentarz do zapa',
'Lightning Address (or LNURL)': 'Lightning Adres (lub LNURL)', 'Lightning Address (or LNURL)': 'Lightning Adres (lub LNURL)',
'Quick zap': 'Szybki zap', 'Quick zap': 'Szybki zap',
'Preferred payto category': 'Preferowana kategoria payto',
'Show this category expanded on payment method lists; other categories collapse behind an accordion.':
'Pokazuj tę kategorię rozwiniętą na listach metod płatności; pozostałe zwijaj w akordeonie.',
'Show all categories': 'Pokaż wszystkie kategorie',
'Other payment categories ({{count}})': 'Inne kategorie płatności ({{count}})',
'paytoCategory.bitcoin': 'Bitcoin',
'paytoCategory.bitcoin-layer': 'Warstwa Bitcoin',
'paytoCategory.monero': 'Monero',
'paytoCategory.crypto': 'Krypto',
'paytoCategory.stablecoin': 'Stablecoin',
'paytoCategory.fiat': 'Fiat',
'paytoCategory.tip': 'Napiwek',
'If enabled, you can zap with a single click. Click and hold for custom amounts': 'If enabled, you can zap with a single click. Click and hold for custom amounts':
'Jeśli włączysz tę opcję, będzie można wysyłać zapa jednym kliknięciem. Naciśnij i przytrzymaj, aby wprowadzić inną kwotę', 'Jeśli włączysz tę opcję, będzie można wysyłać zapa jednym kliknięciem. Naciśnij i przytrzymaj, aby wprowadzić inną kwotę',
'Include public zap receipt': 'Include public zap receipt', 'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/ru.ts

@ -671,6 +671,18 @@ export default {
'Default zap comment': 'Стандартный комментарий для зап', 'Default zap comment': 'Стандартный комментарий для зап',
'Lightning Address (or LNURL)': 'Lightning-адрес (или LNURL)', 'Lightning Address (or LNURL)': 'Lightning-адрес (или LNURL)',
'Quick zap': 'Быстрый зап', 'Quick zap': 'Быстрый зап',
'Preferred payto category': 'Предпочитаемая категория payto',
'Show this category expanded on payment method lists; other categories collapse behind an accordion.':
'Показывать эту категорию развёрнутой в списках способов оплаты; остальные сворачиваются в аккордеон.',
'Show all categories': 'Показать все категории',
'Other payment categories ({{count}})': 'Другие категории оплаты ({{count}})',
'paytoCategory.bitcoin': 'Bitcoin',
'paytoCategory.bitcoin-layer': 'Слой Bitcoin',
'paytoCategory.monero': 'Monero',
'paytoCategory.crypto': 'Крипто',
'paytoCategory.stablecoin': 'Стейблкоин',
'paytoCategory.fiat': 'Фиат',
'paytoCategory.tip': 'Чаевые',
'If enabled, you can zap with a single click. Click and hold for custom amounts': 'If enabled, you can zap with a single click. Click and hold for custom amounts':
'Если включено, вы можете отправить зап одним нажатием. Нажмите и удерживайте для выбора суммы', 'Если включено, вы можете отправить зап одним нажатием. Нажмите и удерживайте для выбора суммы',
'Include public zap receipt': 'Include public zap receipt', 'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/tr.ts

@ -669,6 +669,18 @@ export default {
'Default zap comment': 'Default zap comment', 'Default zap comment': 'Default zap comment',
'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)', 'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)',
'Quick zap': 'Quick zap', 'Quick zap': 'Quick zap',
'Preferred payto category': 'Preferred payto category',
'Show this category expanded on payment method lists; other categories collapse behind an accordion.':
'Show this category expanded on payment method lists; other categories collapse behind an accordion.',
'Show all categories': 'Show all categories',
'Other payment categories ({{count}})': 'Other payment categories ({{count}})',
'paytoCategory.bitcoin': 'Bitcoin',
'paytoCategory.bitcoin-layer': 'Bitcoin layer',
'paytoCategory.monero': 'Monero',
'paytoCategory.crypto': 'Crypto',
'paytoCategory.stablecoin': 'Stablecoin',
'paytoCategory.fiat': 'Fiat',
'paytoCategory.tip': 'Tip',
'If enabled, you can zap with a single click. Click and hold for custom amounts': 'If enabled, you can zap with a single click. Click and hold for custom amounts':
'If enabled, you can zap with a single click. Click and hold for custom amounts', 'If enabled, you can zap with a single click. Click and hold for custom amounts',
'Include public zap receipt': 'Include public zap receipt', 'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/zh.ts

@ -667,6 +667,18 @@ export default {
'Default zap comment': '默认打闪附言', 'Default zap comment': '默认打闪附言',
'Lightning Address (or LNURL)': '闪电地址 (或 LNURL)', 'Lightning Address (or LNURL)': '闪电地址 (或 LNURL)',
'Quick zap': '快速打闪', 'Quick zap': '快速打闪',
'Preferred payto category': '首选 payto 类别',
'Show this category expanded on payment method lists; other categories collapse behind an accordion.':
'在支付方式列表中展开此类别;其他类别折叠在手风琴中。',
'Show all categories': '显示所有类别',
'Other payment categories ({{count}})': '其他支付类别 ({{count}})',
'paytoCategory.bitcoin': '比特币',
'paytoCategory.bitcoin-layer': '比特币二层',
'paytoCategory.monero': '门罗币',
'paytoCategory.crypto': '加密货币',
'paytoCategory.stablecoin': '稳定币',
'paytoCategory.fiat': '法币',
'paytoCategory.tip': '打赏',
'If enabled, you can zap with a single click. Click and hold for custom amounts': 'If enabled, you can zap with a single click. Click and hold for custom amounts':
'如果启用,您单击即可打闪。长按以设置自定义金额', '如果启用,您单击即可打闪。长按以设置自定义金额',
'Include public zap receipt': 'Include public zap receipt', 'Include public zap receipt': 'Include public zap receipt',

69
src/lib/payto-category-display.test.ts

@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import type { PaymentMethodGroup } from '@/lib/merge-payment-methods'
import {
getPaymentMethodGroupCategory,
partitionPaymentGroupsByPreferredCategory
} from '@/lib/payto-category-display'
function group(type: string, displayType: string): PaymentMethodGroup {
return {
displayType,
methods: [{ type, authority: 'addr', payto: `payto://${type}/addr`, displayType }]
}
}
describe('payto-category-display', () => {
it('reads category from first method type', () => {
expect(getPaymentMethodGroupCategory(group('lightning', 'Lightning'))).toBe('bitcoin-layer')
expect(getPaymentMethodGroupCategory(group('monero', 'Monero'))).toBe('monero')
expect(getPaymentMethodGroupCategory(group('xmr', 'Monero'))).toBe('monero')
})
it('treats monero as part of crypto preference', () => {
const groups = [group('monero', 'Monero'), group('ethereum', 'Ethereum'), group('bitcoin', 'Bitcoin')]
const { preferredGroups, otherGroups } = partitionPaymentGroupsByPreferredCategory(groups, 'crypto')
expect(preferredGroups.map((g) => g.displayType).sort()).toEqual(['Ethereum', 'Monero'])
expect(otherGroups).toHaveLength(1)
})
it('partitions monero-only when monero preference is set', () => {
const groups = [group('monero', 'Monero'), group('ethereum', 'Ethereum')]
const { preferredGroups, otherGroups } = partitionPaymentGroupsByPreferredCategory(groups, 'monero')
expect(preferredGroups).toHaveLength(1)
expect(preferredGroups[0].displayType).toBe('Monero')
expect(otherGroups).toHaveLength(1)
})
it('partitions groups by preferred category', () => {
const groups = [
group('lightning', 'Lightning'),
group('monero', 'Monero'),
group('bitcoin', 'Bitcoin')
]
const { preferredGroups, otherGroups } = partitionPaymentGroupsByPreferredCategory(
groups,
'bitcoin-layer'
)
expect(preferredGroups).toHaveLength(1)
expect(preferredGroups[0].displayType).toBe('Lightning')
expect(otherGroups).toHaveLength(2)
})
it('returns all groups as preferred when preference is unset', () => {
const groups = [group('lightning', 'Lightning'), group('monero', 'Monero')]
const { preferredGroups, otherGroups } = partitionPaymentGroupsByPreferredCategory(groups, null)
expect(preferredGroups).toEqual(groups)
expect(otherGroups).toHaveLength(0)
})
it('shows all groups expanded when preferred category has no matches', () => {
const groups = [group('monero', 'Monero'), group('bitcoin', 'Bitcoin')]
const { preferredGroups, otherGroups } = partitionPaymentGroupsByPreferredCategory(
groups,
'fiat'
)
expect(preferredGroups).toEqual(groups)
expect(otherGroups).toHaveLength(0)
})
})

63
src/lib/payto-category-display.ts

@ -0,0 +1,63 @@
import type { PaymentMethodGroup } from '@/lib/merge-payment-methods'
import { getCanonicalPaytoType, getPaytoTypeInfo, type PaytoCategory } from '@/lib/payto-registry'
export const PAYTO_CATEGORIES: readonly PaytoCategory[] = [
'bitcoin',
'bitcoin-layer',
'monero',
'crypto',
'stablecoin',
'fiat',
'tip'
] as const
export function isPaytoCategory(value: string): value is PaytoCategory {
return (PAYTO_CATEGORIES as readonly string[]).includes(value)
}
/** i18n key under `paytoCategory.*` */
export function paytoCategoryTranslationKey(category: PaytoCategory): string {
return `paytoCategory.${category}`
}
export function getPaymentMethodGroupCategory(group: PaymentMethodGroup): PaytoCategory | null {
const type = group.methods.find((m) => m.type)?.type
if (!type) return null
if (getCanonicalPaytoType(type) === 'monero') return 'monero'
return getPaytoTypeInfo(type)?.category ?? null
}
/** Monero stays in the crypto bucket in the catalog but has its own preference category on Nostr. */
export function groupMatchesPreferredPaytoCategory(
group: PaymentMethodGroup,
preferred: PaytoCategory
): boolean {
const category = getPaymentMethodGroupCategory(group)
if (!category) return false
if (category === preferred) return true
if (preferred === 'crypto' && category === 'monero') return true
return false
}
export function partitionPaymentGroupsByPreferredCategory(
groups: PaymentMethodGroup[],
preferred: PaytoCategory | null
): { preferredGroups: PaymentMethodGroup[]; otherGroups: PaymentMethodGroup[] } {
if (!preferred) {
return { preferredGroups: groups, otherGroups: [] }
}
const preferredGroups: PaymentMethodGroup[] = []
const otherGroups: PaymentMethodGroup[] = []
for (const group of groups) {
if (groupMatchesPreferredPaytoCategory(group, preferred)) {
preferredGroups.push(group)
} else {
otherGroups.push(group)
}
}
// No matches on this profile: show everything expanded instead of an empty list + accordion.
if (preferredGroups.length === 0) {
return { preferredGroups: groups, otherGroups: [] }
}
return { preferredGroups, otherGroups }
}

9
src/lib/payto-registry.ts

@ -7,7 +7,14 @@ import paytoTypesCatalog from '@/data/payto-types.json'
import { resolvePaytoLogoAssetPath } from '@/lib/payto-logos' import { resolvePaytoLogoAssetPath } from '@/lib/payto-logos'
import { resolvePaypalPaymentUrl } from '@/lib/payto-paypal-url' import { resolvePaypalPaymentUrl } from '@/lib/payto-paypal-url'
export type PaytoCategory = 'bitcoin' | 'bitcoin-layer' | 'crypto' | 'stablecoin' | 'fiat' | 'tip' export type PaytoCategory =
| 'bitcoin'
| 'bitcoin-layer'
| 'monero'
| 'crypto'
| 'stablecoin'
| 'fiat'
| 'tip'
export type PaytoAuthorityHelp = { export type PaytoAuthorityHelp = {
placeholder: string placeholder: string

52
src/pages/secondary/WalletPage/PreferredPaytoCategorySelect.tsx

@ -0,0 +1,52 @@
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import { usePreferredPaytoCategory } from '@/hooks/usePreferredPaytoCategory'
import {
isPaytoCategory,
PAYTO_CATEGORIES,
paytoCategoryTranslationKey
} from '@/lib/payto-category-display'
import { SelectValue } from '@radix-ui/react-select'
import { useTranslation } from 'react-i18next'
const SHOW_ALL_VALUE = '__all__'
export default function PreferredPaytoCategorySelect() {
const { t } = useTranslation()
const { preferredPaytoCategory, setPreferredPaytoCategory } = usePreferredPaytoCategory()
return (
<div className="w-full space-y-1">
<Label htmlFor="preferred-payto-category">{t('Preferred payto category')}</Label>
<p className="text-sm text-muted-foreground">
{t(
'Show this category expanded on payment method lists; other categories collapse behind an accordion.'
)}
</p>
<Select
value={preferredPaytoCategory ?? SHOW_ALL_VALUE}
onValueChange={(value) => {
if (value === SHOW_ALL_VALUE) {
setPreferredPaytoCategory(null)
return
}
if (isPaytoCategory(value)) {
setPreferredPaytoCategory(value)
}
}}
>
<SelectTrigger id="preferred-payto-category" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={SHOW_ALL_VALUE}>{t('Show all categories')}</SelectItem>
{PAYTO_CATEGORIES.map((category) => (
<SelectItem key={category} value={category}>
{t(paytoCategoryTranslationKey(category))}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

2
src/pages/secondary/WalletPage/index.tsx

@ -5,6 +5,7 @@ import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { forwardRef, useCallback, useEffect, useState } from 'react' import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import LightningAddressInput from './LightningAddressInput' import LightningAddressInput from './LightningAddressInput'
import PreferredPaytoCategorySelect from './PreferredPaytoCategorySelect'
import WalletZapSendingSettings from './WalletZapSendingSettings' import WalletZapSendingSettings from './WalletZapSendingSettings'
const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
@ -32,6 +33,7 @@ const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
<div key={contentKey} className="px-4 pt-3 space-y-4"> <div key={contentKey} className="px-4 pt-3 space-y-4">
{LIGHTNING_WALLET_PAY_ENABLED ? <WalletZapSendingSettings /> : null} {LIGHTNING_WALLET_PAY_ENABLED ? <WalletZapSendingSettings /> : null}
<LightningAddressInput /> <LightningAddressInput />
<PreferredPaytoCategorySelect />
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )

30
src/services/local-storage.service.ts

@ -9,6 +9,8 @@ import {
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { isSameAccount } from '@/lib/account' import { isSameAccount } from '@/lib/account'
import { DEFAULT_ZAP_SATS } from '@/lib/lightning' import { DEFAULT_ZAP_SATS } from '@/lib/lightning'
import { isPaytoCategory } from '@/lib/payto-category-display'
import type { PaytoCategory } from '@/lib/payto-registry'
import { setRestrictConnectionsToMetadataRelaysOnly } from '@/lib/read-only-relay-personal' import { setRestrictConnectionsToMetadataRelaysOnly } from '@/lib/read-only-relay-personal'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { import {
@ -53,6 +55,7 @@ const SETTINGS_KEYS = [
StorageKey.CURRENT_ACCOUNT, StorageKey.CURRENT_ACCOUNT,
StorageKey.DEFAULT_ZAP_SATS, StorageKey.DEFAULT_ZAP_SATS,
StorageKey.DEFAULT_ZAP_COMMENT, StorageKey.DEFAULT_ZAP_COMMENT,
StorageKey.PREFERRED_PAYTO_CATEGORY,
StorageKey.QUICK_ZAP, StorageKey.QUICK_ZAP,
StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT, StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT,
StorageKey.AUTOPLAY, StorageKey.AUTOPLAY,
@ -100,6 +103,7 @@ class LocalStorageService {
private noteListMode: TNoteListMode = 'posts' private noteListMode: TNoteListMode = 'posts'
private defaultZapSats: number = DEFAULT_ZAP_SATS private defaultZapSats: number = DEFAULT_ZAP_SATS
private defaultZapComment: string = 'Zap!' private defaultZapComment: string = 'Zap!'
private preferredPaytoCategory: PaytoCategory | null = null
private quickZap: boolean = false private quickZap: boolean = false
private includePublicZapReceipt: boolean = true private includePublicZapReceipt: boolean = true
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
@ -198,6 +202,11 @@ class LocalStorageService {
} }
} }
this.defaultZapComment = window.localStorage.getItem(StorageKey.DEFAULT_ZAP_COMMENT) ?? 'Zap!' this.defaultZapComment = window.localStorage.getItem(StorageKey.DEFAULT_ZAP_COMMENT) ?? 'Zap!'
const preferredPaytoCategoryStr = window.localStorage.getItem(StorageKey.PREFERRED_PAYTO_CATEGORY)
this.preferredPaytoCategory =
preferredPaytoCategoryStr && isPaytoCategory(preferredPaytoCategoryStr)
? preferredPaytoCategoryStr
: null
this.quickZap = window.localStorage.getItem(StorageKey.QUICK_ZAP) === 'true' this.quickZap = window.localStorage.getItem(StorageKey.QUICK_ZAP) === 'true'
const includeReceiptStr = window.localStorage.getItem(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT) const includeReceiptStr = window.localStorage.getItem(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT)
if (includeReceiptStr != null) { if (includeReceiptStr != null) {
@ -596,6 +605,13 @@ class LocalStorageService {
} }
const defaultZapCommentStr = get(StorageKey.DEFAULT_ZAP_COMMENT) const defaultZapCommentStr = get(StorageKey.DEFAULT_ZAP_COMMENT)
if (defaultZapCommentStr != null) this.defaultZapComment = defaultZapCommentStr if (defaultZapCommentStr != null) this.defaultZapComment = defaultZapCommentStr
const preferredPaytoCategoryStr = get(StorageKey.PREFERRED_PAYTO_CATEGORY)
if (preferredPaytoCategoryStr != null) {
this.preferredPaytoCategory =
preferredPaytoCategoryStr && isPaytoCategory(preferredPaytoCategoryStr)
? preferredPaytoCategoryStr
: null
}
const quickZapStr = get(StorageKey.QUICK_ZAP) const quickZapStr = get(StorageKey.QUICK_ZAP)
if (quickZapStr != null) this.quickZap = quickZapStr === 'true' if (quickZapStr != null) this.quickZap = quickZapStr === 'true'
const includeReceiptStr = get(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT) const includeReceiptStr = get(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT)
@ -803,6 +819,20 @@ class LocalStorageService {
this.persistSetting(StorageKey.DEFAULT_ZAP_COMMENT, comment) this.persistSetting(StorageKey.DEFAULT_ZAP_COMMENT, comment)
} }
getPreferredPaytoCategory(): PaytoCategory | null {
return this.preferredPaytoCategory
}
setPreferredPaytoCategory(category: PaytoCategory | null) {
this.preferredPaytoCategory = category
if (category) {
this.persistSetting(StorageKey.PREFERRED_PAYTO_CATEGORY, category)
} else {
window.localStorage.removeItem(StorageKey.PREFERRED_PAYTO_CATEGORY)
void this.persistSettingToIndexedDb(StorageKey.PREFERRED_PAYTO_CATEGORY, '')
}
}
getQuickZap() { getQuickZap() {
return this.quickZap return this.quickZap
} }

Loading…
Cancel
Save