diff --git a/src/lib/api/lnbits.ts b/src/lib/api/lnbits.ts index 700df9a..9464b9c 100644 --- a/src/lib/api/lnbits.ts +++ b/src/lib/api/lnbits.ts @@ -226,6 +226,27 @@ export class LnbitsAPI extends BaseService { }) } + /** + * Currency codes this LNbits instance accepts for fiat-denominated + * amounts (ISO 4217). Unrestricted instances return all ~165; an admin + * can narrow it via `LNBITS_ALLOWED_CURRENCIES`. + */ + async getCurrencies(): Promise { + return this.request('/currencies', { method: 'GET' }) + } + + /** + * The instance's default accounting currency, or `null` when the admin + * has not configured one. + */ + async getDefaultCurrency(): Promise { + const data = await this.request<{ default_currency: string | null }>( + '/default-currency', + { method: 'GET' } + ) + return data.default_currency ?? null + } + isAuthenticated(): boolean { return !!this.accessToken } diff --git a/src/modules/base/composables/useCurrencies.ts b/src/modules/base/composables/useCurrencies.ts new file mode 100644 index 0000000..8f4effe --- /dev/null +++ b/src/modules/base/composables/useCurrencies.ts @@ -0,0 +1,110 @@ +import { ref, computed, type Ref } from 'vue' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { LnbitsAPI } from '@/lib/api/lnbits' + +/** + * Currency codes accepted by this LNbits instance, plus the instance default. + * + * Both are server configuration that cannot change within a session, so the + * fetch is shared process-wide and de-duplicated: a dozen components calling + * this produce one request, not a dozen. Concurrent callers await the same + * in-flight promise rather than racing. + * + * Failure is non-fatal. `currencies` stays empty and callers fall back to + * sats-only, which keeps the wallet usable when the rate service is down. + */ + +const currencies = ref([]) +const defaultCurrency = ref(null) +const loading = ref(false) +const error = ref(null) + +// Shared across every caller so the request happens exactly once. +let inFlight: Promise | null = null +let loaded = false + +/** + * How many decimal places a currency is conventionally written with. + * + * Fiat is not uniformly two decimals: JPY and KRW have none, and entering + * "5.50 JPY" is meaningless. `Intl.NumberFormat` already knows each + * currency's convention, so ask it rather than hardcoding a table. + */ +export function currencyDecimals(code: string): number { + try { + const fmt = new Intl.NumberFormat(undefined, { + style: 'currency', + currency: code, + }) + return fmt.resolvedOptions().maximumFractionDigits ?? 2 + } catch { + // Unknown or non-ISO code — assume the common case. + return 2 + } +} + +/** Format an amount with its currency symbol, in the viewer's locale. */ +export function formatCurrency(amount: number, code: string): string { + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: code, + }).format(amount) + } catch { + return `${amount.toFixed(currencyDecimals(code))} ${code}` + } +} + +export function useCurrencies(): { + currencies: Ref + defaultCurrency: Ref + loading: Ref + error: Ref + hasCurrencies: Ref + load: () => Promise + currencyDecimals: typeof currencyDecimals + formatCurrency: typeof formatCurrency +} { + const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) + + async function load(): Promise { + if (loaded) return + if (inFlight) return inFlight + + loading.value = true + error.value = null + + inFlight = (async () => { + try { + // The default currency is optional context, so a failure there must + // not cost us the currency list itself. + const [list, fallback] = await Promise.all([ + lnbitsAPI.getCurrencies(), + lnbitsAPI.getDefaultCurrency().catch(() => null), + ]) + currencies.value = Array.isArray(list) ? list : [] + defaultCurrency.value = fallback + loaded = true + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to load currencies' + console.warn('[useCurrencies] load failed:', err) + } finally { + loading.value = false + inFlight = null + } + })() + + return inFlight + } + + return { + currencies, + defaultCurrency, + loading, + error, + hasCurrencies: computed(() => currencies.value.length > 0), + load, + currencyDecimals, + formatCurrency, + } +} diff --git a/src/modules/wallet/components/ReceiveDialog.vue b/src/modules/wallet/components/ReceiveDialog.vue index a35f9af..ccf60a5 100644 --- a/src/modules/wallet/components/ReceiveDialog.vue +++ b/src/modules/wallet/components/ReceiveDialog.vue @@ -1,5 +1,5 @@