mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
feat(mobile): phone styling for tours list, trip settings and admin cards
The tours list in the places browser now uses the same rows as places: a grade or type tile, the day plan's figure badges and a day badge or plus on the right. A shared "Planned on desktop" badge replaces the grey hint box there and sits under the road trip's empty stage. Trip settings and trip edit open phone sheets instead of the desktop dialogs; the edit sheet gains the day count of a dateless trip and the reminder. Questions are asked in phone sheets, not the browser. Admin on the phone: the paddle regions and school holiday cards are phone cards on their shared hooks, long addon descriptions fold to two lines, the addon header keeps the wordmark inside its sentence, the group bands drop the dash and the permissions actions get a row of their own. The Tours addon gets its catalog name and description in every locale. The journey map on the phone shows every layer without the switch bar.
This commit is contained in:
@@ -899,6 +899,24 @@
|
||||
"hooks": ["src/components/Vacay/useVacaySettings.ts"],
|
||||
"desktop": ["src/components/Vacay/VacaySettings.tsx"],
|
||||
"mobile": ["src/mobile/screens/vacay/MVacaySettingsSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Paddle regions admin card",
|
||||
"hooks": ["src/components/Admin/paddle/usePaddleRegionsAdmin.ts", "src/components/Admin/paddle/paddleRegionsModel.ts"],
|
||||
"desktop": ["src/components/Admin/paddle/PaddleRegionsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "School holiday catalog admin card",
|
||||
"hooks": ["src/components/Admin/useSchoolHolidayCatalog.ts"],
|
||||
"desktop": ["src/components/Admin/SchoolHolidayCatalog.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Trip settings: members, guests, public and invite link",
|
||||
"hooks": ["src/components/Trips/useTripShare.ts"],
|
||||
"desktop": ["src/components/Trips/TripShareDialog.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx"]
|
||||
}
|
||||
],
|
||||
"mobileOnly": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"admin.ts": 152,
|
||||
"admin.ts": 126,
|
||||
"atlas.ts": 10,
|
||||
"backup.ts": 10,
|
||||
"budget.ts": 40,
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
"src/components/Studio/StudioSidebar.tsx": 1,
|
||||
"src/components/Studio/TravelElements.tsx": 1,
|
||||
"src/components/Todo/useTodoList.ts": 1,
|
||||
"src/components/Trips/TripMembersModal.tsx": 1,
|
||||
"src/components/shared/ConfirmDialog.tsx": 1,
|
||||
"src/components/shared/CountryFlag.tsx": 1,
|
||||
"src/components/shared/EmptyState.tsx": 1,
|
||||
|
||||
@@ -90,7 +90,6 @@
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
|
||||
"src/components/Tours/planner/useTourPlanner.ts": 2,
|
||||
"src/components/Trips/TripFormModal.tsx": 3,
|
||||
"src/components/Trips/TripMembersModal.tsx": 1,
|
||||
"src/components/Vacay/VacayCalendar.tsx": 1,
|
||||
"src/components/Vacay/VacayPersons.tsx": 4,
|
||||
"src/components/Vacay/VacaySettings.tsx": 2,
|
||||
@@ -110,14 +109,12 @@
|
||||
"src/mobile/screens/trip/MTripShell.tsx": 1,
|
||||
"src/mobile/screens/trip/map/MMapArea.tsx": 1,
|
||||
"src/mobile/screens/trip/places/MPlacesBrowser.tsx": 2,
|
||||
"src/mobile/screens/trip/places/MToursSelectionList.tsx": 6,
|
||||
"src/mobile/screens/trip/plan/planTimelineModel.ts": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 2,
|
||||
"src/mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 2,
|
||||
"src/mobile/screens/trip/sheets/MReservationSheet.tsx": 1,
|
||||
"src/mobile/screens/trip/sheets/MTripSheets.tsx": 1,
|
||||
"src/mobile/screens/trip/tabs/transportsModel.ts": 1,
|
||||
"src/mobile/screens/vacay/MVacaySettingsSheet.tsx": 1,
|
||||
"src/pages/AtlasPage.tsx": 6,
|
||||
|
||||
@@ -116,7 +116,6 @@
|
||||
"components/Todo/TodoListPanel.tsx": 6,
|
||||
"components/Todo/todoListModel.ts": 3,
|
||||
"components/Trips/TripFormModal.tsx": 9,
|
||||
"components/Trips/TripMembersModal.tsx": 6,
|
||||
"components/Vacay/VacayBadge.tsx": 3,
|
||||
"components/Vacay/VacayCalendar.tsx": 20,
|
||||
"components/Vacay/VacayMonthCard.tsx": 5,
|
||||
@@ -156,7 +155,7 @@
|
||||
"mobile/components/MToastHost.tsx": 2,
|
||||
"mobile/components/MUserPickerList.tsx": 1,
|
||||
"mobile/screens/admin/MAdmin.tsx": 9,
|
||||
"mobile/screens/admin/MAdminAddonManager.tsx": 26,
|
||||
"mobile/screens/admin/MAdminAddonManager.tsx": 22,
|
||||
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
|
||||
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
|
||||
"mobile/screens/admin/MAdminCategoryManager.tsx": 8,
|
||||
@@ -166,7 +165,7 @@
|
||||
"mobile/screens/admin/MAdminMcpTokensPanel.tsx": 14,
|
||||
"mobile/screens/admin/MAdminNotifyMatrix.tsx": 5,
|
||||
"mobile/screens/admin/MAdminPackingTemplateManager.tsx": 8,
|
||||
"mobile/screens/admin/MAdminPermissionsPanel.tsx": 7,
|
||||
"mobile/screens/admin/MAdminPermissionsPanel.tsx": 5,
|
||||
"mobile/screens/admin/MAdminPluginsPanel.tsx": 129,
|
||||
"mobile/screens/admin/MAdminSettingsSection.tsx": 10,
|
||||
"mobile/screens/admin/MAdminSheets.tsx": 11,
|
||||
@@ -193,7 +192,7 @@
|
||||
"mobile/screens/collections/MCollections.tsx": 21,
|
||||
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 36,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 21,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 20,
|
||||
"mobile/screens/dashboard/MUserMenu.tsx": 7,
|
||||
"mobile/screens/journey/MJourney.tsx": 10,
|
||||
"mobile/screens/journey/MJourneyCreateSheet.tsx": 10,
|
||||
@@ -230,11 +229,10 @@
|
||||
"mobile/screens/trip/places/MPlacesFilterSheet.tsx": 3,
|
||||
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7,
|
||||
"mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1,
|
||||
"mobile/screens/trip/places/MToursSelectionList.tsx": 9,
|
||||
"mobile/screens/trip/plan/MPlanTimeline.tsx": 14,
|
||||
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 26,
|
||||
"mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 31,
|
||||
"mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 17,
|
||||
"mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 16,
|
||||
"mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 8,
|
||||
"mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 2,
|
||||
"mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 7,
|
||||
|
||||
@@ -49,14 +49,16 @@ export function isPhotosAddon(addon: Addon): boolean {
|
||||
|
||||
/** The catalog name and description for an addon, falling back to what the server sent. */
|
||||
export function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||
const nameKey = `admin.addons.catalog.${addon.id}.name`;
|
||||
const descKey = `admin.addons.catalog.${addon.id}.description`;
|
||||
const translatedName = t(nameKey);
|
||||
const translatedDescription = t(descKey);
|
||||
// Spelled out inside t() so the key check sees which catalog keys this reaches.
|
||||
const translatedName = t(`admin.addons.catalog.${addon.id}.name`);
|
||||
const translatedDescription = t(`admin.addons.catalog.${addon.id}.description`);
|
||||
|
||||
return {
|
||||
name: translatedName !== nameKey ? translatedName : addon.name,
|
||||
description: translatedDescription !== descKey ? translatedDescription : addon.description,
|
||||
name: translatedName !== `admin.addons.catalog.${addon.id}.name` ? translatedName : addon.name,
|
||||
description:
|
||||
translatedDescription !== `admin.addons.catalog.${addon.id}.description`
|
||||
? translatedDescription
|
||||
: addon.description,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,24 +5,17 @@ import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } f
|
||||
import { LABEL, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { AUTO_LOAD_MODES, autoLoadSizes, formatMegabytes } from './paddleRegionsModel';
|
||||
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
|
||||
const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const;
|
||||
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
|
||||
const SIZES = [100, 250, 500, 1000, 2000] as const;
|
||||
|
||||
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
|
||||
/** `formatMegabytes` in the reader's language. */
|
||||
function useMegabytes() {
|
||||
const { language } = useTranslation();
|
||||
return (mb: number) =>
|
||||
new Intl.NumberFormat(language, {
|
||||
style: 'unit',
|
||||
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
|
||||
maximumFractionDigits: mb >= 1000 ? 1 : 0,
|
||||
}).format(mb >= 1000 ? mb / 1000 : mb);
|
||||
return (mb: number) => formatMegabytes(mb, language);
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
@@ -36,9 +29,7 @@ export function AutoLoadSettings({ admin }: { admin: Admin }) {
|
||||
const megabytes = useMegabytes();
|
||||
const { settings, saving, save } = admin.autoLoad;
|
||||
if (!settings) return null;
|
||||
const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number])
|
||||
? SIZES
|
||||
: [...SIZES, settings.max_mb].sort((a, b) => a - b);
|
||||
const sizes = autoLoadSizes(settings.max_mb);
|
||||
return (
|
||||
<div data-testid="paddle-auto-load" className="flex min-w-0 flex-col gap-2.5">
|
||||
<div className="min-w-0">
|
||||
@@ -49,7 +40,7 @@ export function AutoLoadSettings({ admin }: { admin: Admin }) {
|
||||
onChange={(mode) => void save({ mode })}
|
||||
label={t('admin.paddleRegions.auto.title')}
|
||||
fill
|
||||
options={MODES.map((mode) => {
|
||||
options={AUTO_LOAD_MODES.map((mode) => {
|
||||
const Icon = MODE_ICONS[mode];
|
||||
return {
|
||||
value: mode,
|
||||
|
||||
@@ -25,8 +25,8 @@ type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
|
||||
* picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a
|
||||
* place a paddler plans in by itself, with the regions that wait for an admin.
|
||||
* Both the desktop and the phone admin show this one card under the addons,
|
||||
* while the Tours addon is on.
|
||||
* The desktop admin shows this card under the addons while the Tours addon
|
||||
* is on; the phone has MAdminPaddleRegionsPanel on the same hook.
|
||||
*/
|
||||
export default function PaddleRegionsPanel() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
import type { PaddleRegion } from '@trek/shared';
|
||||
import type { PaddleRegion, PaddleRegionAutoMode } from '@trek/shared';
|
||||
|
||||
/** How TREK loads a paddler's region by itself, in the order the switch shows them. */
|
||||
export const AUTO_LOAD_MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
|
||||
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
|
||||
export const AUTO_LOAD_SIZES = [100, 250, 500, 1000, 2000] as const;
|
||||
|
||||
/** The size choices, with a stored size outside the presets kept among them in its place. */
|
||||
export function autoLoadSizes(current: number): number[] {
|
||||
return (AUTO_LOAD_SIZES as readonly number[]).includes(current)
|
||||
? [...AUTO_LOAD_SIZES]
|
||||
: [...AUTO_LOAD_SIZES, current].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
|
||||
export function formatMegabytes(mb: number, language: string): string {
|
||||
return new Intl.NumberFormat(language, {
|
||||
style: 'unit',
|
||||
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
|
||||
maximumFractionDigits: mb >= 1000 ? 1 : 0,
|
||||
}).format(mb >= 1000 ? mb / 1000 : mb);
|
||||
}
|
||||
|
||||
/** A region waiting for its read or being read: the list asks again until none is. */
|
||||
export function isReading(region: Pick<PaddleRegion, 'status'>): boolean {
|
||||
|
||||
@@ -89,6 +89,12 @@ describe('JourneyLayeredMap', () => {
|
||||
expect(last().photos).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-JOURNEY-LAYEREDMAP-002b: the phone draws the layers without their switches', () => {
|
||||
render(<JourneyLayeredMap layers={layers()} hideLayerControl checkins={[]} entries={[]} photos={PHOTOS} />);
|
||||
expect(last()).toMatchObject({ marks: [MARK], layerVisibility: ALL_VISIBLE });
|
||||
expect(screen.queryByRole('group', { name: 'Map layers' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-JOURNEY-LAYEREDMAP-003: a picked mark opens its card, which goes when its layer is switched off', () => {
|
||||
const onOpenEntry = vi.fn();
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -20,8 +20,8 @@ interface Props extends MapProps {
|
||||
layers?: JourneyMapLayersView | null;
|
||||
/** A shared page and the phone timeline draw with Leaflet whatever a setting says. */
|
||||
engine?: 'auto' | 'leaflet';
|
||||
/** Icon-only switches, for a phone. */
|
||||
compactControls?: boolean;
|
||||
/** No layer switches: the phone keeps its map clear and shows every layer (the picked card still opens). */
|
||||
hideLayerControl?: boolean;
|
||||
/** How far from the map's top the switches sit, clear of a bar laid over the map. */
|
||||
controlsTop?: number | string;
|
||||
/** Leads from a picked place or stay to its entry. */
|
||||
@@ -40,7 +40,7 @@ export default function JourneyLayeredMap({
|
||||
ref,
|
||||
layers,
|
||||
engine = 'auto',
|
||||
compactControls = false,
|
||||
hideLayerControl = false,
|
||||
controlsTop = 12,
|
||||
onOpenEntry,
|
||||
entryTitleOf,
|
||||
@@ -88,9 +88,11 @@ export default function JourneyLayeredMap({
|
||||
className="pointer-events-none absolute inset-x-3 z-10 flex flex-col items-start gap-2"
|
||||
style={{ top: controlsTop }}
|
||||
>
|
||||
<div className="pointer-events-auto max-w-full">
|
||||
<JourneyMapLayerControl layers={layers} compact={compactControls} />
|
||||
</div>
|
||||
{!hideLayerControl && (
|
||||
<div className="pointer-events-auto max-w-full">
|
||||
<JourneyMapLayerControl layers={layers} />
|
||||
</div>
|
||||
)}
|
||||
{card && (
|
||||
<JourneyMapPickCard
|
||||
key={card.key}
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function MobileMapTimeline({
|
||||
ref={mapRef}
|
||||
engine="leaflet"
|
||||
layers={layers}
|
||||
compactControls
|
||||
hideLayerControl
|
||||
controlsTop={controlsTop}
|
||||
onOpenEntry={handleMarkerClick}
|
||||
entryTitleOf={entryTitleOf}
|
||||
@@ -155,7 +155,7 @@ export default function MobileMapTimeline({
|
||||
ref={mapRef}
|
||||
engine="leaflet"
|
||||
layers={layers}
|
||||
compactControls
|
||||
hideLayerControl
|
||||
controlsTop={controlsTop}
|
||||
onOpenEntry={handleMarkerClick}
|
||||
entryTitleOf={entryTitleOf}
|
||||
|
||||
@@ -111,6 +111,33 @@ describe('useJourneyMapLayers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-JOURNEY-MAPLAYERS-HOOK-002b: the phone shows every layer whatever was kept, and keeps no switch', () => {
|
||||
vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
|
||||
places: { status: 'ok', items: [] },
|
||||
routes: { status: 'ok', items: [] },
|
||||
stays: { status: 'ok', items: [] },
|
||||
});
|
||||
localStorage.setItem('trek:journey-map-layers:8', JSON.stringify({ plan: false, entries: false }));
|
||||
const { result } = renderHook(() =>
|
||||
useJourneyMapLayers({
|
||||
source: { journeyId: 8 },
|
||||
entryIdsOnMap: NO_ENTRIES,
|
||||
photoCount: 0,
|
||||
colorOfDay,
|
||||
dateOfEntry,
|
||||
allLayers: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.visibility).toMatchObject({ plan: true, entries: true, recorded: true, photos: true });
|
||||
act(() => result.current.toggle('photos'));
|
||||
expect(result.current.visibility.photos).toBe(false);
|
||||
expect(JSON.parse(localStorage.getItem('trek:journey-map-layers:8') ?? '{}')).toEqual({
|
||||
plan: false,
|
||||
entries: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-JOURNEY-MAPLAYERS-HOOK-003: a read that fails says so on the plan and the entries, and the recordings still draw', async () => {
|
||||
vi.spyOn(journeyMapRepo, 'layers').mockRejectedValue(new Error('down'));
|
||||
const recorded: JourneyTrack = {
|
||||
|
||||
@@ -50,6 +50,12 @@ interface Options {
|
||||
colorOfDay: (date: string) => string | null;
|
||||
/** The day an entry is on, for the colour of its stays. */
|
||||
dateOfEntry: (entryId: number) => string | null;
|
||||
/**
|
||||
* Every layer on, whatever this browser kept: the phone shows no switches,
|
||||
* so a layer once switched off there would stay off with no way back. A
|
||||
* switch that is still offered then holds only for the visit.
|
||||
*/
|
||||
allLayers?: boolean;
|
||||
}
|
||||
|
||||
const NO_TRACKS: JourneyTrack[] = [];
|
||||
@@ -119,13 +125,14 @@ export function useJourneyMapLayers({
|
||||
recordedRead = 'ready',
|
||||
colorOfDay,
|
||||
dateOfEntry,
|
||||
allLayers = false,
|
||||
}: Options): JourneyMapLayersView {
|
||||
const { t, locale } = useTranslation();
|
||||
const journeyId = source && 'journeyId' in source ? source.journeyId : null;
|
||||
const token = source && 'token' in source ? source.token : null;
|
||||
const storageKey =
|
||||
journeyId !== null ? layersStorageKey({ journeyId }) : token !== null ? layersStorageKey({ token }) : null;
|
||||
const { visibility, toggle } = useLayerSwitches(storageKey);
|
||||
const { visibility, toggle } = useLayerSwitches(allLayers ? null : storageKey);
|
||||
|
||||
const sourceKey = journeyId !== null ? `j:${journeyId}` : token !== null ? `s:${token}` : null;
|
||||
// A shared page reads once; a journey's own map only while the plan or the entries are shown.
|
||||
|
||||
@@ -1108,77 +1108,3 @@ describe('TripMembersModal', () => {
|
||||
view.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TripMembersModal on a phone', () => {
|
||||
let width: number;
|
||||
beforeEach(() => {
|
||||
width = window.innerWidth;
|
||||
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true, writable: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true });
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-058b: the public link toggles say whether they are on (#2604)', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||||
server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({
|
||||
token: 'abc123', share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false,
|
||||
})));
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Map & Plan' })).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-058: keeps the sheet it had, with "(you)" beside the name', async () => {
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
expect(await screen.findByText(/\(you\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Access \(1/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-059: asks with the browser before a member goes, and only then removes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]);
|
||||
let deletes = 0;
|
||||
server.use(http.delete('/api/trips/1/members/:userId', () => { deletes++; return HttpResponse.json({ success: true }); }));
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
|
||||
await user.click(await screen.findByTitle('Remove access'));
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Remove access for this user?');
|
||||
expect(deletes).toBe(0);
|
||||
|
||||
confirmSpy.mockReturnValue(true);
|
||||
await user.click(screen.getByTitle('Remove access'));
|
||||
await waitFor(() => expect(deletes).toBe(1));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-060: a guest is renamed and removed through the same questions', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
mockRoster([{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true }]);
|
||||
let renamed: string | null = null;
|
||||
let deleted = false;
|
||||
server.use(
|
||||
http.put('/api/trips/1/guests/:userId', async ({ request }) => {
|
||||
renamed = ((await request.json()) as { name: string }).name;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.delete('/api/trips/1/guests/:userId', () => { deleted = true; return HttpResponse.json({ success: true }); }),
|
||||
);
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
|
||||
await user.click(await screen.findByTitle('Rename'));
|
||||
const input = screen.getByDisplayValue('Grandma');
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
await user.click(screen.getByTitle('Rename'));
|
||||
fireEvent.change(screen.getByDisplayValue('Grandma'), { target: { value: 'Granny' } });
|
||||
fireEvent.keyDown(screen.getByDisplayValue('Granny'), { key: 'Enter' });
|
||||
await waitFor(() => expect(renamed).toBe('Granny'));
|
||||
|
||||
await user.click(screen.getByTitle('Remove access'));
|
||||
await waitFor(() => expect(deleted).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,148 +1,4 @@
|
||||
import { TripJournalLinkSection } from './TripJournalLinkSection'
|
||||
import Modal from '../shared/Modal'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { TripMemberAvatar as Avatar } from './TripMemberAvatar'
|
||||
import TripShareDialog from './TripShareDialog'
|
||||
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers } from './useTripShare'
|
||||
|
||||
function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
const { loading, url: shareUrl, perms, copied, create: handleCreate, setPerm, remove: handleDelete, copy: handleCopy } = useShareLink(tripId)
|
||||
const handleUpdatePerms = (key: string, val: boolean) => setPerm(key as Parameters<typeof setPerm>[0], val)
|
||||
|
||||
if (loading) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<Link2 size={14} className="text-content-muted" />
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('share.linkTitle')}</span>
|
||||
</div>
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginBottom: 10, lineHeight: 1.5 }}>{t('share.linkHint')}</p>
|
||||
|
||||
{/* Permission checkboxes */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||
{[...SHARE_SECTIONS, ...SHARE_OPTIONS].map(opt => ({ ...opt, label: t(opt.label), always: 'always' in opt && !!opt.always })).map(opt => (
|
||||
<button type="button" key={opt.key} aria-pressed={!!perms[opt.key]} aria-disabled={opt.always || undefined} onClick={() => !opt.always && handleUpdatePerms(opt.key, !perms[opt.key])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 20,
|
||||
border: '1.5px solid', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, cursor: opt.always ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: perms[opt.key] ? 'var(--text-primary)' : 'transparent',
|
||||
borderColor: perms[opt.key] ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: perms[opt.key] ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
opacity: opt.always ? 0.7 : 1,
|
||||
}}>
|
||||
{perms[opt.key] ? <Check size={10} /> : null}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shareUrl ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className="bg-surface-tertiary border border-edge-faint" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px',
|
||||
borderRadius: 8,
|
||||
}}>
|
||||
<input type="text" value={shareUrl} readOnly className="text-content" style={{
|
||||
flex: 1, border: 'none', background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
outline: 'none', fontFamily: 'monospace',
|
||||
}} />
|
||||
<button type="button" onClick={handleCopy} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
|
||||
border: 'none', background: copied ? '#16a34a' : 'var(--accent)', color: copied ? 'white' : 'var(--accent-text)',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
|
||||
}}>
|
||||
{copied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={handleDelete} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Trash2 size={11} /> {t('share.deleteLink')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={handleCreate} className="border border-dashed border-edge text-content-muted" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 0', borderRadius: 8,
|
||||
background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={12} /> {t('share.createLink')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trip invite link (#1143). One rotating token per trip that an existing,
|
||||
* logged-in user opens to join the trip as a member. Mirrors ShareLinkSection
|
||||
* but the link points at /join/:token (login-required, no registration).
|
||||
*/
|
||||
function TripInviteLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
const { loading, url: inviteUrl, busy, copied, create, remove, copy } = useTripInviteLink(tripId)
|
||||
|
||||
if (loading) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 20, paddingTop: 20, borderTop: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<UserPlus size={14} className="text-content-muted" />
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('trip.invite.linkTitle')}</span>
|
||||
</div>
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginBottom: 12, lineHeight: 1.5 }}>{t('trip.invite.linkHint')}</p>
|
||||
|
||||
{inviteUrl ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className="bg-surface-tertiary border border-edge-faint" style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px', borderRadius: 8 }}>
|
||||
<input type="text" value={inviteUrl} readOnly className="text-content" style={{ flex: 1, border: 'none', background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', outline: 'none', fontFamily: 'monospace' }} />
|
||||
<button type="button" onClick={copy} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
|
||||
border: 'none', background: copied ? '#16a34a' : 'var(--accent)', color: copied ? 'white' : 'var(--accent-text)',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
|
||||
}}>
|
||||
{copied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={create} disabled={busy} className="border border-edge text-content-muted" style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: busy ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={11} /> {t('trip.invite.regenerate')}
|
||||
</button>
|
||||
<button type="button" onClick={remove} disabled={busy} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: busy ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Trash2 size={11} /> {t('trip.invite.disable')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={create} disabled={busy} className="border border-dashed border-edge text-content-muted" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 0', borderRadius: 8,
|
||||
background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: busy ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<UserPlus size={12} /> {t('trip.invite.create')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TripMembersModalProps {
|
||||
isOpen: boolean
|
||||
@@ -154,259 +10,7 @@ interface TripMembersModalProps {
|
||||
onMembersChanged?: () => void
|
||||
}
|
||||
|
||||
/** Share: the new dialog on the desktop; the phone, which opens this from its More sheet, keeps the sheet below. */
|
||||
/** Share: the trip's members, guests and links. The phone has its own sheet (MTripSettingsSheet) on the same hooks. */
|
||||
export default function TripMembersModal(props: TripMembersModalProps) {
|
||||
const isPhone = useIsPhone()
|
||||
return isPhone ? <TripMembersSheet {...props} /> : <TripShareDialog {...props} />
|
||||
}
|
||||
|
||||
function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged }: TripMembersModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const m = useTripMembers({ isOpen, tripId, onClose, onMembersChanged })
|
||||
const {
|
||||
user, loading, realMembers, guests, allUsers, availableUsers, isCurrentOwner, canManageMembers, canManageShare,
|
||||
selectedUserId, setSelectedUserId, adding, transferringId, removingId,
|
||||
newGuestName, setNewGuestName, addingGuest, renamingGuestId, renameValue, setRenameValue,
|
||||
} = m
|
||||
const handleAdd = () => void m.add()
|
||||
const handleAddGuest = () => void m.addGuest()
|
||||
const handleRenameGuest = (userId: number) => void m.commitRename(userId)
|
||||
const handleTransfer = (newOwnerId: number, username: string) => {
|
||||
if (confirm(t('members.confirmTransfer', { name: username }))) void m.transfer(newOwnerId)
|
||||
}
|
||||
const handleRemove = (userId: number, isSelf: boolean) => {
|
||||
if (confirm(isSelf ? t('members.confirmLeave') : t('members.confirmRemove'))) void m.remove(userId, isSelf)
|
||||
}
|
||||
const handleDeleteGuest = (userId: number) => {
|
||||
if (confirm(t('members.confirmRemoveGuest'))) void m.removeGuest(userId)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('trip.settings.title')} size="3xl">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
|
||||
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||
|
||||
{/* Left column: Members */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
|
||||
{/* Trip name */}
|
||||
<div className="bg-surface-secondary border border-edge-secondary" style={{ padding: '10px 14px', borderRadius: 10 }}>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 2 }}>{t('nav.trip')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{tripTitle}</div>
|
||||
</div>
|
||||
|
||||
{/* Add member dropdown */}
|
||||
{canManageMembers && <div>
|
||||
<label className="text-content-secondary" style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, marginBottom: 8 }}>
|
||||
{t('members.inviteUser')}
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<CustomSelect
|
||||
value={selectedUserId}
|
||||
onChange={value => setSelectedUserId(String(value))}
|
||||
placeholder={t('members.selectUser')}
|
||||
options={[
|
||||
{ value: '', label: t('members.selectUser') },
|
||||
// As a string: the select compares values strictly, and the chosen id is
|
||||
// kept as a string, so a number here left the trigger on its placeholder.
|
||||
...availableUsers.map(u => ({
|
||||
value: String(u.id),
|
||||
label: u.username,
|
||||
})),
|
||||
]}
|
||||
searchable
|
||||
style={{ flex: 1 }}
|
||||
size="sm"
|
||||
/>
|
||||
<button type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={adding || !selectedUserId}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: adding || !selectedUserId ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: adding || !selectedUserId ? 0.4 : 1, flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
|
||||
</button>
|
||||
</div>
|
||||
{availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && (
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{/* Members list */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<Users size={13} className="text-content-faint" />
|
||||
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
|
||||
{t('members.access')} ({realMembers.length} {t('members.persons', { count: realMembers.length })})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="bg-surface-tertiary" style={{ height: 48, borderRadius: 10, animation: 'pulse 1.5s ease-in-out infinite' }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{realMembers.map(member => {
|
||||
const isSelf = member.id === user?.id
|
||||
const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
|
||||
return (
|
||||
<div key={member.id} className="bg-surface-secondary border border-edge-secondary" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '8px 12px', borderRadius: 10,
|
||||
}}>
|
||||
<Avatar username={member.username} avatarUrl={member.avatar_url} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{member.username}</span>
|
||||
{isSelf && <span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }}>({t('members.you')})</span>}
|
||||
{member.role === 'owner' && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#d97706', background: '#fef9c3', padding: '1px 6px', borderRadius: 99 }}>
|
||||
<Crown size={9} /> {t('members.owner')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrentOwner && member.role !== 'owner' && (
|
||||
<button type="button"
|
||||
onClick={() => handleTransfer(member.id, member.username)}
|
||||
disabled={transferringId === member.id}
|
||||
title={t('members.makeOwner')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: transferringId === member.id ? 0.4 : 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#d97706'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
<Crown size={14} />
|
||||
</button>
|
||||
)}
|
||||
{canRemove && (
|
||||
<button type="button"
|
||||
onClick={() => handleRemove(member.id, isSelf)}
|
||||
disabled={removingId === member.id}
|
||||
title={isSelf ? t('members.leaveTrip') : t('members.removeAccess')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: removingId === member.id ? 0.4 : 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
{isSelf ? <LogOut size={14} /> : <UserMinus size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Guests (#1362) — accountless participants, managed by the owner */}
|
||||
{(isCurrentOwner || guests.length > 0) && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<UserRound size={13} className="text-content-faint" />
|
||||
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
|
||||
{t('members.guests')}{guests.length > 0 ? ` (${guests.length})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', margin: '0 0 10px', lineHeight: 1.5 }}>{t('members.guestsHint')}</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{guests.map(g => (
|
||||
<div key={g.id} className="bg-surface-secondary border border-edge-secondary" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', borderRadius: 10,
|
||||
}}>
|
||||
<Avatar username={g.username} avatarUrl={null} />
|
||||
{renamingGuestId === g.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameGuest(g.id); if (e.key === 'Escape') m.cancelRename() }}
|
||||
onBlur={() => handleRenameGuest(g.id)}
|
||||
maxLength={50}
|
||||
className="bg-surface border border-edge text-content"
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '4px 8px', borderRadius: 8, outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{g.username}</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99 }}>
|
||||
<UserRound size={9} /> {t('members.guest')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isCurrentOwner && renamingGuestId !== g.id && (
|
||||
<>
|
||||
<button type="button"
|
||||
onClick={() => m.startRename(g)}
|
||||
title={t('common.rename')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleDeleteGuest(g.id)}
|
||||
disabled={removingId === g.id}
|
||||
title={t('members.removeAccess')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: removingId === g.id ? 0.4 : 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isCurrentOwner && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: guests.length > 0 ? 8 : 0 }}>
|
||||
<input
|
||||
value={newGuestName}
|
||||
onChange={e => setNewGuestName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddGuest() }}
|
||||
placeholder={t('members.guestNamePlaceholder')}
|
||||
maxLength={50}
|
||||
className="bg-surface border border-edge text-content"
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '8px 10px', borderRadius: 10, outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
<button type="button"
|
||||
onClick={handleAddGuest}
|
||||
disabled={addingGuest || !newGuestName.trim()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
|
||||
background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: addingGuest || !newGuestName.trim() ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: addingGuest || !newGuestName.trim() ? 0.4 : 1, flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Plus size={13} /> {addingGuest ? '…' : t('members.addGuest')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right column: Share Link */}
|
||||
{canManageShare && <div className="border-s border-edge-faint" style={{ paddingInlineStart: 24 }}>
|
||||
<ShareLinkSection tripId={tripId} t={t} />
|
||||
<TripInviteLinkSection tripId={tripId} t={t} />
|
||||
</div>}
|
||||
|
||||
<TripJournalLinkSection tripId={tripId} />
|
||||
|
||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
return <TripShareDialog {...props} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Monitor } from 'lucide-react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
/**
|
||||
* The one way the phone says "this is planned on a bigger screen": the road
|
||||
* trip's empty stage and the tours list both carry it, so the sentence around
|
||||
* it only has to say what the phone does instead.
|
||||
*/
|
||||
export default function MPlanOnDesktopBadge({ className = '' }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex max-w-full items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-1 pe-3 ps-1 ${className}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-6 w-6 flex-none items-center justify-center rounded-full bg-m-act text-m-actfg"
|
||||
>
|
||||
<Monitor size={12} strokeWidth={2.2} />
|
||||
</span>
|
||||
<span className="truncate font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-m-ink">
|
||||
{t('mobileTrip.planOnDesktop')}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import SchoolHolidayCatalog from '../../../components/Admin/SchoolHolidayCatalog'
|
||||
import PaddleRegionsPanel from '../../../components/Admin/paddle/PaddleRegionsPanel'
|
||||
import MAdminSchoolHolidayCatalog from './MAdminSchoolHolidayCatalog'
|
||||
import MAdminPaddleRegionsPanel from './MAdminPaddleRegionsPanel'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ArrowUp,
|
||||
@@ -206,7 +206,7 @@ export default function MAdmin() {
|
||||
<div className="space-y-4">
|
||||
<MAdminPackingTemplateManager />
|
||||
<MAdminCategoryManager />
|
||||
<SchoolHolidayCatalog />
|
||||
<MAdminSchoolHolidayCatalog />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'addons' && (
|
||||
@@ -217,7 +217,7 @@ export default function MAdmin() {
|
||||
collabFeatures={collabFeatures}
|
||||
onToggleCollabFeature={toggleCollabFeature}
|
||||
/>
|
||||
<PaddleRegionsPanel />
|
||||
<MAdminPaddleRegionsPanel />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'plugins' && <MAdminPluginsPanel />}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { useAddonManager } from '../../../components/Admin/addons/useAddonManager'
|
||||
import { useLlmParsingConfig } from '../../../components/Admin/addons/useLlmParsingConfig'
|
||||
import { ImmichIcon, SynologyIcon } from '../../../components/Admin/addons/PhotoProviderIcons'
|
||||
import { MAdminButton, MAdminCard, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi'
|
||||
import { MAdminButton, MAdminCard, MAdminClampedText, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi'
|
||||
import WandererMark from '../../../components/Tours/wanderer/WandererMark'
|
||||
|
||||
// Keys are the `icon` column from the addons table; anything unknown falls back to
|
||||
@@ -82,9 +82,10 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
|
||||
{/* Header */}
|
||||
<MAdminCard>
|
||||
<div className="text-[0.875rem] font-extrabold text-m-ink">{t('admin.addons.title')}</div>
|
||||
<p className="mt-[2px] flex flex-wrap items-center gap-1 font-geist text-[0.625rem] leading-relaxed text-m-muted">
|
||||
{t('admin.addons.subtitleBefore')}
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />
|
||||
{/* One run of text with the wordmark inside it, so a line break falls between words and never around the logo. */}
|
||||
<p className="mt-[2px] font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] leading-relaxed text-m-muted">
|
||||
{t('admin.addons.subtitleBefore')}{' '}
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" className="inline h-[9px] w-auto align-baseline opacity-70" />{' '}
|
||||
{t('admin.addons.subtitleAfter')}
|
||||
</p>
|
||||
</MAdminCard>
|
||||
@@ -100,7 +101,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
|
||||
{/* Trip Addons */}
|
||||
{tripAddons.length > 0 && (
|
||||
<MAdminCard>
|
||||
<MGroupHead icon={Briefcase} label={`${t('admin.addons.type.trip')} — ${t('admin.addons.tripHint')}`} />
|
||||
<MGroupHead icon={Briefcase} label={t('admin.addons.type.trip')} hint={t('admin.addons.tripHint')} />
|
||||
{tripAddons.map((addon, i) => (
|
||||
<div key={addon.id}>
|
||||
<MAddonRow addon={addon} onToggle={handleToggle} t={t} first={i === 0} />
|
||||
@@ -157,7 +158,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
|
||||
{/* Global Addons */}
|
||||
{globalAddons.length > 0 && (
|
||||
<MAdminCard>
|
||||
<MGroupHead icon={Globe} label={`${t('admin.addons.type.global')} — ${t('admin.addons.globalHint')}`} />
|
||||
<MGroupHead icon={Globe} label={t('admin.addons.type.global')} hint={t('admin.addons.globalHint')} />
|
||||
{globalAddons.map((addon, i) => (
|
||||
<div key={addon.id}>
|
||||
<MAddonRow addon={addon} onToggle={handleToggle} t={t} first={i === 0} />
|
||||
@@ -171,7 +172,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
|
||||
{/* Integration Addons */}
|
||||
{integrationAddons.length > 0 && (
|
||||
<MAdminCard>
|
||||
<MGroupHead icon={Link2} label={`${t('admin.addons.type.integration')} — ${t('admin.addons.integrationHint')}`} />
|
||||
<MGroupHead icon={Link2} label={t('admin.addons.type.integration')} hint={t('admin.addons.integrationHint')} />
|
||||
{integrationAddons.map((addon, i) => (
|
||||
<div key={addon.id}>
|
||||
<MAddonRow addon={addon} onToggle={handleToggle} t={t} first={i === 0} />
|
||||
@@ -191,11 +192,14 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
|
||||
}
|
||||
|
||||
/** Group subheader: small icon + uppercase label, matches the desktop section band. */
|
||||
function MGroupHead({ icon: Icon, label }: { icon: typeof Briefcase; label: string }) {
|
||||
function MGroupHead({ icon: Icon, label, hint }: { icon: typeof Briefcase; label: string; hint: string }) {
|
||||
return (
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Icon size={13} className="flex-none text-m-muted" />
|
||||
<span className="font-geist text-[0.625rem] font-bold uppercase tracking-[0.06em] text-m-muted">{label}</span>
|
||||
<div className="mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={13} className="flex-none text-m-muted" />
|
||||
<span className="font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[0.06em] text-m-muted">{label}</span>
|
||||
</div>
|
||||
<p className="mt-[2px] ps-[21px] font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] leading-snug text-m-faint">{hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -225,7 +229,7 @@ function MAddonRow({ addon, onToggle, t, first }: MAddonRowProps) {
|
||||
{typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-[1px] font-geist text-[0.625rem] leading-relaxed text-m-muted">{label.description}</p>
|
||||
<MAdminClampedText className="mt-[1px] text-[length:calc(10px*var(--fs-scale-caption,1))]">{label.description}</MAdminClampedText>
|
||||
</div>
|
||||
<MToggle checked={addon.enabled} ariaLabel={label.name} onChange={() => onToggle(addon)} />
|
||||
</div>
|
||||
@@ -268,7 +272,7 @@ function MSubRow({ icon: Icon, providerIcon: ProviderIcon, title, subtitle, enab
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[0.75rem] font-bold text-m-ink">{title}</div>
|
||||
<div className="mt-[1px] font-geist text-[0.59375rem] leading-relaxed text-m-muted">{subtitle}</div>
|
||||
<MAdminClampedText className="mt-[1px] text-[length:calc(9.5px*var(--fs-scale-caption,1))]">{subtitle}</MAdminClampedText>
|
||||
</div>
|
||||
<MToggle checked={enabled} ariaLabel={title} onChange={onToggle} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
import type { PaddleRegion, PaddleRegionPending } from '@trek/shared';
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CloudDownload,
|
||||
Hand,
|
||||
Loader2,
|
||||
Plus,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Waves,
|
||||
WifiOff,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AUTO_LOAD_MODES,
|
||||
autoLoadSizes,
|
||||
formatMegabytes,
|
||||
progressPercent,
|
||||
regionStatusTone,
|
||||
} from '../../../components/Admin/paddle/paddleRegionsModel';
|
||||
import { usePaddleRegionsAdmin } from '../../../components/Admin/paddle/usePaddleRegionsAdmin';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import MBadge from '../../components/MBadge';
|
||||
import MChip from '../../components/MChip';
|
||||
import MConfirmSheet from '../settings/MConfirmSheet';
|
||||
import { MAdminButton, MAdminCard, MAdminClampedText } from './MAdminUi';
|
||||
|
||||
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const;
|
||||
const MB = 1024 * 1024;
|
||||
const LABEL =
|
||||
'mb-1.5 block font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.08em] text-m-faint';
|
||||
const ROWS =
|
||||
'flex flex-col divide-y divide-[color:var(--m-rowbr)] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]';
|
||||
const ROUND =
|
||||
'flex h-[32px] w-[32px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] text-m-muted disabled:opacity-40';
|
||||
const TONE = {
|
||||
neutral: '',
|
||||
accent: '!text-[color:var(--m-st-info)]',
|
||||
success: '!text-[color:var(--m-st-confirmed)]',
|
||||
danger: '!text-[color:var(--m-st-danger)]',
|
||||
} as const;
|
||||
|
||||
/** How TREK loads a paddler's region by itself, and up to which size. */
|
||||
function AutoLoad({ admin }: { admin: Admin }) {
|
||||
const { t, language } = useTranslation();
|
||||
const { settings, saving, save } = admin.autoLoad;
|
||||
if (!settings) return null;
|
||||
return (
|
||||
<div data-testid="paddle-auto-load">
|
||||
<span className={LABEL}>{t('admin.paddleRegions.auto.title')}</span>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('admin.paddleRegions.auto.title')}
|
||||
className="flex gap-1 rounded-full bg-[color:var(--m-ic)] p-[3px]"
|
||||
>
|
||||
{AUTO_LOAD_MODES.map((mode) => {
|
||||
const Icon = MODE_ICONS[mode];
|
||||
const active = settings.mode === mode;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
disabled={saving}
|
||||
onClick={() => void save({ mode })}
|
||||
className={`flex h-[34px] min-w-0 flex-1 items-center justify-center gap-1 rounded-full text-[length:calc(11.5px*var(--fs-scale-caption,1))] font-semibold ${
|
||||
active ? 'bg-m-act text-m-actfg' : 'text-m-ink'
|
||||
}`}
|
||||
>
|
||||
<Icon size={13} strokeWidth={2} aria-hidden="true" className="flex-none" />
|
||||
<span className="truncate">{t(`admin.paddleRegions.auto.mode.${mode}`)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<MAdminClampedText className="mt-1.5 text-[length:calc(10px*var(--fs-scale-caption,1))]">
|
||||
{t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)}
|
||||
</MAdminClampedText>
|
||||
{settings.mode === 'auto' && (
|
||||
<div className="mt-3">
|
||||
<span className={LABEL}>{t('admin.paddleRegions.auto.maxSize')}</span>
|
||||
<div role="group" aria-label={t('admin.paddleRegions.auto.maxSize')} className="flex flex-wrap gap-1.5">
|
||||
{autoLoadSizes(settings.max_mb).map((mb) => (
|
||||
<MChip
|
||||
key={mb}
|
||||
pressable
|
||||
active={settings.max_mb === mb}
|
||||
onClick={() => {
|
||||
if (!saving) void save({ max_mb: mb });
|
||||
}}
|
||||
>
|
||||
{formatMegabytes(mb, language)}
|
||||
</MChip>
|
||||
))}
|
||||
</div>
|
||||
<MAdminClampedText className="mt-1.5 text-[length:calc(10px*var(--fs-scale-caption,1))]">
|
||||
{t('admin.paddleRegions.auto.maxSizeHint')}
|
||||
</MAdminClampedText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
|
||||
const { t, language } = useTranslation();
|
||||
const busy = admin.busyId === request.id;
|
||||
return (
|
||||
<div data-testid="paddle-region-request" className={`px-3 py-2.5 ${busy ? 'opacity-60' : ''}`}>
|
||||
<div className="truncate text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">
|
||||
{request.name}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{request.declined ? (
|
||||
<MBadge caps={false} icon={<Ban size={10} strokeWidth={2.4} />} className={TONE.danger}>
|
||||
{t('admin.paddleRegions.requests.declined')}
|
||||
</MBadge>
|
||||
) : (
|
||||
<MBadge caps={false} className="!text-[color:var(--m-st-pending)]">
|
||||
{t(`admin.paddleRegions.requests.reason.${request.reason}`)}
|
||||
</MBadge>
|
||||
)}
|
||||
{request.bytes !== null && (
|
||||
<MBadge caps={false}>{formatMegabytes(Math.max(1, Math.round(request.bytes / MB)), language)}</MBadge>
|
||||
)}
|
||||
{request.requested_by && (
|
||||
<MBadge caps={false}>{t('admin.paddleRegions.requests.by', { user: request.requested_by })}</MBadge>
|
||||
)}
|
||||
<MBadge caps={false}>
|
||||
{new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' })}
|
||||
</MBadge>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end gap-1.5">
|
||||
{!request.declined && (
|
||||
<MAdminButton variant="ghost" disabled={admin.busyId !== null} onClick={() => void admin.decline(request.id)}>
|
||||
{t('admin.paddleRegions.requests.decline')}
|
||||
</MAdminButton>
|
||||
)}
|
||||
<MAdminButton busy={busy} disabled={admin.busyId !== null} onClick={() => void admin.approve(request.id)}>
|
||||
{!busy && <CloudDownload size={12} strokeWidth={2.2} />}
|
||||
{t('admin.paddleRegions.requests.approve')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Requests({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
if (admin.requests.length === 0) return null;
|
||||
const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined));
|
||||
const open = admin.requests.filter((request) => !request.declined).length;
|
||||
return (
|
||||
<div data-testid="paddle-region-requests">
|
||||
<span className={`${LABEL} flex items-center gap-1.5`}>
|
||||
{t('admin.paddleRegions.requests.title')}
|
||||
{open > 0 && (
|
||||
<MBadge tone="strong" caps={false}>
|
||||
{open}
|
||||
</MBadge>
|
||||
)}
|
||||
</span>
|
||||
<div className={ROWS}>
|
||||
{ordered.map((request) => (
|
||||
<RequestRow key={request.id} request={request} admin={admin} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogSearch({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
const { search } = admin;
|
||||
return (
|
||||
<div>
|
||||
<span className={LABEL}>{t('admin.paddleRegions.search')}</span>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute start-[13px] top-1/2 -translate-y-1/2 text-m-faint"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
value={search.query}
|
||||
onChange={(e) => search.setQuery(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t('admin.paddleRegions.search')}
|
||||
placeholder={t('admin.paddleRegions.searchPlaceholder')}
|
||||
className="h-[42px] w-full rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] pe-3 ps-[34px] text-[length:calc(13px*var(--fs-scale-body,1))] text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
</div>
|
||||
{search.searching && (
|
||||
<p
|
||||
role="status"
|
||||
className="mt-2 flex items-center gap-2 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted"
|
||||
>
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
)}
|
||||
{search.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-2 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-[color:var(--m-st-danger)]"
|
||||
>
|
||||
{search.error}
|
||||
</p>
|
||||
)}
|
||||
{search.hits && search.hits.length === 0 && (
|
||||
<p className="mt-2 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-faint">
|
||||
{t('admin.paddleRegions.noHits')}
|
||||
</p>
|
||||
)}
|
||||
{search.hits && search.hits.length > 0 && (
|
||||
<div className={`mt-2 ${ROWS}`}>
|
||||
{search.hits.map((hit) => (
|
||||
<div key={hit.id} data-testid="paddle-region-hit" className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">
|
||||
{hit.name}
|
||||
</div>
|
||||
{hit.path.length > 0 && (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-0.5 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">
|
||||
{hit.path.map((part, index) => (
|
||||
<span key={`${part}-${index}`} className="inline-flex items-center gap-0.5">
|
||||
{index > 0 && <ChevronRight size={10} aria-hidden="true" className="rtl:rotate-180" />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{admin.added.has(hit.id) ? (
|
||||
<MBadge caps={false} icon={<Check size={10} strokeWidth={2.4} />}>
|
||||
{t('admin.paddleRegions.added')}
|
||||
</MBadge>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('common.add')} ${hit.name}`}
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.add(hit.id)}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg disabled:opacity-40"
|
||||
>
|
||||
{admin.busyId === hit.id ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
const { t, language } = useTranslation();
|
||||
const busy = admin.busyId === region.id;
|
||||
const percent = progressPercent(region);
|
||||
const status =
|
||||
region.status === 'importing' && region.progress
|
||||
? t(`admin.paddleRegions.phase.${region.progress.phase}`)
|
||||
: t(`admin.paddleRegions.status.${region.status}`);
|
||||
const date = region.source_timestamp
|
||||
? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' })
|
||||
: null;
|
||||
const km = region.network
|
||||
? new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(region.network.water_km)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
data-testid="paddle-region-row"
|
||||
className={`flex items-center gap-2.5 px-3 py-2.5 ${busy ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheetop)] text-m-muted"
|
||||
>
|
||||
<Waves size={15} strokeWidth={1.9} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">
|
||||
{region.name}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<MBadge
|
||||
caps={false}
|
||||
className={TONE[regionStatusTone(region.status)]}
|
||||
icon={region.status === 'importing' ? <Loader2 size={10} className="animate-spin" /> : undefined}
|
||||
>
|
||||
{status}
|
||||
{percent === null ? '' : ` ${new Intl.NumberFormat(language, { style: 'percent' }).format(percent / 100)}`}
|
||||
</MBadge>
|
||||
{region.origin === 'auto' && (
|
||||
<MBadge caps={false} icon={<Sparkles size={10} strokeWidth={2.2} />}>
|
||||
{t('admin.paddleRegions.origin.auto')}
|
||||
</MBadge>
|
||||
)}
|
||||
{region.imported_at && (
|
||||
<MBadge caps={false}>{t('admin.paddleRegions.objects', { count: region.feature_count })}</MBadge>
|
||||
)}
|
||||
{region.imported_at && date && <MBadge caps={false}>{t('admin.paddleRegions.asOf', { date })}</MBadge>}
|
||||
{km !== null && <MBadge caps={false}>{t('admin.paddleRegions.network.km', { km })}</MBadge>}
|
||||
{!region.network && region.imported_at && region.status !== 'importing' && (
|
||||
<MBadge caps={false} className="!text-[color:var(--m-st-pending)]">
|
||||
{t('admin.paddleRegions.network.missing')}
|
||||
</MBadge>
|
||||
)}
|
||||
</div>
|
||||
{region.status === 'failed' && region.error && (
|
||||
<p className="mt-1 line-clamp-2 break-words font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-[color:var(--m-st-danger)]">
|
||||
{region.error}
|
||||
{region.imported_at ? ` ${t('admin.paddleRegions.keptData')}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('admin.paddleRegions.refresh')} ${region.name}`}
|
||||
disabled={admin.busyId !== null || region.status === 'queued' || region.status === 'importing'}
|
||||
onClick={() => void admin.refresh(region.id)}
|
||||
className={ROUND}
|
||||
>
|
||||
<RefreshCw size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('admin.paddleRegions.remove')} ${region.name}`}
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => admin.askRemove(region)}
|
||||
className={ROUND}
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Regions({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
if (admin.loading) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className="flex items-center gap-2 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted"
|
||||
>
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (admin.regions.length === 0) {
|
||||
return (
|
||||
<p className="rounded-[14px] border border-dashed border-[color:var(--m-rowbr)] px-4 py-4 text-center font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-faint">
|
||||
{t('admin.paddleRegions.empty')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={ROWS}>
|
||||
{admin.regions.map((region) => (
|
||||
<RegionRow key={region.id} region={region} admin={admin} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-05A/PAD-11C on the phone: the regions TREK reads paddle data for, how it
|
||||
* loads a paddler's region by itself and the regions that wait for an admin.
|
||||
* usePaddleRegionsAdmin is the desktop card's state; this is only the markup.
|
||||
*/
|
||||
export default function MAdminPaddleRegionsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const admin = usePaddleRegionsAdmin();
|
||||
if (!admin.toursOn) return null;
|
||||
return (
|
||||
<MAdminCard>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-[11px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Waves size={18} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-extrabold text-m-ink">
|
||||
{t('admin.paddleRegions.title')}
|
||||
</span>
|
||||
{admin.regions.length > 0 && <MBadge caps={false}>{admin.regions.length}</MBadge>}
|
||||
</div>
|
||||
<MAdminClampedText className="mt-[2px] text-[length:calc(10px*var(--fs-scale-caption,1))]">
|
||||
{t('admin.paddleRegions.hint')}
|
||||
</MAdminClampedText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{admin.offline && (
|
||||
<p
|
||||
role="status"
|
||||
className="mt-3 flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2 text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-[color:var(--m-st-pending)]"
|
||||
>
|
||||
<WifiOff size={13} className="flex-none" />
|
||||
{t('admin.paddleRegions.offline')}
|
||||
</p>
|
||||
)}
|
||||
{admin.error && !admin.offline && (
|
||||
<div role="alert" className="mt-3 flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2">
|
||||
<span className="min-w-0 flex-1 text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-[color:var(--m-st-danger)]">
|
||||
{admin.error}
|
||||
</span>
|
||||
<MAdminButton variant="ghost" onClick={admin.retry}>
|
||||
{t('admin.paddleRegions.retry')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<fieldset
|
||||
disabled={admin.offline}
|
||||
className="m-0 mt-3 flex min-w-0 flex-col gap-4 border-0 border-t border-[color:var(--m-rowbr)] p-0 pt-3"
|
||||
>
|
||||
<AutoLoad admin={admin} />
|
||||
<Requests admin={admin} />
|
||||
<CatalogSearch admin={admin} />
|
||||
<Regions admin={admin} />
|
||||
</fieldset>
|
||||
<p className="mt-3 font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] leading-relaxed text-m-faint">
|
||||
{t('admin.paddleRegions.attribution')}
|
||||
</p>
|
||||
|
||||
<MConfirmSheet
|
||||
open={admin.removing !== null}
|
||||
onClose={admin.cancelRemove}
|
||||
title={t('admin.paddleRegions.remove')}
|
||||
message={t('admin.paddleRegions.removeMessage', { name: admin.removing?.name ?? '' })}
|
||||
confirmLabel={t('common.remove')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
onConfirm={admin.confirmRemove}
|
||||
/>
|
||||
</MAdminCard>
|
||||
);
|
||||
}
|
||||
@@ -21,23 +21,21 @@ export default function MAdminPermissionsPanel(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<MAdminCard>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[0.875rem] font-extrabold text-m-ink">{t('perm.title')}</div>
|
||||
<div className="mt-[2px] font-geist text-[0.625rem] leading-relaxed text-m-muted">{t('perm.subtitle')}</div>
|
||||
{/* The title gets the full width and the actions a row of their own: beside a
|
||||
long title they ran into its second line. */}
|
||||
<div className="mb-1">
|
||||
<div className="text-[length:calc(14px*var(--fs-scale-body,1))] font-extrabold text-m-ink">{t('perm.title')}</div>
|
||||
<div className="mt-[2px] font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] leading-relaxed text-m-muted">{t('perm.subtitle')}</div>
|
||||
<div className="mt-2.5 flex items-center justify-end gap-2">
|
||||
<MAdminButton variant="ghost" disabled={saving} onClick={handleReset}>
|
||||
<RotateCcw size={12} strokeWidth={2.2} />
|
||||
{t('perm.resetDefaults')}
|
||||
</MAdminButton>
|
||||
<MAdminButton busy={saving} disabled={saving || !dirty} onClick={handleSave}>
|
||||
{!saving && <Save size={12} strokeWidth={2.2} />}
|
||||
{t('common.save')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
<MAdminButton
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
title={t('perm.resetDefaults')}
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw size={12} strokeWidth={2.2} />
|
||||
</MAdminButton>
|
||||
<MAdminButton busy={saving} disabled={saving || !dirty} onClick={handleSave}>
|
||||
{!saving && <Save size={12} strokeWidth={2.2} />}
|
||||
{t('common.save')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
import {
|
||||
schoolHolidayRegionRequestSchema,
|
||||
type SchoolHolidayRegionDetail,
|
||||
type SchoolHolidayRegionRequest,
|
||||
} from '@trek/shared';
|
||||
import { ChevronRight, GraduationCap, Loader2, MapPinned, Plus, Trash2, WifiOff } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useSchoolHolidayCatalog } from '../../../components/Admin/useSchoolHolidayCatalog';
|
||||
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker';
|
||||
import CustomSelect from '../../../components/shared/CustomSelect';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import MBadge from '../../components/MBadge';
|
||||
import MSheet from '../../components/MSheet';
|
||||
import MConfirmSheet from '../settings/MConfirmSheet';
|
||||
import { MAdminButton, MAdminCard, MAdminClampedText, MAdminInput, MAdminSheetFrame } from './MAdminUi';
|
||||
|
||||
const LABEL =
|
||||
'mb-1.5 block font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.08em] text-m-faint';
|
||||
const ROUND =
|
||||
'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted disabled:opacity-40';
|
||||
const ALERT =
|
||||
'rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2 text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-[color:var(--m-st-danger)]';
|
||||
|
||||
/** A new country of the catalog: its name and its two-letter code. */
|
||||
function AddCountrySheet({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (code: string, name: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const valid = name.trim().length > 0 && /^[A-Z]{2}$/.test(code);
|
||||
const close = () => {
|
||||
setName('');
|
||||
setCode('');
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<MSheet open={open} onClose={close} variant="card" material="opaque" ariaLabel={t('schoolCatalog.addCountry')}>
|
||||
<MAdminSheetFrame
|
||||
title={t('schoolCatalog.addCountry')}
|
||||
onClose={close}
|
||||
footer={
|
||||
<>
|
||||
<MAdminButton variant="ghost" onClick={close}>
|
||||
{t('common.cancel')}
|
||||
</MAdminButton>
|
||||
<MAdminButton disabled={!valid} onClick={() => void onSave(code, name.trim()).then(close)}>
|
||||
{t('common.save')}
|
||||
</MAdminButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label>
|
||||
<span className={LABEL}>{t('schoolCatalog.country')}</span>
|
||||
<MAdminInput autoFocus maxLength={100} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL}>{t('schoolCatalog.code')}</span>
|
||||
<MAdminInput
|
||||
maxLength={2}
|
||||
placeholder="US"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
className="font-geist uppercase tracking-[.08em]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</MAdminSheetFrame>
|
||||
</MSheet>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditorProps {
|
||||
region: SchoolHolidayRegionDetail;
|
||||
busy: boolean;
|
||||
offline: boolean;
|
||||
error: string;
|
||||
onClose: () => void;
|
||||
onSave: (body: SchoolHolidayRegionRequest) => void;
|
||||
}
|
||||
|
||||
/** A region's holidays, the phone's own sheet over the same request the desktop editor sends. */
|
||||
function RegionSheet({ region, busy, offline, error, onClose, onSave }: EditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(region.name);
|
||||
const [holidays, setHolidays] = useState(region.holidays.map((holiday, index) => ({ ...holiday, key: index })));
|
||||
const [nextKey, setNextKey] = useState(holidays.length);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const body = {
|
||||
name,
|
||||
revision: region.revision,
|
||||
holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })),
|
||||
};
|
||||
const edit = (key: number, change: Partial<(typeof holidays)[number]>) =>
|
||||
setHolidays((rows) => rows.map((row) => (row.key === key ? { ...row, ...change } : row)));
|
||||
const save = () => {
|
||||
const parsed = schoolHolidayRegionRequestSchema.safeParse(body);
|
||||
setInvalid(!parsed.success);
|
||||
if (parsed.success) onSave(parsed.data);
|
||||
};
|
||||
const locked = busy || offline;
|
||||
return (
|
||||
<MSheet
|
||||
open
|
||||
onClose={() => {
|
||||
if (!busy) onClose();
|
||||
}}
|
||||
variant="card"
|
||||
material="opaque"
|
||||
ariaLabel={name.trim() || t('schoolCatalog.region')}
|
||||
discardGuard={body}
|
||||
>
|
||||
<MAdminSheetFrame
|
||||
title={name.trim() || t('schoolCatalog.region')}
|
||||
onClose={() => {
|
||||
if (!busy) onClose();
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<MAdminButton variant="ghost" onClick={onClose} disabled={busy}>
|
||||
{t('common.cancel')}
|
||||
</MAdminButton>
|
||||
<MAdminButton busy={busy} disabled={locked} onClick={save}>
|
||||
{t('common.save')}
|
||||
</MAdminButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<fieldset
|
||||
disabled={locked}
|
||||
className={`m-0 flex min-w-0 flex-col gap-3 border-0 p-0 ${busy ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<label>
|
||||
<span className={LABEL}>{t('schoolCatalog.region')}</span>
|
||||
<MAdminInput maxLength={150} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<MAdminClampedText className="text-[length:calc(10px*var(--fs-scale-caption,1))]">
|
||||
{t('schoolCatalog.periodHint')}
|
||||
</MAdminClampedText>
|
||||
{holidays.map((holiday, index) => (
|
||||
<div
|
||||
key={holiday.key}
|
||||
className="rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-2.5"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<MAdminInput
|
||||
maxLength={150}
|
||||
aria-label={t('schoolCatalog.name')}
|
||||
placeholder={t('schoolCatalog.name')}
|
||||
value={holiday.name}
|
||||
onChange={(e) => edit(holiday.key, { name: e.target.value })}
|
||||
className="bg-[color:var(--m-sheetop)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('common.delete')} ${holiday.name || index + 1}`}
|
||||
className={ROUND}
|
||||
onClick={() => setHolidays((rows) => rows.filter((row) => row.key !== holiday.key))}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<div role="group" aria-label={t('schoolCatalog.start')} className="min-w-0">
|
||||
<span className={LABEL}>{t('schoolCatalog.start')}</span>
|
||||
<CustomDatePicker
|
||||
key={String(locked)}
|
||||
value={holiday.startDate}
|
||||
placeholder={t('schoolCatalog.start')}
|
||||
max={holiday.endDate || undefined}
|
||||
onChange={(startDate) => edit(holiday.key, { startDate })}
|
||||
/>
|
||||
</div>
|
||||
<div role="group" aria-label={t('schoolCatalog.end')} className="min-w-0">
|
||||
<span className={LABEL}>{t('schoolCatalog.end')}</span>
|
||||
<CustomDatePicker
|
||||
key={String(locked)}
|
||||
value={holiday.endDate}
|
||||
placeholder={t('schoolCatalog.end')}
|
||||
min={holiday.startDate || undefined}
|
||||
onChange={(endDate) => edit(holiday.key, { endDate })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={holidays.length >= 500}
|
||||
onClick={() => {
|
||||
setHolidays((rows) => [...rows, { key: nextKey, name: '', startDate: '', endDate: '' }]);
|
||||
setNextKey((key) => key + 1);
|
||||
}}
|
||||
className="flex h-[42px] items-center justify-center gap-1.5 rounded-full border border-dashed border-[color:var(--m-rowbr)] text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-m-muted"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('schoolCatalog.addPeriod')}
|
||||
</button>
|
||||
{invalid && (
|
||||
<p role="alert" className={ALERT}>
|
||||
{t('schoolCatalog.invalid')}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className={ALERT}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
</MAdminSheetFrame>
|
||||
</MSheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The school holiday catalog on the phone: the shared holiday regions per
|
||||
* country that everyone can pick in their vacation settings. The state is
|
||||
* useSchoolHolidayCatalog's, the desktop card's; this is the phone's markup.
|
||||
*/
|
||||
export default function MAdminSchoolHolidayCatalog() {
|
||||
const { t } = useTranslation();
|
||||
const state = useSchoolHolidayCatalog();
|
||||
const [country, setCountry] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [remove, setRemove] = useState<{ label: string; action: () => Promise<boolean> } | null>(null);
|
||||
const selectedCountry =
|
||||
state.catalog.countries.find((item) => item.code === country)?.code || state.catalog.countries[0]?.code || '';
|
||||
const regions = state.catalog.regions.filter((region) => region.country === selectedCountry);
|
||||
const disabled = state.busy || state.loading || state.offline;
|
||||
|
||||
return (
|
||||
<MAdminCard>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-[11px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<GraduationCap size={18} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-extrabold text-m-ink">
|
||||
{t('schoolCatalog.title')}
|
||||
</span>
|
||||
{state.catalog.countries.length > 0 && <MBadge caps={false}>{state.catalog.regions.length}</MBadge>}
|
||||
</div>
|
||||
<MAdminClampedText className="mt-[2px] text-[length:calc(10px*var(--fs-scale-caption,1))]">
|
||||
{t('schoolCatalog.hint')}
|
||||
</MAdminClampedText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-3 flex flex-col gap-3 border-t border-[color:var(--m-rowbr)] pt-3 ${state.busy ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{state.offline && (
|
||||
<p
|
||||
role="status"
|
||||
className="flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2 text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-[color:var(--m-st-pending)]"
|
||||
>
|
||||
<WifiOff size={13} className="flex-none" />
|
||||
{t('schoolCatalog.offline')}
|
||||
</p>
|
||||
)}
|
||||
{state.loading && (
|
||||
<p
|
||||
role="status"
|
||||
className="flex items-center gap-2 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted"
|
||||
>
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
)}
|
||||
{state.error && !state.editor && (
|
||||
<div role="alert" className="flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2">
|
||||
<span className="min-w-0 flex-1 text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-[color:var(--m-st-danger)]">
|
||||
{state.error}
|
||||
</span>
|
||||
<MAdminButton variant="ghost" disabled={disabled} onClick={() => void state.refresh()}>
|
||||
{t('schoolCatalog.retry')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.catalog.countries.length > 0 ? (
|
||||
<div>
|
||||
<span className={LABEL}>{t('schoolCatalog.country')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<CustomSelect
|
||||
value={selectedCountry}
|
||||
onChange={(value) => setCountry(String(value))}
|
||||
searchable
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
options={state.catalog.countries.map((item) => ({ value: item.code, label: item.name }))}
|
||||
placeholder={t('schoolCatalog.country')}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('schoolCatalog.addCountry')}
|
||||
disabled={disabled}
|
||||
onClick={() => setAdding(true)}
|
||||
className={ROUND}
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('schoolCatalog.deleteCountry')}
|
||||
disabled={disabled || regions.length > 0}
|
||||
onClick={() =>
|
||||
setRemove({ label: selectedCountry, action: () => state.deleteCountry(selectedCountry) })
|
||||
}
|
||||
className={ROUND}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
{regions.length > 0 && (
|
||||
<p className="mt-1.5 font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] text-m-faint">
|
||||
{t('schoolCatalog.deleteHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
!state.loading && (
|
||||
<MAdminButton variant="ghost" disabled={disabled} onClick={() => setAdding(true)} className="self-start">
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
{t('schoolCatalog.addCountry')}
|
||||
</MAdminButton>
|
||||
)
|
||||
)}
|
||||
|
||||
{selectedCountry && (
|
||||
<>
|
||||
{regions.length === 0 ? (
|
||||
<p className="rounded-[14px] border border-dashed border-[color:var(--m-rowbr)] px-4 py-4 text-center font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-faint">
|
||||
{t('schoolCatalog.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-[color:var(--m-rowbr)] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
|
||||
{regions.map((region) => (
|
||||
<div key={region.id} className="flex items-center gap-2 py-1.5 pe-2 ps-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => void state.openRegion(region.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 px-1.5 py-1 text-start"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-[32px] w-[32px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheetop)] text-m-muted"
|
||||
>
|
||||
<MapPinned size={14} strokeWidth={1.9} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">
|
||||
{region.name}
|
||||
</span>
|
||||
<ChevronRight aria-hidden="true" size={15} className="flex-none text-m-faint rtl:rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('common.delete')} ${region.name}`}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
setRemove({ label: region.name, action: () => state.deleteRegion(region.id, region.revision) })
|
||||
}
|
||||
className="flex h-[32px] w-[32px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheetop)] text-m-muted disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
state.setEditor({ id: 0, country: selectedCountry, name: '', code: '', revision: 0, holidays: [] })
|
||||
}
|
||||
className="flex h-[42px] items-center justify-center gap-1.5 rounded-full border border-dashed border-[color:var(--m-rowbr)] text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-m-muted disabled:opacity-40"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('schoolCatalog.addRegion')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddCountrySheet
|
||||
open={adding}
|
||||
onClose={() => setAdding(false)}
|
||||
onSave={async (code, name) => {
|
||||
if (await state.createCountry({ code, name })) setCountry(code);
|
||||
}}
|
||||
/>
|
||||
{state.editor && (
|
||||
<RegionSheet
|
||||
key={state.editor.id}
|
||||
region={state.editor}
|
||||
busy={state.busy}
|
||||
offline={state.offline}
|
||||
error={state.offline ? t('schoolCatalog.offline') : state.error}
|
||||
onClose={() => state.setEditor(null)}
|
||||
onSave={(body) => {
|
||||
if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MConfirmSheet
|
||||
open={remove !== null}
|
||||
onClose={() => setRemove(null)}
|
||||
title={t('common.delete')}
|
||||
message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`}
|
||||
confirmLabel={t('common.delete')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
onConfirm={() => {
|
||||
if (remove) void remove.action();
|
||||
setRemove(null);
|
||||
}}
|
||||
/>
|
||||
</MAdminCard>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,25 @@ export function MAdminRow({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A description that keeps to two lines until it is tapped. On a phone the long
|
||||
* ones (a map provider's terms, how a loader decides) pushed every control off
|
||||
* screen; the whole text stays one tap away.
|
||||
*/
|
||||
export function MAdminClampedText({ children, className = '' }: { children: string; className?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={`block w-full text-start font-geist leading-relaxed text-m-muted ${open ? '' : 'line-clamp-2'} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Labelled form field with an optional hint line under the control. */
|
||||
export function MAdminField({ label, hint, children }: { label: ReactNode; hint?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Archive, ArchiveRestore, Camera, Search, X } from 'lucide-react'
|
||||
import { Archive, ArchiveRestore, Bell, Camera, Search, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useCanDo } from '../../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../../store/settingsStore'
|
||||
import { useAuthStore } from '../../../store/authStore'
|
||||
import MChip from '../../components/MChip'
|
||||
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
|
||||
import CustomSelect from '../../../components/shared/CustomSelect'
|
||||
import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.constants'
|
||||
import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel'
|
||||
import { type Trip, type TripCreateRequest } from '@trek/shared'
|
||||
import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MListRow from '../../components/MListRow'
|
||||
@@ -29,6 +31,9 @@ interface MNewTripSheetProps {
|
||||
onArchive?: () => void
|
||||
}
|
||||
|
||||
/** The reminder presets the desktop form offers; anything else is a custom number of days. */
|
||||
const REMINDER_PRESETS = [0, 1, 3, 9]
|
||||
|
||||
function FieldLabel({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<div className="font-geist text-[0.5625rem] font-bold uppercase tracking-[.1em] text-m-faint">{children}</div>
|
||||
@@ -37,7 +42,9 @@ function FieldLabel({ children }: { children: React.ReactNode }): React.ReactEle
|
||||
|
||||
/**
|
||||
* Create/edit trip sheet — the mobile counterpart of TripFormModal's core flow:
|
||||
* title, date range and Unsplash cover search (plus device upload). Archiving
|
||||
* title, date range (or a day count without one), the reminder and the
|
||||
* Unsplash cover search (plus device upload). The trip planner's More sheet
|
||||
* opens it for editing too. Archiving
|
||||
* lives here in edit mode, as decided for the grid cards.
|
||||
*/
|
||||
export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpdate, onArchive }: MNewTripSheetProps): React.ReactElement {
|
||||
@@ -48,12 +55,19 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const canEditTrip = !isEditing || can('trip_edit', trip)
|
||||
const canUploadCover = !isEditing || can('trip_cover_upload', trip)
|
||||
const currentUser = useAuthStore(s => s.user)
|
||||
const remindersEnabled = useAuthStore(s => s.tripRemindersEnabled)
|
||||
// As on the desktop: the reminder is the owner's (or an admin's), and set when creating.
|
||||
const showReminder = !isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin'
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [endDate, setEndDate] = useState('')
|
||||
const [currency, setCurrency] = useState('EUR')
|
||||
const [dayCount, setDayCount] = useState<number | ''>(7)
|
||||
const [reminderDays, setReminderDays] = useState(0)
|
||||
const [customReminder, setCustomReminder] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const cover = useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewOnChange: true })
|
||||
@@ -71,6 +85,10 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
|
||||
setStartDate(trip?.start_date || '')
|
||||
setEndDate(trip?.end_date || '')
|
||||
setCurrency(trip?.currency || defaultCurrency)
|
||||
setDayCount(trip?.day_count || 7)
|
||||
const reminder = trip ? (trip.reminder_days ?? 3) : (remindersEnabled ? 3 : 0)
|
||||
setReminderDays(reminder)
|
||||
setCustomReminder(!REMINDER_PRESETS.includes(reminder))
|
||||
cover.resetCover(trip?.cover_image || null)
|
||||
setError('')
|
||||
setPendingRemoval(null)
|
||||
@@ -89,13 +107,21 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
|
||||
setError('')
|
||||
const formError = tripFormError({ title, startDate, endDate }, trip, t)
|
||||
if (formError) { setError(formError); return null }
|
||||
const dateless = !startDate && !endDate
|
||||
if (dateless && (dayCount === '' || dayCount < 1 || dayCount > MAX_TRIP_DAYS)) {
|
||||
setError(t('dashboard.dayCountRequired')); return null
|
||||
}
|
||||
// The count goes along only when it says something new: the trip's own one
|
||||
// goes stale in the planner, and sending it back would trim a day just added.
|
||||
const sendDayCount = dateless && (!isEditing || dayCount !== trip?.day_count)
|
||||
return {
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
start_date: startDate || null,
|
||||
end_date: endDate || null,
|
||||
currency,
|
||||
...(!startDate && !endDate && !isEditing ? { day_count: 7 } : {}),
|
||||
...(showReminder ? { reminder_days: reminderDays } : {}),
|
||||
...(sendDayCount ? { day_count: Number(dayCount) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,8 +223,28 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing && !startDate && !endDate && (
|
||||
<div className="mt-[6px] px-1 font-geist text-[0.625rem] text-m-faint">{t('dashboard.noDateHint')}</div>
|
||||
{!startDate && !endDate && (
|
||||
<>
|
||||
<div className={`${boxCls} mt-2`}>
|
||||
<FieldLabel>{t('dashboard.dayCount')}</FieldLabel>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_TRIP_DAYS}
|
||||
value={dayCount}
|
||||
onChange={e => {
|
||||
if (!canEditTrip) return
|
||||
setError('')
|
||||
const raw = e.target.value
|
||||
setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1)))
|
||||
}}
|
||||
readOnly={!canEditTrip}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
{!isEditing && <div className="mt-[6px] px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</div>}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
@@ -214,6 +260,47 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showReminder && (
|
||||
<div className={`${boxCls} mt-2`}>
|
||||
<FieldLabel>
|
||||
<span className="inline-flex items-center gap-1"><Bell size={10} strokeWidth={2.4} />{t('trips.reminder')}</span>
|
||||
</FieldLabel>
|
||||
{!remindersEnabled ? (
|
||||
<p className="mt-1 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] leading-snug text-m-muted">{t('trips.reminderDisabledHint')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{REMINDER_PRESETS.map(days => (
|
||||
<MChip key={days} pressable active={!customReminder && reminderDays === days}
|
||||
onClick={() => { setError(''); setReminderDays(days); setCustomReminder(false) }}>
|
||||
{days === 0 ? t('trips.reminderNone') : `${days} ${t(days === 1 ? 'trips.reminderDay' : 'trips.reminderDays')}`}
|
||||
</MChip>
|
||||
))}
|
||||
<MChip pressable active={customReminder}
|
||||
onClick={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}>
|
||||
{t('trips.reminderCustom')}
|
||||
</MChip>
|
||||
</div>
|
||||
{customReminder && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={30}
|
||||
value={reminderDays}
|
||||
onChange={e => setReminderDays(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
|
||||
aria-label={t('trips.reminderDaysBefore')}
|
||||
className="h-[34px] w-16 rounded-full border border-[color:var(--m-rowbr)] bg-transparent px-3 text-center text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink outline-none"
|
||||
/>
|
||||
<span className="font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted">{t('trips.reminderDaysBefore')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUploadCover && (
|
||||
<div className="mt-2">
|
||||
<input
|
||||
|
||||
@@ -304,7 +304,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
<JourneyLayeredMap
|
||||
ref={mapRef}
|
||||
layers={mapLayers}
|
||||
compactControls
|
||||
hideLayerControl
|
||||
controlsTop="calc(var(--m-safe-top, 12px) + 50px)"
|
||||
onOpenEntry={handleMarkerClick}
|
||||
entryTitleOf={entryTitleOf}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { render, screen } from '../../../../../tests/helpers/render'
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import en from '@trek/shared/i18n/en'
|
||||
import de from '@trek/shared/i18n/de'
|
||||
import { resetAllStores, seedStore } from '../../../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import type { TripPlanner, MTripShellApi } from '../MTripShell'
|
||||
import MToursSelectionList from './MToursSelectionList'
|
||||
import type { ComponentProps } from 'react'
|
||||
@@ -33,37 +34,34 @@ const tour: TourListItem = {
|
||||
caution: false,
|
||||
}
|
||||
|
||||
const reloadTourPlaceIds = vi.fn().mockResolvedValue(undefined)
|
||||
const day = { id: 11, day_number: 1, date: '2026-07-08', title: null } as never
|
||||
|
||||
const handlePlaceClick = vi.fn()
|
||||
const openSheet = vi.fn()
|
||||
|
||||
function planner(): TripPlanner {
|
||||
function translate(table: Record<string, unknown>) {
|
||||
return (key: string, params?: Record<string, string | number>) => {
|
||||
const value = table[key] ?? en[key]
|
||||
if (typeof value !== 'string') return key
|
||||
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(params?.[name] ?? ''))
|
||||
}
|
||||
}
|
||||
|
||||
function planner(overrides: Partial<TripPlanner> = {}): TripPlanner {
|
||||
return {
|
||||
t: (key: string) => {
|
||||
const value = en[key]
|
||||
return typeof value === 'string' ? value : key
|
||||
},
|
||||
t: translate(en),
|
||||
tripId: 7,
|
||||
tours: [tour],
|
||||
places: [{ id: 42, name: tour.name, route_geometry: '[[47,11],[47.1,11.1]]', route_color: '#ff0000' }],
|
||||
selectedPlaceId: null,
|
||||
selectedPlace: null,
|
||||
selectedTour: null,
|
||||
selectedDayId: null,
|
||||
selectedAssignmentId: null,
|
||||
assignments: {},
|
||||
days: [],
|
||||
files: [],
|
||||
canUploadFiles: false,
|
||||
reloadTourPlaceIds,
|
||||
days: [day],
|
||||
handlePlaceClick,
|
||||
...overrides,
|
||||
} as unknown as TripPlanner
|
||||
}
|
||||
|
||||
function shell(sheet: MTripShellApi['sheet'] = null): MTripShellApi {
|
||||
return {
|
||||
sheet,
|
||||
openSheet,
|
||||
} as unknown as MTripShellApi
|
||||
function shell(): MTripShellApi {
|
||||
return { sheet: null, openSheet } as unknown as MTripShellApi
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -73,84 +71,72 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('MToursSelectionList', () => {
|
||||
it('shows German ADD, difficulty and empty-state text without raw Tours keys', () => {
|
||||
const germanPlanner = { ...planner(), t: (key: string) => {
|
||||
const value = de[key] ?? en[key]
|
||||
return typeof value === 'string' ? value : key
|
||||
} }
|
||||
const { rerender } = render(<List planner={germanPlanner} shell={shell()} />)
|
||||
it('lists a tour like a place: its grade tile, its name, its figures and a plus to put it on a day', () => {
|
||||
render(<List planner={planner()} shell={shell()} canAssign />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toBeInTheDocument()
|
||||
expect(screen.getByTitle('T2 – Bergwandern')).toHaveAttribute('aria-label', 'T2 – Bergwandern')
|
||||
expect(screen.getByText(germanPlanner.t('tours.planner.mobileHint'))).toBeInTheDocument()
|
||||
rerender(<List planner={{ ...germanPlanner, tours: [] }} shell={shell()} />)
|
||||
expect(screen.getByText('Noch keine Touren')).toBeInTheDocument()
|
||||
expect(screen.getByText(germanPlanner.t('tours.empty.body'))).toBeInTheDocument()
|
||||
expect(document.body.textContent).not.toMatch(/\btours\.[a-zA-Z]/)
|
||||
})
|
||||
expect(screen.getByRole('img', { name: en['tours.planner.difficulty.t2'] as string })).toHaveTextContent('T2')
|
||||
expect(screen.getByText(tour.name)).toHaveClass('truncate')
|
||||
expect(screen.getByTestId('mobile-tour-metrics')).toHaveTextContent('12.5 km')
|
||||
expect(screen.getByTestId('mobile-tour-metrics')).toHaveTextContent('420 m')
|
||||
expect(screen.getByText('1 tour')).toBeInTheDocument()
|
||||
|
||||
it('gives a tour name the full row and up to two lines', () => {
|
||||
render(<List planner={planner()} shell={shell()} />)
|
||||
|
||||
const name = screen.getByText(tour.name)
|
||||
expect(name).toHaveClass('line-clamp-2', 'break-words')
|
||||
expect(name).toHaveAttribute('title', tour.name)
|
||||
expect(name).not.toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('keeps the mobile TRIP-PLAN Tours browse list limited to viewing and assignment', () => {
|
||||
const { container } = render(<List planner={{ ...planner(), selectedPlaceId: tour.place_id }} shell={shell()} canEdit canAssign />)
|
||||
expect(screen.getByRole('option', { name: new RegExp(tour.name) })).toHaveAttribute('aria-selected', 'true')
|
||||
const metrics = screen.getByTestId('mobile-tour-metrics')
|
||||
expect(metrics).toHaveTextContent('12.5 km')
|
||||
expect(metrics).toHaveTextContent('420 m')
|
||||
expect(metrics).toHaveTextContent('390 m')
|
||||
for (const metric of metrics.children) expect(metric).toHaveClass('whitespace-nowrap')
|
||||
expect(metrics.children[0].getAttribute('aria-label')).toContain('12.5 km')
|
||||
expect(metrics.children[1].getAttribute('aria-label')).toContain('420 m')
|
||||
expect(metrics.children[2].getAttribute('aria-label')).toContain('390 m')
|
||||
expect(screen.getByText('TREK')).toBeInTheDocument()
|
||||
expect(screen.getByTitle(en['tours.planner.difficulty.t2'] as string)).toHaveAttribute('aria-label', en['tours.planner.difficulty.t2'])
|
||||
const add = screen.getByRole('button', { name: 'Add to day' })
|
||||
expect(add).toHaveClass('w-full')
|
||||
add.click()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to day' }))
|
||||
expect(openSheet).toHaveBeenCalledWith('bract', { placeId: tour.place_id, dayPicker: true })
|
||||
expect(container.querySelector('[data-testid="mobile-tour-metrics"]')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a long German name and source/difficulty labels intact', () => {
|
||||
const germanPlanner = {
|
||||
...planner(),
|
||||
tours: [{ ...tour, name: 'Eine sehr lange deutsche Wanderung durch das abgelegene Gebirge', has_waypoints: false }],
|
||||
t: (key: string) => {
|
||||
const value = de[key] ?? en[key]
|
||||
return typeof value === 'string' ? value : key
|
||||
},
|
||||
}
|
||||
render(<List planner={germanPlanner} shell={shell()} />)
|
||||
expect(screen.getByRole('option', { name: /Eine sehr lange deutsche Wanderung/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('GPX-Import')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('T2 – Bergwandern')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toHaveClass('w-full')
|
||||
it('opens the tour on a tap and marks the selected one', () => {
|
||||
render(<List planner={planner({ selectedPlaceId: tour.place_id })} shell={shell()} canAssign />)
|
||||
|
||||
const open = screen.getByRole('button', { name: new RegExp(tour.name) })
|
||||
expect(open).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(open)
|
||||
expect(handlePlaceClick).toHaveBeenCalledWith(tour.place_id)
|
||||
})
|
||||
|
||||
it('shows no T-badge for an imported tour that carries no difficulty', () => {
|
||||
const imported = { ...tour, has_waypoints: false, max_hiking_difficulty: null }
|
||||
render(<List planner={{ ...planner(), tours: [imported] }} shell={shell()} />)
|
||||
it('names the day a planned tour is on, and how many more, instead of the plus', () => {
|
||||
seedStore(useTripStore, { assignments: { '11': [{ id: 1, place_id: 42 }], '12': [{ id: 2, place_id: 42 }] } })
|
||||
const days = [day, { id: 12, day_number: 2, date: '2026-07-09', title: null }] as never
|
||||
render(<List planner={planner({ days })} shell={shell()} canAssign />)
|
||||
|
||||
const row = screen.getByRole('option', { name: new RegExp(tour.name) })
|
||||
expect(row).not.toHaveTextContent(/T\d/)
|
||||
expect(row).not.toHaveTextContent('Tnull')
|
||||
expect(screen.queryByTitle(/^T\d/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(en['tours.badge.gpx'] as string)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Day 1/)).toHaveTextContent('Day 1 +1')
|
||||
expect(screen.queryByRole('button', { name: 'Add to day' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not refresh Tours merely because the mobile assignment sheet closes', () => {
|
||||
const { rerender } = render(<List planner={planner()} shell={shell({ id: 'bract' })} />)
|
||||
it('offers no plus to someone who may not plan days', () => {
|
||||
render(<List planner={planner()} shell={shell()} canAssign={false} />)
|
||||
expect(screen.queryByRole('button', { name: 'Add to day' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
rerender(<List planner={planner()} shell={shell()} />)
|
||||
it('shows the type of an ungraded tour and where an import came from', () => {
|
||||
const imported = { ...tour, has_waypoints: false, max_hiking_difficulty: null, tour_type: 'kayak' as const }
|
||||
render(<List planner={planner({ tours: [imported] })} shell={shell()} />)
|
||||
|
||||
expect(reloadTourPlaceIds).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('img', { name: en['tourTypes.kayak'] as string })).not.toHaveTextContent(/T\d/)
|
||||
expect(screen.getByLabelText(en['tours.badge.gpx'] as string)).toHaveTextContent('GPX')
|
||||
})
|
||||
|
||||
it('says once that tours are planned on a bigger screen, in German too', () => {
|
||||
render(<List planner={planner({ t: translate(de) })} shell={shell()} />)
|
||||
expect(screen.getByText('Geplant wird am Desktop')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 Tour')).toBeInTheDocument()
|
||||
expect(document.body.textContent).not.toMatch(/\b(tours|mobileTrip)\.[a-zA-Z]/)
|
||||
})
|
||||
|
||||
it('without tours shows the empty state with the same badge', () => {
|
||||
render(<List planner={planner({ tours: [], t: translate(de) })} shell={shell()} />)
|
||||
expect(screen.getByText('Noch keine Touren')).toBeInTheDocument()
|
||||
expect(screen.getByText(de['mobileTrip.toursEmptyBody'] as string)).toBeInTheDocument()
|
||||
expect(screen.getByText('Geplant wird am Desktop')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('narrows the list by a facet and widens it again', () => {
|
||||
const other = { ...tour, place_id: 43, name: 'Other', max_hiking_difficulty: 4 }
|
||||
render(<List planner={planner({ tours: [tour, other] })} shell={shell()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^T4/ }))
|
||||
expect(screen.getByText('Other')).toBeInTheDocument()
|
||||
expect(screen.queryByText(tour.name)).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^T4/ }))
|
||||
expect(screen.getByText(tour.name)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { ArrowDown, ArrowUp, Mountain, AlertTriangle, CalendarDays } from 'lucide-react'
|
||||
import { createElement } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { formatDistance, formatElevation } from '../../../../utils/units'
|
||||
import { hikeSourceBadgeLabel, ORIGIN_LABEL_KEY } from '../../../../components/Tours/tourPresentation'
|
||||
import { hikeSourceBadgeLabel, ORIGIN_LABEL_KEY, tourSource } from '../../../../components/Tours/tourPresentation'
|
||||
import { tourDayRowIcon } from '../../../../components/Tours/tourDayRowIcon'
|
||||
import { useTourDays } from '../../../../components/Tours/tourDays'
|
||||
import { resolveTrackColor } from '../../../../components/Map/trackColors'
|
||||
import type { TourListFilters } from '../../../../components/Tours/useTourListFilters'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import TourDayRowFacts from '../../../../components/Tours/TourDayRowFacts'
|
||||
import { TourCautionBadge } from '../../../../components/Tours/tourParts'
|
||||
import MPlanOnDesktopBadge from '../../../components/MPlanOnDesktopBadge'
|
||||
import type { TripPlanner, MTripShellApi } from '../MTripShell'
|
||||
import { useTourPermissions, type TourPermissionProps } from '../../../../components/Tours/useTourPermissions'
|
||||
|
||||
@@ -32,20 +38,105 @@ function FacetChip({ active, label, count, onClick }: { active: boolean; label:
|
||||
}
|
||||
|
||||
/**
|
||||
* Tours mode of the mobile places browser: a selection list that mirrors
|
||||
* the desktop ToursSidebar's data, with viewing and day assignment only.
|
||||
* This list does not provide GPX import.
|
||||
* The tour's tile, the size of a place's avatar: its grade where it has one,
|
||||
* its type otherwise, and a small mark under it when it came in from outside.
|
||||
*/
|
||||
function TourTile({ tour, t }: { tour: TourListItem; t: TripPlanner['t'] }) {
|
||||
const level = tour.max_hiking_difficulty
|
||||
const source = tourSource(tour)
|
||||
const label = level != null ? t(`tours.planner.difficulty.t${level}`) : t(`tourTypes.${tour.tour_type}`)
|
||||
return (
|
||||
<span className="relative flex-none">
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border-[1.5px] border-[color:var(--m-avbr)] bg-[color:var(--m-ic)] font-geist text-[length:calc(12px*var(--fs-scale-body,1))] font-bold tabular-nums text-m-ink"
|
||||
>
|
||||
{level != null
|
||||
? `T${level}`
|
||||
: createElement(tourDayRowIcon(tour.tour_type), { size: 16, strokeWidth: 1.9, 'aria-hidden': true })}
|
||||
</span>
|
||||
{source !== 'trek' && (
|
||||
<span
|
||||
aria-label={hikeSourceBadgeLabel(tour, t)}
|
||||
className="absolute inset-x-0 -bottom-1 mx-auto w-fit rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] px-[4px] font-geist text-[length:calc(7px*var(--fs-scale-caption,1))] font-bold normal-case leading-[11px] tracking-[.02em] text-m-muted"
|
||||
>
|
||||
{t(`tours.badgeShort.${source}`)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** A tour as a row of the browser, laid out like a place: tap opens it, the right side is its day. */
|
||||
function TourRow({ tour, planner, shell, canAssign }: {
|
||||
tour: TourListItem
|
||||
planner: TripPlanner
|
||||
shell: MTripShellApi
|
||||
canAssign: boolean
|
||||
}) {
|
||||
const { t } = planner
|
||||
const tourDays = useTourDays(tour.place_id, planner.days)
|
||||
const place = planner.places.find(p => p.id === tour.place_id)
|
||||
const selected = planner.selectedPlaceId === tour.place_id
|
||||
return (
|
||||
<div
|
||||
data-testid="mobile-tour-row"
|
||||
className={`flex items-center gap-[11px] border-b border-[color:var(--m-rowbr)] px-[2px] py-[9px] ${selected ? 'bg-[color:var(--m-ic)]' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => planner.handlePlaceClick(tour.place_id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-[11px] text-start"
|
||||
>
|
||||
<TourTile tour={tour} t={t} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-[6px]">
|
||||
{place?.route_geometry && (
|
||||
// The line's colour on the map: on the phone the row is the only thing tying the two together.
|
||||
<span aria-hidden="true" className="h-[3px] w-[14px] flex-none rounded-full" style={{ background: resolveTrackColor(place) }} />
|
||||
)}
|
||||
<span className="truncate text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">{tour.name}</span>
|
||||
</span>
|
||||
<span data-testid="mobile-tour-metrics" className="mt-[3px] flex min-w-0 items-center gap-1">
|
||||
<TourCautionBadge tour={tour} />
|
||||
<span className="min-w-0 flex-1"><TourDayRowFacts tour={tour} mobile /></span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{tourDays.length > 0 ? (
|
||||
<span className="flex-none whitespace-nowrap rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[9px] py-1 font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.05em] text-m-muted">
|
||||
{t('planner.dayN', { n: tourDays[0].index + 1 })}
|
||||
{tourDays.length > 1 && ` +${tourDays.length - 1}`}
|
||||
</span>
|
||||
) : canAssign && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => shell.openSheet('bract', { placeId: tour.place_id, dayPicker: true })}
|
||||
aria-label={t('tours.addToDay')}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tours mode of the mobile places browser: the trip's tours in the same rows
|
||||
* as its places, to open and to put on a day. Tours are planned on a bigger
|
||||
* screen, which the badge under the list says once; the phone never creates or
|
||||
* imports one.
|
||||
*
|
||||
* "Add to day" reuses the existing 'bract' place-actions sheet's day picker
|
||||
* (the same one MPlacesBrowser's unplanned-place rows open) — a tour is a
|
||||
* Place under the hood, so no new linking code is needed here either.
|
||||
* "Add to day" reuses the 'bract' place-actions sheet's day picker, the same
|
||||
* one an unplanned place's plus opens: a tour is a Place under the hood.
|
||||
*/
|
||||
export default function MToursSelectionList({ planner, shell, filters, canAssign: assignPermission }: MToursSelectionListProps) {
|
||||
const { canAssign } = useTourPermissions({ tripId: planner.tripId, canAssign: assignPermission })
|
||||
const { t } = planner
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const filtered: TourListItem[] = filters.filtered
|
||||
const { facets, selection } = filters
|
||||
const { facets, selection, filtered } = filters
|
||||
const facetRow = filters.hasFacets && (
|
||||
<div className="-mx-4 mt-2 flex gap-[6px] overflow-x-auto px-4 pb-1">
|
||||
{facets.origin.map(option => (
|
||||
@@ -63,105 +154,41 @@ export default function MToursSelectionList({ planner, shell, filters, canAssign
|
||||
</div>
|
||||
)
|
||||
|
||||
if (filters.emptyByFilter) {
|
||||
if (planner.tours.length === 0) {
|
||||
return (
|
||||
<>
|
||||
{facetRow}
|
||||
<div role="status" className="mt-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-6 text-center">
|
||||
<p className="font-geist text-caption text-m-muted">{t('tours.filter.empty')}</p>
|
||||
<button type="button" onClick={filters.reset} className="mt-3 rounded-full bg-m-act px-4 py-2 text-caption font-semibold text-m-actfg">
|
||||
{t('tours.filter.reset')}
|
||||
</button>
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-8 py-10 text-center">
|
||||
<MDancingTrek scene="guide" className="mb-2" />
|
||||
<p className="text-[length:calc(15px*var(--fs-scale-body,1))] font-semibold text-m-ink">{t('tours.empty.title')}</p>
|
||||
<p className="mt-1.5 max-w-[27ch] font-geist text-[length:calc(12.5px*var(--fs-scale-body,1))] leading-[1.5] text-m-muted">{t('mobileTrip.toursEmptyBody')}</p>
|
||||
<MPlanOnDesktopBadge className="mt-3" />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<p className="mt-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.75rem] text-m-muted">
|
||||
{t('tours.planner.mobileHint')}
|
||||
</p>
|
||||
<div className="mt-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-6 text-center">
|
||||
<Mountain className="mx-auto mb-2 h-6 w-6 text-m-faint" strokeWidth={1.6} />
|
||||
<p className="font-semibold text-m-ink">{t('tours.empty.title')}</p>
|
||||
<p className="mt-1 font-geist text-[0.78125rem] text-m-muted">{t('tours.empty.body')}</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mt-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.75rem] text-m-muted">
|
||||
{t('tours.planner.mobileHint')}
|
||||
</p>
|
||||
{facetRow}
|
||||
<ul className="mt-3 space-y-2">
|
||||
{filtered.map(tour => {
|
||||
const isSelected = planner.selectedPlaceId === tour.place_id
|
||||
return (
|
||||
<li
|
||||
key={tour.place_id}
|
||||
role="option"
|
||||
tabIndex={0}
|
||||
aria-selected={isSelected}
|
||||
onClick={() => planner.handlePlaceClick(tour.place_id)}
|
||||
onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); planner.handlePlaceClick(tour.place_id) } }}
|
||||
className="rounded-2xl border border-[color:var(--m-rowbr)] p-3"
|
||||
style={{ background: isSelected ? 'var(--border-faint)' : 'var(--m-ic)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="block min-w-0 line-clamp-2 break-words font-semibold text-m-ink" title={tour.name}>{tour.name}</span>
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 font-geist text-[0.75rem] text-m-muted">
|
||||
<span className="shrink-0 rounded-md border border-[color:var(--m-rowbr)] px-1.5 py-0.5 text-[10px] font-medium text-m-faint">
|
||||
{hikeSourceBadgeLabel(tour, t)}
|
||||
</span>
|
||||
{tour.max_hiking_difficulty != null && (
|
||||
<span
|
||||
title={t(`tours.planner.difficulty.t${tour.max_hiking_difficulty}`)}
|
||||
aria-label={t(`tours.planner.difficulty.t${tour.max_hiking_difficulty}`)}
|
||||
className="shrink-0 rounded-md border border-[color:var(--m-rowbr)] px-1.5 py-0.5 text-[10px] font-semibold text-m-muted"
|
||||
>T{tour.max_hiking_difficulty}</span>
|
||||
)}
|
||||
{tour.caution && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-md border border-[color:var(--warning)] px-1.5 py-0.5 text-[10px] text-[color:var(--warning)]" aria-label={t('tours.caution.badge')} title={t('tours.caution.tooltip')}>
|
||||
<AlertTriangle className="h-3 w-3" strokeWidth={2} aria-hidden="true" />{t('tours.caution.badge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div data-testid="mobile-tour-metrics" className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 font-geist text-[0.75rem] text-m-muted">
|
||||
<span className="whitespace-nowrap" aria-label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance ?? 0, distanceUnit)}`}>
|
||||
{formatDistance(tour.distance ?? 0, distanceUnit)}
|
||||
</span>
|
||||
{tour.elevation_gain != null && (
|
||||
<span className="inline-flex items-center gap-0.5 whitespace-nowrap" aria-label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, distanceUnit)}`}>
|
||||
<ArrowUp className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
|
||||
{formatElevation(tour.elevation_gain, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{tour.elevation_loss != null && (
|
||||
<span className="inline-flex items-center gap-0.5 whitespace-nowrap" aria-label={`${t('tours.detail.descent')}: ${formatElevation(tour.elevation_loss, distanceUnit)}`}>
|
||||
<ArrowDown className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
|
||||
{formatElevation(tour.elevation_loss, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canAssign}
|
||||
onClick={(e) => { e.stopPropagation(); if (canAssign) shell.openSheet('bract', { placeId: tour.place_id, dayPicker: true }) }}
|
||||
className="mt-2 inline-flex min-h-9 w-full items-center justify-center gap-1 rounded-lg border border-[color:var(--m-rowbr)] px-3 py-1.5 text-[0.8125rem] font-medium text-m-ink"
|
||||
>
|
||||
<CalendarDays className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
{t('tours.addToDay')}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{facetRow}
|
||||
<div className="mb-1 mt-[14px] flex items-center gap-[10px]">
|
||||
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
|
||||
<span className="whitespace-nowrap font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.09em] text-m-faint">
|
||||
{t('mobileTrip.toursCount', { count: filtered.length })}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
|
||||
</div>
|
||||
{filters.emptyByFilter ? (
|
||||
<div role="status" className="flex min-h-[40vh] flex-col items-center justify-center px-8 py-10 text-center">
|
||||
<MDancingTrek scene="search" className="mb-2" />
|
||||
<p className="font-geist text-[length:calc(13px*var(--fs-scale-body,1))] font-medium text-m-muted">{t('tours.filter.empty')}</p>
|
||||
<button type="button" onClick={filters.reset} className="mt-3 rounded-full bg-m-act px-4 py-2 text-caption font-semibold text-m-actfg">
|
||||
{t('tours.filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(tour => <TourRow key={tour.place_id} tour={tour} planner={planner} shell={shell} canAssign={canAssign} />)
|
||||
)}
|
||||
<div className="mt-5 flex justify-center">
|
||||
<MPlanOnDesktopBadge />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import MRtAlternativesBar from './MRtAlternativesBar'
|
||||
import { RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
|
||||
import MBadge from '../../../components/MBadge'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import MPlanOnDesktopBadge from '../../../components/MPlanOnDesktopBadge'
|
||||
import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
|
||||
import { navigationTargetLabel, getNavigationTargets } from '../../../../components/Planner/placeNavigation'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
@@ -485,9 +486,12 @@ function EmptyStage({ planner, loading }: { planner: MTripTabPanelProps['planner
|
||||
{loading ? t('roadtrip.summary.partial') : t('roadtrip.empty.title')}
|
||||
</p>
|
||||
{!loading && (
|
||||
<p className="mt-1.5 max-w-[27ch] font-geist text-[0.78125rem] leading-[1.5] text-m-muted">
|
||||
{t('mobileTrip.rtPlanOnDesktop')}
|
||||
</p>
|
||||
<>
|
||||
<p className="mt-1.5 max-w-[27ch] font-geist text-[length:calc(12.5px*var(--fs-scale-body,1))] leading-[1.5] text-m-muted">
|
||||
{t('mobileTrip.rtDriveOnTwoPlaces')}
|
||||
</p>
|
||||
<MPlanOnDesktopBadge className="mt-3" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Crown,
|
||||
Link2,
|
||||
LogOut,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import CustomSelect from '../../../../components/shared/CustomSelect';
|
||||
import { TripJournalLinkSection } from '../../../../components/Trips/TripJournalLinkSection';
|
||||
import { TripMemberAvatar } from '../../../../components/Trips/TripMemberAvatar';
|
||||
import {
|
||||
SHARE_OPTIONS,
|
||||
SHARE_SECTIONS,
|
||||
useShareLink,
|
||||
useTripInviteLink,
|
||||
useTripMembers,
|
||||
type ListedMember,
|
||||
} from '../../../../components/Trips/useTripShare';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import MChip from '../../../components/MChip';
|
||||
import MSheet from '../../../components/MSheet';
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet';
|
||||
import { Eyebrow, INNER_CLS, TileHeader } from './MTripSheetUi';
|
||||
|
||||
interface MTripSettingsSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
tripId: number;
|
||||
tripTitle: string;
|
||||
onMembersChanged?: () => void;
|
||||
}
|
||||
|
||||
type Question =
|
||||
| { kind: 'transfer'; id: number; name: string }
|
||||
| { kind: 'remove'; id: number; self: boolean }
|
||||
| { kind: 'guest'; id: number };
|
||||
|
||||
const FIELD =
|
||||
'h-[42px] min-w-0 flex-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] text-[length:calc(13px*var(--fs-scale-body,1))] font-medium text-m-ink outline-none placeholder:text-m-faint';
|
||||
const PRIMARY =
|
||||
'flex h-[42px] flex-none items-center gap-1.5 rounded-full bg-m-act px-4 text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-actfg disabled:opacity-40';
|
||||
const ROUND =
|
||||
'flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted disabled:opacity-40';
|
||||
const PILL =
|
||||
'flex h-[22px] flex-none items-center gap-[3px] rounded-full px-[8px] font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.06em]';
|
||||
|
||||
/** A titled block of the sheet: the eyebrow, an optional line under it and the content on the inner surface. */
|
||||
function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className={`rounded-[18px] p-3 ${INNER_CLS}`}>
|
||||
<Eyebrow className="uppercase">{title}</Eyebrow>
|
||||
{hint && (
|
||||
<p className="mt-1 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] leading-snug text-m-muted">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2.5 flex flex-col gap-2">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A person of the trip: the avatar, the name with its badges, and the round actions. */
|
||||
function PersonRow({
|
||||
name,
|
||||
avatarUrl,
|
||||
badges,
|
||||
children,
|
||||
}: {
|
||||
name: ReactNode;
|
||||
avatarUrl: string | null;
|
||||
badges?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[44px] items-center gap-[10px]">
|
||||
<TripMemberAvatar username={typeof name === 'string' ? name : '?'} avatarUrl={avatarUrl} size={34} />
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
<span className="min-w-0 truncate text-[length:calc(13.5px*var(--fs-scale-body,1))] font-semibold text-m-ink">
|
||||
{name}
|
||||
</span>
|
||||
{badges}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A link with its copy button, read-only, the way the phone shows one to hand on. */
|
||||
function LinkField({
|
||||
url,
|
||||
copied,
|
||||
onCopy,
|
||||
t,
|
||||
}: {
|
||||
url: string;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-1 pe-1 ps-[14px]">
|
||||
<input
|
||||
readOnly
|
||||
value={url}
|
||||
aria-label={url}
|
||||
className="min-w-0 flex-1 border-none bg-transparent font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className={`${PRIMARY} h-[32px] px-3 text-[length:calc(11.5px*var(--fs-scale-caption,1))]`}
|
||||
>
|
||||
{copied ? <Check size={13} strokeWidth={2.4} /> : <Copy size={13} strokeWidth={2.2} />}
|
||||
{copied ? t('common.copied') : t('common.copy')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareLinkSection({ tripId }: { tripId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const link = useShareLink(tripId);
|
||||
if (link.loading) return null;
|
||||
return (
|
||||
<Section title={t('share.linkTitle')} hint={t('share.linkHint')}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[...SHARE_SECTIONS, ...SHARE_OPTIONS].map((option) => {
|
||||
const always = 'always' in option && !!option.always;
|
||||
const on = !!link.perms[option.key];
|
||||
return (
|
||||
<MChip
|
||||
key={option.key}
|
||||
active={on}
|
||||
pressable
|
||||
className={always ? 'opacity-70' : ''}
|
||||
onClick={() => {
|
||||
if (!always) void link.setPerm(option.key, !on);
|
||||
}}
|
||||
>
|
||||
{on && <Check size={12} strokeWidth={2.4} />}
|
||||
{t(option.label)}
|
||||
</MChip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{link.url ? (
|
||||
<>
|
||||
<LinkField url={link.url} copied={link.copied} onCopy={() => void link.copy()} t={t} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void link.remove()}
|
||||
className="flex h-[38px] items-center justify-center gap-1.5 rounded-full text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-[color:var(--m-st-danger)]"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2.2} /> {t('share.deleteLink')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={() => void link.create()} className={`${PRIMARY} justify-center`}>
|
||||
<Link2 size={14} strokeWidth={2.2} /> {t('share.createLink')}
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteLinkSection({ tripId }: { tripId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const invite = useTripInviteLink(tripId);
|
||||
if (invite.loading) return null;
|
||||
return (
|
||||
<Section title={t('trip.invite.linkTitle')} hint={t('trip.invite.linkHint')}>
|
||||
{invite.url ? (
|
||||
<>
|
||||
<LinkField url={invite.url} copied={invite.copied} onCopy={() => void invite.copy()} t={t} />
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void invite.create()}
|
||||
disabled={invite.busy}
|
||||
className="flex h-[38px] flex-1 items-center justify-center gap-1.5 rounded-full bg-[color:var(--m-ic)] text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-m-ink disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw size={13} strokeWidth={2.2} /> {t('trip.invite.regenerate')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void invite.remove()}
|
||||
disabled={invite.busy}
|
||||
className="flex h-[38px] flex-1 items-center justify-center gap-1.5 rounded-full bg-[color:var(--m-ic)] text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-[color:var(--m-st-danger)] disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2.2} /> {t('trip.invite.disable')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void invite.create()}
|
||||
disabled={invite.busy}
|
||||
className={`${PRIMARY} justify-center`}
|
||||
>
|
||||
<UserPlus size={14} strokeWidth={2.2} /> {t('trip.invite.create')}
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip's settings on the phone (the Mehr sheet's "Trip settings"): who has
|
||||
* access, the guests without an account, the public link, the invite link and
|
||||
* the linked journal. The state is useTripShare's, the same the desktop share
|
||||
* dialog runs on; only the markup and the questions are the phone's own.
|
||||
*/
|
||||
export default function MTripSettingsSheet({
|
||||
open,
|
||||
onClose,
|
||||
tripId,
|
||||
tripTitle,
|
||||
onMembersChanged,
|
||||
}: MTripSettingsSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const m = useTripMembers({ isOpen: open, tripId, onClose, onMembersChanged });
|
||||
const [question, setQuestion] = useState<Question | null>(null);
|
||||
|
||||
const answer = () => {
|
||||
if (!question) return;
|
||||
if (question.kind === 'transfer') void m.transfer(question.id);
|
||||
else if (question.kind === 'remove') void m.remove(question.id, question.self);
|
||||
else void m.removeGuest(question.id);
|
||||
setQuestion(null);
|
||||
};
|
||||
const questionText = !question
|
||||
? ''
|
||||
: question.kind === 'transfer'
|
||||
? t('members.confirmTransfer', { name: question.name })
|
||||
: question.kind === 'guest'
|
||||
? t('members.confirmRemoveGuest')
|
||||
: question.self
|
||||
? t('members.confirmLeave')
|
||||
: t('members.confirmRemove');
|
||||
const questionAction =
|
||||
question?.kind === 'transfer'
|
||||
? t('members.makeOwner')
|
||||
: question?.kind === 'remove' && question.self
|
||||
? t('members.leaveTrip')
|
||||
: t('members.removeAccess');
|
||||
|
||||
const memberActions = (member: ListedMember) => {
|
||||
const self = member.id === m.user?.id;
|
||||
const canRemove = self || (m.canManageMembers && member.role !== 'owner');
|
||||
return (
|
||||
<>
|
||||
{m.isCurrentOwner && member.role !== 'owner' && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('members.makeOwner')}
|
||||
disabled={m.transferringId === member.id}
|
||||
onClick={() => setQuestion({ kind: 'transfer', id: member.id, name: member.username })}
|
||||
className={ROUND}
|
||||
>
|
||||
<Crown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={self ? t('members.leaveTrip') : t('members.removeAccess')}
|
||||
disabled={m.removingId === member.id}
|
||||
onClick={() => setQuestion({ kind: 'remove', id: member.id, self })}
|
||||
className={ROUND}
|
||||
>
|
||||
{self ? <LogOut size={14} strokeWidth={2} /> : <UserMinus size={14} strokeWidth={2} />}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('trip.settings.title')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Settings2 size={19} strokeWidth={1.8} />}
|
||||
title={t('trip.settings.title')}
|
||||
sub={tripTitle}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-[18px] pb-[18px] pt-3">
|
||||
<Section title={`${t('members.access')} ${m.realMembers.length}`}>
|
||||
{m.canManageMembers && (
|
||||
<div className="flex gap-2">
|
||||
<CustomSelect
|
||||
value={m.selectedUserId}
|
||||
onChange={(value) => m.setSelectedUserId(String(value))}
|
||||
placeholder={t('members.selectUser')}
|
||||
options={[
|
||||
{ value: '', label: t('members.selectUser') },
|
||||
...m.availableUsers.map((u) => ({ value: String(u.id), label: u.username })),
|
||||
]}
|
||||
searchable
|
||||
size="sm"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void m.add()}
|
||||
disabled={m.adding || !m.selectedUserId}
|
||||
aria-label={t('members.invite')}
|
||||
className={PRIMARY}
|
||||
>
|
||||
<UserPlus size={14} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{m.canManageMembers && m.availableUsers.length === 0 && m.allUsers.length > 0 && (
|
||||
<p className="font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-faint">
|
||||
{t('members.allHaveAccess')}
|
||||
</p>
|
||||
)}
|
||||
{m.loading && m.realMembers.length === 0 ? (
|
||||
<div className="h-[44px] animate-pulse rounded-full bg-[color:var(--m-ic)]" />
|
||||
) : (
|
||||
m.realMembers.map((member) => (
|
||||
<PersonRow
|
||||
key={member.id}
|
||||
name={member.username}
|
||||
avatarUrl={member.avatar_url}
|
||||
badges={
|
||||
<>
|
||||
{member.id === m.user?.id && (
|
||||
<span className={`${PILL} bg-[color:var(--m-ic)] text-m-muted`}>{t('members.you')}</span>
|
||||
)}
|
||||
{member.role === 'owner' && (
|
||||
<span className={`${PILL} bg-m-act text-m-actfg`}>
|
||||
<Crown size={9} strokeWidth={2.4} />
|
||||
{t('members.owner')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{memberActions(member)}
|
||||
</PersonRow>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{(m.isCurrentOwner || m.guests.length > 0) && (
|
||||
<Section
|
||||
title={`${t('members.guests')}${m.guests.length > 0 ? ` ${m.guests.length}` : ''}`}
|
||||
hint={t('members.guestsHint')}
|
||||
>
|
||||
{m.guests.map((guest) =>
|
||||
m.renamingGuestId === guest.id ? (
|
||||
<input
|
||||
key={guest.id}
|
||||
autoFocus
|
||||
value={m.renameValue}
|
||||
maxLength={50}
|
||||
aria-label={t('common.rename')}
|
||||
onChange={(e) => m.setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void m.commitRename(guest.id);
|
||||
if (e.key === 'Escape') m.cancelRename();
|
||||
}}
|
||||
onBlur={() => void m.commitRename(guest.id)}
|
||||
className={FIELD}
|
||||
/>
|
||||
) : (
|
||||
<PersonRow
|
||||
key={guest.id}
|
||||
name={guest.username}
|
||||
avatarUrl={null}
|
||||
badges={
|
||||
<span className={`${PILL} bg-[color:var(--m-ic)] text-m-muted`}>
|
||||
<UserRound size={9} strokeWidth={2.4} />
|
||||
{t('members.guest')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{m.isCurrentOwner && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.rename')}
|
||||
onClick={() => m.startRename(guest)}
|
||||
className={ROUND}
|
||||
>
|
||||
<Pencil size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('members.removeAccess')}
|
||||
disabled={m.removingId === guest.id}
|
||||
onClick={() => setQuestion({ kind: 'guest', id: guest.id })}
|
||||
className={ROUND}
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</PersonRow>
|
||||
)
|
||||
)}
|
||||
{m.isCurrentOwner && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={m.newGuestName}
|
||||
maxLength={50}
|
||||
placeholder={t('members.guestNamePlaceholder')}
|
||||
onChange={(e) => m.setNewGuestName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void m.addGuest();
|
||||
}}
|
||||
className={FIELD}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void m.addGuest()}
|
||||
disabled={m.addingGuest || !m.newGuestName.trim()}
|
||||
aria-label={t('members.addGuest')}
|
||||
className={PRIMARY}
|
||||
>
|
||||
<Plus size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{m.canManageShare && <ShareLinkSection tripId={tripId} />}
|
||||
{m.canManageShare && <InviteLinkSection tripId={tripId} />}
|
||||
<TripJournalLinkSection tripId={tripId} />
|
||||
</div>
|
||||
</MSheet>
|
||||
|
||||
<MConfirmSheet
|
||||
open={question != null}
|
||||
onClose={() => setQuestion(null)}
|
||||
title={questionAction}
|
||||
message={questionText}
|
||||
confirmLabel={questionAction}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger={question?.kind !== 'transfer'}
|
||||
onConfirm={answer}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { createPortal } from 'react-dom'
|
||||
import TransitJourneyModal from '../../../../components/Planner/TransitJourneyModal'
|
||||
import BookingImportModal from '../../../../components/Planner/BookingImportModal'
|
||||
import AirTrailImportModal from '../../../../components/Planner/AirTrailImportModal'
|
||||
import TripFormModal from '../../../../components/Trips/TripFormModal'
|
||||
import TripMembersModal from '../../../../components/Trips/TripMembersModal'
|
||||
import TourDetailDialog from '../../../../components/Tours/TourDetailDialog'
|
||||
import { useBookingExpenseEditor } from '../../../../pages/tripPlanner/useBookingExpenseEditor'
|
||||
import { applyTripCoverUpdate } from '../../../../pages/tripPlanner/tripCover'
|
||||
@@ -26,6 +24,8 @@ import MNoteSheet, { type MNoteSheetPayload } from './MNoteSheet'
|
||||
import MImportSheet from './MImportSheet'
|
||||
import MExportSheet from './MExportSheet'
|
||||
import MMehrSheet from './MMehrSheet'
|
||||
import MTripSettingsSheet from './MTripSettingsSheet'
|
||||
import MNewTripSheet from '../../dashboard/MNewTripSheet'
|
||||
import MPlacesFilterSheet from '../places/MPlacesFilterSheet'
|
||||
import MRtStopSheet from '../roadtrip/MRtStopSheet'
|
||||
import MRtStaySheet from '../roadtrip/MRtStaySheet'
|
||||
@@ -97,9 +97,10 @@ export function MSelectedTourDetail({ planner }: Pick<MTripSheetsProps, 'planner
|
||||
* transport, bract, note, import, export, mehr — the place inspector keys off
|
||||
* the planner's place selection instead), and the planner-flag editors that
|
||||
* every entry point (?create=, import review, timeline, map long-press) opens
|
||||
* through useTripPlanner state. The transport/booking/transit/import/member
|
||||
* through useTripPlanner state. The transport/booking/transit/import
|
||||
* editors reuse the shared desktop modals until they get mobile counterparts;
|
||||
* they carry the full behaviour (undo, WS sync, review flow) unchanged.
|
||||
* they carry the full behaviour (undo, WS sync, review flow) unchanged. Trip edit and
|
||||
* the trip settings have phone sheets of their own.
|
||||
*/
|
||||
export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
const { t, toast, tripId, trip, tripActions } = planner
|
||||
@@ -197,21 +198,21 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
<AirTrailImportModal isOpen={planner.showAirTrailImport} onClose={() => planner.setShowAirTrailImport(false)} tripId={tripId} pushUndo={planner.pushUndo} />
|
||||
|
||||
{/* Trip edit + share/members, opened from the Mehr sheet. */}
|
||||
<TripFormModal
|
||||
isOpen={sheet?.id === 'tripedit'}
|
||||
<MNewTripSheet
|
||||
open={sheet?.id === 'tripedit'}
|
||||
trip={trip ?? null}
|
||||
onClose={shell.closeSheet}
|
||||
onSave={async (data) => {
|
||||
await tripActions.updateTrip(tripId, data)
|
||||
toast.success(t('trip.toast.tripUpdated'))
|
||||
}}
|
||||
trip={trip}
|
||||
onCoverUpdate={applyTripCoverUpdate}
|
||||
/>
|
||||
<TripMembersModal
|
||||
isOpen={sheet?.id === 'members'}
|
||||
<MTripSettingsSheet
|
||||
open={sheet?.id === 'members'}
|
||||
onClose={shell.closeSheet}
|
||||
tripId={tripId}
|
||||
tripTitle={trip?.title}
|
||||
tripTitle={trip?.title ?? ''}
|
||||
onMembersChanged={planner.refreshMembers}
|
||||
/>
|
||||
|
||||
|
||||
@@ -511,7 +511,7 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
|
||||
dayImport, dayImportDates,
|
||||
} = useJourneyDetailLayers({
|
||||
journey: current, loaded: journeyLoaded, canEditEntries, loadJourney, dawarich, dawarichByDate, dawarichTrail,
|
||||
tripDates, sidebarMapItems, photoCount: mapPhotos.length, showTripTracks,
|
||||
tripDates, sidebarMapItems, photoCount: mapPhotos.length, showTripTracks, allLayers: isMobile,
|
||||
})
|
||||
|
||||
/** Studio's margin to the window on all four sides — see `.st-root` in studio.css. */
|
||||
|
||||
@@ -30,6 +30,7 @@ export function useJourneyDetailLayers({
|
||||
sidebarMapItems,
|
||||
photoCount,
|
||||
showTripTracks,
|
||||
allLayers = false,
|
||||
}: {
|
||||
journey: JourneyDetail | null;
|
||||
/** Whether `journey` is the one the route names yet: the store keeps the last one while the next loads. */
|
||||
@@ -43,6 +44,8 @@ export function useJourneyDetailLayers({
|
||||
sidebarMapItems: ReadonlyArray<{ id: string; entry_date: string; dayColor: string }>;
|
||||
photoCount: number;
|
||||
showTripTracks: boolean;
|
||||
/** The phone: every map layer on, as it offers no switches (see useJourneyMapLayers). */
|
||||
allLayers?: boolean;
|
||||
}) {
|
||||
// The editor's own status, the owner's switch, and where each day of the trips stands.
|
||||
const isAddonEnabled = useAddonStore((s) => s.isEnabled);
|
||||
@@ -111,6 +114,7 @@ export function useJourneyDetailLayers({
|
||||
recordedRead,
|
||||
colorOfDay,
|
||||
dateOfEntry,
|
||||
allLayers,
|
||||
});
|
||||
|
||||
// The drafts a day's take-over wrote reach the timeline, and its stays leave the pending list.
|
||||
|
||||
@@ -136,6 +136,7 @@ export function useJourneyPublic() {
|
||||
photoCount: mapPhotos.length,
|
||||
colorOfDay,
|
||||
dateOfEntry,
|
||||
allLayers: isMobile,
|
||||
})
|
||||
|
||||
// A journey shared while the trip is still running reads like a blog, so the owner
|
||||
|
||||
@@ -60,7 +60,7 @@ vi.mock('../../../../src/mobile/screens/admin/MAdminStoragePanel', () => ({
|
||||
}));
|
||||
|
||||
// The addon manager is reduced to the two controls MAdmin wires itself.
|
||||
vi.mock('../../../../src/components/Admin/paddle/PaddleRegionsPanel', () => ({
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminPaddleRegionsPanel', () => ({
|
||||
default: () => <div data-testid="paddle-regions-panel" />,
|
||||
}));
|
||||
vi.mock('../../../../src/mobile/screens/admin/MAdminAddonManager', () => ({
|
||||
|
||||
@@ -118,9 +118,10 @@ describe('MAdminAddonManager', () => {
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('Lists');
|
||||
expect(screen.getByText('Trip — Available as a tab within each trip')).toBeInTheDocument();
|
||||
// The band names the type and says what it means on a line of its own, no dash between them.
|
||||
expect(screen.getByText('Available as a tab within each trip')).toBeInTheDocument();
|
||||
expect(screen.getByText('Packing lists and to-do tasks for your trips')).toBeInTheDocument();
|
||||
expect(screen.getByText('Trip')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Trip')).toHaveLength(2);
|
||||
expect(screen.getByRole('switch', { name: 'Lists' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
@@ -275,7 +276,7 @@ describe('MAdminAddonManager', () => {
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('Immich');
|
||||
expect(screen.getByText('Global — Available as a standalone section in the main navigation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Available as a standalone section in the main navigation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Synology Photos')).toBeInTheDocument();
|
||||
expect(screen.getByText('Unsplash')).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'true');
|
||||
@@ -499,7 +500,7 @@ describe('MAdminAddonManager', () => {
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
await screen.findByText('AI Parsing');
|
||||
expect(screen.getByText('Integration — Backend services and API integrations with no dedicated page')).toBeInTheDocument();
|
||||
expect(screen.getByText('Backend services and API integrations with no dedicated page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// FE-MOB-APAD-001 to FE-MOB-APAD-010: the paddle regions card of the phone admin, drawn from a
|
||||
// controlled usePaddleRegionsAdmin (the hook has its own test).
|
||||
import type { PaddleRegion, PaddleRegionPending } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { usePaddleRegionsAdmin } from '../../../../src/components/Admin/paddle/usePaddleRegionsAdmin';
|
||||
import MAdminPaddleRegionsPanel from '../../../../src/mobile/screens/admin/MAdminPaddleRegionsPanel';
|
||||
import { fireEvent, render, screen, within } from '../../../helpers/render';
|
||||
|
||||
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
const hook = vi.hoisted(() => ({ usePaddleRegionsAdmin: vi.fn<() => Admin>() }));
|
||||
vi.mock('../../../../src/components/Admin/paddle/usePaddleRegionsAdmin', () => ({
|
||||
usePaddleRegionsAdmin: hook.usePaddleRegionsAdmin,
|
||||
}));
|
||||
|
||||
function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
|
||||
return {
|
||||
id: 'oberpfalz',
|
||||
name: 'Oberpfalz',
|
||||
status: 'ready',
|
||||
origin: 'admin',
|
||||
source_timestamp: '2026-10-03T12:00:00Z',
|
||||
imported_at: '2026-10-04T08:00:00Z',
|
||||
feature_count: 1234,
|
||||
error: null,
|
||||
progress: null,
|
||||
network: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const REQUEST: PaddleRegionPending = {
|
||||
id: 'germany/bayern',
|
||||
name: 'Bayern',
|
||||
reason: 'too_large',
|
||||
bytes: 700 * 1024 * 1024,
|
||||
requested_by: 'alice',
|
||||
requested_at: '2026-10-05T12:00:00Z',
|
||||
declined: false,
|
||||
} as PaddleRegionPending;
|
||||
|
||||
function show(overrides: Partial<Admin> = {}, search: Partial<Admin['search']> = {}) {
|
||||
const state: Admin = {
|
||||
toursOn: true,
|
||||
offline: false,
|
||||
regions: [],
|
||||
requests: [],
|
||||
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
|
||||
loading: false,
|
||||
error: null,
|
||||
retry: vi.fn(),
|
||||
added: new Set<string>(),
|
||||
busyId: null,
|
||||
add: vi.fn(async () => {}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
approve: vi.fn(async () => {}),
|
||||
decline: vi.fn(async () => {}),
|
||||
removing: null,
|
||||
askRemove: vi.fn(),
|
||||
cancelRemove: vi.fn(),
|
||||
confirmRemove: vi.fn(),
|
||||
...overrides,
|
||||
search: { query: '', setQuery: vi.fn(), hits: null, searching: false, error: null, ...search },
|
||||
};
|
||||
hook.usePaddleRegionsAdmin.mockReturnValue(state);
|
||||
render(<MAdminPaddleRegionsPanel />);
|
||||
return state;
|
||||
}
|
||||
|
||||
describe('MAdminPaddleRegionsPanel', () => {
|
||||
it('FE-MOB-APAD-001: shows nothing while the Tours addon is off', () => {
|
||||
show({ toursOn: false });
|
||||
expect(screen.queryByText('Paddle water data')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APAD-002: a region shows its status, its objects and its actions', () => {
|
||||
const state = show({ regions: [region({ network: { water_km: 812 } as PaddleRegion['network'] })] });
|
||||
const row = screen.getByTestId('paddle-region-row');
|
||||
expect(within(row).getByText('Oberpfalz')).toBeInTheDocument();
|
||||
expect(within(row).getByText('Ready')).toBeInTheDocument();
|
||||
expect(within(row).getByText('1234 objects')).toBeInTheDocument();
|
||||
expect(within(row).getByText('812 km of water')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Read again Oberpfalz' }));
|
||||
expect(state.refresh).toHaveBeenCalledWith('oberpfalz');
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Remove region Oberpfalz' }));
|
||||
expect(state.askRemove).toHaveBeenCalledWith(expect.objectContaining({ id: 'oberpfalz' }));
|
||||
});
|
||||
|
||||
it('FE-MOB-APAD-003: a failed read says why and that the old data stays', () => {
|
||||
show({ regions: [region({ status: 'failed', error: 'Download broke off.' })] });
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Download broke off\. The data read before stays in use\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APAD-004: the mode and the size are switched in place', () => {
|
||||
const save = vi.fn(async () => {});
|
||||
show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } as Admin['autoLoad'] });
|
||||
const modes = screen.getByRole('radiogroup', { name: 'Load regions automatically' });
|
||||
expect(within(modes).getByRole('radio', { name: /Automatic/ })).toHaveAttribute('aria-checked', 'true');
|
||||
fireEvent.click(within(modes).getByRole('radio', { name: /On request/ }));
|
||||
expect(save).toHaveBeenCalledWith({ mode: 'ask' });
|
||||
|
||||
const sizes = screen.getByRole('group', { name: 'Largest region loaded automatically' });
|
||||
fireEvent.click(within(sizes).getByRole('button', { name: /1 GB/ }));
|
||||
expect(save).toHaveBeenCalledWith({ max_mb: 1000 });
|
||||
});
|
||||
|
||||
it('FE-MOB-APAD-005: no size choice outside the automatic mode', () => {
|
||||
show({
|
||||
autoLoad: {
|
||||
settings: { mode: 'off', max_mb: 500 },
|
||||
saving: false,
|
||||
save: vi.fn(),
|
||||
} as unknown as Admin['autoLoad'],
|
||||
});
|
||||
expect(screen.queryByRole('group', { name: 'Largest region loaded automatically' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APAD-006: a requested region is loaded or declined', () => {
|
||||
const state = show({ requests: [REQUEST] });
|
||||
const row = screen.getByTestId('paddle-region-request');
|
||||
expect(within(row).getByText('Bayern')).toBeInTheDocument();
|
||||
fireEvent.click(within(row).getByRole('button', { name: /Load/ }));
|
||||
expect(state.approve).toHaveBeenCalledWith('germany/bayern');
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Decline' }));
|
||||
expect(state.decline).toHaveBeenCalledWith('germany/bayern');
|
||||
});
|
||||
|
||||
it('FE-MOB-APAD-007: a search hit is added, an added one says so', () => {
|
||||
const state = show(
|
||||
{ added: new Set(['netherlands/limburg']) },
|
||||
{
|
||||
query: 'Limburg',
|
||||
hits: [
|
||||
{ id: 'belgium/limburg', name: 'Limburg', path: ['Europe', 'Belgium'] },
|
||||
{ id: 'netherlands/limburg', name: 'Limburg NL', path: ['Europe', 'Netherlands'] },
|
||||
],
|
||||
}
|
||||
);
|
||||
const hits = screen.getAllByTestId('paddle-region-hit');
|
||||
expect(within(hits[0]).getByText('Belgium')).toBeInTheDocument();
|
||||
fireEvent.click(within(hits[0]).getByRole('button', { name: /Limburg/ }));
|
||||
expect(state.add).toHaveBeenCalledWith('belgium/limburg');
|
||||
expect(within(hits[1]).getByText('Added')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APAD-008: typing searches, and a search without hits says so', () => {
|
||||
const state = show({}, { hits: [] });
|
||||
fireEvent.change(screen.getByRole('searchbox', { name: 'Add a region' }), { target: { value: 'Atlantis' } });
|
||||
expect(state.search.setQuery).toHaveBeenCalledWith('Atlantis');
|
||||
expect(screen.getByText('No region by that name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APAD-009: a failed read of the list says so and can be retried', () => {
|
||||
const state = show({ error: 'Server down' });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||
expect(state.retry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-APAD-010: removing asks in a phone sheet first', () => {
|
||||
const state = show({ regions: [region()], removing: region() });
|
||||
const question = screen.getByRole('dialog', { name: 'Remove region' });
|
||||
fireEvent.click(within(question).getByRole('button', { name: 'Remove' }));
|
||||
expect(state.confirmRemove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// FE-MOB-ASCH-001 to FE-MOB-ASCH-008: the school holiday catalog of the phone admin, drawn from a
|
||||
// controlled useSchoolHolidayCatalog (the hook has its own test).
|
||||
import type { SchoolHolidayRegionDetail } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { useSchoolHolidayCatalog } from '../../../../src/components/Admin/useSchoolHolidayCatalog';
|
||||
import MAdminSchoolHolidayCatalog from '../../../../src/mobile/screens/admin/MAdminSchoolHolidayCatalog';
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render';
|
||||
|
||||
type Catalog = ReturnType<typeof useSchoolHolidayCatalog>;
|
||||
|
||||
const hook = vi.hoisted(() => ({ useSchoolHolidayCatalog: vi.fn<() => Catalog>() }));
|
||||
vi.mock('../../../../src/components/Admin/useSchoolHolidayCatalog', () => ({
|
||||
useSchoolHolidayCatalog: hook.useSchoolHolidayCatalog,
|
||||
}));
|
||||
|
||||
const REGION: SchoolHolidayRegionDetail = {
|
||||
id: 5,
|
||||
country: 'DE',
|
||||
name: 'Bayern',
|
||||
code: 'BY',
|
||||
revision: 2,
|
||||
holidays: [{ name: 'Herbstferien', startDate: '2026-11-02', endDate: '2026-11-06' }],
|
||||
};
|
||||
|
||||
function show(overrides: Partial<Catalog> = {}) {
|
||||
const state = {
|
||||
catalog: {
|
||||
countries: [{ code: 'DE', name: 'Deutschland' }],
|
||||
regions: [{ id: 5, country: 'DE', name: 'Bayern', code: 'BY', revision: 2 }],
|
||||
},
|
||||
loading: false,
|
||||
busy: false,
|
||||
error: '',
|
||||
offline: false,
|
||||
editor: null,
|
||||
setEditor: vi.fn(),
|
||||
refresh: vi.fn(async () => true),
|
||||
createCountry: vi.fn(async () => true),
|
||||
deleteCountry: vi.fn(async () => true),
|
||||
openRegion: vi.fn(async () => true),
|
||||
saveRegion: vi.fn(async () => true),
|
||||
deleteRegion: vi.fn(async () => true),
|
||||
...overrides,
|
||||
} as unknown as Catalog;
|
||||
hook.useSchoolHolidayCatalog.mockReturnValue(state);
|
||||
render(<MAdminSchoolHolidayCatalog />);
|
||||
return state;
|
||||
}
|
||||
|
||||
describe('MAdminSchoolHolidayCatalog', () => {
|
||||
it('FE-MOB-ASCH-001: lists the regions of the chosen country and opens one', () => {
|
||||
const state = show();
|
||||
expect(screen.getByText('School holidays')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bayern' }));
|
||||
expect(state.openRegion).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASCH-002: a country with regions cannot be deleted, and says why', () => {
|
||||
show();
|
||||
expect(screen.getByRole('button', { name: 'Delete country' })).toBeDisabled();
|
||||
expect(screen.getByText('Remove regions first. Regions used by calendars cannot be deleted.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASCH-003: a region is deleted after the phone question', () => {
|
||||
const state = show();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Bayern' }));
|
||||
const question = screen.getByRole('dialog', { name: 'Delete' });
|
||||
fireEvent.click(within(question).getByRole('button', { name: 'Delete' }));
|
||||
expect(state.deleteRegion).toHaveBeenCalledWith(5, 2);
|
||||
});
|
||||
|
||||
it('FE-MOB-ASCH-004: an empty country says so and offers a new region', () => {
|
||||
const state = show({
|
||||
catalog: { countries: [{ code: 'AT', name: 'Österreich' }], regions: [] } as Catalog['catalog'],
|
||||
});
|
||||
expect(screen.getByText('No regions yet.')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add region' }));
|
||||
expect(state.setEditor).toHaveBeenCalledWith(expect.objectContaining({ id: 0, country: 'AT', holidays: [] }));
|
||||
});
|
||||
|
||||
it('FE-MOB-ASCH-005: a country is added in its own sheet, with a two-letter code', async () => {
|
||||
const state = show({ catalog: { countries: [], regions: [] } as Catalog['catalog'] });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add country' }));
|
||||
const sheet = screen.getByRole('dialog', { name: 'Add country' });
|
||||
const save = within(sheet).getByRole('button', { name: 'Save' });
|
||||
expect(save).toBeDisabled();
|
||||
const [name, code] = within(sheet).getAllByRole('textbox');
|
||||
fireEvent.change(name, { target: { value: 'France' } });
|
||||
fireEvent.change(code, { target: { value: 'fr' } });
|
||||
expect(code).toHaveValue('FR');
|
||||
fireEvent.click(save);
|
||||
await waitFor(() => expect(state.createCountry).toHaveBeenCalledWith({ code: 'FR', name: 'France' }));
|
||||
});
|
||||
|
||||
it('FE-MOB-ASCH-006: the region sheet saves its periods as the request the server takes', () => {
|
||||
const state = show({ editor: REGION });
|
||||
const sheet = screen.getByRole('dialog', { name: 'Bayern' });
|
||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Add holiday period' }));
|
||||
expect(within(sheet).getAllByPlaceholderText('Holiday name')).toHaveLength(2);
|
||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Delete 2' }));
|
||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Save' }));
|
||||
expect(state.saveRegion).toHaveBeenCalledWith('DE', 5, {
|
||||
name: 'Bayern',
|
||||
revision: 2,
|
||||
holidays: [{ name: 'Herbstferien', startDate: '2026-11-02', endDate: '2026-11-06' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-ASCH-007: an incomplete period is refused before it is sent', () => {
|
||||
const state = show({ editor: REGION });
|
||||
const sheet = screen.getByRole('dialog', { name: 'Bayern' });
|
||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Add holiday period' }));
|
||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Save' }));
|
||||
expect(within(sheet).getByRole('alert')).toHaveTextContent('Check the names and dates.');
|
||||
expect(state.saveRegion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASCH-008: a failed read says so and can be tried again', () => {
|
||||
const state = show({ error: 'Server down' });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Server down');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
expect(state.refresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -178,6 +178,7 @@ describe('MNewTripSheet', () => {
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
currency: 'USD',
|
||||
reminder_days: 0,
|
||||
day_count: 7,
|
||||
}));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
@@ -664,3 +665,66 @@ describe('MNewTripSheet', () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MNewTripSheet day count and reminder', () => {
|
||||
it('FE-MOB-NTSH-090: a dateless trip sends a changed day count, an unchanged one not', async () => {
|
||||
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
|
||||
const trip = buildDashTrip({ start_date: null, end_date: null, day_count: 5 } as Partial<DashboardTrip>);
|
||||
const { unmount } = render(<MNewTripSheet open trip={trip} onClose={vi.fn()} onSave={onSave} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
expect(onSave.mock.calls[0][0]).not.toHaveProperty('day_count');
|
||||
unmount();
|
||||
|
||||
render(<MNewTripSheet open trip={trip} onClose={vi.fn()} onSave={onSave} />);
|
||||
fireEvent.change(screen.getByDisplayValue('5'), { target: { value: '9' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(2));
|
||||
expect(onSave.mock.calls[1][0]).toMatchObject({ day_count: 9 });
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-091: an empty day count is refused', async () => {
|
||||
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
|
||||
render(<MNewTripSheet open trip={buildDashTrip({ start_date: null, end_date: null, day_count: 5 } as Partial<DashboardTrip>)} onClose={vi.fn()} onSave={onSave} />);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('5'), { target: { value: '' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||
expect(await screen.findByText('Number of days is required')).toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-092: the owner picks a reminder, a preset or a number of their own', async () => {
|
||||
useAuthStore.setState({ tripRemindersEnabled: true });
|
||||
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
|
||||
render(<MNewTripSheet open trip={buildDashTrip({ reminder_days: 3 } as Partial<DashboardTrip>)} onClose={vi.fn()} onSave={onSave} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: '3 days' })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Custom' }));
|
||||
const days = screen.getByRole('spinbutton', { name: 'days before departure' });
|
||||
expect(days).toHaveValue(7);
|
||||
fireEvent.change(days, { target: { value: '12' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ reminder_days: 12 })));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'None' }));
|
||||
expect(screen.queryByRole('spinbutton', { name: 'days before departure' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-093: with reminders off the owner is told where to switch them on', () => {
|
||||
useAuthStore.setState({ tripRemindersEnabled: false });
|
||||
render(<MNewTripSheet open trip={buildDashTrip()} onClose={vi.fn()} onSave={vi.fn()} />);
|
||||
expect(screen.getByText(/Trip reminders are disabled/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-094: someone else\'s trip offers no reminder and sends none', async () => {
|
||||
useAuthStore.setState({ tripRemindersEnabled: true, user: buildUser({ id: 9, role: 'user' }) });
|
||||
const onSave = vi.fn(async (_data: TripCreateRequest) => undefined);
|
||||
render(<MNewTripSheet open trip={buildDashTrip()} onClose={vi.fn()} onSave={onSave} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Custom' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0]).not.toHaveProperty('reminder_days');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,7 +272,7 @@ describe('MRoadtripTab', () => {
|
||||
renderTab(planner({ roadtripRoutes: routes({ days: [] }) }))
|
||||
|
||||
expect(screen.getByText('roadtrip.empty.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('mobileTrip.rtPlanOnDesktop')).toBeInTheDocument()
|
||||
expect(screen.getByText('mobileTrip.rtDriveOnTwoPlaces')).toBeInTheDocument()
|
||||
expect(screen.queryByText('mobileTrip.rtStart')).toBeNull()
|
||||
expect(screen.queryByText(/roadtrip\.day\.stopCount/)).toBeNull()
|
||||
})
|
||||
@@ -282,7 +282,7 @@ describe('MRoadtripTab', () => {
|
||||
|
||||
expect(screen.getByText('roadtrip.summary.partial')).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.empty.title')).toBeNull()
|
||||
expect(screen.queryByText('mobileTrip.rtPlanOnDesktop')).toBeNull()
|
||||
expect(screen.queryByText('mobileTrip.rtDriveOnTwoPlaces')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-053: with the drive routed and no day picked it asks for a day, not for a route', () => {
|
||||
@@ -294,7 +294,7 @@ describe('MRoadtripTab', () => {
|
||||
expect(screen.getByText('mobileTrip.rtNoDay')).toBeInTheDocument()
|
||||
expect(screen.getByText('mobileTrip.rtNoDayHint')).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.empty.title')).toBeNull()
|
||||
expect(screen.queryByText('mobileTrip.rtPlanOnDesktop')).toBeNull()
|
||||
expect(screen.queryByText('mobileTrip.rtDriveOnTwoPlaces')).toBeNull()
|
||||
expect(screen.queryByText('mobileTrip.rtStart')).toBeNull()
|
||||
|
||||
// A picked day the routing has nothing for is still the empty stage: that one is
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import MTripSettingsSheet from '../../../../src/mobile/screens/trip/sheets/MTripSettingsSheet';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { usePermissionsStore } from '../../../../src/store/permissionsStore';
|
||||
import { useTripStore } from '../../../../src/store/tripStore';
|
||||
import { buildTrip, buildUser } from '../../../helpers/factories';
|
||||
import { server } from '../../../helpers/msw/server';
|
||||
import { render, screen, waitFor, within } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
|
||||
// FE-MOB-TSET-001 to FE-MOB-TSET-008: the phone's trip settings, on useTripShare.
|
||||
|
||||
const owner = buildUser({ id: 1, username: 'owner' });
|
||||
const alice = buildUser({ id: 2, username: 'alice' });
|
||||
|
||||
interface Row {
|
||||
id: number;
|
||||
username: string;
|
||||
avatar_url?: string | null;
|
||||
is_guest?: boolean;
|
||||
}
|
||||
|
||||
function roster(members: Row[]) {
|
||||
server.use(
|
||||
http.get('/api/trips/1/members', () =>
|
||||
HttpResponse.json({
|
||||
owner: { id: owner.id, username: owner.username, avatar_url: null },
|
||||
members,
|
||||
current_user_id: owner.id,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderSheet(props: Partial<Parameters<typeof MTripSettingsSheet>[0]> = {}) {
|
||||
const onClose = vi.fn();
|
||||
const onMembersChanged = vi.fn();
|
||||
render(
|
||||
<MTripSettingsSheet
|
||||
open
|
||||
onClose={onClose}
|
||||
tripId={1}
|
||||
tripTitle="Japan 2026"
|
||||
onMembersChanged={onMembersChanged}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return { onClose, onMembersChanged };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: owner, isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, title: 'Japan 2026', user_id: owner.id }) });
|
||||
roster([]);
|
||||
server.use(
|
||||
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: null })),
|
||||
http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: null })),
|
||||
http.get('/api/auth/users', () => HttpResponse.json({ users: [alice] }))
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('MTripSettingsSheet', () => {
|
||||
it('FE-MOB-TSET-001: names the trip in its header and the owner with their badges', async () => {
|
||||
renderSheet();
|
||||
const sheet = screen.getByRole('dialog', { name: 'Trip settings' });
|
||||
expect(within(sheet).getByText('Japan 2026')).toBeInTheDocument();
|
||||
expect(await within(sheet).findByText('owner')).toBeInTheDocument();
|
||||
expect(within(sheet).getByText('you')).toBeInTheDocument();
|
||||
expect(within(sheet).getByText('Owner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-TSET-002: asks in a sheet of its own before a member goes, and only then removes', async () => {
|
||||
const user = userEvent.setup();
|
||||
roster([{ id: alice.id, username: 'alice', avatar_url: null }]);
|
||||
let deletes = 0;
|
||||
server.use(
|
||||
http.delete('/api/trips/1/members/:userId', () => {
|
||||
deletes++;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
const { onMembersChanged } = renderSheet();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Remove access' }));
|
||||
const question = await screen.findByRole('dialog', { name: 'Remove access' });
|
||||
expect(within(question).getByText('Remove access for this user?')).toBeInTheDocument();
|
||||
expect(deletes).toBe(0);
|
||||
|
||||
await user.click(within(question).getByRole('button', { name: 'Remove access' }));
|
||||
await waitFor(() => expect(deletes).toBe(1));
|
||||
await waitFor(() => expect(onMembersChanged).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('FE-MOB-TSET-003: a question that is cancelled changes nothing', async () => {
|
||||
const user = userEvent.setup();
|
||||
roster([{ id: alice.id, username: 'alice', avatar_url: null }]);
|
||||
let transfers = 0;
|
||||
server.use(
|
||||
http.post('/api/trips/1/transfer', () => {
|
||||
transfers++;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
renderSheet();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Make owner' }));
|
||||
const question = await screen.findByRole('dialog', { name: 'Make owner' });
|
||||
await user.click(within(question).getByRole('button', { name: 'Cancel' }));
|
||||
expect(transfers).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-TSET-004: a guest is added, renamed and removed', async () => {
|
||||
const user = userEvent.setup();
|
||||
roster([{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true }]);
|
||||
let created: string | null = null;
|
||||
let renamed: string | null = null;
|
||||
let deleted = false;
|
||||
server.use(
|
||||
http.post('/api/trips/1/guests', async ({ request }) => {
|
||||
created = ((await request.json()) as { name: string }).name;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.put('/api/trips/1/guests/:userId', async ({ request }) => {
|
||||
renamed = ((await request.json()) as { name: string }).name;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.delete('/api/trips/1/guests/:userId', () => {
|
||||
deleted = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
renderSheet();
|
||||
|
||||
expect(await screen.findByText('Grandma')).toBeInTheDocument();
|
||||
await user.type(screen.getByPlaceholderText('Guest name'), 'Grandpa');
|
||||
await user.click(screen.getByRole('button', { name: 'Add guest' }));
|
||||
await waitFor(() => expect(created).toBe('Grandpa'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rename' }));
|
||||
const input = screen.getByDisplayValue('Grandma');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Granny{Enter}');
|
||||
await waitFor(() => expect(renamed).toBe('Granny'));
|
||||
|
||||
const removeGuest = screen.getAllByRole('button', { name: 'Remove access' });
|
||||
await user.click(removeGuest[removeGuest.length - 1]);
|
||||
await user.click(
|
||||
within(await screen.findByRole('dialog', { name: 'Remove access' })).getByRole('button', {
|
||||
name: 'Remove access',
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(deleted).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-MOB-TSET-005: the public link toggles say whether they are on (#2604)', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||||
server.use(
|
||||
http.get('/api/trips/1/share-link', () =>
|
||||
HttpResponse.json({
|
||||
token: 'abc123',
|
||||
share_map: true,
|
||||
share_bookings: true,
|
||||
share_packing: false,
|
||||
share_budget: false,
|
||||
share_collab: false,
|
||||
})
|
||||
)
|
||||
);
|
||||
renderSheet();
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByDisplayValue(/\/shared\/abc123$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-TSET-006: a public link is created with what it should show', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||||
let body: Record<string, boolean> | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/share-link', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, boolean>;
|
||||
return HttpResponse.json({ token: 'fresh' });
|
||||
})
|
||||
);
|
||||
renderSheet();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Packing' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Create link' }));
|
||||
await waitFor(() => expect(body).toMatchObject({ share_map: true, share_packing: true }));
|
||||
expect(await screen.findByDisplayValue(/\/shared\/fresh$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-TSET-007: the invite link is created and switched off again', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||||
let removed = false;
|
||||
server.use(
|
||||
http.post('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'join1' })),
|
||||
http.delete('/api/trips/1/invite-link', () => {
|
||||
removed = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
renderSheet();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Create invite link' }));
|
||||
expect(await screen.findByDisplayValue(/\/join\/join1$/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Disable' }));
|
||||
await waitFor(() => expect(removed).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-MOB-TSET-008: closes through its close button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = renderSheet();
|
||||
await user.click(
|
||||
within(screen.getByRole('dialog', { name: 'Trip settings' })).getByRole('button', { name: 'Close' })
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,9 +11,8 @@ import { render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-SHOST-030 to FE-MOB-SHOST-031 (001 to 029 and 032 live in MTripSheets.test.tsx)
|
||||
//
|
||||
// The phone has no share sheet of its own: the Mehr sheet opens the desktop
|
||||
// TripMembersModal through this host. Every other sheet is stubbed away so the real
|
||||
// modal is what the phone user gets (#2478).
|
||||
// The Mehr sheet opens the phone's own MTripSettingsSheet through this host. Every
|
||||
// other sheet is stubbed away so the real sheet is what the phone user gets (#2478).
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MPlaceSheet', () => ({ default: () => null }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MDaySheet', () => ({ default: () => null }))
|
||||
@@ -39,7 +38,7 @@ vi.mock('../../../../src/mobile/screens/settings/MConfirmSheet', () => ({ defaul
|
||||
vi.mock('../../../../src/components/Planner/BookingImportModal', () => ({ default: () => null }))
|
||||
vi.mock('../../../../src/components/Planner/AirTrailImportModal', () => ({ default: () => null }))
|
||||
vi.mock('../../../../src/components/Planner/TransitJourneyModal', () => ({ default: () => null }))
|
||||
vi.mock('../../../../src/components/Trips/TripFormModal', () => ({ default: () => null }))
|
||||
vi.mock('../../../../src/mobile/screens/dashboard/MNewTripSheet', () => ({ default: () => null }))
|
||||
|
||||
import MTripSheets from '../../../../src/mobile/screens/trip/sheets/MTripSheets'
|
||||
|
||||
|
||||
@@ -133,9 +133,9 @@ vi.mock('../../../../src/components/Planner/AirTrailImportModal', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Trips/TripFormModal', () => ({
|
||||
default: ({ isOpen, onClose, onSave, trip, onCoverUpdate }: {
|
||||
isOpen: boolean; onClose: () => void
|
||||
vi.mock('../../../../src/mobile/screens/dashboard/MNewTripSheet', () => ({
|
||||
default: ({ open: isOpen, onClose, onSave, trip, onCoverUpdate }: {
|
||||
open: boolean; onClose: () => void
|
||||
onSave: (data: Record<string, unknown>) => Promise<void>
|
||||
trip?: Trip | null
|
||||
onCoverUpdate: (id: number, coverUrl: string) => void
|
||||
@@ -148,9 +148,9 @@ vi.mock('../../../../src/components/Trips/TripFormModal', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Trips/TripMembersModal', () => ({
|
||||
default: ({ isOpen, onClose, tripTitle, onMembersChanged }: {
|
||||
isOpen: boolean; onClose: () => void; tripTitle?: string; onMembersChanged: () => void
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MTripSettingsSheet', () => ({
|
||||
default: ({ open: isOpen, onClose, tripTitle, onMembersChanged }: {
|
||||
open: boolean; onClose: () => void; tripTitle?: string; onMembersChanged: () => void
|
||||
}) => (
|
||||
<div data-testid="stub-members" data-open={String(isOpen)} data-title={tripTitle ?? 'none'}>
|
||||
<button type="button" onClick={onMembersChanged}>members changed</button>
|
||||
|
||||
Reference in New Issue
Block a user