Browse Source

add a preferred payment setting

imwald
Silberengel 3 weeks ago
parent
commit
2c7a14c0a2
  1. 167
      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

167
src/components/PaymentMethodsSection/index.tsx

@ -1,14 +1,95 @@ @@ -1,14 +1,95 @@
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 { partitionPaymentGroupsByPreferredCategory } from '@/lib/payto-category-display'
import { PRIMARY_LINK_HOVER_CLASS } from '@/lib/link-styles'
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 { toast } from 'sonner'
import { NostrEvent } from 'nostr-tools'
import type { PostPaymentContext } from '@/lib/post-payment-context'
function PaymentMethodGroupsList({
groups,
recipientPubkey,
referencedEvent,
offerTipNoticeOnClose,
onPostPaymentRequest
}: {
groups: PaymentMethodGroup[]
recipientPubkey?: string
referencedEvent?: NostrEvent
offerTipNoticeOnClose: boolean
onPostPaymentRequest?: (context: PostPaymentContext) => void
}) {
const { t } = useTranslation()
return (
<>
{groups.map((group, groupIdx) => (
<div key={groupIdx} className="text-sm min-w-0">
<div className="font-medium">{group.displayType}</div>
<div className="space-y-1.5 mt-1">
{group.methods.map((method, idx) => (
<div key={idx} className="min-w-0">
{method.authority && (
<div className="text-muted-foreground flex items-center gap-1 min-w-0">
<PaytoLink
type={method.type}
authority={method.authority}
paytoUri={method.payto}
displayFormat="full"
pubkey={recipientPubkey}
offerTipNoticeOnClose={offerTipNoticeOnClose}
onPostPaymentRequest={onPostPaymentRequest}
referencedEvent={referencedEvent}
className={cn(PRIMARY_LINK_HOVER_CLASS, 'break-all min-w-0 flex-1')}
>
{method.authority}
</PaytoLink>
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
navigator.clipboard.writeText(method.authority)
toast.success(t('Copied to clipboard'))
}}
className="shrink-0 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted"
title={t('Copy address')}
>
<Copy className="size-3.5" />
</button>
</div>
)}
{(method.currency ||
(method.minAmount !== undefined && method.maxAmount !== undefined)) && (
<div className="text-muted-foreground text-xs mt-0.5">
{method.currency && <span>({method.currency})</span>}
{method.minAmount !== undefined && method.maxAmount !== undefined && (
<span className="ml-2">
{method.minAmount}-{method.maxAmount}
</span>
)}
</div>
)}
</div>
))}
</div>
</div>
))}
</>
)
}
export default function PaymentMethodsSection({
groups,
recipientPubkey,
@ -29,67 +110,47 @@ export default function PaymentMethodsSection({ @@ -29,67 +110,47 @@ export default function PaymentMethodsSection({
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">
{groups.map((group, groupIdx) => (
<div key={groupIdx} className="text-sm min-w-0">
<div className="font-medium">{group.displayType}</div>
<div className="space-y-1.5 mt-1">
{group.methods.map((method, idx) => (
<div key={idx} className="min-w-0">
{method.authority && (
<div className="text-muted-foreground flex items-center gap-1 min-w-0">
<PaytoLink
type={method.type}
authority={method.authority}
paytoUri={method.payto}
displayFormat="full"
pubkey={recipientPubkey}
offerTipNoticeOnClose={offerTipNoticeOnClose}
onPostPaymentRequest={onPostPaymentRequest}
referencedEvent={referencedEvent}
className={cn(PRIMARY_LINK_HOVER_CLASS, 'break-all min-w-0 flex-1')}
>
{method.authority}
</PaytoLink>
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
navigator.clipboard.writeText(method.authority)
toast.success(t('Copied to clipboard'))
}}
className="shrink-0 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted"
title={t('Copy address')}
>
<Copy className="size-3.5" />
</button>
</div>
)}
{(method.currency ||
(method.minAmount !== undefined && method.maxAmount !== undefined)) && (
<div className="text-muted-foreground text-xs mt-0.5">
{method.currency && <span>({method.currency})</span>}
{method.minAmount !== undefined && method.maxAmount !== undefined && (
<span className="ml-2">
{method.minAmount}-{method.maxAmount}
</span>
)}
</div>
)}
</div>
))}
</div>
</div>
))}
<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>
)

2
src/constants.ts

@ -334,6 +334,8 @@ export const StorageKey = { @@ -334,6 +334,8 @@ export const StorageKey = {
NOTIFICATION_TYPE: 'notificationType',
DEFAULT_ZAP_SATS: 'defaultZapSats',
DEFAULT_ZAP_COMMENT: 'defaultZapComment',
/** Expanded payto category on payment method lists; empty = show all. */
PREFERRED_PAYTO_CATEGORY: 'preferredPaytoCategory',
QUICK_ZAP: 'quickZap',
INCLUDE_PUBLIC_ZAP_RECEIPT: 'includePublicZapReceipt',
/** 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 @@ @@ -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 { @@ -669,6 +669,18 @@ export default {
'Default zap comment': 'Default zap comment',
'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)',
'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',
'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/de.ts

@ -677,6 +677,18 @@ export default { @@ -677,6 +677,18 @@ export default {
'Default zap comment': 'Standard-Zap-Kommentar',
'Lightning Address (or LNURL)': 'Lightning-Adresse (oder LNURL)',
'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':
'Wenn aktiviert, kannst du mit einem Klick zapen. Klicke und halte für individuelle Beträge',
'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/en.ts

@ -667,6 +667,18 @@ export default { @@ -667,6 +667,18 @@ export default {
'Default zap comment': 'Default zap comment',
'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)',
'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',
'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/es.ts

@ -671,6 +671,18 @@ export default { @@ -671,6 +671,18 @@ export default {
'Default zap comment': 'Comentario predeterminado de zap',
'Lightning Address (or LNURL)': 'Dirección Lightning (o LNURL)',
'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':
'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',

12
src/i18n/locales/fr.ts

@ -670,6 +670,18 @@ export default { @@ -670,6 +670,18 @@ export default {
'Default zap comment': 'Commentaire de zap par défaut',
'Lightning Address (or LNURL)': 'Adresse Lightning (ou LNURL)',
'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':
'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',

12
src/i18n/locales/nl.ts

@ -669,6 +669,18 @@ export default { @@ -669,6 +669,18 @@ export default {
'Default zap comment': 'Default zap comment',
'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)',
'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',
'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/pl.ts

@ -668,6 +668,18 @@ export default { @@ -668,6 +668,18 @@ export default {
'Default zap comment': 'Standardowy komentarz do zapa',
'Lightning Address (or LNURL)': 'Lightning Adres (lub LNURL)',
'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':
'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',

12
src/i18n/locales/ru.ts

@ -671,6 +671,18 @@ export default { @@ -671,6 +671,18 @@ export default {
'Default zap comment': 'Стандартный комментарий для зап',
'Lightning Address (or LNURL)': 'Lightning-адрес (или LNURL)',
'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':
'Если включено, вы можете отправить зап одним нажатием. Нажмите и удерживайте для выбора суммы',
'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/tr.ts

@ -669,6 +669,18 @@ export default { @@ -669,6 +669,18 @@ export default {
'Default zap comment': 'Default zap comment',
'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)',
'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',
'Include public zap receipt': 'Include public zap receipt',

12
src/i18n/locales/zh.ts

@ -667,6 +667,18 @@ export default { @@ -667,6 +667,18 @@ export default {
'Default zap comment': '默认打闪附言',
'Lightning Address (or LNURL)': '闪电地址 (或 LNURL)',
'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':
'如果启用,您单击即可打闪。长按以设置自定义金额',
'Include public zap receipt': 'Include public zap receipt',

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

@ -0,0 +1,69 @@ @@ -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 @@ @@ -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' @@ -7,7 +7,14 @@ import paytoTypesCatalog from '@/data/payto-types.json'
import { resolvePaytoLogoAssetPath } from '@/lib/payto-logos'
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 = {
placeholder: string

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

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

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

@ -9,6 +9,8 @@ import { @@ -9,6 +9,8 @@ import {
import { kinds } from 'nostr-tools'
import { isSameAccount } from '@/lib/account'
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 { randomString } from '@/lib/random'
import {
@ -53,6 +55,7 @@ const SETTINGS_KEYS = [ @@ -53,6 +55,7 @@ const SETTINGS_KEYS = [
StorageKey.CURRENT_ACCOUNT,
StorageKey.DEFAULT_ZAP_SATS,
StorageKey.DEFAULT_ZAP_COMMENT,
StorageKey.PREFERRED_PAYTO_CATEGORY,
StorageKey.QUICK_ZAP,
StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT,
StorageKey.AUTOPLAY,
@ -100,6 +103,7 @@ class LocalStorageService { @@ -100,6 +103,7 @@ class LocalStorageService {
private noteListMode: TNoteListMode = 'posts'
private defaultZapSats: number = DEFAULT_ZAP_SATS
private defaultZapComment: string = 'Zap!'
private preferredPaytoCategory: PaytoCategory | null = null
private quickZap: boolean = false
private includePublicZapReceipt: boolean = true
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
@ -198,6 +202,11 @@ class LocalStorageService { @@ -198,6 +202,11 @@ class LocalStorageService {
}
}
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'
const includeReceiptStr = window.localStorage.getItem(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT)
if (includeReceiptStr != null) {
@ -596,6 +605,13 @@ class LocalStorageService { @@ -596,6 +605,13 @@ class LocalStorageService {
}
const defaultZapCommentStr = get(StorageKey.DEFAULT_ZAP_COMMENT)
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)
if (quickZapStr != null) this.quickZap = quickZapStr === 'true'
const includeReceiptStr = get(StorageKey.INCLUDE_PUBLIC_ZAP_RECEIPT)
@ -803,6 +819,20 @@ class LocalStorageService { @@ -803,6 +819,20 @@ class LocalStorageService {
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() {
return this.quickZap
}

Loading…
Cancel
Save