Files
TREK/client/src/components/Trips/TripFormModal.tsx
T
Maurice 9cbf31da27 fix(client): the trip form's review and the slow-open splash, and the tests that followed
Inside the trip settings, Escape and the backdrop no longer close the dialog
while the date question is up, and the embedded form does not load the
members it does not show. Every trip open is timed afresh for the splash.
The tests follow the cover image card, the danger button, the delayed splash
and the open and download mocks.
2026-11-13 12:57:57 +01:00

849 lines
36 KiB
TypeScript

import { addIsoDays, MAX_TRIP_DAYS, tripSpanDays, type TripCreateRequest } from '@trek/shared';
import { CalendarDays, Camera, Image as ImageIcon, Pencil, Plane, Search, Type, X } from 'lucide-react';
import { useEffect, useId, useRef, useState } from 'react';
import { authApi, tripsApi } from '../../api/client';
import { useTripRangeGuard, type RangeCheck } from '../../hooks/useTripRangeGuard';
import { useTranslation } from '../../i18n';
import { tripChangeNeedsConnection } from '../../repo/tripRepo';
import { useAuthStore } from '../../store/authStore';
import { useCanDo } from '../../store/permissionsStore';
import { useSettingsStore } from '../../store/settingsStore';
import { isEffectivelyOffline } from '../../sync/networkMode';
import { type Trip } from '../../types';
import type { ShiftMode } from '../../utils/dayImpactLines';
import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants';
import { CustomDatePicker } from '../shared/CustomDateTimePicker';
import CustomSelect from '../shared/CustomSelect';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { NumericInput } from '../shared/NumericInput';
import { useToast } from '../shared/Toast';
import { CardHead, INPUT, LABEL, PANEL } from '../shared/dialogParts';
import TripDateReview, { dateReviewIsWide } from './TripDateReview';
import { tripFormError } from './tripFormModel';
import { useTripCoverPicker } from './useTripCoverPicker';
type DateShiftMode = ShiftMode;
type TripPayload = TripCreateRequest & { date_shift_mode?: DateShiftMode };
/**
* A save held back for a look first: the start of a dated trip moved and the
* bookings need a direction (#1288), or the new range removes days with
* something on them. The payload waits here until the traveller confirms.
*/
interface PendingReview {
payload: TripPayload;
askShift: boolean;
removal: RangeCheck;
}
interface TripFormModalProps {
isOpen: boolean;
onClose: () => void;
// Create returns the new trip (so we can attach members / upload the cover);
// update resolves without a payload.
onSave: (data: TripCreateRequest & { date_shift_mode?: DateShiftMode }) => Promise<{ trip?: Trip } | void> | void;
trip: Trip | null;
onCoverUpdate?: (tripId: number, coverUrl: string | null) => void;
/**
* Drawn inside the trip settings rather than as a dialog of its own (#2811): no
* frame and no Cancel, the settings around it close it. One column there, since
* beside the settings' own side column two would cut the dates and wrap the chips.
*/
embedded?: boolean;
/** Embedded, told while the date question is up, so the dialog around it keeps Escape and its backdrop off. */
onReviewChange?: (reviewing: boolean) => void;
}
export default function TripFormModal({
isOpen,
onClose,
onSave,
trip,
onCoverUpdate,
embedded = false,
onReviewChange,
}: TripFormModalProps) {
const isEditing = !!trip;
const labelId = useId();
const fileRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const { t } = useTranslation();
const currentUser = useAuthStore((s) => s.user);
const defaultCurrency = useSettingsStore((s) => s.settings.default_currency) || 'EUR';
const tripRemindersEnabled = useAuthStore((s) => s.tripRemindersEnabled);
const setTripRemindersEnabled = useAuthStore((s) => s.setTripRemindersEnabled);
const can = useCanDo();
const canUploadCover = !isEditing || can('trip_cover_upload', trip);
const canEditTrip = !isEditing || can('trip_edit', trip);
const [formData, setFormData] = useState({
title: '',
description: '',
start_date: '',
end_date: '',
currency: 'EUR',
reminder_days: 0 as number,
day_count: 7 as number | '',
});
const [customReminder, setCustomReminder] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const cover = useTripCoverPicker({ trip, title: formData.title, onCoverUpdate, releasePreviewOnChange: false });
const {
coverPreview,
uploadingCover,
searchQuery: coverSearchQuery,
setSearchQuery: setCoverSearchQuery,
searchResults: coverSearchResults,
searchError: coverSearchError,
searching: searchingCover,
} = cover;
// Drives the drop zone's hover look. Used to be four handlers writing
// element.style directly, which is how the indigo dragover colour survived
// the move to a configurable accent.
const [coverDragActive, setCoverDragActive] = useState(false);
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([]);
const [selectedMembers, setSelectedMembers] = useState<number[]>([]);
const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([]);
const [memberSelectValue, setMemberSelectValue] = useState('');
const [pendingReview, setPendingReview] = useState<PendingReview | null>(null);
const [dateShiftMode, setDateShiftMode] = useState<DateShiftMode>('keep_bookings');
const rangeGuard = useTripRangeGuard();
const busy = isLoading || rangeGuard.checking;
useEffect(() => {
if (trip) {
const rd = trip.reminder_days ?? 3;
setFormData({
title: trip.title || '',
description: trip.description || '',
start_date: trip.start_date || '',
end_date: trip.end_date || '',
currency: trip.currency || 'EUR',
reminder_days: rd,
day_count: trip.day_count || 7,
});
setCustomReminder(![0, 1, 3, 9].includes(rd));
cover.resetCover(trip.cover_image || null);
} else {
setFormData({
title: '',
description: '',
start_date: '',
end_date: '',
currency: defaultCurrency,
reminder_days: tripRemindersEnabled ? 3 : 0,
day_count: 7,
});
setCustomReminder(false);
cover.resetCover(null);
}
setSelectedMembers([]);
setPendingReview(null);
setDateShiftMode('keep_bookings');
setError('');
setExistingMembers([]);
// The planner keeps this modal mounted while it is closed, so nothing may be
// fetched until it is actually open.
if (isOpen) {
authApi
.getAppConfig()
.then((c: { trip_reminders_enabled?: boolean }) => {
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled);
})
.catch(() => {});
// The members block is not drawn inside the trip settings, whose People area holds them.
if (!embedded) {
authApi
.listUsers()
.then((d) => setAllUsers(d.users || []))
.catch(() => {});
if (trip)
tripsApi
.getMembers(trip.id)
.then((d) => setExistingMembers(d.members || []))
.catch(() => {});
}
}
}, [trip, isOpen, embedded]);
useEffect(() => {
if (!trip && isOpen) {
setFormData((prev) => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 }));
}
}, [tripRemindersEnabled]);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
const formError = tripFormError(
{ title: formData.title, startDate: formData.start_date, endDate: formData.end_date },
trip,
t
);
if (formError) {
setError(formError);
return;
}
if (!formData.start_date && !formData.end_date) {
const dc = Number(formData.day_count);
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > MAX_TRIP_DAYS) {
setError(t('dashboard.dayCountRequired'));
return;
}
}
// The day count goes along only when it says something new. The trip's own
// count goes stale in the planner (adding a day does not reload the trip),
// and sending the stale number trims the day just added off a trip that was
// only renamed. On a trip left without dates the server keeps its days anyway.
const dayCount = Number(formData.day_count);
const sendDayCount = !formData.start_date && !formData.end_date && (!isEditing || dayCount !== trip?.day_count);
const payload: TripPayload = {
title: formData.title.trim(),
description: formData.description.trim() || null,
start_date: formData.start_date || null,
end_date: formData.end_date || null,
currency: formData.currency,
reminder_days: formData.reminder_days,
...(sendDayCount ? { day_count: dayCount } : {}),
};
// Moving the start of a dated trip shifts the whole day grid, so let the user
// choose how bookings follow before anything is saved (#1288). End-date-only
// changes don't shift days and dateless transitions have nothing to shift.
if (refusedOffline(payload)) return;
const askShift = !!(
isEditing &&
trip?.start_date &&
trip?.end_date &&
payload.start_date &&
payload.end_date &&
payload.start_date !== trip.start_date
);
// And say which days the new range removes, with what is on them, before
// they are gone: the server takes the last days by position.
const removal = isEditing && trip ? await rangeGuard.check(trip, payload) : null;
if (askShift || removal) {
setDateShiftMode('keep_bookings');
setPendingReview({ payload, askShift, removal });
return;
}
await performSave(payload);
};
// A new trip, new dates or a new currency are made by the server; a new name
// or description waits in the queue offline like any other change.
const refusedOffline = (payload: TripPayload): boolean => {
if (!isEffectivelyOffline() || (isEditing && !tripChangeNeedsConnection(trip ?? undefined, payload))) return false;
setError(t(isEditing ? 'dashboard.tripChangeNeedsConnection' : 'common.needsConnection'));
return true;
};
const performSave = async (payload: TripPayload) => {
if (refusedOffline(payload)) return;
setIsLoading(true);
try {
const result = await onSave(payload);
const createdTrip = result ? result.trip : undefined;
// Add selected members for newly created trips
if (selectedMembers.length > 0 && createdTrip?.id) {
let memberAddFailed = false;
for (const userId of selectedMembers) {
const user = allUsers.find((u) => u.id === userId);
if (user) {
try {
await tripsApi.addMember(createdTrip.id, user.username);
} catch {
memberAddFailed = true;
}
}
}
if (memberAddFailed) toast.error(t('trips.memberAddError'));
}
// Upload pending cover for newly created trips
await cover.applyToCreatedTrip(createdTrip?.id);
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('places.saveError'));
} finally {
setIsLoading(false);
}
};
const handleCoverSelect = cover.selectFile;
const handleCoverChange = (e: React.ChangeEvent<HTMLInputElement>) => {
void handleCoverSelect(e.target.files?.[0]);
e.target.value = '';
};
const handleCoverSearch = cover.search;
const handleUnsplashSelect = cover.selectPhoto;
const handleRemoveCover = cover.removeCover;
// Paste support for cover image
const handlePaste = (e: React.ClipboardEvent) => {
if (!canUploadCover) return;
const items = e.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) void handleCoverSelect(file);
return;
}
}
};
// An edit answers whatever the last save complained about, so the complaint
// goes with it rather than standing over the fixed field until the next save.
const update = (field, value) => {
setError('');
setFormData((prev) => {
const next = { ...prev, [field]: value };
if (field === 'start_date' && value) {
if (!prev.end_date || prev.end_date < value) {
next.end_date = value;
} else if (prev.start_date) {
// Keep the trip's length, counted in UTC calendar days: a local-time
// shift across a DST change used to drop or add a day.
next.end_date = addIsoDays(value, tripSpanDays(prev.start_date, prev.end_date) - 1);
}
}
return next;
});
};
/* The dashboard's shapes, in the app's own tokens.
`.trek-dash` deliberately scopes its palette to that page, so none of its
variables are reachable from here — what carries over is the geometry:
generous radii, grouped panels instead of a single stack of labelled
fields, and overline captions rather than sentence-case labels. */
// The dialogs' own language: their fields, eyebrows, panels and buttons, so the
// form reads alike on its own and inside the trip settings.
const inputCls = INPUT;
const labelCls = `${LABEL.replace('block ', '')} flex items-center gap-1`;
/* Two columns once there is room for them: the form had grown to eight stacked
blocks and the create button sat a full screen below the title. Left is what
the trip looks like, right is when and with whom. One column below md. */
const columnCls = 'flex flex-col gap-4';
const panelCls = PANEL;
// Escape in the date question goes back to the form; embedded, the settings around it must not close on it.
const reviewing = pendingReview != null;
useEffect(() => {
onReviewChange?.(reviewing);
}, [reviewing, onReviewChange]);
const reviewTitle = pendingReview
? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle')
: '';
const buttons = pendingReview ? (
<>
<DialogButton onClick={() => setPendingReview(null)} disabled={busy}>
{t('common.back')}
</DialogButton>
{/* Removing days with something on them is the one save here that
loses work, so it asks in the danger tone and says so. */}
<DialogButton
variant={pendingReview.removal ? 'danger' : 'primary'}
disabled={busy}
onClick={() =>
performSave(
pendingReview.askShift
? { ...pendingReview.payload, date_shift_mode: dateShiftMode }
: pendingReview.payload
)
}
>
{isLoading ? t('common.saving') : t(pendingReview.removal ? 'dashboard.shrinkConfirm' : 'common.update')}
</DialogButton>
</>
) : (
<>
{!embedded && <DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>}
<DialogButton variant="primary" onClick={handleSubmit} disabled={busy}>
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
</DialogButton>
</>
);
const content = (
<>
{pendingReview && (
<TripDateReview
askShift={pendingReview.askShift}
shiftMode={dateShiftMode}
onShiftMode={setDateShiftMode}
removal={pendingReview.removal}
error={error}
/>
)}
<form onSubmit={handleSubmit} className={pendingReview ? 'hidden' : 'space-y-4'} onPaste={handlePaste}>
{error && (
<div className="border-danger/30 rounded-xl border bg-danger-soft p-3 text-body text-danger">{error}</div>
)}
<div className={`grid grid-cols-1 items-start gap-x-6 gap-y-4 ${embedded ? '' : 'md:grid-cols-2'}`}>
{/* Left column — what the trip is: its picture, its name, what it is about. */}
<div className={columnCls}>
{/* Cover image — gated by trip_cover_upload permission */}
{canUploadCover && (
<div className={panelCls}>
<CardHead
icon={<ImageIcon size={16} strokeWidth={2} />}
title={t('trip.form.coverTitle')}
hint={t('trip.form.coverHint')}
/>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleCoverChange} />
{coverPreview ? (
<div className={`relative h-[110px] overflow-hidden rounded-xl`}>
<img src={coverPreview} alt="" className="h-full w-full object-cover" />
<div className="absolute bottom-2 end-2 flex gap-1.5">
{/* Chrome sitting on top of a photo, so it is deliberately dark in
both themes rather than following the surface tokens. */}
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploadingCover}
className="flex items-center gap-1 rounded-lg bg-black/55 px-2.5 py-[5px] text-caption font-semibold text-white backdrop-blur-sm"
>
<Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')}
</button>
<button
type="button"
onClick={handleRemoveCover}
aria-label={t('common.remove')}
className="flex items-center rounded-lg bg-black/55 px-2 py-[5px] text-white backdrop-blur-sm"
>
<X size={12} />
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploadingCover}
onDragOver={(e) => {
e.preventDefault();
setCoverDragActive(true);
}}
onDragLeave={() => setCoverDragActive(false)}
onDrop={(e) => {
e.preventDefault();
setCoverDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file?.type.startsWith('image/')) void handleCoverSelect(file);
}}
className={`flex h-11 w-full items-center justify-center gap-1.5 rounded-[10px] border-[1.5px] border-dashed px-4 text-[length:calc(13px*var(--fs-scale-body,1))] transition-colors ${
coverDragActive
? 'border-accent bg-accent-subtle text-content'
: 'border-edge text-content-faint hover:border-edge-secondary hover:text-content-muted'
}`}
>
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
</button>
)}
<div className="mt-2 flex gap-2">
<input
type="text"
value={coverSearchQuery}
onChange={(e) => setCoverSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void handleCoverSearch();
}
}}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
className={inputCls}
/>
<button
type="button"
onClick={handleCoverSearch}
disabled={searchingCover || (!coverSearchQuery.trim() && !formData.title.trim())}
className="inline-flex flex-none items-center gap-1.5 whitespace-nowrap rounded-[10px] bg-accent px-4 py-2 text-[length:calc(13px*var(--fs-scale-body,1))] font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50"
>
{searchingCover ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-edge border-t-content-muted" />
) : (
<Search size={14} />
)}
{t('dashboard.searchUnsplash')}
</button>
</div>
{coverSearchError && <p className="mt-1.5 text-caption text-danger">{coverSearchError}</p>}
{coverSearchResults.length > 0 && (
<div className="mt-2 grid grid-cols-3 gap-2">
{coverSearchResults.map((photo) => (
<button
type="button"
key={photo.id}
onClick={() => handleUnsplashSelect(photo)}
aria-label={t('dashboard.useUnsplashPhoto', { photographer: photo.photographer || 'Unsplash' })}
className={`relative h-20 overflow-hidden rounded-xl border transition-colors ${coverPreview === photo.url ? 'ring-accent/20 border-accent ring-2' : 'border-edge hover:border-content-faint'}`}
>
<img
src={photo.thumb}
alt={photo.description || ''}
loading="lazy"
className="h-full w-full object-cover"
/>
{photo.photographer && (
<span className="absolute inset-x-0 bottom-0 truncate bg-black/55 px-1.5 py-1 text-caption text-white">
{photo.photographer}
</span>
)}
</button>
))}
</div>
)}
</div>
)}
<div className={`${panelCls} flex-1`}>
<CardHead
icon={<Type size={16} strokeWidth={2} />}
title={t('trip.form.aboutTitle')}
hint={t('trip.form.aboutHint')}
/>
<div>
<label className={labelCls}>
{t('dashboard.tripTitle')} <span className="text-danger">*</span>
</label>
<input
type="text"
value={formData.title}
onChange={(e) => canEditTrip && update('title', e.target.value)}
required
readOnly={!canEditTrip}
placeholder={t('dashboard.tripTitlePlaceholder')}
className={inputCls}
/>
</div>
<div>
<label className={labelCls}>{t('dashboard.tripDescription')}</label>
<textarea
value={formData.description}
onChange={(e) => canEditTrip && update('description', e.target.value)}
readOnly={!canEditTrip}
placeholder={t('dashboard.tripDescriptionPlaceholder')}
rows={3}
className={`${inputCls} resize-none`}
/>
</div>
</div>
</div>
{/* /left column */}
{/* Right column — when it happens, in what currency, with whom. */}
<div className={columnCls}>
<div className={panelCls}>
<CardHead
icon={<CalendarDays size={16} strokeWidth={2} />}
title={t('trip.form.whenTitle')}
hint={t('trip.form.whenHint')}
/>
{/* min-w-0 on both cells: a grid item defaults to min-width:auto, so a
long date ("12. Sept. 2026" and the keyboard button beside it) pushed
the cell past its track and the two columns overlapped (#2267). */}
<div className="grid grid-cols-2 gap-3">
<div className="min-w-0">
<label className={labelCls}>{t('dashboard.startDate')}</label>
<CustomDatePicker
value={formData.start_date}
onChange={(v) => update('start_date', v)}
placeholder={t('dashboard.startDate')}
/>
</div>
<div className="min-w-0">
<label className={labelCls}>{t('dashboard.endDate')}</label>
<CustomDatePicker
value={formData.end_date}
onChange={(v) => update('end_date', v)}
placeholder={t('dashboard.endDate')}
/>
</div>
</div>
{!formData.start_date && !formData.end_date && (
<div>
<label className={labelCls}>{t('dashboard.dayCount')}</label>
<NumericInput
min={1}
max={MAX_TRIP_DAYS}
value={formData.day_count}
onValueChange={(raw) => {
if (raw === '') {
update('day_count', '');
return;
}
const n = Math.floor(Number(raw));
if (Number.isFinite(n)) update('day_count', Math.min(MAX_TRIP_DAYS, Math.max(1, n)));
}}
className={inputCls}
/>
<p className="mt-1.5 text-caption text-content-faint">{t('dashboard.dayCountHint')}</p>
</div>
)}
<div>
<label className={labelCls}>{t('dashboard.currency')}</label>
<CustomSelect
value={formData.currency}
onChange={(v) => canEditTrip && update('currency', v)}
disabled={!canEditTrip}
options={currenciesWith(formData.currency).map((c) => ({
value: c,
label: `${c} (${SYMBOLS[c] || c})`,
}))}
searchable
/>
</div>
{/* Reminder — only visible to owner (or when creating) */}
{(!isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin') && (
<div className={!tripRemindersEnabled ? 'opacity-50' : ''}>
<label className={labelCls}>{t('trips.reminder')}</label>
{!tripRemindersEnabled ? (
<p className="rounded-xl bg-surface p-3 text-caption text-content-faint">
{t('trips.reminderDisabledHint')}
</p>
) : (
<>
<div className="flex flex-wrap gap-2">
{[
{ value: 0, label: t('trips.reminderNone') },
{ value: 1, label: `1 ${t('trips.reminderDay')}` },
{ value: 3, label: `3 ${t('trips.reminderDays')}` },
{ value: 9, label: `9 ${t('trips.reminderDays')}` },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
update('reminder_days', opt.value);
setCustomReminder(false);
}}
className={`rounded-full border px-3 py-1.5 text-caption font-medium transition-colors ${
!customReminder && formData.reminder_days === opt.value
? 'border-accent bg-accent text-accent-text'
: 'border-edge bg-surface text-content-secondary hover:border-content-faint'
}`}
>
{opt.label}
</button>
))}
<button
type="button"
onClick={() => {
setCustomReminder(true);
if ([0, 1, 3, 9].includes(formData.reminder_days)) update('reminder_days', 7);
}}
className={`rounded-full border px-3 py-1.5 text-caption font-medium transition-colors ${
customReminder
? 'border-accent bg-accent text-accent-text'
: 'border-edge bg-surface text-content-secondary hover:border-content-faint'
}`}
>
{t('trips.reminderCustom')}
</button>
</div>
{customReminder && (
<div className="mt-2 flex items-center gap-2">
<NumericInput
min={1}
max={30}
value={formData.reminder_days}
onValueChange={(raw) =>
update('reminder_days', Math.max(1, Math.min(30, Number(raw) || 1)))
}
className="focus:ring-accent/40 w-20 rounded-xl border border-edge bg-surface-input px-3 py-1.5 text-body text-content focus:outline-none focus:ring-2"
/>
<span className="text-caption text-content-muted">{t('trips.reminderDaysBefore')}</span>
</div>
)}
</>
)}
</div>
)}
{/* Members. Not inside the trip settings, whose People area is where they are managed. */}
{!embedded && allUsers.filter((u) => u.id !== currentUser?.id).length > 0 && (
<div>
<label className={labelCls}>{t('dashboard.addMembers')}</label>
{/* Existing members (editing mode) */}
{isEditing && existingMembers.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{existingMembers.map((m) => (
<button
type="button"
key={m.id}
disabled={m.id === currentUser?.id}
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-[3px] text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content shadow-sm ring-1 ring-edge-faint"
onClick={async () => {
if (m.id === currentUser?.id) return;
if (isEffectivelyOffline()) {
toast.error(t('common.needsConnection'));
return;
}
try {
await tripsApi.removeMember(trip!.id, m.id);
setExistingMembers((prev) => prev.filter((x) => x.id !== m.id));
toast.success(t('trips.memberRemoved', { username: m.username }));
} catch {
toast.error(t('trips.memberRemoveError'));
}
}}
style={{ cursor: m.id === currentUser?.id ? 'default' : 'pointer' }}
>
{m.username}
{m.id !== currentUser?.id && <X size={11} className="text-content-faint" />}
</button>
))}
</div>
)}
{/* Newly selected members (both modes) */}
{selectedMembers.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{selectedMembers.map((uid) => {
const user = allUsers.find((u) => u.id === uid)!;
return (
<button
type="button"
key={uid}
onClick={() => setSelectedMembers((prev) => prev.filter((id) => id !== uid))}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-[3px] text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content shadow-sm ring-1 ring-edge-faint"
>
{user.username}
<X size={11} className="text-content-faint" />
</button>
);
})}
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<CustomSelect
value={memberSelectValue}
onChange={async (value) => {
if (isEditing && trip?.id) {
const user = allUsers.find((u) => u.id === Number(value));
if (user && isEffectivelyOffline()) {
toast.error(t('common.needsConnection'));
} else if (user) {
try {
await tripsApi.addMember(trip.id, user.username);
setExistingMembers((prev) => [...prev, { id: user.id, username: user.username }]);
toast.success(t('trips.memberAdded', { username: user.username }));
} catch {
toast.error(t('trips.memberAddError'));
}
}
} else {
setSelectedMembers((prev) =>
prev.includes(Number(value)) ? prev : [...prev, Number(value)]
);
}
setMemberSelectValue('');
}}
placeholder={t('dashboard.addMember')}
options={allUsers
.filter(
(u) =>
u.id !== currentUser?.id &&
!selectedMembers.includes(u.id) &&
!existingMembers.some((m) => m.id === u.id)
)
.map((u) => ({ value: u.id, label: u.username }))}
searchable
size="sm"
/>
</div>
</div>
)}
</div>
{/* /right panel */}
</div>
{/* /right column */}
</div>
{/* /two-column grid */}
</form>
</>
);
if (embedded) {
return (
<div className="flex flex-col gap-4">
{pendingReview && <h4 className="m-0 text-subtitle font-semibold text-content">{reviewTitle}</h4>}
{content}
<div className="flex justify-end gap-2">{buttons}</div>
</div>
);
}
return (
<DialogShell
open={isOpen}
// In the review step Escape, the close button and a click beside the
// dialog go back to the form, like Back, instead of throwing the whole
// edit away. Outside that step they close the dialog as before.
onClose={
pendingReview
? () => {
if (!busy) setPendingReview(null);
}
: onClose
}
labelledBy={labelId}
/* The review step is a few radio buttons and a short list; alone, either
takes the width of the day dialog, where the same list stands. With both,
they stand side by side at the form's own width. */
width={pendingReview && !dateReviewIsWide(pendingReview.askShift, pendingReview.removal) ? 'detail' : 'wide'}
header={
<DialogHeader
tile={
<DialogTile>
{isEditing ? (
<Pencil size={20} strokeWidth={1.9} className="text-content-muted" />
) : (
<Plane size={20} strokeWidth={1.9} className="text-content-muted" />
)}
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={
pendingReview
? () => {
if (!busy) setPendingReview(null);
}
: onClose
}
eyebrow={pendingReview ? undefined : isEditing ? t('dashboard.editTrip') : undefined}
title={
pendingReview ? reviewTitle : isEditing ? trip?.title || t('dashboard.editTrip') : t('dashboard.createTrip')
}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
{buttons}
</DialogFooter>
}
>
{content}
</DialogShell>
);
}