add currency service, update directus setup

This commit is contained in:
2026-01-31 13:28:41 +01:00
parent 51346bdd7d
commit 32bc5aed05
2 changed files with 484 additions and 278 deletions

View File

@@ -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 |
---

205
js/services/currency.js Normal file
View File

@@ -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<Object>} 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
};