|
|
|
@@ -1,5 +1,5 @@
|
|
|
|
|
import React, { useEffect, useRef, useState } from 'react'
|
|
|
|
|
import { Archive, ArchiveRestore, Bell, Camera, Search, X } from 'lucide-react'
|
|
|
|
|
import { Archive, ArchiveRestore, Bell, Camera, Luggage, Search, X } from 'lucide-react'
|
|
|
|
|
import { useTranslation } from '../../../i18n'
|
|
|
|
|
import { useCanDo } from '../../../store/permissionsStore'
|
|
|
|
|
import { useSettingsStore } from '../../../store/settingsStore'
|
|
|
|
@@ -11,8 +11,8 @@ import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.
|
|
|
|
|
import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel'
|
|
|
|
|
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'
|
|
|
|
|
import { FIELD_AREA_CLS } from '../trip/sheets/PlSheetChrome'
|
|
|
|
|
import { Eyebrow, SHEET_FIELD, SHEET_PRIMARY, SHEET_ROUND_PRIMARY, SHEET_SECONDARY, SheetSection, TileHeader } from '../trip/sheets/MTripSheetUi'
|
|
|
|
|
import MDayImpactList from '../../components/MDayImpactList'
|
|
|
|
|
import MConfirmSheet from '../settings/MConfirmSheet'
|
|
|
|
|
import { useTripRangeGuard, type RangeRemoval } from '../../../hooks/useTripRangeGuard'
|
|
|
|
@@ -31,17 +31,12 @@ interface MNewTripSheetProps {
|
|
|
|
|
onArchive?: () => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BOX_CLS = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]'
|
|
|
|
|
const INPUT_CLS = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint'
|
|
|
|
|
/** A date picker in the sheets' round field. */
|
|
|
|
|
const DATE_FIELD = 'flex h-[42px] items-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px]'
|
|
|
|
|
|
|
|
|
|
/** 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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create/edit trip sheet — the mobile counterpart of TripFormModal's core flow:
|
|
|
|
@@ -151,79 +146,78 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const boxCls = BOX_CLS
|
|
|
|
|
const inputCls = INPUT_CLS
|
|
|
|
|
|
|
|
|
|
const removalList = pendingRemoval && pendingRemoval.removal !== 'unknown' ? pendingRemoval.removal : null
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
|
|
|
|
|
<div className="flex items-center gap-[11px] p-[16px_16px_0]">
|
|
|
|
|
<div className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold">
|
|
|
|
|
{isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
|
|
|
|
|
</div>
|
|
|
|
|
<MIconBtn ariaLabel={t('common.cancel')} variant="neutral" size={34} onClick={onClose}>
|
|
|
|
|
<X size={16} strokeWidth={2.2} />
|
|
|
|
|
</MIconBtn>
|
|
|
|
|
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
|
|
|
|
|
<div className="flex-none px-[18px] pt-4">
|
|
|
|
|
<TileHeader
|
|
|
|
|
icon={<Luggage size={19} strokeWidth={1.8} />}
|
|
|
|
|
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
|
|
|
|
|
sub={isEditing ? trip?.title : undefined}
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
closeLabel={t('common.cancel')}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
|
|
|
|
<div className="m-scroll-fade mt-2 flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="mb-3 rounded-[14px] bg-[color:var(--m-ic)] p-[11px_12px] text-[0.75rem] font-semibold text-[color:var(--m-st-danger)]">
|
|
|
|
|
<div className="flex-none rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-caption font-semibold text-[color:var(--m-st-danger)]">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className={boxCls}>
|
|
|
|
|
<FieldLabel>{t('dashboard.tripTitle')}</FieldLabel>
|
|
|
|
|
<SheetSection title={t('dashboard.tripTitle')}>
|
|
|
|
|
<input
|
|
|
|
|
value={title}
|
|
|
|
|
onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }}
|
|
|
|
|
readOnly={!canEditTrip}
|
|
|
|
|
placeholder={t('dashboard.tripTitlePlaceholder')}
|
|
|
|
|
className={inputCls}
|
|
|
|
|
aria-label={t('dashboard.tripTitle')}
|
|
|
|
|
className={`${SHEET_FIELD} w-full flex-none font-semibold`}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={`${boxCls} mt-2`}>
|
|
|
|
|
<FieldLabel>{t('dashboard.tripDescription')}</FieldLabel>
|
|
|
|
|
<Eyebrow className="mt-1 uppercase">{t('dashboard.tripDescription')}</Eyebrow>
|
|
|
|
|
<textarea
|
|
|
|
|
value={description}
|
|
|
|
|
onChange={e => { if (canEditTrip) { setError(''); setDescription(e.target.value) } }}
|
|
|
|
|
readOnly={!canEditTrip}
|
|
|
|
|
placeholder={t('dashboard.tripDescriptionPlaceholder')}
|
|
|
|
|
aria-label={t('dashboard.tripDescription')}
|
|
|
|
|
rows={2}
|
|
|
|
|
className={`${inputCls} resize-none text-[0.8125rem] font-medium`}
|
|
|
|
|
className={FIELD_AREA_CLS}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</SheetSection>
|
|
|
|
|
|
|
|
|
|
<div className="mt-2 flex gap-2">
|
|
|
|
|
<div className={`${boxCls} min-w-0 flex-1`}>
|
|
|
|
|
<FieldLabel>{t('dashboard.startDate')}</FieldLabel>
|
|
|
|
|
<CustomDatePicker
|
|
|
|
|
value={startDate}
|
|
|
|
|
onChange={v => { if (canEditTrip) changeStart(v) }}
|
|
|
|
|
placeholder={t('dashboard.startDate')}
|
|
|
|
|
borderless
|
|
|
|
|
style={{ marginTop: 3 }}
|
|
|
|
|
/>
|
|
|
|
|
<SheetSection>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.startDate')}</Eyebrow>
|
|
|
|
|
<div className={DATE_FIELD}>
|
|
|
|
|
<CustomDatePicker
|
|
|
|
|
value={startDate}
|
|
|
|
|
onChange={v => { if (canEditTrip) changeStart(v) }}
|
|
|
|
|
placeholder={t('dashboard.startDate')}
|
|
|
|
|
borderless
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.endDate')}</Eyebrow>
|
|
|
|
|
<div className={DATE_FIELD}>
|
|
|
|
|
<CustomDatePicker
|
|
|
|
|
value={endDate}
|
|
|
|
|
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
|
|
|
|
|
placeholder={t('dashboard.endDate')}
|
|
|
|
|
borderless
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={`${boxCls} min-w-0 flex-1`}>
|
|
|
|
|
<FieldLabel>{t('dashboard.endDate')}</FieldLabel>
|
|
|
|
|
<CustomDatePicker
|
|
|
|
|
value={endDate}
|
|
|
|
|
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
|
|
|
|
|
placeholder={t('dashboard.endDate')}
|
|
|
|
|
borderless
|
|
|
|
|
style={{ marginTop: 3 }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{!startDate && !endDate && (
|
|
|
|
|
<>
|
|
|
|
|
<div className={`${boxCls} mt-2`}>
|
|
|
|
|
<FieldLabel>{t('dashboard.dayCount')}</FieldLabel>
|
|
|
|
|
{!startDate && !endDate && (
|
|
|
|
|
<>
|
|
|
|
|
<Eyebrow className="mt-1 uppercase">{t('dashboard.dayCount')}</Eyebrow>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
inputMode="numeric"
|
|
|
|
@@ -237,15 +231,15 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
|
|
|
|
|
setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1)))
|
|
|
|
|
}}
|
|
|
|
|
readOnly={!canEditTrip}
|
|
|
|
|
className={inputCls}
|
|
|
|
|
aria-label={t('dashboard.dayCount')}
|
|
|
|
|
className={`${SHEET_FIELD} w-full flex-none`}
|
|
|
|
|
/>
|
|
|
|
|
</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>}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{!isEditing && <p className="px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</p>}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</SheetSection>
|
|
|
|
|
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
<FieldLabel>{t('dashboard.currency')}</FieldLabel>
|
|
|
|
|
<SheetSection title={t('dashboard.currency')}>
|
|
|
|
|
<CustomSelect
|
|
|
|
|
value={currency}
|
|
|
|
|
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
|
|
|
|
@@ -253,48 +247,46 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
|
|
|
|
|
searchable
|
|
|
|
|
size="sm"
|
|
|
|
|
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
|
|
|
|
style={{ width: '100%', marginTop: 5 }}
|
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</SheetSection>
|
|
|
|
|
|
|
|
|
|
{showReminder && (
|
|
|
|
|
<ReminderField
|
|
|
|
|
enabled={remindersEnabled}
|
|
|
|
|
days={reminderDays}
|
|
|
|
|
custom={customReminder}
|
|
|
|
|
onPreset={days => { setError(''); setReminderDays(days); setCustomReminder(false) }}
|
|
|
|
|
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
|
|
|
|
|
onDays={setReminderDays}
|
|
|
|
|
/>
|
|
|
|
|
<SheetSection>
|
|
|
|
|
<ReminderField
|
|
|
|
|
enabled={remindersEnabled}
|
|
|
|
|
days={reminderDays}
|
|
|
|
|
custom={customReminder}
|
|
|
|
|
onPreset={days => { setError(''); setReminderDays(days); setCustomReminder(false) }}
|
|
|
|
|
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
|
|
|
|
|
onDays={setReminderDays}
|
|
|
|
|
/>
|
|
|
|
|
</SheetSection>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{canUploadCover && <CoverField cover={cover} title={title} />}
|
|
|
|
|
{canUploadCover && (
|
|
|
|
|
<SheetSection>
|
|
|
|
|
<CoverField cover={cover} title={title} />
|
|
|
|
|
</SheetSection>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{isEditing && onArchive && (
|
|
|
|
|
<div className="mt-2 rounded-[14px] bg-[color:var(--m-ic)]">
|
|
|
|
|
<MListRow
|
|
|
|
|
icon={trip?.is_archived ? ArchiveRestore : Archive}
|
|
|
|
|
label={trip?.is_archived ? t('dashboard.restore') : t('dashboard.archive')}
|
|
|
|
|
onClick={() => { onArchive(); onClose() }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => { onArchive(); onClose() }}
|
|
|
|
|
className={`${SHEET_SECONDARY} flex-none`}
|
|
|
|
|
>
|
|
|
|
|
{trip?.is_archived ? <ArchiveRestore size={14} strokeWidth={2.2} /> : <Archive size={14} strokeWidth={2.2} />}
|
|
|
|
|
{trip?.is_archived ? t('dashboard.restore') : t('dashboard.archive')}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-2 p-[0_16px_16px]">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
className="flex-1 rounded-full bg-[color:var(--m-ic)] py-[10px] text-[0.8125rem] font-semibold text-m-ink"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
|
|
|
|
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} ms-auto`}>
|
|
|
|
|
{t('common.cancel')}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handleSave}
|
|
|
|
|
disabled={busy}
|
|
|
|
|
className="flex-1 rounded-full bg-m-act py-[10px] text-[0.8125rem] font-semibold text-m-actfg disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<button type="button" onClick={handleSave} disabled={busy} className={SHEET_PRIMARY}>
|
|
|
|
|
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
@@ -340,10 +332,10 @@ function ReminderField({ enabled, days, custom, onPreset, onCustom, onDays }: {
|
|
|
|
|
}): React.ReactElement {
|
|
|
|
|
const { t } = useTranslation()
|
|
|
|
|
return (
|
|
|
|
|
<div className={`${BOX_CLS} mt-2`}>
|
|
|
|
|
<FieldLabel>
|
|
|
|
|
<div>
|
|
|
|
|
<Eyebrow className="uppercase">
|
|
|
|
|
<span className="inline-flex items-center gap-1"><Bell size={10} strokeWidth={2.4} />{t('trips.reminder')}</span>
|
|
|
|
|
</FieldLabel>
|
|
|
|
|
</Eyebrow>
|
|
|
|
|
{!enabled ? (
|
|
|
|
|
<p className="mt-1 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] leading-snug text-m-muted">{t('trips.reminderDisabledHint')}</p>
|
|
|
|
|
) : (
|
|
|
|
@@ -429,21 +421,20 @@ function CoverField({ cover, title }: { cover: ReturnType<typeof useTripCoverPic
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="mt-2 flex gap-2">
|
|
|
|
|
<div className={`${BOX_CLS} min-w-0 flex-1 py-[9px]`}>
|
|
|
|
|
<input
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
|
|
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void cover.search() } }}
|
|
|
|
|
placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
|
|
|
|
className={`${INPUT_CLS} pt-0 text-[0.8125rem] font-medium`}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<input
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
|
|
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void cover.search() } }}
|
|
|
|
|
placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
|
|
|
|
aria-label={t('dashboard.unsplashSearchPlaceholder')}
|
|
|
|
|
className={SHEET_FIELD}
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={cover.search}
|
|
|
|
|
disabled={searching || (!searchQuery.trim() && !title.trim())}
|
|
|
|
|
aria-label={t('dashboard.searchUnsplash')}
|
|
|
|
|
className="flex h-auto w-[42px] flex-none items-center justify-center rounded-[14px] bg-m-act text-m-actfg disabled:opacity-50"
|
|
|
|
|
className={SHEET_ROUND_PRIMARY}
|
|
|
|
|
>
|
|
|
|
|
<Search size={15} strokeWidth={2.2} />
|
|
|
|
|
</button>
|
|
|
|
|