feat: add currency setting with fiat conversion, display prices in user's preferred currency

This commit is contained in:
2026-02-05 17:02:03 +01:00
parent 84493942fe
commit 56cf5a63c3
9 changed files with 125 additions and 20 deletions

View File

@@ -4,28 +4,43 @@ import { getXmrRates, formatFiat } from '../services/currency.js'
class AppFooter extends HTMLElement { class AppFooter extends HTMLElement {
constructor() { constructor() {
super() super()
this.xmrRate = null this.rates = null
this.handleCurrencyChange = this.handleCurrencyChange.bind(this)
} }
async connectedCallback() { async connectedCallback() {
this.render() this.render()
await this.loadXmrRate() await this.loadXmrRates()
window.addEventListener('currency-changed', this.handleCurrencyChange)
} }
async loadXmrRate() { disconnectedCallback() {
window.removeEventListener('currency-changed', this.handleCurrencyChange)
}
handleCurrencyChange() {
this.updateRateDisplay()
}
getCurrency() {
return localStorage.getItem('dgray_currency') || 'USD'
}
async loadXmrRates() {
try { try {
const rates = await getXmrRates() this.rates = await getXmrRates()
this.xmrRate = rates.EUR
this.updateRateDisplay() this.updateRateDisplay()
} catch (e) { } catch (e) {
console.error('Failed to load XMR rate:', e) console.error('Failed to load XMR rates:', e)
} }
} }
updateRateDisplay() { updateRateDisplay() {
const rateEl = this.querySelector('.xmr-rate') const rateEl = this.querySelector('.xmr-rate')
if (rateEl && this.xmrRate) { if (rateEl && this.rates) {
rateEl.textContent = `1 XMR ≈ ${formatFiat(this.xmrRate, 'EUR')}` const currency = this.getCurrency()
const rate = this.rates[currency] || this.rates.USD
rateEl.textContent = `1 XMR ≈ ${formatFiat(rate, currency)}`
rateEl.classList.add('loaded') rateEl.classList.add('loaded')
} }
} }
@@ -51,7 +66,7 @@ class AppFooter extends HTMLElement {
updateTranslations() { updateTranslations() {
this.render() this.render()
if (this.xmrRate) this.updateRateDisplay() if (this.rates) this.updateRateDisplay()
} }
} }

View File

@@ -15,6 +15,7 @@ class ListingCard extends HTMLElement {
this.isFavorite = false this.isFavorite = false
this.rates = null this.rates = null
this.isOwner = false this.isOwner = false
this.handleCurrencyChange = this.handleCurrencyChange.bind(this)
} }
async connectedCallback() { async connectedCallback() {
@@ -23,6 +24,16 @@ class ListingCard extends HTMLElement {
await this.checkOwnership() await this.checkOwnership()
this.render() this.render()
this.setupEventListeners() this.setupEventListeners()
window.addEventListener('currency-changed', this.handleCurrencyChange)
}
disconnectedCallback() {
window.removeEventListener('currency-changed', this.handleCurrencyChange)
}
handleCurrencyChange() {
this.render()
this.setupEventListeners()
} }
async checkOwnership() { async checkOwnership() {

View File

@@ -2,7 +2,7 @@ import { t, i18n } from '../../i18n.js'
import { router } from '../../router.js' import { router } from '../../router.js'
import { auth } from '../../services/auth.js' import { auth } from '../../services/auth.js'
import { directus } from '../../services/directus.js' import { directus } from '../../services/directus.js'
import { SUPPORTED_CURRENCIES } from '../../services/currency.js' import { SUPPORTED_CURRENCIES, getDisplayCurrency } from '../../services/currency.js'
import { escapeHTML } from '../../utils/helpers.js' import { escapeHTML } from '../../utils/helpers.js'
import '../location-picker.js' import '../location-picker.js'
import '../pow-captcha.js' import '../pow-captcha.js'
@@ -25,11 +25,16 @@ class PageCreate extends HTMLElement {
} }
getEmptyFormData() { getEmptyFormData() {
// Use user's preferred currency from settings as default
const defaultCurrency = getDisplayCurrency()
// Map display currencies to listing currencies (XMR not for fiat listings)
const currency = ['EUR', 'USD', 'CHF'].includes(defaultCurrency) ? defaultCurrency : 'EUR'
return { return {
title: '', title: '',
description: '', description: '',
price: '', price: '',
currency: 'EUR', currency,
price_mode: 'fiat', price_mode: 'fiat',
price_type: 'fixed', price_type: 'fixed',
category: '', category: '',

View File

@@ -17,6 +17,7 @@ class PageListing extends HTMLElement {
this.isFavorite = false this.isFavorite = false
this.rates = null this.rates = null
this.isOwner = false this.isOwner = false
this.handleCurrencyChange = this.handleCurrencyChange.bind(this)
} }
connectedCallback() { connectedCallback() {
@@ -24,10 +25,16 @@ class PageListing extends HTMLElement {
this.render() this.render()
this.loadListing() this.loadListing()
this.unsubscribe = i18n.subscribe(() => this.render()) this.unsubscribe = i18n.subscribe(() => this.render())
window.addEventListener('currency-changed', this.handleCurrencyChange)
} }
disconnectedCallback() { disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe() if (this.unsubscribe) this.unsubscribe()
window.removeEventListener('currency-changed', this.handleCurrencyChange)
}
handleCurrencyChange() {
this.render()
} }
async loadListing() { async loadListing() {

View File

@@ -50,6 +50,12 @@ class PageSettings extends HTMLElement {
i18n.setLocale(e.target.value) i18n.setLocale(e.target.value)
}) })
// Currency select
this.querySelector('#currency-select')?.addEventListener('change', (e) => {
this.setCurrency(e.target.value)
this.showToast(t('settings.currencyChanged'))
})
// Clear favorites // Clear favorites
this.querySelector('#clear-favorites')?.addEventListener('click', () => { this.querySelector('#clear-favorites')?.addEventListener('click', () => {
if (confirm(t('settings.confirmClearFavorites'))) { if (confirm(t('settings.confirmClearFavorites'))) {
@@ -93,6 +99,15 @@ class PageSettings extends HTMLElement {
return localStorage.getItem('theme') || 'system' return localStorage.getItem('theme') || 'system'
} }
getCurrentCurrency() {
return localStorage.getItem('dgray_currency') || 'USD'
}
setCurrency(currency) {
localStorage.setItem('dgray_currency', currency)
window.dispatchEvent(new CustomEvent('currency-changed', { detail: { currency } }))
}
showToast(message) { showToast(message) {
const existing = document.querySelector('.settings-toast') const existing = document.querySelector('.settings-toast')
existing?.remove() existing?.remove()
@@ -152,6 +167,15 @@ class PageSettings extends HTMLElement {
<option value="fr" ${currentLang === 'fr' ? 'selected' : ''}>Français</option> <option value="fr" ${currentLang === 'fr' ? 'selected' : ''}>Français</option>
</select> </select>
</div> </div>
<div class="setting-item">
<label for="currency-select">${t('settings.currency')}</label>
<select id="currency-select">
<option value="USD" ${this.getCurrentCurrency() === 'USD' ? 'selected' : ''}>USD ($)</option>
<option value="EUR" ${this.getCurrentCurrency() === 'EUR' ? 'selected' : ''}>EUR (€)</option>
<option value="CHF" ${this.getCurrentCurrency() === 'CHF' ? 'selected' : ''}>CHF</option>
</select>
</div>
</section> </section>
<!-- Account --> <!-- Account -->

View File

@@ -165,14 +165,44 @@ export function convertFromXmr(xmrAmount, currency, rates) {
return xmrAmount * rates[currency] return xmrAmount * rates[currency]
} }
/**
* Converts between fiat currencies using XMR as bridge
* @param {number} amount - Amount in source currency
* @param {string} fromCurrency - Source currency
* @param {string} toCurrency - Target currency
* @param {Object} rates - Rates from getXmrRates() (XMR -> Fiat)
* @returns {number} Amount in target currency
*/
export function convertFiat(amount, fromCurrency, toCurrency, rates) {
if (fromCurrency === toCurrency) return amount
if (!rates[fromCurrency] || !rates[toCurrency]) return amount
// Use XMR as bridge: EUR -> XMR -> USD
// 1 XMR = rates[EUR] EUR, so 1 EUR = 1/rates[EUR] XMR
// 1 XMR = rates[USD] USD
// Therefore: 1 EUR = rates[USD] / rates[EUR] USD
return amount * (rates[toCurrency] / rates[fromCurrency])
}
/**
* Get user's preferred display currency from settings
* @returns {string} Currency code (default: 'USD')
*/
export function getDisplayCurrency() {
return localStorage.getItem('dgray_currency') || 'USD'
}
/** /**
* Formats a price for display * Formats a price for display
* Shows price in user's preferred currency with XMR equivalent
* @param {Object} listing - Listing with price, currency, price_mode * @param {Object} listing - Listing with price, currency, price_mode
* @param {Object} rates - Rates from getXmrRates() * @param {Object} rates - Rates from getXmrRates()
* @param {string} [displayCurrency] - Override display currency
* @returns {Object} { primary, secondary, xmrAmount } * @returns {Object} { primary, secondary, xmrAmount }
*/ */
export function formatPrice(listing, rates) { export function formatPrice(listing, rates, displayCurrency = null) {
const { price, currency, price_mode } = listing const { price, currency, price_mode } = listing
const userCurrency = displayCurrency || getDisplayCurrency()
if (!price || price === 0) { if (!price || price === 0) {
return { return {
@@ -185,20 +215,25 @@ export function formatPrice(listing, rates) {
// XMR mode: XMR is the reference price // XMR mode: XMR is the reference price
if (price_mode === 'xmr' || currency === 'XMR') { if (price_mode === 'xmr' || currency === 'XMR') {
const xmrPrice = currency === 'XMR' ? price : convertToXmr(price, currency, rates) const xmrPrice = currency === 'XMR' ? price : convertToXmr(price, currency, rates)
const fiatEquivalent = currency !== 'XMR' ? price : convertFromXmr(price, 'EUR', rates) const fiatEquivalent = convertFromXmr(xmrPrice, userCurrency, rates)
return { return {
primary: formatXmr(xmrPrice), primary: formatFiat(fiatEquivalent, userCurrency),
secondary: currency !== 'XMR' ? `${formatFiat(price, currency)}` : `${formatFiat(fiatEquivalent, 'EUR')}`, secondary: `${formatXmr(xmrPrice)}`,
xmrAmount: xmrPrice xmrAmount: xmrPrice
} }
} }
// Fiat mode: Fiat is the reference price // Fiat mode: Convert to user's preferred currency
const xmrEquivalent = convertToXmr(price, currency, rates) const xmrEquivalent = convertToXmr(price, currency, rates)
// Always show in user's currency as primary
const displayPrice = userCurrency === currency
? price
: convertFiat(price, currency, userCurrency, rates)
return { return {
primary: formatFiat(price, currency), primary: formatFiat(displayPrice, userCurrency),
secondary: `${formatXmr(xmrEquivalent)}`, secondary: `${formatXmr(xmrEquivalent)}`,
xmrAmount: xmrEquivalent xmrAmount: xmrEquivalent
} }
@@ -253,8 +288,10 @@ export const SUPPORTED_CURRENCIES = ['XMR', 'EUR', 'CHF', 'USD', 'GBP', 'JPY']
export default { export default {
getXmrRates, getXmrRates,
getDisplayCurrency,
convertToXmr, convertToXmr,
convertFromXmr, convertFromXmr,
convertFiat,
formatPrice, formatPrice,
formatXmr, formatXmr,
formatFiat, formatFiat,

View File

@@ -305,6 +305,8 @@
"confirmClearSearch": "Suchverlauf löschen?", "confirmClearSearch": "Suchverlauf löschen?",
"favoritesCleared": "Favoriten gelöscht", "favoritesCleared": "Favoriten gelöscht",
"searchCleared": "Suchverlauf gelöscht", "searchCleared": "Suchverlauf gelöscht",
"about": "Über" "about": "Über",
"currency": "Währung",
"currencyChanged": "Währung geändert"
} }
} }

View File

@@ -305,6 +305,8 @@
"confirmClearSearch": "Delete search history?", "confirmClearSearch": "Delete search history?",
"favoritesCleared": "Favorites deleted", "favoritesCleared": "Favorites deleted",
"searchCleared": "Search history deleted", "searchCleared": "Search history deleted",
"about": "About" "about": "About",
"currency": "Currency",
"currencyChanged": "Currency changed"
} }
} }

View File

@@ -305,6 +305,8 @@
"confirmClearSearch": "Supprimer l'historique de recherche ?", "confirmClearSearch": "Supprimer l'historique de recherche ?",
"favoritesCleared": "Favoris supprimés", "favoritesCleared": "Favoris supprimés",
"searchCleared": "Historique de recherche supprimé", "searchCleared": "Historique de recherche supprimé",
"about": "À propos" "about": "À propos",
"currency": "Devise",
"currencyChanged": "Devise modifiée"
} }
} }