19 changed files with 486 additions and 54 deletions
@ -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 } |
||||
} |
||||
@ -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) |
||||
}) |
||||
}) |
||||
@ -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 } |
||||
} |
||||
@ -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> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue