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>