mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +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:
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user