feat(settings): make the display currency optional, falling back to the trip's

Costs already resolved `default_currency || trip.currency || 'EUR'`, but the
setting could never actually be empty: the store seeded 'USD', so a user who had
never touched it silently had every trip converted into dollars, and the picker
offered no way to unset it.

Seed it empty and lead the picker with a "Trip currency" option, so an unset
preference means "show each trip in its own currency" instead of forcing them all
through one code. An explicit empty value persists and beats the admin-set
instance default — it is a deliberate choice, not an absence.

This also brings the public share's fallback to life: the share payload has
resolved sharer's currency -> trip currency since #1361, but the trip-currency
branch was unreachable while every owner had a currency forced on them.

Plugins are handed `formats.currency` as a concrete code, so PluginFrame now
resolves the same chain rather than passing an empty string through the bridge.
This commit is contained in:
jubnl
2026-07-13 22:22:34 +02:00
parent 8367b6dbe9
commit ae71bcfd1e
27 changed files with 43 additions and 6 deletions
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { useTranslation } from '../../i18n'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import ConfirmDialog from '../shared/ConfirmDialog'
import { pluginsApi } from '../../api/client'
@@ -141,6 +142,10 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
const userAvatar = useAuthStore((s) => s.user?.avatar_url ?? null)
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
const settings = useSettingsStore((s) => s.settings)
// Plugins format money against a concrete code, so resolve the same chain Costs
// uses: the user's display currency, else the trip's own.
const tripCurrency = useTripStore((s) => s.trip?.currency)
const displayCurrency = (settings.default_currency || tripCurrency || 'EUR').toUpperCase()
const [height, setHeight] = useState<number | null>(null)
// A host-rendered ConfirmDialog on the plugin's behalf: sandboxed frames have no
// allow-modals and can't overlay the host, so destructive plugin actions get the
@@ -177,7 +182,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
appearance: readAppearance(),
formats: {
locale,
currency: settings.default_currency,
currency: displayCurrency,
timeFormat: settings.time_format,
distanceUnit: settings.distance_unit,
temperatureUnit: settings.temperature_unit,
@@ -40,13 +40,18 @@ export default function DisplaySettingsTab(): React.ReactElement {
{/* Display currency */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.currency')}</label>
{/* Unset ('') means "no personal preference": Costs then shows each trip in its
own currency, instead of forcing every trip through one display currency. */}
<CustomSelect
value={settings.default_currency || 'EUR'}
value={settings.default_currency || ''}
onChange={async v => {
try { await updateSetting('default_currency', String(v)) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
options={currenciesWith(settings.default_currency).map(c => ({ value: c, label: `${c} — ${SYMBOLS[c] || c}` }))}
options={[
{ value: '', label: t('settings.currencyTrip') },
...currenciesWith(settings.default_currency || '').map(c => ({ value: c, label: `${c} — ${SYMBOLS[c] || c}` })),
]}
searchable
/>
<p className="text-xs text-content-faint mt-2">{t('settings.currencyHint')}</p>
+2 -1
View File
@@ -28,7 +28,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
default_lng: 2.3522,
default_zoom: 10,
dark_mode: false,
default_currency: 'USD',
// Empty = no personal display currency, so Costs falls back to the trip's own.
default_currency: '',
language: localStorage.getItem('app_language') || 'en',
temperature_unit: 'fahrenheit',
distance_unit: 'metric',
+2 -1
View File
@@ -110,7 +110,8 @@ export interface Settings {
default_lng: number
default_zoom: number
dark_mode: boolean | string
default_currency: string
/** Display currency for Costs. Empty/null = follow each trip's own currency. */
default_currency: string | null
language: string
temperature_unit: string
distance_unit?: DistanceUnit
@@ -158,7 +158,10 @@ describe('settingsStore', () => {
const state = useSettingsStore.getState();
expect(state.settings.dark_mode).toBe(true);
expect(state.settings.default_currency).toBe('USD');
expect(state.settings.language).toBe('en');
// No display currency of their own: Costs then follows each trip's own currency
// rather than forcing every trip through one code.
expect(state.settings.default_currency).toBe('');
});
});
+1
View File
@@ -290,6 +290,7 @@ const settings: TranslationStrings = {
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate', // en-fallback
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': 'عملة الرحلة',
'settings.passkey.title': 'مفاتيح المرور',
'settings.passkey.description':
'سجّل الدخول بشكل أسرع وأكثر مقاومة للتصيّد باستخدام مفتاح مرور — ببصمة إصبعك أو وجهك أو رمز PIN أو مفتاح أمان مادي. تبقى كلمة المرور كنسخة احتياطية.',
+1
View File
@@ -303,6 +303,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': 'Moeda da viagem',
'settings.passkey.title': 'Passkeys',
'settings.passkey.description':
'Entre mais rápido e com proteção contra phishing usando uma passkey — sua impressão digital, rosto, PIN ou uma chave de segurança física. Sua senha continua disponível como reserva.',
+1
View File
@@ -297,6 +297,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': 'Měna cesty',
'settings.passkey.title': 'Přístupové klíče',
'settings.passkey.description':
'Přihlašujte se rychleji a s ochranou proti phishingu pomocí přístupového klíče — otiskem prstu, obličejem, PINem nebo hardwarovým klíčem. Vaše heslo zůstává jako záloha.',
+1
View File
@@ -305,6 +305,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.currency': 'Währung',
'settings.currencyHint': 'Alle Beträge in Costs werden in diese Währung umgerechnet und angezeigt.',
'settings.currencyTrip': 'Reisewährung',
'settings.passkey.title': 'Passkeys',
'settings.passkey.description':
'Melde dich schneller und phishing-resistent mit einem Passkey an — per Fingerabdruck, Gesicht, PIN oder Hardware-Schlüssel. Dein Passwort bleibt als Backup erhalten.',
+1
View File
@@ -309,6 +309,7 @@ const settings: TranslationStrings = {
'settings.mfa.demoBlocked': 'Not available in demo mode',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': 'Trip currency',
'settings.passkey.title': 'Passkeys',
'settings.passkey.description':
'Sign in faster and phishing-resistant with a passkey — your fingerprint, face, PIN, or a hardware key. Your password stays as a backup.',
+1
View File
@@ -305,6 +305,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': 'Moneda del viaje',
'settings.passkey.title': 'Passkeys',
'settings.passkey.description':
'Inicia sesión más rápido y con protección frente al phishing usando una passkey: tu huella, tu cara, tu PIN o una llave de seguridad física. Tu contraseña sigue disponible como respaldo.',
+1
View File
@@ -308,6 +308,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': 'Devise du voyage',
'settings.passkey.title': 'Passkeys',
'settings.passkey.description':
'Connectez-vous plus rapidement et de façon résistante au phishing avec une passkey — votre empreinte digitale, votre visage, votre code PIN ou une clé matérielle. Votre mot de passe reste disponible en secours.',
+1
View File
@@ -312,6 +312,7 @@ const settings: TranslationStrings = {
'settings.mfa.demoBlocked': 'Δεν είναι διαθέσιμο σε λειτουργία demo',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': 'Νόμισμα ταξιδιού',
'settings.passkey.title': 'Passkeys',
'settings.passkey.description':
'Συνδεθείτε πιο γρήγορα και με προστασία από phishing χρησιμοποιώντας ένα passkey — το δαχτυλικό σας αποτύπωμα, το πρόσωπό σας, ένα PIN ή ένα κλειδί υλικού. Ο κωδικός σας παραμένει ως εφεδρεία.',
+1
View File
@@ -304,6 +304,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': 'Az utazás pénzneme',
'settings.passkey.title': 'Passkey-k',
'settings.passkey.description':
'Jelentkezz be gyorsabban és adathalászat-állóan egy passkey-jel — ujjlenyomattal, arccal, PIN-kóddal vagy hardveres kulccsal. A jelszavad tartalékként megmarad.',
+1
View File
@@ -301,6 +301,7 @@ const settings: TranslationStrings = {
'settings.bookingLabelsHint': 'Menampilkan nama stasiun / bandara di peta. Jika mati, hanya ikon ditampilkan.',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': 'Mata uang perjalanan',
'settings.passkey.title': 'Passkey',
'settings.passkey.description':
'Masuk lebih cepat dan tahan terhadap phishing dengan passkey — sidik jari, wajah, PIN, atau kunci keamanan fisik kamu. Kata sandimu tetap tersedia sebagai cadangan.',
+1
View File
@@ -303,6 +303,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': 'Valuta del viaggio',
'settings.passkey.title': 'Passkey',
'settings.passkey.description':
'Accedi più velocemente e in modo resistente al phishing con una passkey — la tua impronta digitale, il volto, il PIN o una chiave hardware. La tua password resta come riserva.',
+1
View File
@@ -278,6 +278,7 @@ const settings: TranslationStrings = {
'settings.oauth.badge.machine': 'マシン',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': '旅行の通貨',
'settings.passkey.title': 'パスキー',
'settings.passkey.description':
'指紋、顔認証、PIN、またはハードウェアキーを使うパスキーで、より速く、フィッシングに強いサインインができます。パスワードはバックアップとして残ります。',
+1
View File
@@ -294,6 +294,7 @@ const settings: TranslationStrings = {
'settings.oauth.badge.machine': '머신',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': '여행 통화',
'settings.passkey.title': '패스키',
'settings.passkey.description':
'지문, 얼굴, PIN 또는 하드웨어 키 같은 패스키로 더 빠르고 피싱에 강하게 로그인하세요. 비밀번호는 백업으로 그대로 유지됩니다.',
+1
View File
@@ -304,6 +304,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.currency': 'Valuta',
'settings.currencyHint': 'Alle bedragen onder Onkosten worden omgerekend naar en weergegeven in deze valuta.',
'settings.currencyTrip': 'Valuta van de reis',
'settings.passkey.title': 'Passkeys',
'settings.passkey.description':
'Log sneller en phishingbestendig in met een passkey — je vingerafdruk, gezicht, pincode of een hardwaresleutel. Je wachtwoord blijft als back-up bestaan.',
+1
View File
@@ -301,6 +301,7 @@ const settings: TranslationStrings = {
'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': 'Waluta wyjazdu',
'settings.passkey.title': 'Klucze dostępu',
'settings.passkey.description':
'Loguj się szybciej i z odpornością na phishing za pomocą klucza dostępu — odcisku palca, twarzy, kodu PIN lub klucza sprzętowego. Twoje hasło pozostaje jako zapasowa opcja.',
+1
View File
@@ -302,6 +302,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.currency': 'Валюта',
'settings.currencyHint': 'Все расходы будут конвертированы и показаны в этой валюте.',
'settings.currencyTrip': 'Валюта поездки',
'settings.passkey.title': 'Passkeys',
'settings.passkey.description':
'Входите быстрее и с защитой от фишинга с помощью passkey — отпечатка пальца, лица, PIN-кода или аппаратного ключа. Ваш пароль остаётся как резервный способ.',
+1
View File
@@ -306,6 +306,7 @@ const settings: TranslationStrings = {
'settings.mfa.demoBlocked': 'Finns inte i demoläge',
'settings.currency': 'Valuta',
'settings.currencyHint': 'Alla belopp under kostnader omräknas till och redovisas i denna valuta.',
'settings.currencyTrip': 'Resans valuta',
'settings.passkey.title': 'Inloggningsnycklar',
'settings.passkey.description':
'Logga in snabbare och med bättre skydd mot nätfiske med en inloggningsnyckel – ditt fingeravtryck, ditt ansikte, din PIN-kod eller en hårdvarunyckel. Ditt lösenord finns kvar som reserv.',
+1
View File
@@ -300,6 +300,7 @@ const settings: TranslationStrings = {
'settings.oauth.badge.machine': 'makine',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': 'Gezi para birimi',
'settings.passkey.title': 'Passkey’ler',
'settings.passkey.description':
'Passkey ile daha hızlı ve kimlik avına dayanıklı şekilde oturum açın — parmak iziniz, yüzünüz, PIN’iniz veya bir donanım anahtarı. Şifreniz yedek olarak kalır.',
+1
View File
@@ -302,6 +302,7 @@ const settings: TranslationStrings = {
'settings.oauth.badge.machine': 'машина',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': 'Валюта подорожі',
'settings.passkey.title': 'Passkeys',
'settings.passkey.description':
'Входьте швидше та з захистом від фішингу за допомогою passkey — відбитка пальця, обличчя, PIN-коду або апаратного ключа. Ваш пароль залишається як резервний варіант.',
+1
View File
@@ -308,6 +308,7 @@ const settings: TranslationStrings = {
'settings.mfa.demoBlocked': 'Không có sẵn ở chế độ demo',
'settings.currency': 'Tiền tệ',
'settings.currencyHint': 'Tất cả số tiền trong Chi phí được chuyển đổi sang và hiển thị bằng loại tiền tệ này.',
'settings.currencyTrip': 'Tiền tệ của chuyến đi',
'settings.passkey.title': 'Mật mã',
'settings.passkey.description':
'Đăng nhập nhanh hơn và chống lừa đảo bằng mật mã — vân tay, khuôn mặt, PIN hoặc khóa phần cứng của bạn. Mật khẩu của bạn vẫn là bản sao lưu.',
+1
View File
@@ -285,6 +285,7 @@ const settings: TranslationStrings = {
'settings.bookingLabelsHint': '在地圖上顯示車站 / 機場名稱。關閉時僅顯示圖示。',
'settings.currency': 'Currency',
'settings.currencyHint': 'All amounts in Costs are converted to and shown in this currency.',
'settings.currencyTrip': '行程貨幣',
'settings.passkey.title': 'Passkey',
'settings.passkey.description':
'使用 Passkey 更快登入,並可抵禦網路釣魚——透過你的指紋、臉部、PIN 碼或硬體金鑰。你的密碼仍會保留作為備援。',
+1
View File
@@ -283,6 +283,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.ntfy': 'Ntfy',
'settings.currency': '货币',
'settings.currencyHint': '费用中的所有金额都会换算并以此货币显示。',
'settings.currencyTrip': '行程货币',
'settings.passkey.title': '通行密钥',
'settings.passkey.description':
'使用通行密钥更快登录,并能抵御钓鱼攻击——通过指纹、面容、PIN 或硬件密钥验证。你的密码仍可作为备用方式。',