fix(mobile): split the new phone sheets and cards below the lint limits, follow the tests to the new rows

This commit is contained in:
Maurice
2026-10-22 21:43:15 +02:00
parent 49afee161d
commit 8e420cc1f1
14 changed files with 644 additions and 514 deletions
@@ -3,7 +3,7 @@ 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;
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[] {
@@ -148,12 +148,13 @@ describe('Tours permissions without database setup', () => {
expect(remove).toHaveBeenCalledTimes(canAssign ? 1 : 0)
const view = vi.fn()
const openSheet = vi.fn()
rerender(<List planner={{ t: (key: string) => key, tours: [tour], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} canEdit={canEdit} canAssign={canAssign} />)
fireEvent.click(screen.getByRole('option'))
rerender(<List planner={{ t: (key: string) => key, tours: [tour], places: [], days: [], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} canEdit={canEdit} canAssign={canAssign} />)
fireEvent.click(screen.getByRole('button', { name: new RegExp(tour.name) }))
expect(view).toHaveBeenCalledWith(42)
const attach = screen.getByRole('button', { name: 'tours.addToDay' }) as HTMLButtonElement
expect(attach.disabled).toBe(!canAssign)
fireEvent.click(attach)
// Without the permission the row offers no plus at all, as an unplanned place's row does.
const attach = screen.queryByRole('button', { name: 'tours.addToDay' })
expect(Boolean(attach)).toBe(canAssign)
if (attach) fireEvent.click(attach)
expect(openSheet).toHaveBeenCalledTimes(canAssign ? 1 : 0)
})
it.each([[false, false], [true, false], [false, true], [true, true]])('rails edit=%s assign=%s separate save and existing-tour attach', (canEdit, canAssign) => {
@@ -266,9 +266,9 @@ function CatalogSearch({ admin }: { admin: Admin }) {
);
}
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
/** What a region's read stands at: its status, where it came from, its objects, its date and its water. */
function RegionBadges({ region }: { region: PaddleRegion }) {
const { t, language } = useTranslation();
const busy = admin.busyId === region.id;
const percent = progressPercent(region);
const status =
region.status === 'importing' && region.progress
@@ -280,6 +280,38 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
const km = region.network
? new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(region.network.water_km)
: null;
return (
<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>
);
}
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
const { t } = useTranslation();
const busy = admin.busyId === region.id;
return (
<div
data-testid="paddle-region-row"
@@ -295,31 +327,7 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
<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>
<RegionBadges region={region} />
{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}
@@ -219,6 +219,188 @@ function RegionSheet({ region, busy, offline, error, onClose, onSave }: EditorPr
);
}
type Catalog = ReturnType<typeof useSchoolHolidayCatalog>;
type Removal = { label: string; action: () => Promise<boolean> };
/** Offline, loading and a failed read, each said where the card starts. */
function CatalogNotices({ state, disabled }: { state: Catalog; disabled: boolean }) {
const { t } = useTranslation();
return (
<>
{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>
)}
</>
);
}
/** The country shown, with adding one and deleting the shown one (only while it has no regions). */
function CountryPicker({
state,
selected,
regionCount,
disabled,
onSelect,
onAdd,
onRemove,
}: {
state: Catalog;
selected: string;
regionCount: number;
disabled: boolean;
onSelect: (code: string) => void;
onAdd: () => void;
onRemove: (removal: Removal) => void;
}) {
const { t } = useTranslation();
return (
<>
{state.catalog.countries.length > 0 ? (
<div>
<span className={LABEL}>{t('schoolCatalog.country')}</span>
<div className="flex items-center gap-2">
<CustomSelect
value={selected}
onChange={(value) => onSelect(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={onAdd}
className={ROUND}
>
<Plus size={16} strokeWidth={2.2} />
</button>
<button
type="button"
aria-label={t('schoolCatalog.deleteCountry')}
disabled={disabled || regionCount > 0}
onClick={() => onRemove({ label: selected, action: () => state.deleteCountry(selected) })}
className={ROUND}
>
<Trash2 size={15} strokeWidth={2} />
</button>
</div>
{regionCount > 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={onAdd} className="self-start">
<Plus size={13} strokeWidth={2.2} />
{t('schoolCatalog.addCountry')}
</MAdminButton>
)
)}
</>
);
}
/** The regions of a country, each opened or deleted, and the button that adds one. */
function RegionList({
state,
country,
regions,
disabled,
onRemove,
}: {
state: Catalog;
country: string;
regions: Catalog['catalog']['regions'];
disabled: boolean;
onRemove: (removal: Removal) => void;
}) {
const { t } = useTranslation();
return (
<>
{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={() =>
onRemove({ 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, 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>
</>
);
}
/**
* The school holiday catalog on the phone: the shared holiday regions per
* country that everyone can pick in their vacation settings. The state is
@@ -229,7 +411,7 @@ export default function MAdminSchoolHolidayCatalog() {
const state = useSchoolHolidayCatalog();
const [country, setCountry] = useState('');
const [adding, setAdding] = useState(false);
const [remove, setRemove] = useState<{ label: string; action: () => Promise<boolean> } | null>(null);
const [remove, setRemove] = useState<Removal | 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);
@@ -257,139 +439,26 @@ export default function MAdminSchoolHolidayCatalog() {
<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>
)}
<CatalogNotices state={state} disabled={disabled} />
{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>
)
)}
<CountryPicker
state={state}
selected={selectedCountry}
regionCount={regions.length}
disabled={disabled}
onSelect={setCountry}
onAdd={() => setAdding(true)}
onRemove={setRemove}
/>
{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>
</>
<RegionList
state={state}
country={selectedCountry}
regions={regions}
disabled={disabled}
onRemove={setRemove}
/>
)}
</div>
@@ -22,8 +22,8 @@ import { endDateForNewStart, tripFormError } from '../../../components/Trips/tri
interface MNewTripSheetProps {
open: boolean
/** null = create, otherwise edit */
trip: DashboardTrip | null
/** null or undefined = create, otherwise edit (the planner may not have its trip yet) */
trip: DashboardTrip | null | undefined
onClose: () => void
onSave: (data: TripCreateRequest) => Promise<{ trip?: Trip } | void> | void
onCoverUpdate?: (tripId: number, coverUrl: string | null) => void
@@ -31,6 +31,9 @@ interface MNewTripSheetProps {
onArchive?: () => void
}
const BOX_CLS = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]'
const INPUT_CLS = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint'
/** The reminder presets the desktop form offers; anything else is a custom number of days. */
const REMINDER_PRESETS = [0, 1, 3, 9]
@@ -47,12 +50,12 @@ function FieldLabel({ children }: { children: React.ReactNode }): React.ReactEle
* 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 {
export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, onCoverUpdate, onArchive }: MNewTripSheetProps): React.ReactElement {
const trip = tripProp ?? null
const isEditing = !!trip
const { t } = useTranslation()
const can = useCanDo()
const defaultCurrency = useSettingsStore(s => s.settings.default_currency) || 'EUR'
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)
@@ -71,7 +74,6 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
const [error, setError] = useState('')
const [isSaving, setIsSaving] = useState(false)
const cover = useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewOnChange: true })
const { coverPreview, uploadingCover, searchQuery, setSearchQuery, searchResults, searchError, searching } = cover
// A save whose new dates remove days with something on them, held until the
// traveller confirms. The phone has no shift step, so bookings keep their dates.
const [pendingRemoval, setPendingRemoval] = useState<{ payload: TripCreateRequest; removal: RangeRemoval | 'unknown' } | null>(null)
@@ -149,13 +151,8 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
}
}
const handleCoverFile = cover.selectFile
const handleSearch = cover.search
const selectUnsplash = cover.selectPhoto
const removeCover = cover.removeCover
const boxCls = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]'
const inputCls = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint'
const boxCls = BOX_CLS
const inputCls = INPUT_CLS
const removalList = pendingRemoval && pendingRemoval.removal !== 'unknown' ? pendingRemoval.removal : null
@@ -261,135 +258,17 @@ 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>
<ReminderField
enabled={remindersEnabled}
days={reminderDays}
custom={customReminder}
onPreset={days => { setError(''); setReminderDays(days); setCustomReminder(false) }}
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
onDays={setReminderDays}
/>
)}
{canUploadCover && (
<div className="mt-2">
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => { void handleCoverFile(e.target.files?.[0]); e.target.value = '' }}
/>
{coverPreview ? (
<div className="relative h-[130px] overflow-hidden rounded-[16px]">
<img src={coverPreview} alt="" className="h-full w-full object-cover" />
<div className="absolute bottom-2 end-2 flex gap-[6px]">
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploadingCover}
className="flex h-[34px] items-center gap-1 rounded-full border border-white/30 bg-white/[.22] px-3 text-[0.6875rem] font-semibold text-white backdrop-blur-[8px]"
>
<Camera size={13} strokeWidth={2.1} />
{uploadingCover ? t('common.uploading') : t('common.change')}
</button>
<button
type="button"
onClick={removeCover}
aria-label={t('common.delete')}
className="flex h-[34px] w-[34px] items-center justify-center rounded-full border border-white/30 bg-white/[.22] text-white backdrop-blur-[8px]"
>
<X size={14} strokeWidth={2.1} />
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploadingCover}
className="flex w-full items-center justify-center gap-[6px] rounded-[14px] border border-dashed border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[18px] text-[0.8125rem] font-medium text-m-muted"
>
<Camera size={15} strokeWidth={2} />
{uploadingCover ? t('common.uploading') : t('dashboard.mobile.addCoverImage')}
</button>
)}
<div className="mt-2 flex gap-2">
<div className={`${boxCls} min-w-0 flex-1 py-[9px]`}>
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void handleSearch() } }}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
className={`${inputCls} pt-0 text-[0.8125rem] font-medium`}
/>
</div>
<button
type="button"
onClick={handleSearch}
disabled={searching || (!searchQuery.trim() && !title.trim())}
aria-label={t('dashboard.searchUnsplash')}
className="flex h-auto w-[42px] flex-none items-center justify-center rounded-[14px] bg-m-act text-m-actfg disabled:opacity-50"
>
<Search size={15} strokeWidth={2.2} />
</button>
</div>
{searchError && <p className="mt-[6px] px-1 text-[0.6875rem] font-medium text-[color:var(--m-st-danger)]">{searchError}</p>}
{searchResults.length > 0 && (
<div className="mt-2 grid grid-cols-3 gap-2">
{searchResults.map(photo => (
<button
key={photo.id}
type="button"
onClick={() => selectUnsplash(photo)}
aria-label={t('dashboard.useUnsplashPhoto', { photographer: photo.photographer || 'Unsplash' })}
className={`relative h-20 overflow-hidden rounded-[12px] border ${
coverPreview === photo.url ? 'border-[color:var(--m-act)]' : 'border-[color:var(--m-rowbr)]'
}`}
>
<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-[6px] py-1 text-start font-geist text-[0.625rem] text-white">
{photo.photographer}
</span>
)}
</button>
))}
</div>
)}
</div>
)}
{canUploadCover && <CoverField cover={cover} title={title} />}
{isEditing && onArchive && (
<div className="mt-2 rounded-[14px] bg-[color:var(--m-ic)]">
@@ -449,3 +328,149 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
</>
)
}
/** The trip's reminder: the presets, or a number of days of one's own; a hint while reminders are off. */
function ReminderField({ enabled, days, custom, onPreset, onCustom, onDays }: {
enabled: boolean
days: number
custom: boolean
onPreset: (days: number) => void
onCustom: () => void
onDays: (days: number) => void
}): React.ReactElement {
const { t } = useTranslation()
return (
<div className={`${BOX_CLS} mt-2`}>
<FieldLabel>
<span className="inline-flex items-center gap-1"><Bell size={10} strokeWidth={2.4} />{t('trips.reminder')}</span>
</FieldLabel>
{!enabled ? (
<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(preset => (
<MChip key={preset} pressable active={!custom && days === preset} onClick={() => onPreset(preset)}>
{preset === 0 ? t('trips.reminderNone') : `${preset} ${t(preset === 1 ? 'trips.reminderDay' : 'trips.reminderDays')}`}
</MChip>
))}
<MChip pressable active={custom} onClick={onCustom}>
{t('trips.reminderCustom')}
</MChip>
</div>
{custom && (
<div className="mt-2 flex items-center gap-2">
<input
type="number"
inputMode="numeric"
min={1}
max={30}
value={days}
onChange={e => onDays(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>
)
}
/** The cover: a photo from the device or from Unsplash, with its preview. */
function CoverField({ cover, title }: { cover: ReturnType<typeof useTripCoverPicker>; title: string }): React.ReactElement {
const { t } = useTranslation()
const fileRef = useRef<HTMLInputElement>(null)
const { coverPreview, uploadingCover, searchQuery, setSearchQuery, searchResults, searchError, searching } = cover
return (
<div className="mt-2">
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => { void cover.selectFile(e.target.files?.[0]); e.target.value = '' }}
/>
{coverPreview ? (
<div className="relative h-[130px] overflow-hidden rounded-[16px]">
<img src={coverPreview} alt="" className="h-full w-full object-cover" />
<div className="absolute bottom-2 end-2 flex gap-[6px]">
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploadingCover}
className="flex h-[34px] items-center gap-1 rounded-full border border-white/30 bg-white/[.22] px-3 text-[0.6875rem] font-semibold text-white backdrop-blur-[8px]"
>
<Camera size={13} strokeWidth={2.1} />
{uploadingCover ? t('common.uploading') : t('common.change')}
</button>
<button
type="button"
onClick={cover.removeCover}
aria-label={t('common.delete')}
className="flex h-[34px] w-[34px] items-center justify-center rounded-full border border-white/30 bg-white/[.22] text-white backdrop-blur-[8px]"
>
<X size={14} strokeWidth={2.1} />
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploadingCover}
className="flex w-full items-center justify-center gap-[6px] rounded-[14px] border border-dashed border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[18px] text-[0.8125rem] font-medium text-m-muted"
>
<Camera size={15} strokeWidth={2} />
{uploadingCover ? t('common.uploading') : t('dashboard.mobile.addCoverImage')}
</button>
)}
<div className="mt-2 flex gap-2">
<div className={`${BOX_CLS} min-w-0 flex-1 py-[9px]`}>
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void cover.search() } }}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
className={`${INPUT_CLS} pt-0 text-[0.8125rem] font-medium`}
/>
</div>
<button
type="button"
onClick={cover.search}
disabled={searching || (!searchQuery.trim() && !title.trim())}
aria-label={t('dashboard.searchUnsplash')}
className="flex h-auto w-[42px] flex-none items-center justify-center rounded-[14px] bg-m-act text-m-actfg disabled:opacity-50"
>
<Search size={15} strokeWidth={2.2} />
</button>
</div>
{searchError && <p className="mt-[6px] px-1 text-[0.6875rem] font-medium text-[color:var(--m-st-danger)]">{searchError}</p>}
{searchResults.length > 0 && (
<div className="mt-2 grid grid-cols-3 gap-2">
{searchResults.map(photo => (
<button
key={photo.id}
type="button"
onClick={() => cover.selectPhoto(photo)}
aria-label={t('dashboard.useUnsplashPhoto', { photographer: photo.photographer || 'Unsplash' })}
className={`relative h-20 overflow-hidden rounded-[12px] border ${
coverPreview === photo.url ? 'border-[color:var(--m-act)]' : 'border-[color:var(--m-rowbr)]'
}`}
>
<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-[6px] py-1 text-start font-geist text-[0.625rem] text-white">
{photo.photographer}
</span>
)}
</button>
))}
</div>
)}
</div>
)
}
@@ -41,7 +41,9 @@ const openSheet = vi.fn()
function translate(table: Record<string, unknown>) {
return (key: string, params?: Record<string, string | number>) => {
const value = table[key] ?? en[key]
// The one form is all a count of 1 needs here; the real t() picks it by Intl.PluralRules.
const one = params?.count === 1 ? table[`${key}.one`] : undefined
const value = one ?? table[key] ?? en[key]
if (typeof value !== 'string') return key
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(params?.[name] ?? ''))
}
@@ -115,9 +117,10 @@ describe('MToursSelectionList', () => {
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', () => {
it('says once that tours are planned on a bigger screen, and counts in German', () => {
render(<List planner={planner({ t: translate(de) })} shell={shell()} />)
expect(screen.getByText('Geplant wird am Desktop')).toBeInTheDocument()
// The badge reads the app's language, English in this suite.
expect(screen.getByText('Planned on desktop')).toBeInTheDocument()
expect(screen.getByText('1 Tour')).toBeInTheDocument()
expect(document.body.textContent).not.toMatch(/\b(tours|mobileTrip)\.[a-zA-Z]/)
})
@@ -126,17 +129,17 @@ describe('MToursSelectionList', () => {
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()
expect(screen.getByText('Planned on 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/ }))
fireEvent.click(screen.getByRole('button', { name: /^T4 \d+$/ }))
expect(screen.getByText('Other')).toBeInTheDocument()
expect(screen.queryByText(tour.name)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^T4/ }))
fireEvent.click(screen.getByRole('button', { name: /^T4 \d+$/ }))
expect(screen.getByText(tour.name)).toBeInTheDocument()
})
})
@@ -35,7 +35,7 @@ interface MTripSettingsSheetProps {
open: boolean;
onClose: () => void;
tripId: number;
tripTitle: string;
tripTitle?: string;
onMembersChanged?: () => void;
}
@@ -214,6 +214,215 @@ function InviteLinkSection({ tripId }: { tripId: number }) {
);
}
type Members = ReturnType<typeof useTripMembers>;
type Translate = ReturnType<typeof useTranslation>['t'];
type Ask = (question: Question) => void;
/** What a question says and what its button does, by the kind of question. */
function questionWords(question: Question | null, t: Translate): { text: string; action: string } {
if (!question) return { text: '', action: '' };
if (question.kind === 'transfer')
return { text: t('members.confirmTransfer', { name: question.name }), action: t('members.makeOwner') };
if (question.kind === 'guest') return { text: t('members.confirmRemoveGuest'), action: t('members.removeAccess') };
return question.self
? { text: t('members.confirmLeave'), action: t('members.leaveTrip') }
: { text: t('members.confirmRemove'), action: t('members.removeAccess') };
}
/** Make owner and remove (or leave) beside a member, as far as the reader may. */
function MemberActions({ member, m, ask }: { member: ListedMember; m: Members; ask: Ask }) {
const { t } = useTranslation();
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={() => ask({ 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={() => ask({ kind: 'remove', id: member.id, self })}
className={ROUND}
>
{self ? <LogOut size={14} strokeWidth={2} /> : <UserMinus size={14} strokeWidth={2} />}
</button>
)}
</>
);
}
/** The badges beside a member's name: you, and the owner's crown. */
function MemberBadges({ member, m }: { member: ListedMember; m: Members }) {
const { t } = useTranslation();
return (
<>
{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>
)}
</>
);
}
/** Who has access: the invite picker for those who may add people, then everyone with their badges. */
function AccessSection({ m, ask }: { m: Members; ask: Ask }) {
const { t } = useTranslation();
return (
<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 ?? null}
badges={<MemberBadges member={member} m={m} />}
>
<MemberActions member={member} m={m} ask={ask} />
</PersonRow>
))
)}
</Section>
);
}
/** A guest's row, or the field its new name is typed into. */
function GuestRow({ guest, m, ask }: { guest: Members['guests'][number]; m: Members; ask: Ask }) {
const { t } = useTranslation();
if (m.renamingGuestId === guest.id) {
return (
<input
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}
/>
);
}
return (
<PersonRow
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={() => ask({ kind: 'guest', id: guest.id })}
className={ROUND}
>
<Trash2 size={13} strokeWidth={2} />
</button>
</>
)}
</PersonRow>
);
}
/** The guests without an account, and for the owner the field that adds one. */
function GuestsSection({ m, ask }: { m: Members; ask: Ask }) {
const { t } = useTranslation();
if (!m.isCurrentOwner && m.guests.length === 0) return null;
return (
<Section
title={m.guests.length > 0 ? `${t('members.guests')} ${m.guests.length}` : t('members.guests')}
hint={t('members.guestsHint')}
>
{m.guests.map((guest) => (
<GuestRow key={guest.id} guest={guest} m={m} ask={ask} />
))}
{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>
);
}
/**
* 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
@@ -230,6 +439,7 @@ export default function MTripSettingsSheet({
const { t } = useTranslation();
const m = useTripMembers({ isOpen: open, tripId, onClose, onMembersChanged });
const [question, setQuestion] = useState<Question | null>(null);
const words = questionWords(question, t);
const answer = () => {
if (!question) return;
@@ -238,52 +448,6 @@ export default function MTripSettingsSheet({
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 (
<>
@@ -299,148 +463,8 @@ export default function MTripSettingsSheet({
</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>
)}
<AccessSection m={m} ask={setQuestion} />
<GuestsSection m={m} ask={setQuestion} />
{m.canManageShare && <ShareLinkSection tripId={tripId} />}
{m.canManageShare && <InviteLinkSection tripId={tripId} />}
<TripJournalLinkSection tripId={tripId} />
@@ -450,9 +474,9 @@ export default function MTripSettingsSheet({
<MConfirmSheet
open={question != null}
onClose={() => setQuestion(null)}
title={questionAction}
message={questionText}
confirmLabel={questionAction}
title={words.action}
message={words.text}
confirmLabel={words.action}
cancelLabel={t('common.cancel')}
danger={question?.kind !== 'transfer'}
onConfirm={answer}
@@ -200,7 +200,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
{/* Trip edit + share/members, opened from the Mehr sheet. */}
<MNewTripSheet
open={sheet?.id === 'tripedit'}
trip={trip ?? null}
trip={trip}
onClose={shell.closeSheet}
onSave={async (data) => {
await tripActions.updateTrip(tripId, data)
@@ -212,7 +212,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
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, allLayers: isMobile,
tripDates, sidebarMapItems, photoCount: mapPhotos.length, showTripTracks,
})
/** Studio's margin to the window on all four sides — see `.st-root` in studio.css. */
@@ -5,6 +5,7 @@ import { useJourneyMapLayers } from '../../components/Journey/useJourneyMapLayer
import { useJourneyRecordingPrep } from '../../components/Journey/useJourneyRecordingPrep';
import type { useDawarichJournalTrail } from '../../hooks/useDawarichJournalTrail';
import type { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useAddonStore } from '../../store/addonStore';
import type { JourneyDetail } from '../../store/journeyStore';
import { useJourneyDetailDayImport } from './useJourneyDetailDayImport';
@@ -30,7 +31,6 @@ 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. */
@@ -44,8 +44,6 @@ 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);
@@ -114,7 +112,7 @@ export function useJourneyDetailLayers({
recordedRead,
colorOfDay,
dateOfEntry,
allLayers,
allLayers: useIsMobile(),
});
// The drafts a day's take-over wrote reach the timeline, and its stays leave the pending list.