From 32bc5aed0500375e61742361f029945323b09621 Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Sat, 31 Jan 2026 13:28:41 +0100 Subject: [PATCH] add currency service, update directus setup --- docs/DIRECTUS-SETUP.md | 557 ++++++++++++++++++++-------------------- js/services/currency.js | 205 +++++++++++++++ 2 files changed, 484 insertions(+), 278 deletions(-) create mode 100644 js/services/currency.js diff --git a/docs/DIRECTUS-SETUP.md b/docs/DIRECTUS-SETUP.md index 7a0635a..004b245 100644 --- a/docs/DIRECTUS-SETUP.md +++ b/docs/DIRECTUS-SETUP.md @@ -233,7 +233,7 @@ E2E-verschlüsselter Chat mit **Zero-Knowledge Metadaten** - Server weiß nicht, | `user` | User (M2O) | User | Auto (user_created) | | `listing` | Listings (M2O) | Many-to-One | Required | -**Unique Constraint:** `user` + `listing` +**Duplikat-Prüfung:** Frontend-seitig vor dem Erstellen prüfen, ob bereits favorisiert. --- @@ -243,9 +243,9 @@ E2E-verschlüsselter Chat mit **Zero-Knowledge Metadaten** - Server weiß nicht, |------|-----|-----------|---------------| | `id` | UUID | – | Primary Key | | `date_created` | DateTime | DateTime | Auto | -| `reporter` | User (M2O) | User | Auto | -| `listing` | Listings (M2O) | Many-to-One | Optional | -| `reported_user` | User (M2O) | Many-to-One | Optional | +| `reporter` | directus_users (M2O) | Many-to-One | Auto (user_created) | +| `listing` | listings (M2O) | Many-to-One | Optional | +| `reported_user` | directus_users (M2O) | Many-to-One | Optional | | `reason` | String | Dropdown | `spam`, `fraud`, `inappropriate`, `illegal`, `other` | | `details` | Text | Textarea | | | `status` | String | Dropdown | `pending`, `reviewed`, `resolved`, `dismissed` | @@ -328,193 +328,149 @@ App Access: ON ## 3. Access Policies +Konfiguration unter: **Settings > Access Control > [Rolle] > [Collection]** + +--- + ### 3.1 Public (Nicht angemeldet) -**listings:** -```json -{ - "read": { - "_and": [ - { "status": { "_eq": "published" } } - ] - }, - "fields": ["id", "title", "slug", "description", "price", "currency", "price_type", "category", "condition", "images", "location", "shipping", "date_created", "views"] -} -``` +Pfad: Settings > Access Control > Public -**categories:** -```json -{ - "read": { - "status": { "_eq": "published" } - }, - "fields": "*" -} -``` +#### listings -**locations:** -```json -{ - "read": true, - "fields": "*" -} -``` +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Read | ✅ Custom | `status` equals `published` | +| Create | ❌ | – | +| Update | ❌ | – | +| Delete | ❌ | – | -**directus_files:** -```json -{ - "read": true -} -``` +**Field Permissions (Read):** `id`, `title`, `slug`, `description`, `price`, `currency`, `price_mode`, `price_type`, `category`, `condition`, `images`, `location`, `shipping`, `date_created`, `views` + +#### categories + +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Read | ✅ Custom | `status` equals `published` | + +**Field Permissions:** Alle Felder + +#### locations + +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Read | ✅ All | – | + +#### directus_files + +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Read | ✅ All | – | --- ### 3.2 User Role Permissions -**listings:** -```json -{ - "create": true, - "read": { - "_or": [ - { "status": { "_eq": "published" } }, - { "user_created": { "_eq": "$CURRENT_USER" } } - ] - }, - "update": { - "user_created": { "_eq": "$CURRENT_USER" } - }, - "delete": { - "_and": [ - { "user_created": { "_eq": "$CURRENT_USER" } }, - { "status": { "_in": ["draft", "expired"] } } - ] - } -} -``` +Pfad: Settings > Access Control > User -**conversations:** -```json -{ - "create": true, - "read": { - "_or": [ - { "buyer": { "_eq": "$CURRENT_USER" } }, - { "seller": { "_eq": "$CURRENT_USER" } } - ] - }, - "update": { - "_or": [ - { "buyer": { "_eq": "$CURRENT_USER" } }, - { "seller": { "_eq": "$CURRENT_USER" } } - ] - } -} -``` +#### listings -**messages:** -```json -{ - "create": { - "conversation": { - "_or": [ - { "buyer": { "_eq": "$CURRENT_USER" } }, - { "seller": { "_eq": "$CURRENT_USER" } } - ] - } - }, - "read": { - "conversation": { - "_or": [ - { "buyer": { "_eq": "$CURRENT_USER" } }, - { "seller": { "_eq": "$CURRENT_USER" } } - ] - } - } -} -``` +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Create | ✅ All | – | +| Read | ✅ Custom | `status` equals `published` OR `user_created` equals `$CURRENT_USER` | +| Update | ✅ Custom | `user_created` equals `$CURRENT_USER` | +| Delete | ✅ Custom | `user_created` equals `$CURRENT_USER` AND `status` in `draft, expired` | -**favorites:** -```json -{ - "create": true, - "read": { - "user": { "_eq": "$CURRENT_USER" } - }, - "delete": { - "user": { "_eq": "$CURRENT_USER" } - } -} -``` +#### conversations -**reports:** -```json -{ - "create": true -} -``` +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Create | ✅ All | – | +| Read | ✅ All | – | +| Update | ✅ All | – | -**directus_files:** -```json -{ - "create": true, - "read": true, - "update": { - "uploaded_by": { "_eq": "$CURRENT_USER" } - }, - "delete": { - "uploaded_by": { "_eq": "$CURRENT_USER" } - } -} -``` +**Privacy-Hinweis:** Serverseitiges Filtern nicht möglich (Zero-Knowledge). Das Frontend: +1. Lädt alle Conversations für ein Listing (`listing_id` Filter) +2. Prüft client-seitig, ob eigener Hash in `participant_hash_1` oder `participant_hash_2` ist +3. Zeigt nur passende Conversations an -**directus_users (eigenes Profil):** -```json -{ - "read": { - "id": { "_eq": "$CURRENT_USER" } - }, - "update": { - "id": { "_eq": "$CURRENT_USER" } - }, - "fields": ["id", "email", "first_name", "last_name", "avatar", "status"] -} -``` +#### messages + +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Create | ✅ All | – | +| Read | ✅ All | – | + +**Hinweis:** Inhalte sind E2E-verschlüsselt, daher keine serverseitige Filterung nötig. + +#### favorites + +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Create | ✅ All | – | +| Read | ✅ Custom | `user` equals `$CURRENT_USER` | +| Delete | ✅ Custom | `user` equals `$CURRENT_USER` | + +#### reports + +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Create | ✅ All | – | +| Read | ❌ | – | +| Update | ❌ | – | + +#### directus_files + +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Create | ✅ All | – | +| Read | ✅ All | – | +| Update | ✅ Custom | `uploaded_by` equals `$CURRENT_USER` | +| Delete | ✅ Custom | `uploaded_by` equals `$CURRENT_USER` | + +#### directus_users (eigenes Profil) + +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Read | ✅ Custom | `id` equals `$CURRENT_USER` | +| Update | ✅ Custom | `id` equals `$CURRENT_USER` | + +**Field Permissions:** `id`, `first_name`, `avatar`, `status` --- ### 3.3 Moderator Role Permissions -Zusätzlich zu User-Permissions: +Pfad: Settings > Access Control > Moderator -**listings:** -```json -{ - "read": true, - "update": { - "fields": ["status", "admin_notes"] - } -} -``` +Alle User-Permissions plus: -**reports:** -```json -{ - "read": true, - "update": { - "fields": ["status", "admin_notes", "resolved_by", "resolved_at"] - } -} -``` +#### listings -**directus_users (öffentliche Felder anderer User):** -```json -{ - "read": { - "fields": ["id", "first_name", "avatar", "status"] - } -} -``` +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Read | ✅ All | – | +| Update | ✅ All | – | + +**Field Permissions (Update):** nur `status`, `admin_notes` + +#### reports + +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Read | ✅ All | – | +| Update | ✅ All | – | + +**Field Permissions (Update):** `status`, `admin_notes`, `resolved_by`, `resolved_at` + +#### directus_users (andere User sehen) + +| Aktion | Erlaubt | Filter | +|--------|---------|--------| +| Read | ✅ All | – | + +**Field Permissions (Read):** nur `id`, `first_name`, `avatar`, `status` --- @@ -558,9 +514,16 @@ Payload: { "slug": "{{$last.slug}}" } ### 4.2 Auto-Set Expiry Date -**Trigger:** `items.create` auf `listings` +Setzt automatisch ein Ablaufdatum 30 Tage in der Zukunft. + +**Flow-Aufbau:** + +1. **Trigger:** `items.create` auf `listings` +2. **Operation 1:** Run Script (Datum berechnen) +3. **Operation 2:** Update Data (Datum speichern) ```javascript +// Operation 1: Run Script module.exports = async function(data) { const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 30); @@ -568,54 +531,88 @@ module.exports = async function(data) { } ``` ---- - -### 4.3 Set Seller on Conversation Create - -**Trigger:** `items.create` auf `conversations` - -```javascript -module.exports = async function(data, { database }) { - const listing = await database('listings') - .where('id', data.listing) - .first(); - - return { seller: listing.user_created }; -} +``` +// Operation 2: Update Data +Collection: listings +ID: {{$trigger.key}} +Payload: { "expires_at": "{{$last.expires_at}}" } ``` +**Ergebnis:** Listing am 01.01. erstellt → `expires_at` = 31.01. + --- -### 4.4 Increment View Counter +### 4.3 Increment View Counter -**Trigger:** Custom Endpoint oder Hook +Erhöht den View-Counter bei jedem Aufruf einer Listing-Detailseite. + +**Flow-Aufbau:** + +1. **Trigger:** Webhook (vom Frontend aufgerufen) +2. **Operation 1:** Run Script (Counter erhöhen) + +``` +// Trigger: Webhook +Name: increment-views +Method: POST +URL: /flows/trigger/increment-views +``` ```javascript -// Webhook/Endpoint für View-Tracking +// Operation 1: Run Script module.exports = async function(data, { database }) { + if (!data.listing_id) return; + await database('listings') .where('id', data.listing_id) .increment('views', 1); + + return { success: true }; } ``` +**Frontend-Aufruf:** +```javascript +fetch('/flows/trigger/increment-views', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ listing_id: 'uuid-here' }) +}); +``` + --- -### 4.5 Auto-Expire Listings (Scheduled) +### 4.4 Auto-Expire Listings (Scheduled) -**Trigger:** Schedule (täglich um 00:00) +Markiert abgelaufene Listings automatisch als `expired`. + +**Flow-Aufbau:** + +1. **Trigger:** Schedule (Cron) +2. **Operation 1:** Run Script (Abgelaufene updaten) + +``` +// Trigger: Schedule +Type: Schedule (Cron) +Cron: 0 0 * * * (täglich um 00:00 UTC) +``` ```javascript +// Operation 1: Run Script module.exports = async function(data, { database }) { const now = new Date().toISOString(); - await database('listings') + const updated = await database('listings') .where('expires_at', '<', now) .where('status', 'published') .update({ status: 'expired' }); + + return { expired_count: updated }; } ``` +**Ergebnis:** Alle Listings mit `expires_at` in der Vergangenheit werden auf `status: expired` gesetzt. + --- ## 5. Einstellungen @@ -632,9 +629,31 @@ Project Color: #555555 ### 5.2 CORS Settings -**Environment Variables (.env):** +**Option 1: docker-compose.yml** + +```yaml +services: + directus: + image: directus/directus:latest + environment: + CORS_ENABLED: "true" + CORS_ORIGIN: "https://dgray.io,https://www.dgray.io,http://localhost:8080" + CORS_METHODS: "GET,POST,PATCH,DELETE" + CORS_ALLOWED_HEADERS: "Content-Type,Authorization" + CORS_CREDENTIALS: "true" +``` + +**Option 2: .env Datei + Docker Compose** + +```yaml +# docker-compose.yml +services: + directus: + env_file: .env +``` ```env +# .env CORS_ENABLED=true CORS_ORIGIN=https://dgray.io,https://www.dgray.io,http://localhost:8080 CORS_METHODS=GET,POST,PATCH,DELETE @@ -644,36 +663,68 @@ CORS_CREDENTIALS=true ### 5.3 Auth Settings +Konfiguration wie CORS via **docker-compose.yml** oder **.env Datei**: + +```yaml +# docker-compose.yml +environment: + ACCESS_TOKEN_TTL: "15m" + REFRESH_TOKEN_TTL: "7d" + AUTH_PASSWORD_POLICY: "/^.{36,}$/" # UUID = 36 chars + PUBLIC_REGISTRATION: "true" +``` + +Oder in `.env`: ```env -# Token Expiry ACCESS_TOKEN_TTL=15m REFRESH_TOKEN_TTL=7d - -# Password Policy -AUTH_PASSWORD_POLICY=/^.{8,}$/ - -# Disable Public Registration (optional) -# USER_REGISTER_URL_ALLOW_LIST=https://dgray.io +AUTH_PASSWORD_POLICY=/^.{36,}$/ +PUBLIC_REGISTRATION=true ``` ### 5.4 File Storage -```env -STORAGE_LOCATIONS=local -STORAGE_LOCAL_ROOT=./uploads +Konfiguration via **docker-compose.yml** oder **.env Datei**: -# Für S3/Cloudflare R2: -# STORAGE_LOCATIONS=s3 -# STORAGE_S3_DRIVER=s3 -# STORAGE_S3_KEY=xxx -# STORAGE_S3_SECRET=xxx -# STORAGE_S3_BUCKET=dgray-files -# STORAGE_S3_REGION=auto -# STORAGE_S3_ENDPOINT=https://xxx.r2.cloudflarestorage.com +**Option 1: Lokal (einfach)** + +```yaml +# docker-compose.yml +environment: + STORAGE_LOCATIONS: "local" + STORAGE_LOCAL_ROOT: "./uploads" +volumes: + - ./uploads:/directus/uploads +``` + +**Option 2: Cloudflare R2 / S3 (empfohlen für Produktion)** + +```yaml +# docker-compose.yml +environment: + STORAGE_LOCATIONS: "s3" + STORAGE_S3_DRIVER: "s3" + STORAGE_S3_KEY: "your-access-key" + STORAGE_S3_SECRET: "your-secret-key" + STORAGE_S3_BUCKET: "dgray-files" + STORAGE_S3_REGION: "auto" + STORAGE_S3_ENDPOINT: "https://xxx.r2.cloudflarestorage.com" ``` ### 5.5 Rate Limiting +Konfiguration via **docker-compose.yml** oder **.env Datei**: + +```yaml +# docker-compose.yml +environment: + RATE_LIMITER_ENABLED: "true" + RATE_LIMITER_STORE: "memory" # oder "redis" für Multi-Instance + RATE_LIMITER_POINTS: "100" # Max Requests + RATE_LIMITER_DURATION: "60" # Pro Minute +``` + +Oder in `.env`: ```env RATE_LIMITER_ENABLED=true RATE_LIMITER_STORE=memory @@ -683,92 +734,42 @@ RATE_LIMITER_DURATION=60 **Hinweis:** Keine E-Mail-Konfiguration nötig - dgray.io nutzt keine E-Mails (Privacy by Design). -### 5.6 Währungsumrechnung (Kraken API) +### 5.6 Währungsumrechnung & Preismodus -Für die Anzeige von Fiat-Preisen in XMR wird die Kraken API genutzt. +Implementiert im Frontend-Service: **`js/services/currency.js`** -**API Endpoint:** -``` -https://api.kraken.com/0/public/Ticker?pair=XMRUSD,XMREUR,XMRGBP,XMRCHF,XMRJPY -``` +**Features:** +- Kraken API für Echtzeit-Kurse +- 5 Minuten Client-Cache +- Unterstützte Währungen: XMR, EUR, CHF, USD, GBP, JPY +- Zwei Preismodi: `fiat` (Fiat-fix) und `xmr` (XMR-fix) -**Frontend-Implementation:** +**Nutzung:** ```javascript -const KRAKEN_API = 'https://api.kraken.com/0/public/Ticker'; -const PAIRS = { - USD: 'XMRUSD', - EUR: 'XMREUR', - GBP: 'XMRGBP', - CHF: 'XMRCHF', - JPY: 'XMRJPY' -}; +import { getXmrRates, formatPrice } from './services/currency.js'; -async function getXmrRates() { - const pairs = Object.values(PAIRS).join(','); - const response = await fetch(`${KRAKEN_API}?pair=${pairs}`); - const data = await response.json(); - - const rates = {}; - for (const [currency, pair] of Object.entries(PAIRS)) { - const ticker = data.result[pair]; - if (ticker) { - rates[currency] = parseFloat(ticker.c[0]); // Last trade price - } - } - return rates; -} +// Kurse laden +const rates = await getXmrRates(); -function convertToXmr(amount, currency, rates) { - if (currency === 'XMR') return amount; - return amount / rates[currency]; -} - -// Beispiel: 100 EUR in XMR -// const rates = await getXmrRates(); -// const xmrAmount = convertToXmr(100, 'EUR', rates); +// Preis formatieren +const listing = { price: 100, currency: 'EUR', price_mode: 'fiat' }; +const display = formatPrice(listing, rates); +// → { primary: "€ 100,00", secondary: "≈ 0.6667 XMR", xmrAmount: 0.6667 } ``` -**Caching:** Rates werden client-side für 5 Minuten gecached. - -### 5.7 Preismodus (Fiat vs. XMR) - -Listings können in zwei Modi erstellt werden: +**Preismodi:** | Modus | `price_mode` | Verhalten | |-------|--------------|-----------| | **Fiat-fix** | `fiat` | Preis bleibt z.B. 100 EUR, XMR-Äquivalent ändert sich mit Kurs | | **XMR-fix** | `xmr` | Preis bleibt z.B. 0.5 XMR, Fiat-Äquivalent ändert sich mit Kurs | -**Frontend-Anzeige:** -```javascript -function displayPrice(listing, rates) { - const { price, currency, price_mode } = listing; - - if (price_mode === 'xmr' || currency === 'XMR') { - // XMR ist der Referenzpreis - const xmrPrice = currency === 'XMR' ? price : price / rates[currency]; - return { - primary: `${xmrPrice.toFixed(4)} XMR`, - secondary: currency !== 'XMR' ? `≈ ${price} ${currency}` : null - }; - } else { - // Fiat ist der Referenzpreis - const xmrEquivalent = price / rates[currency]; - return { - primary: `${price} ${currency}`, - secondary: `≈ ${xmrEquivalent.toFixed(4)} XMR` - }; - } -} -``` +**Beispiele (bei 1 XMR = 150 EUR):** -**Beispiele:** - -| Listing | Anzeige (bei 1 XMR = 150 EUR) | -|---------|-------------------------------| -| 100 EUR, mode=`fiat` | **100 EUR** ≈ 0.6667 XMR | -| 100 EUR, mode=`xmr` | **0.6667 XMR** ≈ 100 EUR | -| 0.5 XMR, mode=`xmr` | **0.5 XMR** ≈ 75 EUR | +| Listing | Anzeige | +|---------|---------| +| 100 EUR, mode=`fiat` | **€ 100,00** ≈ 0.6667 XMR | +| 0.5 XMR, mode=`xmr` | **0.5000 XMR** ≈ € 75,00 | --- diff --git a/js/services/currency.js b/js/services/currency.js new file mode 100644 index 0000000..156572d --- /dev/null +++ b/js/services/currency.js @@ -0,0 +1,205 @@ +/** + * Currency Service - XMR/Fiat Conversion + * + * Uses Kraken API for real-time exchange rates + * Supports two modes: fiat-fix and xmr-fix + */ + +const KRAKEN_API = 'https://api.kraken.com/0/public/Ticker'; + +const PAIRS = { + USD: 'XXMRZUSD', + EUR: 'XXMRZEUR', + GBP: 'XXMRZGBP', + CHF: 'XMRCHF', + JPY: 'XMRJPY' +}; + +const CURRENCY_SYMBOLS = { + XMR: 'ɱ', + EUR: '€', + USD: '$', + GBP: '£', + CHF: 'CHF', + JPY: '¥' +}; + +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + +let cachedRates = null; +let cacheTimestamp = 0; + +/** + * Fetches current XMR rates from Kraken + * @returns {Promise} Rates per currency (e.g. { EUR: 150.5, USD: 165.2 }) + */ +export async function getXmrRates() { + // Check cache + if (cachedRates && Date.now() - cacheTimestamp < CACHE_DURATION) { + return cachedRates; + } + + try { + const pairs = Object.values(PAIRS).join(','); + const response = await fetch(`${KRAKEN_API}?pair=${pairs}`); + const data = await response.json(); + + if (data.error && data.error.length > 0) { + console.error('Kraken API Error:', data.error); + return cachedRates || getDefaultRates(); + } + + const rates = {}; + for (const [currency, pair] of Object.entries(PAIRS)) { + const ticker = data.result[pair]; + if (ticker) { + rates[currency] = parseFloat(ticker.c[0]); // Last trade price + } + } + + // Update cache + cachedRates = rates; + cacheTimestamp = Date.now(); + + return rates; + } catch (error) { + console.error('Failed to fetch XMR rates:', error); + return cachedRates || getDefaultRates(); + } +} + +/** + * Fallback rates if API is unreachable + */ +function getDefaultRates() { + return { + EUR: 150, + USD: 165, + GBP: 130, + CHF: 145, + JPY: 24000 + }; +} + +/** + * Converts amount to XMR + * @param {number} amount - Amount in source currency + * @param {string} currency - Source currency (EUR, USD, etc.) + * @param {Object} rates - Rates from getXmrRates() + * @returns {number} Amount in XMR + */ +export function convertToXmr(amount, currency, rates) { + if (currency === 'XMR') return amount; + if (!rates[currency]) return amount; + return amount / rates[currency]; +} + +/** + * Converts XMR to fiat + * @param {number} xmrAmount - Amount in XMR + * @param {string} currency - Target currency (EUR, USD, etc.) + * @param {Object} rates - Rates from getXmrRates() + * @returns {number} Amount in target currency + */ +export function convertFromXmr(xmrAmount, currency, rates) { + if (currency === 'XMR') return xmrAmount; + if (!rates[currency]) return xmrAmount; + return xmrAmount * rates[currency]; +} + +/** + * Formats a price for display + * @param {Object} listing - Listing with price, currency, price_mode + * @param {Object} rates - Rates from getXmrRates() + * @returns {Object} { primary, secondary, xmrAmount } + */ +export function formatPrice(listing, rates) { + const { price, currency, price_mode } = listing; + + if (!price || price === 0) { + return { + primary: 'Free', + secondary: null, + xmrAmount: 0 + }; + } + + // XMR mode: XMR is the reference price + if (price_mode === 'xmr' || currency === 'XMR') { + const xmrPrice = currency === 'XMR' ? price : convertToXmr(price, currency, rates); + const fiatEquivalent = currency !== 'XMR' ? price : convertFromXmr(price, 'EUR', rates); + + return { + primary: formatXmr(xmrPrice), + secondary: currency !== 'XMR' ? `≈ ${formatFiat(price, currency)}` : `≈ ${formatFiat(fiatEquivalent, 'EUR')}`, + xmrAmount: xmrPrice + }; + } + + // Fiat mode: Fiat is the reference price + const xmrEquivalent = convertToXmr(price, currency, rates); + + return { + primary: formatFiat(price, currency), + secondary: `≈ ${formatXmr(xmrEquivalent)}`, + xmrAmount: xmrEquivalent + }; +} + +/** + * Formats XMR amount + * @param {number} amount - Amount in XMR + * @returns {string} Formatted string (e.g. "0.5234 XMR") + */ +export function formatXmr(amount) { + if (amount >= 1) { + return `${amount.toFixed(4)} XMR`; + } + return `${amount.toFixed(6)} XMR`; +} + +/** + * Formats fiat amount + * @param {number} amount - Amount + * @param {string} currency - Currency + * @returns {string} Formatted string (e.g. "€ 150,00") + */ +export function formatFiat(amount, currency) { + const symbol = CURRENCY_SYMBOLS[currency] || currency; + + const formatted = new Intl.NumberFormat('de-DE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(amount); + + // Symbol before or after amount + if (['EUR', 'GBP', 'USD'].includes(currency)) { + return `${symbol} ${formatted}`; + } + return `${formatted} ${symbol}`; +} + +/** + * Returns the currency symbol + * @param {string} currency - Currency code + * @returns {string} Symbol + */ +export function getCurrencySymbol(currency) { + return CURRENCY_SYMBOLS[currency] || currency; +} + +/** + * List of supported currencies + */ +export const SUPPORTED_CURRENCIES = ['XMR', 'EUR', 'CHF', 'USD', 'GBP', 'JPY']; + +export default { + getXmrRates, + convertToXmr, + convertFromXmr, + formatPrice, + formatXmr, + formatFiat, + getCurrencySymbol, + SUPPORTED_CURRENCIES +};