19 changed files with 486 additions and 54 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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