refactor(admin): share the default user settings logic between desktop and phone

Loading, saving and resetting the defaults for users and the map field handling live in one useDefaultUserSettings hook that reads the presets and style rules from mapSettingsModel. Each shell passes its own error text and preview stamp.
This commit is contained in:
Maurice
2026-10-09 10:15:41 +02:00
parent 0722a3b6ad
commit 05394644b9
4 changed files with 394 additions and 311 deletions
@@ -1,8 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'
import React from 'react'
import { Loader2, Map as MapIcon, Settings2 } from 'lucide-react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import Section from '../Settings/Section'
import { SettingRows, SettingsHint } from '../Settings/settingsKit'
import { fs } from '../shared/DialogShell'
@@ -10,64 +8,13 @@ import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import type { DistanceUnit, Place, WeekStart } from '../../types'
import type { DistanceUnit, WeekStart } from '../../types'
import { weekStartOptions } from '../../utils/calendarWeek'
import { normalizeTileUrl, withTileApiKey } from '../../utils/tileUrl'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
getStylePresets,
isOpenFreeMapStyle,
normalizeStyleForProvider,
styleSettingKey,
type GlMapProvider,
} from '../Map/glProviders'
import { useAuthStore } from '../../store/authStore'
import RoutingInstanceFields, { type RoutingDefaults } from './RoutingInstanceFields'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
// The app default, and a vector style rather than a {z}/{x}/{y} template: no
// key, no registration, no request limits.
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron' },
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright' },
// CARTO watermarks keyless tiles since 26.08.2026 and issues keys by mail, so
// these two need one; without it the map falls back to the default (#2054).
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
]
type Defaults = RoutingDefaults & {
temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean
time_format?: string
week_start?: WeekStart
default_currency?: string
blur_booking_codes?: boolean
map_tile_url?: string
carto_api_key?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
maplibre_style?: string
mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean
}
type MapProvider = 'leaflet' | GlMapProvider
function normalizeProvider(value: unknown): MapProvider {
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
}
/** Only the GL providers keep a style — Leaflet is handled by its callers. */
function styleForProvider(provider: GlMapProvider, style?: string | null): string {
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
return normalizeStyleForProvider(provider, style)
}
import { withTileApiKey } from '../../utils/tileUrl'
import { defaultStyleForProvider } from '../Map/glProviders'
import { DEFAULT_MAP_PRESETS as MAP_PRESETS, type MapProvider } from '../Settings/mapSettingsModel'
import RoutingInstanceFields from './RoutingInstanceFields'
import { useDefaultUserSettings, type Defaults } from './useDefaultUserSettings'
/**
* One default as a row of the card's white box: its name (with the reset link
@@ -114,57 +61,18 @@ function colorModeValue(value: string | boolean | undefined): string {
return value ?? ''
}
/** A failed save names the error it threw, or falls back to the generic one. */
function errorText(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback
}
export default function DefaultUserSettingsTab(): React.ReactElement {
const { t, locale } = useTranslation()
const toast = useToast()
const [defaults, setDefaults] = useState<Defaults>({})
const [loaded, setLoaded] = useState(false)
const [mapTileUrl, setMapTileUrl] = useState('')
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => {
const provider = normalizeProvider(data.map_provider)
setDefaults(data)
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
setMapboxToken(data.mapbox_access_token || '')
setCartoKey(data.carto_api_key || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
}, [])
const save = async (patch: Partial<Defaults>) => {
try {
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>)
setDefaults(updated)
toast.success(t('admin.defaultSettings.saved'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'))
}
}
const reset = async (key: keyof Defaults) => {
try {
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'carto_api_key') setCartoKey('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
}
toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'))
}
}
const isSet = (key: keyof Defaults) => defaults[key] !== undefined
const {
defaults, loaded, managed, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, mapboxStyle, setMapboxStyle, mapPreviewPlaces, mapProvider, glStylePresets, styleKey,
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
} = useDefaultUserSettings({ errorMessage: errorText, previewCreatedAt: () => String(new Date()) })
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
@@ -179,31 +87,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</button>
) : null
const mapPreviewPlaces = useMemo((): Place[] => [{
id: 1,
trip_id: 1,
name: 'Preview center',
description: null,
notes: null,
lat: 48.8566,
lng: 2.3522,
address: null,
category_id: null,
price: null,
currency: null,
image_url: null,
google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null,
end_time: null,
duration_minutes: null,
transport_mode: null,
website: null,
phone: null,
created_at: String(new Date()),
}], [])
if (!loaded) {
return (
<div className="flex items-center gap-2.5 rounded-2xl border border-edge-faint bg-surface-secondary px-4 py-5 text-content-faint" style={fs(12.5, 'body')}>
@@ -214,20 +97,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
}
const darkMode = defaults.dark_mode
const mapProvider = normalizeProvider(defaults.map_provider)
const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
const saveMapProvider = (nextProvider: MapProvider) => {
const patch: Partial<Defaults> = { map_provider: nextProvider }
if (nextProvider !== 'leaflet') {
// Load + save the new provider's own style slot so the other provider's style is kept.
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
const nextStyle = styleForProvider(nextProvider, slot)
setMapboxStyle(nextStyle)
patch[styleSettingKey(nextProvider)] = nextStyle
}
void save(patch)
}
return (
<div>
@@ -346,7 +215,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<div className="flex flex-col gap-2">
<CustomSelect
value={mapTileUrl}
onChange={(value: string) => { if (value) { setMapTileUrl(value); void save({ map_tile_url: value }) } }}
onChange={pickTilePreset}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
/>
@@ -445,7 +314,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<div className="flex flex-col gap-2">
<CustomSelect
value={mapboxStyle}
onChange={(value: string) => { if (value) { setMapboxStyle(value); void save({ [styleKey]: value }) } }}
onChange={pickStylePreset}
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
/>
@@ -453,11 +322,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
type="text"
value={mapboxStyle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
onBlur={() => {
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
setMapboxStyle(nextStyle)
void save({ [styleKey]: nextStyle })
}}
onBlur={commitStyle}
placeholder={defaultStyleForProvider(mapProvider)}
className={`${INPUT} font-geist`}
/>
@@ -0,0 +1,155 @@
// FE-COMP-DEFAULTSETTINGS-001 onwards: the defaults for users behind both admin shells.
import { act, renderHook, waitFor } from '@testing-library/react';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { adminApi } from '../../api/client';
import { useAuthStore } from '../../store/authStore';
import { MAPBOX_DEFAULT_STYLE, OPENFREEMAP_DEFAULT_STYLE } from '../Map/glProviders';
import { type Defaults, useDefaultUserSettings } from './useDefaultUserSettings';
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
const errorMessage = vi.fn((err: unknown, fallback: string) =>
err instanceof Error ? `msg:${err.message}` : fallback
);
const previewCreatedAt = vi.fn(() => 'stamp');
function stub(data: Defaults) {
vi.spyOn(adminApi, 'getDefaultUserSettings').mockResolvedValue(data);
return vi.spyOn(adminApi, 'updateDefaultUserSettings').mockImplementation(async (patch) => ({ ...data, ...patch }));
}
async function loaded() {
const hook = renderHook(() => useDefaultUserSettings({ errorMessage, previewCreatedAt }));
await waitFor(() => expect(hook.result.current.loaded).toBe(true));
return hook;
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { managed: true });
toast.success.mockReset();
toast.error.mockReset();
errorMessage.mockClear();
previewCreatedAt.mockClear();
});
afterEach(() => vi.restoreAllMocks());
describe('useDefaultUserSettings', () => {
it('FE-COMP-DEFAULTSETTINGS-001: loads the defaults into the map fields', async () => {
stub({
map_provider: 'maplibre-gl',
map_tile_url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
mapbox_access_token: 'pk',
carto_api_key: 'ck',
maplibre_style: '',
});
const { result } = await loaded();
expect(result.current.mapTileUrl).toBe('https://tile.openstreetmap.org/{z}/{x}/{y}.png');
expect(result.current.mapboxToken).toBe('pk');
expect(result.current.cartoKey).toBe('ck');
expect(result.current.mapboxStyle).toBe(OPENFREEMAP_DEFAULT_STYLE);
expect(result.current.mapProvider).toBe('maplibre-gl');
expect(result.current.styleKey).toBe('maplibre_style');
expect(result.current.glStylePresets.length).toBeGreaterThan(0);
expect(result.current.managed).toBe(true);
expect(result.current.isSet('map_provider')).toBe(true);
expect(result.current.isSet('dark_mode')).toBe(false);
expect(result.current.mapPreviewPlaces).toEqual([
expect.objectContaining({ name: 'Preview center', created_at: 'stamp' }),
]);
expect(previewCreatedAt).toHaveBeenCalledTimes(1);
});
it('FE-COMP-DEFAULTSETTINGS-002: leaflet loads the stored mapbox style as is, and a failed load still finishes', async () => {
stub({ mapbox_style: 'mapbox://styles/me/x' });
const first = await loaded();
expect(first.result.current.mapboxStyle).toBe('mapbox://styles/me/x');
expect(first.result.current.glStylePresets).toEqual([]);
vi.spyOn(adminApi, 'getDefaultUserSettings').mockRejectedValue(new Error('x'));
const second = await loaded();
expect(second.result.current.defaults).toEqual({});
});
it('FE-COMP-DEFAULTSETTINGS-003: save stores what the server returns; a failure goes through the shell error text', async () => {
const update = stub({});
const { result } = await loaded();
await act(() => result.current.save({ temperature_unit: 'celsius' }));
expect(update).toHaveBeenCalledWith({ temperature_unit: 'celsius' });
expect(result.current.defaults.temperature_unit).toBe('celsius');
expect(toast.success).toHaveBeenCalledWith('admin.defaultSettings.saved');
update.mockRejectedValueOnce(new Error('bad'));
await act(() => result.current.save({ time_format: '12h' }));
expect(errorMessage).toHaveBeenCalledWith(expect.any(Error), 'common.error');
expect(toast.error).toHaveBeenCalledWith('msg:bad');
});
it('FE-COMP-DEFAULTSETTINGS-004: reset clears the matching local field and the style per provider', async () => {
const update = stub({
map_provider: 'mapbox-gl',
mapbox_access_token: 'pk',
carto_api_key: 'ck',
map_tile_url: 'u',
});
update.mockImplementation(async () => ({ map_provider: 'mapbox-gl' }));
const { result } = await loaded();
act(() => result.current.setMapboxStyle('custom'));
await act(() => result.current.reset('mapbox_access_token'));
expect(update).toHaveBeenLastCalledWith({ mapbox_access_token: null });
expect(result.current.mapboxToken).toBe('');
await act(() => result.current.reset('carto_api_key'));
expect(result.current.cartoKey).toBe('');
await act(() => result.current.reset('map_tile_url'));
expect(result.current.mapTileUrl).toBe('');
await act(() => result.current.reset('mapbox_style'));
expect(result.current.mapboxStyle).toBe(MAPBOX_DEFAULT_STYLE);
expect(toast.success).toHaveBeenCalledWith('admin.defaultSettings.reset');
update.mockRejectedValueOnce('x');
await act(() => result.current.reset('dark_mode'));
expect(toast.error).toHaveBeenCalledWith('common.error');
});
it('FE-COMP-DEFAULTSETTINGS-005: switching provider saves the new slot style, leaflet saves the provider alone', async () => {
const update = stub({ map_provider: 'leaflet', maplibre_style: 'https://tiles.openfreemap.org/styles/bright' });
const { result } = await loaded();
act(() => result.current.saveMapProvider('maplibre-gl'));
await waitFor(() => expect(result.current.mapProvider).toBe('maplibre-gl'));
expect(update).toHaveBeenCalledWith({
map_provider: 'maplibre-gl',
maplibre_style: 'https://tiles.openfreemap.org/styles/bright',
});
expect(result.current.mapboxStyle).toBe('https://tiles.openfreemap.org/styles/bright');
act(() => result.current.saveMapProvider('leaflet'));
await waitFor(() => expect(update).toHaveBeenLastCalledWith({ map_provider: 'leaflet' }));
});
it('FE-COMP-DEFAULTSETTINGS-006: picks save at once, an empty pick does nothing, a typed style is normalized', async () => {
const update = stub({ map_provider: 'maplibre-gl' });
const { result } = await loaded();
act(() => result.current.pickTilePreset(''));
act(() => result.current.pickStylePreset(''));
expect(update).not.toHaveBeenCalled();
act(() => result.current.pickTilePreset('https://tile.openstreetmap.de/{z}/{x}/{y}.png'));
expect(result.current.mapTileUrl).toBe('https://tile.openstreetmap.de/{z}/{x}/{y}.png');
expect(update).toHaveBeenLastCalledWith({ map_tile_url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' });
act(() => result.current.pickStylePreset('https://tiles.openfreemap.org/styles/positron'));
expect(update).toHaveBeenLastCalledWith({ maplibre_style: 'https://tiles.openfreemap.org/styles/positron' });
act(() => result.current.setMapboxStyle('mapbox://styles/not-for-maplibre'));
act(() => result.current.commitStyle());
expect(result.current.mapboxStyle).toBe(OPENFREEMAP_DEFAULT_STYLE);
expect(update).toHaveBeenLastCalledWith({ maplibre_style: OPENFREEMAP_DEFAULT_STYLE });
await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(3));
});
});
@@ -0,0 +1,204 @@
import { useEffect, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import type { DistanceUnit, Place, WeekStart } from '../../types';
import { normalizeTileUrl } from '../../utils/tileUrl';
import {
defaultStyleForProvider,
getStylePresets,
normalizeStyleForProvider,
styleSettingKey,
} from '../Map/glProviders';
import { type MapProvider, normalizeProvider, styleForProvider } from '../Settings/mapSettingsModel';
import { useToast } from '../shared/Toast';
import type { RoutingDefaults } from './RoutingInstanceFields';
export type Defaults = RoutingDefaults & {
temperature_unit?: string;
distance_unit?: DistanceUnit;
dark_mode?: string | boolean;
time_format?: string;
week_start?: WeekStart;
default_currency?: string;
blur_booking_codes?: boolean;
map_tile_url?: string;
carto_api_key?: string;
map_provider?: string;
mapbox_access_token?: string;
mapbox_style?: string;
maplibre_style?: string;
mapbox_3d_enabled?: boolean;
mapbox_quality_mode?: boolean;
};
export interface DefaultUserSettingsOptions {
/** How a failed save or reset turns into the toast's text; each shell keeps its own. */
errorMessage: (err: unknown, fallback: string) => string;
/** The preview place's creation stamp; each shell keeps its own format. */
previewCreatedAt: () => string;
}
/**
* The admin's defaults for every user, behind both admin shells (the desktop tab and
* the phone screen render their own markup over this): loading them, the per change
* auto save, the reset to the built in value, and the map fields that are edited
* locally and saved on blur or on a pick.
*/
export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: DefaultUserSettingsOptions) {
const { t } = useTranslation();
const toast = useToast();
const [defaults, setDefaults] = useState<Defaults>({});
const [loaded, setLoaded] = useState(false);
const [mapTileUrl, setMapTileUrl] = useState('');
const managed = useAuthStore((s) => s.managed);
const [mapboxToken, setMapboxToken] = useState('');
const [cartoKey, setCartoKey] = useState('');
const [mapboxStyle, setMapboxStyle] = useState('');
useEffect(() => {
adminApi
.getDefaultUserSettings()
.then((data: Defaults) => {
const provider = normalizeProvider(data.map_provider);
setDefaults(data);
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''));
setMapboxToken(data.mapbox_access_token || '');
setCartoKey(data.carto_api_key || '');
setMapboxStyle(
provider === 'leaflet'
? data.mapbox_style || ''
: styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style)
);
setLoaded(true);
})
.catch(() => setLoaded(true));
}, []);
const save = async (patch: Partial<Defaults>) => {
try {
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>);
setDefaults(updated);
toast.success(t('admin.defaultSettings.saved'));
} catch (err: unknown) {
toast.error(errorMessage(err, t('common.error')));
}
};
const reset = async (key: keyof Defaults) => {
try {
const updated = await adminApi.updateDefaultUserSettings({ [key]: null });
setDefaults(updated);
if (key === 'map_tile_url') setMapTileUrl('');
if (key === 'mapbox_access_token') setMapboxToken('');
if (key === 'carto_api_key') setCartoKey('');
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider);
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider));
}
toast.success(t('admin.defaultSettings.reset'));
} catch (err: unknown) {
toast.error(errorMessage(err, t('common.error')));
}
};
const isSet = (key: keyof Defaults) => defaults[key] !== undefined;
// Built once per mount, like the preview it feeds.
const [mapPreviewPlaces] = useState((): Place[] => [adminPreviewPlace(previewCreatedAt())]);
const mapProvider = normalizeProvider(defaults.map_provider);
const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider);
const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style';
const saveMapProvider = (nextProvider: MapProvider) => {
const patch: Partial<Defaults> = { map_provider: nextProvider };
if (nextProvider !== 'leaflet') {
// Load + save the new provider's own style slot so the other provider's style is kept.
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style;
const nextStyle = styleForProvider(nextProvider, slot);
setMapboxStyle(nextStyle);
patch[styleSettingKey(nextProvider)] = nextStyle;
}
void save(patch);
};
/** A tile preset picked from the list: shown and saved at once. */
const pickTilePreset = (value: string) => {
if (value) {
setMapTileUrl(value);
void save({ map_tile_url: value });
}
};
/** A style preset picked from the list: shown and saved at once. */
const pickStylePreset = (value: string) => {
if (value) {
setMapboxStyle(value);
void save({ [styleKey]: value });
}
};
/** A typed style is normalized for the provider before it is saved. */
const commitStyle = () => {
// The style input never renders for leaflet; this only narrows the provider type.
if (mapProvider === 'leaflet') return;
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle);
setMapboxStyle(nextStyle);
void save({ [styleKey]: nextStyle });
};
return {
defaults,
loaded,
managed,
isSet,
save,
reset,
mapTileUrl,
setMapTileUrl,
mapboxToken,
setMapboxToken,
cartoKey,
setCartoKey,
mapboxStyle,
setMapboxStyle,
mapPreviewPlaces,
mapProvider,
glStylePresets,
styleKey,
saveMapProvider,
pickTilePreset,
pickStylePreset,
commitStyle,
};
}
/** The one place the admin's tile preview shows. */
function adminPreviewPlace(createdAt: string): Place {
return {
id: 1,
trip_id: 1,
name: 'Preview center',
description: null,
notes: null,
lat: 48.8566,
lng: 2.3522,
address: null,
category_id: null,
price: null,
currency: null,
image_url: null,
google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null,
end_time: null,
duration_minutes: null,
transport_mode: null,
website: null,
phone: null,
created_at: createdAt,
};
}
@@ -1,132 +1,35 @@
import React, { useEffect, useMemo, useState } from 'react'
import React, { useState } from 'react'
import { ChevronDown, Settings2 } from 'lucide-react'
import { adminApi } from '../../../api/client'
import { useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import { MapView } from '../../../components/Map/MapView'
import { SYMBOLS, currenciesWith } from '../../../components/Budget/BudgetPanel.constants'
import { getApiErrorMessage, type DistanceUnit, type Place, type WeekStart } from '../../../types'
import { getApiErrorMessage, type DistanceUnit, type WeekStart } from '../../../types'
import { weekStartOptions } from '../../../utils/calendarWeek'
import { normalizeTileUrl, withTileApiKey } from '../../../utils/tileUrl'
import {
MAPBOX_DEFAULT_STYLE,
defaultStyleForProvider,
getStylePresets,
isOpenFreeMapStyle,
normalizeStyleForProvider,
styleSettingKey,
type GlMapProvider,
} from '../../../components/Map/glProviders'
import { useAuthStore } from '../../../store/authStore'
import { withTileApiKey } from '../../../utils/tileUrl'
import { defaultStyleForProvider } from '../../../components/Map/glProviders'
import { DEFAULT_MAP_PRESETS as MAP_PRESETS, type MapProvider } from '../../../components/Settings/mapSettingsModel'
import { useDefaultUserSettings, type Defaults } from '../../../components/Admin/useDefaultUserSettings'
import MToggle from '../../components/MToggle'
import MSegmented from '../../components/MSegmented'
import { MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminRow } from './MAdminUi'
import { MSetSelectRow } from '../settings/MSettingsUi'
import MSetPickerSheet from '../settings/MSetPickerSheet'
import RoutingInstanceFields, { type RoutingDefaults } from '../../../components/Admin/RoutingInstanceFields'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
// The app default, and a vector style rather than a {z}/{x}/{y} template: no
// key, no registration, no request limits.
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron' },
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright' },
// CARTO watermarks keyless tiles since 26.08.2026 and issues keys by mail, so
// these two need one; without it the map falls back to the default (#2054).
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
]
type Defaults = RoutingDefaults & {
temperature_unit?: string
distance_unit?: DistanceUnit
dark_mode?: string | boolean
time_format?: string
week_start?: WeekStart
default_currency?: string
blur_booking_codes?: boolean
map_tile_url?: string
carto_api_key?: string
map_provider?: string
mapbox_access_token?: string
mapbox_style?: string
maplibre_style?: string
mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean
}
type MapProvider = 'leaflet' | GlMapProvider
function normalizeProvider(value: unknown): MapProvider {
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
}
function styleForProvider(provider: MapProvider, style?: string | null): string {
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
return normalizeStyleForProvider(provider, style)
}
import RoutingInstanceFields from '../../../components/Admin/RoutingInstanceFields'
// Mobile-native rebuild of the desktop DefaultUserSettingsTab. Identical data
// layer (adminApi defaults, per-change auto-save, reset-to-built-in) — only the
// presentation is relaid on the admin mobile design system.
export default function MAdminDefaultUserSettings(): React.ReactElement {
const { t, locale } = useTranslation()
const toast = useToast()
const [defaults, setDefaults] = useState<Defaults>({})
const [loaded, setLoaded] = useState(false)
const [mapTileUrl, setMapTileUrl] = useState('')
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [cartoKey, setCartoKey] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
const {
defaults, loaded, managed, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, mapboxStyle, setMapboxStyle, mapPreviewPlaces, mapProvider, glStylePresets, styleKey,
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
} = useDefaultUserSettings({ errorMessage: getApiErrorMessage, previewCreatedAt: () => new Date().toISOString() })
const [currencyOpen, setCurrencyOpen] = useState(false)
const [presetOpen, setPresetOpen] = useState(false)
const [styleOpen, setStyleOpen] = useState(false)
useEffect(() => {
adminApi.getDefaultUserSettings().then((data: Defaults) => {
const provider = normalizeProvider(data.map_provider)
setDefaults(data)
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
setMapboxToken(data.mapbox_access_token || '')
setCartoKey(data.carto_api_key || '')
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
setLoaded(true)
}).catch(() => setLoaded(true))
}, [])
const save = async (patch: Partial<Defaults>) => {
try {
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>)
setDefaults(updated)
toast.success(t('admin.defaultSettings.saved'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const reset = async (key: keyof Defaults) => {
try {
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
setDefaults(updated)
if (key === 'map_tile_url') setMapTileUrl('')
if (key === 'mapbox_access_token') setMapboxToken('')
if (key === 'carto_api_key') setCartoKey('')
if (key === 'mapbox_style' || key === 'maplibre_style') {
const provider = normalizeProvider(defaults.map_provider)
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
}
toast.success(t('admin.defaultSettings.reset'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const isSet = (key: keyof Defaults) => defaults[key] !== undefined
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button
@@ -138,31 +41,6 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
</button>
) : null
const mapPreviewPlaces = useMemo((): Place[] => [{
id: 1,
trip_id: 1,
name: 'Preview center',
description: null,
notes: null,
lat: 48.8566,
lng: 2.3522,
address: null,
category_id: null,
price: null,
currency: null,
image_url: null,
google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null,
end_time: null,
duration_minutes: null,
transport_mode: null,
website: null,
phone: null,
created_at: new Date().toISOString(),
}], [])
if (!loaded) {
return (
<MAdminCard>
@@ -172,21 +50,6 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
}
const darkMode = defaults.dark_mode
const mapProvider = normalizeProvider(defaults.map_provider)
const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
const saveMapProvider = (nextProvider: MapProvider) => {
const patch: Partial<Defaults> = { map_provider: nextProvider }
if (nextProvider !== 'leaflet') {
// Load + save the new provider's own style slot so the other provider's style is kept.
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
const nextStyle = styleForProvider(nextProvider, slot)
setMapboxStyle(nextStyle)
patch[styleSettingKey(nextProvider)] = nextStyle
}
void save(patch)
}
// No active value when the setting is unset → segmented shows no pill, matching
// the desktop tab (which only highlights a button once a default is chosen).
const colorModeValue =
@@ -388,11 +251,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
className="mt-[6px]"
value={mapboxStyle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
onBlur={() => {
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
setMapboxStyle(nextStyle)
void save({ [styleKey]: nextStyle })
}}
onBlur={commitStyle}
placeholder={defaultStyleForProvider(mapProvider)}
/>
</MAdminField>
@@ -442,7 +301,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
onClose={() => setPresetOpen(false)}
title={t('settings.mapTemplate')}
value={mapTileUrl}
onSelect={(value) => { if (value) { setMapTileUrl(value); void save({ map_tile_url: value }) } }}
onSelect={pickTilePreset}
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
/>
@@ -451,7 +310,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
onClose={() => setStyleOpen(false)}
title={t('admin.defaultSettings.mapboxStyle')}
value={mapboxStyle}
onSelect={(value) => { if (value) { setMapboxStyle(value); void save({ [styleKey]: value }) } }}
onSelect={pickStylePreset}
options={glStylePresets.map((p) => ({ value: p.url, label: p.name }))}
/>
</div>