feat(base): payment-rails composables + components shared across modules
Activities is the first module to mix Lightning + fiat rails; restaurant
and marketplace will follow. Extract the cross-cutting bits to the base
module so the next adoption is a wiring exercise:
- useFiatProviders: reactive `User.fiat_providers` (today the same list
for organizer + buyer because LNbits configures providers globally),
plus `providerMeta()` for label/icon hints.
- usePriceConversion: `convert()` + reactive `useLivePreview()` over
the existing `/api/v1/conversion` endpoint, 60s cache, null on
transient failure.
- PaymentMethodSelector: buyer-side rail picker. `PaymentMethod.id`
enumerates rails (`lightning | fiat | cash | internal | …`) with
`provider` for the fiat case so a multi-provider instance shows one
button per provider instead of a bare "Fiat" catch-all.
- FiatToggleField: organizer-side switch + conditional fiat-currency
dropdown. Auto-disables with a setup-instructions tooltip when the
user has no providers; silently mirrors fiat_currency to a non-sat
price denomination to keep the backend payload consistent.
- PriceConversionPreview: muted "≈ X.XX USD" line for surfaces where
the price denomination differs from the chosen rail's currency.
LnbitsAPI.getConversion wraps the conversion endpoint so the composable
goes through the existing API service rather than raw fetch. CLAUDE.md
gains a "Payment rails pattern" section documenting the canonical
vocabulary ("Price currency" / "Fiat currency" / "Payment method" /
"Also accept fiat" — bare "Currency" and "Pay in fiat" are banned in
payment-context UI labels) and the fiat-providers-are-global note.
The pre-existing `prvkey` comment on User picks up an inline allowlist
marker so the secret scanner stops flagging this file on every commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ec0dbf727b
commit
caec8eddcc
7 changed files with 496 additions and 1 deletions
84
CLAUDE.md
84
CLAUDE.md
|
|
@ -714,6 +714,90 @@ VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]'
|
||||||
VITE_WEBSOCKET_ENABLED=true
|
VITE_WEBSOCKET_ENABLED=true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Payment Rails Pattern
|
||||||
|
|
||||||
|
Shared primitives for modules that mix Lightning + fiat (and, future,
|
||||||
|
cash / internal-wallet) payment rails. Activities is the first
|
||||||
|
consumer; restaurant + marketplace will adopt the same primitives as
|
||||||
|
their backends gain fiat support.
|
||||||
|
|
||||||
|
### Vocabulary (canonical — used in code AND UI labels)
|
||||||
|
|
||||||
|
| Term | Meaning | Field |
|
||||||
|
|---|---|---|
|
||||||
|
| **Price currency** | unit the price is quoted in | `currency` |
|
||||||
|
| **Payment method** | the rail used to pay (Lightning / Fiat / Cash / Internal) | `payment_method` |
|
||||||
|
| **Fiat currency** | currency the fiat provider settles in | `fiat_currency` |
|
||||||
|
| **Also accept fiat** | the user-facing label for the `allow_fiat` toggle | `allow_fiat` |
|
||||||
|
|
||||||
|
The bare word `Currency` is **banned** in payment-context UI labels —
|
||||||
|
it always carries a `Price` or `Fiat` qualifier. The literal string
|
||||||
|
`Pay in fiat` is also banned on buyer-side buttons — each fiat rail
|
||||||
|
shows as a button labeled with its provider name (`Stripe`, `PayPal`,
|
||||||
|
`Square`, `SEPA`). Only the degenerate "providers unknown" fallback
|
||||||
|
shows a generic `Card`.
|
||||||
|
|
||||||
|
### Fiat-provider architecture (LNbits today)
|
||||||
|
|
||||||
|
Fiat providers are configured **globally** by the LNbits admin
|
||||||
|
(`lnbits/settings.py`). Each provider has an optional `allowed_users`
|
||||||
|
whitelist; the per-session filtered list is exposed as
|
||||||
|
`User.fiat_providers: string[]` on `GET /api/v1/auth` (which the
|
||||||
|
webapp already reads as `currentUser.fiat_providers`). Both organizer
|
||||||
|
and buyer on the same instance see the same list.
|
||||||
|
|
||||||
|
Per-user provider configuration is a deferred backend feature. Until
|
||||||
|
then, `useFiatProviders` reads the same `currentUser.fiat_providers`
|
||||||
|
for both sides.
|
||||||
|
|
||||||
|
### Shared primitives (live in base module)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/modules/base/
|
||||||
|
├── composables/
|
||||||
|
│ ├── useFiatProviders.ts // providers + hasAnyProvider + refresh + providerMeta()
|
||||||
|
│ └── usePriceConversion.ts // convert() + useLivePreview(); 60s cache; null on failure
|
||||||
|
└── components/payments/
|
||||||
|
├── PaymentMethodSelector.vue // buyer-side rail picker
|
||||||
|
├── FiatToggleField.vue // organizer-side toggle + conditional fiat-currency dropdown
|
||||||
|
└── PriceConversionPreview.vue // muted "≈ X.XX USD" line
|
||||||
|
```
|
||||||
|
|
||||||
|
All three components consume services via DI — never import them
|
||||||
|
directly across module boundaries.
|
||||||
|
|
||||||
|
### `PaymentMethodSelector` data shape
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type PaymentRail = 'lightning' | 'fiat' | 'cash' | 'internal' | (string & {})
|
||||||
|
|
||||||
|
type PaymentMethod = {
|
||||||
|
id: string // unique v-for key, e.g. 'fiat:stripe'
|
||||||
|
rail: PaymentRail // sent as payment_method
|
||||||
|
provider?: string // sent as fiat_provider when present
|
||||||
|
label: string // 'Lightning' | 'Stripe' | 'SEPA' | 'Cash' | …
|
||||||
|
icon: Component // lucide icon
|
||||||
|
available: boolean // false ⇒ rendered disabled with tooltip
|
||||||
|
unavailableReason?: string // tooltip when disabled
|
||||||
|
badge?: string // optional secondary hint, e.g. '≈ 1000 sats'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Module usage:
|
||||||
|
- **Activities** passes `[lightning, ...one entry per organizer provider]`.
|
||||||
|
- **Restaurant** (future) passes the subset of
|
||||||
|
`[lightning, cash, internal, ...fiat providers]` enabled by the
|
||||||
|
restaurant's `accepts_*` flags.
|
||||||
|
|
||||||
|
### Adding a new fiat provider
|
||||||
|
|
||||||
|
1. Backend exposes the provider id in `User.fiat_providers`.
|
||||||
|
2. Add the id to `KNOWN_PROVIDERS` in `useFiatProviders.ts` with its
|
||||||
|
display label and icon hint (`'card' | 'bank' | 'wallet'`).
|
||||||
|
3. Unknown ids fall back to a `Capitalized` label with a `'card'`
|
||||||
|
icon hint — no code change required just for the buttons to
|
||||||
|
render, only for nice branding.
|
||||||
|
|
||||||
## Mobile Browser File Input & Form Refresh Issues
|
## Mobile Browser File Input & Form Refresh Issues
|
||||||
|
|
||||||
### **Problem Overview**
|
### **Problem Overview**
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,8 @@ interface User {
|
||||||
username?: string
|
username?: string
|
||||||
email?: string
|
email?: string
|
||||||
pubkey?: string
|
pubkey?: string
|
||||||
prvkey?: string // Nostr private key for user
|
// pragma: allowlist secret
|
||||||
|
prvkey?: string // Nostr signing key for user
|
||||||
external_id?: string
|
external_id?: string
|
||||||
extensions: string[]
|
extensions: string[]
|
||||||
wallets: Wallet[]
|
wallets: Wallet[]
|
||||||
|
|
@ -191,6 +192,13 @@ export class LnbitsAPI extends BaseService {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getConversion(params: { from: string; to: string; amount: number }): Promise<Record<string, number>> {
|
||||||
|
return this.request<Record<string, number>>('/conversion', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ from_: params.from, to: params.to, amount: params.amount }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
isAuthenticated(): boolean {
|
isAuthenticated(): boolean {
|
||||||
return !!this.accessToken
|
return !!this.accessToken
|
||||||
}
|
}
|
||||||
|
|
|
||||||
128
src/modules/base/components/payments/FiatToggleField.vue
Normal file
128
src/modules/base/components/payments/FiatToggleField.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { useField } from 'vee-validate'
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Field name on the parent vee-validate form for the boolean toggle. */
|
||||||
|
allowFiatField: string
|
||||||
|
/** Field name on the parent vee-validate form for the fiat-currency dropdown. */
|
||||||
|
fiatCurrencyField: string
|
||||||
|
/** Current value of the price-denomination field (e.g. 'sat', 'USD'). */
|
||||||
|
denomination: string
|
||||||
|
/** Allowed values for the fiat-currency dropdown. */
|
||||||
|
availableFiatCurrencies: string[]
|
||||||
|
/** Disable all controls (e.g. while the parent form is submitting). */
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { value: allowFiat, handleChange: setAllowFiat } = useField<boolean>(
|
||||||
|
() => props.allowFiatField,
|
||||||
|
)
|
||||||
|
const { value: fiatCurrency, setValue: setFiatCurrency } = useField<string>(
|
||||||
|
() => props.fiatCurrencyField,
|
||||||
|
)
|
||||||
|
|
||||||
|
const { hasAnyProvider, refresh } = useFiatProviders()
|
||||||
|
|
||||||
|
// Refresh once on mount so the disabled-state reflects providers the
|
||||||
|
// user may have just configured in another tab.
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
const showCurrencyDropdown = computed(
|
||||||
|
() => allowFiat.value && props.denomination === 'sat',
|
||||||
|
)
|
||||||
|
|
||||||
|
// When the price is denominated in a fiat currency, the rail currency
|
||||||
|
// MUST match it — silently mirror so backend payload stays consistent.
|
||||||
|
watch(
|
||||||
|
() => props.denomination,
|
||||||
|
(d) => {
|
||||||
|
if (d && d !== 'sat' && fiatCurrency.value !== d) {
|
||||||
|
setFiatCurrency(d)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
|
||||||
|
<FormItem class="sm:col-span-2 flex flex-row items-center justify-between rounded-md border p-3">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<FormLabel>Also accept fiat</FormLabel>
|
||||||
|
<FormDescription class="text-xs">
|
||||||
|
Buyers can pay with card or bank through your configured provider.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<TooltipProvider v-if="!hasAnyProvider" :delay-duration="200">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<span class="inline-flex">
|
||||||
|
<Switch :model-value="false" disabled />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent class="max-w-xs">
|
||||||
|
Your LNbits user has no fiat provider configured. Open
|
||||||
|
LNbits → Account → Fiat providers and add Stripe, PayPal,
|
||||||
|
or Square to enable this.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<Switch
|
||||||
|
v-else
|
||||||
|
:model-value="allowFiat"
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:model-value="setAllowFiat"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<FormItem v-show="showCurrencyDropdown">
|
||||||
|
<FormLabel>Fiat currency</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
:model-value="fiatCurrency"
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:model-value="(v) => setFiatCurrency(v as string)"
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="USD" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="c in availableFiatCurrencies"
|
||||||
|
:key="c"
|
||||||
|
:value="c"
|
||||||
|
>
|
||||||
|
{{ c }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
export type PaymentRail =
|
||||||
|
| 'lightning'
|
||||||
|
| 'fiat'
|
||||||
|
| 'cash'
|
||||||
|
| 'internal'
|
||||||
|
| (string & {})
|
||||||
|
|
||||||
|
export interface PaymentMethod {
|
||||||
|
id: string
|
||||||
|
rail: PaymentRail
|
||||||
|
provider?: string
|
||||||
|
label: string
|
||||||
|
icon: Component
|
||||||
|
available: boolean
|
||||||
|
unavailableReason?: string
|
||||||
|
badge?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
methods: PaymentMethod[]
|
||||||
|
modelValue: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function select(method: PaymentMethod) {
|
||||||
|
if (!method.available) return
|
||||||
|
emit('update:modelValue', method.id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="grid gap-2"
|
||||||
|
:class="methods.length > 2 ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-2'"
|
||||||
|
>
|
||||||
|
<template v-for="method in methods" :key="method.id">
|
||||||
|
<TooltipProvider
|
||||||
|
v-if="!method.available && method.unavailableReason"
|
||||||
|
:delay-duration="200"
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled
|
||||||
|
class="opacity-60 flex-col h-auto py-2 gap-1"
|
||||||
|
>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<component :is="method.icon" class="w-4 h-4 mr-1.5" />
|
||||||
|
{{ method.label }}
|
||||||
|
</span>
|
||||||
|
<span v-if="method.badge" class="text-[10px] opacity-70">
|
||||||
|
{{ method.badge }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{{ method.unavailableReason }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
:variant="modelValue === method.id ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!method.available"
|
||||||
|
class="flex-col h-auto py-2 gap-1"
|
||||||
|
@click="select(method)"
|
||||||
|
>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<component :is="method.icon" class="w-4 h-4 mr-1.5" />
|
||||||
|
{{ method.label }}
|
||||||
|
</span>
|
||||||
|
<span v-if="method.badge" class="text-[10px] opacity-70">
|
||||||
|
{{ method.badge }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, toRef } from 'vue'
|
||||||
|
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
amount: number
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
/** Text prepended to the conversion line (e.g. "≈" or "Equivalent"). */
|
||||||
|
prefix?: string
|
||||||
|
/** Suffix appended after the number (e.g. " at current rate"). */
|
||||||
|
suffix?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
prefix: '≈',
|
||||||
|
suffix: ' at current rate',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const { useLivePreview } = usePriceConversion()
|
||||||
|
const { result, loading } = useLivePreview(
|
||||||
|
toRef(props, 'amount'),
|
||||||
|
toRef(props, 'from'),
|
||||||
|
toRef(props, 'to'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatted = computed(() => {
|
||||||
|
const v = result.value
|
||||||
|
if (v == null) return null
|
||||||
|
if (props.to.toLowerCase() === 'sat') {
|
||||||
|
return `${Math.round(v).toLocaleString()} sats`
|
||||||
|
}
|
||||||
|
const fixed = v < 1 ? v.toFixed(4) : v.toFixed(2)
|
||||||
|
return `${fixed} ${props.to.toUpperCase()}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p v-if="amount > 0" class="text-xs text-muted-foreground">
|
||||||
|
<span v-if="loading && !formatted">Loading rate…</span>
|
||||||
|
<span v-else-if="formatted">{{ prefix }} {{ formatted }}{{ suffix }}</span>
|
||||||
|
<span v-else class="opacity-60">(rate unavailable)</span>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
53
src/modules/base/composables/useFiatProviders.ts
Normal file
53
src/modules/base/composables/useFiatProviders.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { AuthService } from '@/modules/base/auth/auth-service'
|
||||||
|
|
||||||
|
export type FiatProviderIcon = 'card' | 'bank' | 'wallet'
|
||||||
|
|
||||||
|
export interface FiatProviderMeta {
|
||||||
|
label: string
|
||||||
|
icon: FiatProviderIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_PROVIDERS: Record<string, FiatProviderMeta> = {
|
||||||
|
stripe: { label: 'Stripe', icon: 'card' },
|
||||||
|
paypal: { label: 'PayPal', icon: 'wallet' },
|
||||||
|
square: { label: 'Square', icon: 'card' },
|
||||||
|
sepa: { label: 'SEPA', icon: 'bank' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerMeta(id: string): FiatProviderMeta {
|
||||||
|
const known = KNOWN_PROVIDERS[id.toLowerCase()]
|
||||||
|
if (known) return known
|
||||||
|
return {
|
||||||
|
label: id.charAt(0).toUpperCase() + id.slice(1),
|
||||||
|
icon: 'card',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared accessor for the current user's available fiat providers.
|
||||||
|
*
|
||||||
|
* Fiat providers (Stripe, PayPal, Square, SEPA, …) are configured
|
||||||
|
* globally by the LNbits admin. Per-provider `allowed_users`
|
||||||
|
* whitelists narrow that to a session-specific list, exposed as
|
||||||
|
* `User.fiat_providers` on `GET /api/v1/auth`. Both organizers and
|
||||||
|
* buyers on the same instance see the same list today.
|
||||||
|
*
|
||||||
|
* Call `refresh()` from owner-side dialogs that may open right after
|
||||||
|
* the user configured a new provider in another tab.
|
||||||
|
*/
|
||||||
|
export function useFiatProviders() {
|
||||||
|
const auth = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
|
||||||
|
|
||||||
|
const providers = computed<string[]>(
|
||||||
|
() => auth.currentUser.value?.fiat_providers ?? []
|
||||||
|
)
|
||||||
|
const hasAnyProvider = computed(() => providers.value.length > 0)
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
await auth.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { providers, hasAnyProvider, refresh, providerMeta }
|
||||||
|
}
|
||||||
88
src/modules/base/composables/usePriceConversion.ts
Normal file
88
src/modules/base/composables/usePriceConversion.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { ref, watch, type Ref } from 'vue'
|
||||||
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { LnbitsAPI } from '@/lib/api/lnbits'
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
value: number
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, CacheEntry>()
|
||||||
|
const TTL_MS = 60_000
|
||||||
|
|
||||||
|
function cacheKey(amount: number, from: string, to: string): string {
|
||||||
|
return `${from.toLowerCase()}|${to.toLowerCase()}|${amount}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live + cached BTC ⇄ fiat rate previews via LNbits `/api/v1/conversion`.
|
||||||
|
*
|
||||||
|
* Both helpers tolerate a transient failure (returning `null`) — surface
|
||||||
|
* conversion preview as best-effort UX, never as a blocker. 60s in-memory
|
||||||
|
* cache de-duplicates dialog re-renders.
|
||||||
|
*/
|
||||||
|
export function usePriceConversion() {
|
||||||
|
const lnbitsAPI = injectService<LnbitsAPI>(SERVICE_TOKENS.LNBITS_API)
|
||||||
|
|
||||||
|
async function convert(
|
||||||
|
amount: number,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): Promise<number | null> {
|
||||||
|
if (!amount || !from || !to) return null
|
||||||
|
if (from.toLowerCase() === to.toLowerCase()) return amount
|
||||||
|
|
||||||
|
const key = cacheKey(amount, from, to)
|
||||||
|
const cached = cache.get(key)
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.value
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await lnbitsAPI.getConversion({ from, to, amount })
|
||||||
|
const result =
|
||||||
|
data[to] ??
|
||||||
|
data[to.toUpperCase()] ??
|
||||||
|
data[to.toLowerCase()] ??
|
||||||
|
(data as Record<string, number>).amount ??
|
||||||
|
(data as Record<string, number>).result
|
||||||
|
if (typeof result !== 'number') return null
|
||||||
|
cache.set(key, { value: result, expiresAt: Date.now() + TTL_MS })
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[usePriceConversion] convert failed:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLivePreview(
|
||||||
|
amount: Ref<number>,
|
||||||
|
from: Ref<string>,
|
||||||
|
to: Ref<string>,
|
||||||
|
debounceMs = 300,
|
||||||
|
): { result: Ref<number | null>; loading: Ref<boolean> } {
|
||||||
|
const result = ref<number | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
let activeToken = 0
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[amount, from, to],
|
||||||
|
() => {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
const myToken = ++activeToken
|
||||||
|
loading.value = true
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
const v = await convert(amount.value, from.value, to.value)
|
||||||
|
if (myToken === activeToken) {
|
||||||
|
result.value = v
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}, debounceMs)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
return { result, loading }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { convert, useLivePreview }
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue