mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
fix(mobile): forms on the shared form sheet, and the details the review found
Expense, payment, booking, transport, day note, place edit, new trip, journey entry, collab note, poll, task, packing item and packing import use the form sheet: one header with its icon tile, a body that scrolls with a fade, the footer, and full height with the actions beside the title on a short window. Create New Trip loses its nested section cards. Details: - Add payment gets its icon tile and the currency inside the amount field. - The expense's category field has the same fill as every other field. - Day and date fields keep their year at phone width; From and To stack in a narrow form. - The place sheet's category chip sits under the name, uncut, the photo on the cards' leading edge, labels in the sheets' small capitals. - Stars no longer keep a hover preview after a tap. - A hotel booking shows its stay's check-in and check-out when it has none of its own, instead of the grey hint reading as a saved time. - The collab note's title field is labelled Title (common.title). - Shorter airport hint in German and Russian; long hints end in an ellipsis. - The journey story grows with its lines (useAutoGrow). - Member avatars show the member's initial, yours too, and turn over on a selected chip. - The trip's More sheet scrolls, dims a short window and is marked in the dock while open; long titles wrap to two lines.
This commit is contained in:
@@ -112,6 +112,46 @@ describe('reservation form fields', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESFORM-017: a hotel booking without its own times shows the ones its stay keeps', () => {
|
||||
// The stay's check-in and check-out live on the accommodation; a booking that
|
||||
// never had them written into its own metadata showed the "15:00" hint as if
|
||||
// it were the saved time.
|
||||
const acc = {
|
||||
id: 9,
|
||||
place_id: 4,
|
||||
start_day_id: 2,
|
||||
end_day_id: 3,
|
||||
check_in: '15:00',
|
||||
check_in_end: '22:00',
|
||||
check_out: '11:00',
|
||||
} as Accommodation;
|
||||
const res = buildReservation({
|
||||
type: 'hotel',
|
||||
accommodation_id: 9,
|
||||
metadata: JSON.stringify({ price: '2184' }),
|
||||
} as Partial<Reservation>);
|
||||
expect(reservationFieldsFrom(res, [acc], [], false)).toMatchObject({
|
||||
meta_check_in_time: '15:00',
|
||||
meta_check_out_time: '11:00',
|
||||
});
|
||||
expect(reservationFieldsFrom(res, [acc], [], true)).toMatchObject({ meta_check_in_end_time: '22:00' });
|
||||
// The booking's own times still win.
|
||||
const own = buildReservation({
|
||||
type: 'hotel',
|
||||
accommodation_id: 9,
|
||||
metadata: JSON.stringify({ check_in_time: '14:00', check_out_time: '10:00' }),
|
||||
} as Partial<Reservation>);
|
||||
expect(reservationFieldsFrom(own, [acc], [], false)).toMatchObject({
|
||||
meta_check_in_time: '14:00',
|
||||
meta_check_out_time: '10:00',
|
||||
});
|
||||
// Nothing anywhere stays empty, so the hint shows as a hint.
|
||||
expect(reservationFieldsFrom(buildReservation({ type: 'hotel' }), [], [], false)).toMatchObject({
|
||||
meta_check_in_time: '',
|
||||
meta_check_out_time: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESFORM-004: an import under review resolves its stay days and keeps its source details', () => {
|
||||
const pf = {
|
||||
title: 'Inn',
|
||||
|
||||
@@ -97,9 +97,13 @@ export function reservationFieldsFrom(
|
||||
url: res.url || '',
|
||||
place_id: res.place_id || '',
|
||||
accommodation_id: res.accommodation_id || '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
...(withCheckInEnd ? { meta_check_in_end_time: meta.check_in_end_time || '' } : {}),
|
||||
meta_check_out_time: meta.check_out_time || '',
|
||||
// The stay's own times when the booking carries none: a hotel booked before
|
||||
// the times were written on it, or whose stay was edited from the plan,
|
||||
// keeps them on the accommodation, and the empty field showed the
|
||||
// "15:00" hint as if it were the saved check-in.
|
||||
meta_check_in_time: meta.check_in_time || acc?.check_in || '',
|
||||
...(withCheckInEnd ? { meta_check_in_end_time: meta.check_in_end_time || acc?.check_in_end || '' } : {}),
|
||||
meta_check_out_time: meta.check_out_time || acc?.check_out || '',
|
||||
hotel_place_id: acc?.place_id || '',
|
||||
hotel_start_day: acc?.start_day_id || '',
|
||||
hotel_end_day: acc?.end_day_id || '',
|
||||
|
||||
@@ -70,13 +70,23 @@ describe('Stars', () => {
|
||||
const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length
|
||||
|
||||
expect(filled()).toBe(1)
|
||||
fireEvent.mouseEnter(screen.getByRole('radio', { name: '4' }))
|
||||
fireEvent.pointerEnter(screen.getByRole('radio', { name: '4' }), { pointerType: 'mouse' })
|
||||
expect(filled()).toBe(4)
|
||||
|
||||
fireEvent.mouseLeave(container.querySelector('[role="radiogroup"]')!)
|
||||
fireEvent.pointerLeave(container.querySelector('[role="radiogroup"]')!, { pointerType: 'mouse' })
|
||||
expect(filled()).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-W4STAR-016: a finger previews nothing, so a tap cannot leave the stars filled', () => {
|
||||
// A tap fires an enter with no leave after it: the preview used to stick,
|
||||
// and an unrated place showed five full stars beside "Not rated yet".
|
||||
const { container } = render(<Stars value={0} onRate={() => {}} />)
|
||||
const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length
|
||||
|
||||
fireEvent.pointerEnter(screen.getByRole('radio', { name: '5' }), { pointerType: 'touch' })
|
||||
expect(filled()).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-W4STAR-008: focus previews and blur clears the preview', () => {
|
||||
const { container } = render(<Stars value={0} onRate={() => {}} />)
|
||||
const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Stars({ value, size = 15, onRate, myRating = null, ariaLabel }:
|
||||
role={onRate ? 'radiogroup' : undefined}
|
||||
aria-label={ariaLabel}
|
||||
style={{ display: 'inline-flex', gap: 2, cursor: onRate ? 'pointer' : 'default' }}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map(i => {
|
||||
// Fractional fill for averages: full below, empty above, clipped between.
|
||||
@@ -52,7 +52,11 @@ export function Stars({ value, size = 15, onRate, myRating = null, ariaLabel }:
|
||||
role="radio"
|
||||
aria-checked={myRating === i}
|
||||
aria-label={`${i}`}
|
||||
onMouseEnter={() => setHover(i)}
|
||||
// A preview for a pointer that hovers (a mouse, a trackpad, a pen held
|
||||
// above the screen). A finger has no hover: the enter it fires on a
|
||||
// tap has no leave after it, so the preview stuck and an unrated
|
||||
// place showed five full stars beside "Not rated yet".
|
||||
onPointerEnter={e => { if (e.pointerType !== 'touch') setHover(i) }}
|
||||
onFocus={() => setHover(i)}
|
||||
onBlur={() => setHover(null)}
|
||||
onClick={() => onRate(myRating === i ? null : i)}
|
||||
|
||||
@@ -5,14 +5,13 @@ 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 { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared'
|
||||
import MSheet from '../../components/MSheet'
|
||||
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 { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader, SheetDateField } from '../trip/sheets/PlSheetChrome'
|
||||
import { SHEET_FIELD, SHEET_ROUND_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
|
||||
import MDayImpactList from '../../components/MDayImpactList'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
import { useTripRangeGuard, type RangeRemoval } from '../../../hooks/useTripRangeGuard'
|
||||
@@ -31,9 +30,6 @@ interface MNewTripSheetProps {
|
||||
onArchive?: () => void
|
||||
}
|
||||
|
||||
/** 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]
|
||||
|
||||
@@ -151,108 +147,100 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<MSheet open={open} onClose={onClose} form ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
|
||||
<FormSheetHeader
|
||||
icon={Luggage}
|
||||
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
|
||||
subtitle={isEditing ? trip?.title : undefined}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
/>
|
||||
|
||||
<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]">
|
||||
{/* Flat fields under their labels, as in every other form sheet: no
|
||||
cards inside the card. */}
|
||||
<FormSheetBody>
|
||||
{error && (
|
||||
<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)]">
|
||||
<div className="mt-2 rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-caption font-semibold text-[color:var(--m-st-danger)]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SheetSection title={t('dashboard.tripTitle')}>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }}
|
||||
readOnly={!canEditTrip}
|
||||
placeholder={t('dashboard.tripTitlePlaceholder')}
|
||||
aria-label={t('dashboard.tripTitle')}
|
||||
className={`${SHEET_FIELD} w-full flex-none font-semibold`}
|
||||
/>
|
||||
<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={FIELD_AREA_CLS}
|
||||
/>
|
||||
</SheetSection>
|
||||
<Eyebrow className="mb-[5px] mt-2 uppercase">{t('dashboard.tripTitle')}</Eyebrow>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }}
|
||||
readOnly={!canEditTrip}
|
||||
placeholder={t('dashboard.tripTitlePlaceholder')}
|
||||
aria-label={t('dashboard.tripTitle')}
|
||||
className={`${FIELD_CLS} font-semibold`}
|
||||
/>
|
||||
<Eyebrow className="mb-[5px] mt-3 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={FIELD_AREA_CLS}
|
||||
/>
|
||||
|
||||
<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>
|
||||
{/* Side by side. A placeholder longer than its field ("Дата окончания")
|
||||
ends in an ellipsis inside it rather than running past its edge. */}
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[5px] truncate uppercase">{t('dashboard.startDate')}</Eyebrow>
|
||||
<SheetDateField
|
||||
value={startDate}
|
||||
onChange={v => { if (canEditTrip) changeStart(v) }}
|
||||
placeholder={t('dashboard.startDate')}
|
||||
/>
|
||||
</div>
|
||||
{!startDate && !endDate && (
|
||||
<>
|
||||
<Eyebrow className="mt-1 uppercase">{t('dashboard.dayCount')}</Eyebrow>
|
||||
<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}
|
||||
aria-label={t('dashboard.dayCount')}
|
||||
className={`${SHEET_FIELD} w-full flex-none`}
|
||||
/>
|
||||
{!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="min-w-0">
|
||||
<Eyebrow className="mb-[5px] truncate uppercase">{t('dashboard.endDate')}</Eyebrow>
|
||||
<SheetDateField
|
||||
value={endDate}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
|
||||
placeholder={t('dashboard.endDate')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!startDate && !endDate && (
|
||||
<>
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('dashboard.dayCount')}</Eyebrow>
|
||||
<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}
|
||||
aria-label={t('dashboard.dayCount')}
|
||||
className={FIELD_CLS}
|
||||
/>
|
||||
{!isEditing && <p className="mt-[6px] px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SheetSection title={t('dashboard.currency')}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
|
||||
disabled={!canEditTrip}
|
||||
searchable
|
||||
size="sm"
|
||||
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</SheetSection>
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('dashboard.currency')}</Eyebrow>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
|
||||
disabled={!canEditTrip}
|
||||
searchable
|
||||
size="sm"
|
||||
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
{showReminder && (
|
||||
<SheetSection>
|
||||
<div className="mt-3">
|
||||
<ReminderField
|
||||
enabled={remindersEnabled}
|
||||
days={reminderDays}
|
||||
@@ -261,35 +249,35 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
|
||||
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
|
||||
onDays={setReminderDays}
|
||||
/>
|
||||
</SheetSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUploadCover && (
|
||||
<SheetSection>
|
||||
<>
|
||||
<Eyebrow className="mt-3 uppercase">{t('dashboard.coverImage')}</Eyebrow>
|
||||
<CoverField cover={cover} title={title} />
|
||||
</SheetSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEditing && onArchive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onArchive(); onClose() }}
|
||||
className={`${SHEET_SECONDARY} flex-none`}
|
||||
className={`${SHEET_SECONDARY} mt-4 w-full`}
|
||||
>
|
||||
{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>
|
||||
</FormSheetBody>
|
||||
|
||||
<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={SHEET_PRIMARY}>
|
||||
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
|
||||
</button>
|
||||
</div>
|
||||
<FormSheetFooter
|
||||
onCancel={onClose}
|
||||
cancelLabel={t('common.cancel')}
|
||||
onSubmit={handleSave}
|
||||
submitLabel={busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
|
||||
submitDisabled={busy}
|
||||
/>
|
||||
</MSheet>
|
||||
|
||||
{/* After the sheet, so it opens on top of it. */}
|
||||
|
||||
@@ -4,9 +4,10 @@ import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import { SHEET_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
|
||||
import { FormSheetBody, SheetDateField } from '../trip/sheets/PlSheetChrome'
|
||||
import { useAutoGrow } from '../../../hooks/useAutoGrow'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import CustomTimePicker from '../../../components/shared/CustomTimePicker'
|
||||
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
|
||||
import { isVideoFile } from '../../../utils/videoPoster'
|
||||
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
|
||||
@@ -108,6 +109,9 @@ export default function MJourneyEntrySheet({
|
||||
const [captureOnly, setCaptureOnly] = useState(quickCapture)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const cameraRef = useRef<HTMLInputElement>(null)
|
||||
// The story grows with its lines instead of slicing the fourth one in half.
|
||||
const storyRef = useRef<HTMLTextAreaElement>(null)
|
||||
useAutoGrow(storyRef, story)
|
||||
|
||||
const activeProvider = externalProvider || providers[0]?.id || null
|
||||
const providerExistingAssetIds = providerAssetIds(activeProvider)
|
||||
@@ -125,15 +129,15 @@ export default function MJourneyEntrySheet({
|
||||
open
|
||||
onClose={handleClose}
|
||||
variant="card"
|
||||
material="glass"
|
||||
form
|
||||
ariaLabel={entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
|
||||
>
|
||||
{/* TileHeader's shape, with the move buttons between the title and the close. */}
|
||||
<div className="flex flex-none items-center gap-3 px-[18px] pb-1 pt-4">
|
||||
{/* FormSheetHeader's shape, with the move buttons between the title and the close. */}
|
||||
<div className="m-fs-head flex flex-none items-center gap-3 px-[18px] pb-2 pt-4">
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<NotebookPen size={19} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold leading-tight">
|
||||
<span className="line-clamp-2 min-w-0 flex-1 break-words text-[1.0625rem] font-bold leading-tight">
|
||||
{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
|
||||
</span>
|
||||
{(onMoveEarlier || onMoveLater) && (
|
||||
@@ -146,12 +150,12 @@ export default function MJourneyEntrySheet({
|
||||
</MIconBtn>
|
||||
</span>
|
||||
)}
|
||||
<MIconBtn variant="neutral" size={34} onClick={handleClose} ariaLabel={t('common.cancel')}>
|
||||
<MIconBtn variant="neutral" size={34} onClick={handleClose} ariaLabel={t('common.cancel')} className="m-fs-close">
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
<FormSheetBody>
|
||||
{/* Quick capture had no title field at all: it asked for a note and nothing
|
||||
else, so every entry caught on the move arrived nameless and the day's
|
||||
list read as a column of identical placeholders (discussion #2299). The
|
||||
@@ -416,6 +420,7 @@ export default function MJourneyEntrySheet({
|
||||
)
|
||||
) : (
|
||||
<textarea
|
||||
ref={storyRef}
|
||||
rows={3}
|
||||
value={story}
|
||||
onChange={e => setStory(e.target.value)}
|
||||
@@ -512,7 +517,7 @@ export default function MJourneyEntrySheet({
|
||||
{/* TREK's own picker, like the time field beside it: a native
|
||||
`<input type="date">` paints itself from the OS locale and takes
|
||||
no theme, so the two sat in one row looking like two apps. */}
|
||||
<CustomDatePicker value={entryDate} onChange={setEntryDate} disabled={readOnly} />
|
||||
<SheetDateField value={entryDate} onChange={setEntryDate} disabled={readOnly} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className={`${eyebrow} mb-[5px]`}>{t('mobileJourney.time')}</div>
|
||||
@@ -693,27 +698,26 @@ export default function MJourneyEntrySheet({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* A suggestion is not deleted, it is put down: the row survives so the trip
|
||||
sync does not offer the same place again (discussion #2299).
|
||||
|
||||
{/* A suggestion is not deleted, it is put down: the row survives so the trip
|
||||
sync does not offer the same place again (discussion #2299).
|
||||
Its own row at the foot of the fields rather than among the buttons: the
|
||||
sentence is longer than a button label and wrapped into two lines between
|
||||
Delete and Cancel. Full width, it reads as what it is, an alternative to
|
||||
saving this suggestion rather than a fourth thing competing with them. */}
|
||||
{!readOnly && onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="mt-4 flex w-full items-center gap-[9px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[13px] py-[10px] text-start"
|
||||
>
|
||||
<EyeOff size={15} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span className="min-w-0 flex-1 text-[0.8125rem] font-semibold">{t('journey.suggestions.dismiss')}</span>
|
||||
</button>
|
||||
)}
|
||||
</FormSheetBody>
|
||||
|
||||
Its own row above the buttons rather than among them: the sentence is
|
||||
longer than a button label and wrapped into two lines between Delete and
|
||||
Cancel. Full width, it reads as what it is, an alternative to saving this
|
||||
suggestion rather than a fourth thing competing with them. */}
|
||||
{!readOnly && onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="mx-[18px] mt-1 mb-[10px] flex flex-none items-center gap-[9px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[13px] py-[10px] text-start"
|
||||
>
|
||||
<EyeOff size={15} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span className="min-w-0 flex-1 text-[0.8125rem] font-semibold">{t('journey.suggestions.dismiss')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<div className="m-fs-foot flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
{!readOnly && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -46,7 +46,9 @@ export default function MNotifications() {
|
||||
<ArrowLeft size={18} strokeWidth={2} />
|
||||
</MIconBtn>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h1 className="truncate text-[0.9375rem] font-bold text-m-ink">{t('notifications.title')}</h1>
|
||||
{/* Two lines, hyphenated, before an ellipsis: "Benachrichtigungen" was
|
||||
cut to "Benachrichtigu…" beside the badge and two buttons. */}
|
||||
<h1 className="line-clamp-2 min-w-0 break-words text-[0.9375rem] font-bold leading-tight text-m-ink [hyphens:auto]">{t('notifications.title')}</h1>
|
||||
{unreadCount > 0 && (
|
||||
<span className="flex-none rounded-full bg-m-act px-[7px] py-[2px] font-geist text-[0.625rem] font-bold text-m-actfg">
|
||||
{unreadCount}
|
||||
|
||||
@@ -893,7 +893,8 @@ export default function MTripShell({
|
||||
{/* ── Bottom dock (replaces the global bottom nav on this screen) ── */}
|
||||
{!wide && <nav className="absolute inset-x-4 z-40 flex h-[62px] items-center justify-around rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-[14px] shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.8] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
|
||||
{dockTabs.map(({ id, icon: Icon }) => {
|
||||
const active = trTab === id
|
||||
// While More is open the dock points at More, not at the tab under it.
|
||||
const active = trTab === id && sheet?.id !== 'mehr'
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
@@ -913,7 +914,10 @@ export default function MTripShell({
|
||||
type="button"
|
||||
onClick={() => openSheet('mehr')}
|
||||
aria-label={t('mobileTrip.more')}
|
||||
className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full text-m-muted"
|
||||
aria-expanded={sheet?.id === 'mehr'}
|
||||
className={`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
|
||||
sheet?.id === 'mehr' ? 'bg-m-act text-m-actfg' : 'text-m-muted'
|
||||
}`}
|
||||
>
|
||||
<MoreHorizontal size={19} strokeWidth={2.2} />
|
||||
</button>
|
||||
|
||||
@@ -2,9 +2,8 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, Plus, Trash2, Wallet, Receipt, Paperclip } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import CustomSelect from '../../../../components/shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
|
||||
import { NumericInput } from '../../../../components/shared/NumericInput'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import { AMOUNT_FIELD_CLS, Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader, SELECT_FIELD_CLS, SheetDateField } from './PlSheetChrome'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useToast } from '../../../../components/shared/Toast'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
@@ -99,7 +98,9 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
}
|
||||
}
|
||||
|
||||
const initialOf = (p: TripMember) => (p.id === me ? t('costs.youShort') : (p.username || '?').charAt(0)).toUpperCase()
|
||||
// The member's own initial, yours too: "You" is the label beside it, and the
|
||||
// word squeezed into the circle read "Du Du" or ran out of it ("أنت").
|
||||
const initialOf = (p: TripMember) => (p.username || '?').charAt(0).toUpperCase()
|
||||
|
||||
const Avatar = ({ p, idx, size = 22, dim = false }: { p: TripMember; idx: number; size?: number; dim?: boolean }) =>
|
||||
p.avatar_url
|
||||
@@ -122,7 +123,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
<MSheet
|
||||
open={open}
|
||||
onClose={requestClose}
|
||||
material="glass"
|
||||
form
|
||||
ariaLabel={editing ? t('costs.editExpense') : t('costs.addExpense')}
|
||||
>
|
||||
<FormSheetHeader
|
||||
@@ -132,7 +133,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-[2px]">
|
||||
<FormSheetBody>
|
||||
{/* NAME */}
|
||||
<Eyebrow className="mb-[5px] mt-2 uppercase">{t('costs.whatFor')} *</Eyebrow>
|
||||
<input
|
||||
@@ -145,8 +146,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
|
||||
{/* TOTAL AMOUNT */}
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('costs.totalAmount')}</Eyebrow>
|
||||
<div className={`flex items-center gap-1 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[10px] ${isTicketMode ? 'opacity-60' : ''}`}>
|
||||
<span className="text-[0.84375rem] font-medium text-m-faint">{sym(currency)}</span>
|
||||
<div className={`${AMOUNT_FIELD_CLS} ${isTicketMode ? 'opacity-60' : ''}`}>
|
||||
<span className="flex-none text-[0.84375rem] font-medium text-m-faint">{sym(currency)}</span>
|
||||
<NumericInput
|
||||
mode="signed-decimal"
|
||||
signToggleLabel={t('costs.toggleSign')}
|
||||
@@ -158,9 +159,10 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CURRENCY + DAY */}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* CURRENCY + DAY. The day gets the room: the currency only ever shows a
|
||||
code and a symbol, and a date cut to "Nov 7, 20…" says nothing. */}
|
||||
<div className="mt-3 grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-2">
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('costs.currency')}</Eyebrow>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
@@ -171,9 +173,9 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('costs.day')}</Eyebrow>
|
||||
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
|
||||
<SheetDateField value={day} onChange={setDay} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -201,7 +203,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
type="button"
|
||||
aria-expanded={catOpen}
|
||||
onClick={() => setCatOpen(v => !v)}
|
||||
className="flex w-full items-center gap-[10px] overflow-hidden rounded-xl border border-[color:var(--m-rowbr)] bg-m-card px-[13px] py-[11px] text-start"
|
||||
className={`${SELECT_FIELD_CLS} gap-[10px] text-start`}
|
||||
>
|
||||
{(() => {
|
||||
const meta = catMeta(cat)
|
||||
@@ -521,7 +523,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormSheetBody>
|
||||
|
||||
<FormSheetFooter
|
||||
onDelete={editing ? handleDelete : undefined}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ChevronRight, FileDown, Settings, Settings2, Sparkles } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { edgeFadeClass, useEdgeFade } from '../../../components/useEdgeFade'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
import { dockTabIds } from '../dockTabs'
|
||||
import { useIsWide } from '../../../../hooks/sizeClass'
|
||||
@@ -32,6 +34,8 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const assistant = useAssistantAsk(planner.tripId)
|
||||
// Warnings not shown yet turn the tile's line into a count; the dock gets no dot.
|
||||
const hints = useAssistantHints(planner.tripId, { withStays: false })
|
||||
const [list, setList] = useState<HTMLDivElement | null>(null)
|
||||
const fade = useEdgeFade(list, 'y')
|
||||
|
||||
// Derived from the same priority list the dock seats itself from, so a section
|
||||
// can never show up in both places at once.
|
||||
@@ -67,7 +71,9 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={shell.closeSheet} variant="bottom" dimTransparent wideWidth="compact" wideAnchor="rail-foot" ariaLabel={t('mobileTrip.more')}>
|
||||
<div className="overflow-y-auto p-[10px]">
|
||||
{/* min-h-0: without it the list keeps its full height inside the panel
|
||||
and the panel cuts its last row instead of letting it scroll. */}
|
||||
<div ref={setList} className={`min-h-0 overflow-y-auto overscroll-contain p-[10px] ${edgeFadeClass(fade, 'y')}`}>
|
||||
{/* The assistant leads the sheet: the one way into it on the phone besides "Ask" on a place. */}
|
||||
{assistant.available && (
|
||||
<button
|
||||
@@ -112,7 +118,9 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
|
||||
>
|
||||
{Icon && <Icon size={19} strokeWidth={1.9} />}
|
||||
</div>
|
||||
<div className="mt-3 truncate text-[0.90625rem] font-bold">{tab.label}</div>
|
||||
{/* Two lines before an ellipsis: "Совместная работа" was cut to
|
||||
"Совместная раб…" in half a phone's width. */}
|
||||
<div className="mt-3 line-clamp-2 break-words text-[0.90625rem] font-bold leading-tight [hyphens:auto]">{tab.label}</div>
|
||||
{stat && <div className="truncate font-geist text-[0.65625rem] text-m-muted">{stat}</div>}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { noteSurface } from '../../../../components/Planner/noteSurface'
|
||||
import NoteFormatToolbar from '../../../../components/shared/NoteFormatToolbar'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { dayNoteDraft, writeDayNote } from '../../../../hooks/useDayNotes'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import type { DayNote } from '../../../../types'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
|
||||
@@ -103,14 +103,14 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
|
||||
}
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} ariaLabel={note ? t('dayplan.noteEdit') : t('dayplan.noteAdd')}>
|
||||
<MSheet open={open} onClose={onClose} form ariaLabel={note ? t('dayplan.noteEdit') : t('dayplan.noteAdd')}>
|
||||
<FormSheetHeader
|
||||
title={note ? t('dayplan.noteEdit') : t('dayplan.noteAdd')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
|
||||
<FormSheetBody>
|
||||
<Eyebrow className="mb-[7px] uppercase">{t('notes.appearance')}</Eyebrow>
|
||||
<div className="flex items-center gap-[10px]">
|
||||
{/* The chosen icon in the chosen colour: the picker and the preview
|
||||
@@ -206,7 +206,7 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
|
||||
{detail.length}/{DAY_NOTE_DETAIL_MAX}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FormSheetBody>
|
||||
|
||||
<FormSheetFooter
|
||||
onDelete={note ? handleDelete : undefined}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type PlaceFormData,
|
||||
} from '../../../../components/Planner/PlaceFormModal.helpers'
|
||||
import { usePlaceForm } from '../../../../components/Planner/usePlaceForm'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import { useAddonStore } from '../../../../store/addonStore'
|
||||
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
|
||||
import PlPlaceSearch, { type PlSearchPick } from './PlPlaceSearch'
|
||||
@@ -233,7 +233,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
|
||||
<MSheet
|
||||
open={showPlaceForm}
|
||||
onClose={handleClose}
|
||||
material="glass"
|
||||
form
|
||||
ariaLabel={sheetPlace ? t('places.editPlace') : t('places.addPlace')}
|
||||
discardGuard={showPlaceForm ? { form, files: pendingFiles.length } : undefined}
|
||||
>
|
||||
@@ -244,7 +244,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-[2px]" onPaste={handlePaste}>
|
||||
<FormSheetBody onPaste={handlePaste}>
|
||||
<PlPlaceSearch
|
||||
planner={planner}
|
||||
locationBias={locationBias}
|
||||
@@ -387,7 +387,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
|
||||
onEdit={item => onOpenExpense({ editItem: item })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormSheetBody>
|
||||
|
||||
<FormSheetFooter
|
||||
onDelete={sheetPlace ? handleDelete : undefined}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { getPlaceBookings } from '../../../../utils/dayMerge'
|
||||
import type { Assignment, Day, Reservation, TripMember } from '../../../../types'
|
||||
import PlacePhotoGallery from '../../../../components/Planner/PlacePhotoGallery'
|
||||
import { ActionCircle, Eyebrow, INNER_CLS } from './MTripSheetUi'
|
||||
import { FormSheetBody } from './PlSheetChrome'
|
||||
|
||||
/**
|
||||
* Place inspector sheet (glass card), opened by the current place selection —
|
||||
@@ -190,13 +191,14 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={close} variant="card" material="glass" ariaLabel={place?.name}>
|
||||
<MSheet open={open} onClose={close} variant="card" form ariaLabel={place?.name}>
|
||||
{place && (
|
||||
<>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex flex-none flex-col items-center gap-[5px]">
|
||||
{/* The picture opens the place's photos; it is changed in the editor. */}
|
||||
{/* The picture opens the place's photos; it is changed in the editor.
|
||||
It stands on the same leading edge as the cards below. */}
|
||||
<div className="flex-none">
|
||||
<PlacePhotoGallery place={place} language={language} label={t('places.openPhotos')} className="rounded-[16px]">
|
||||
{place.image_url ? (
|
||||
<div
|
||||
@@ -209,12 +211,6 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
</div>
|
||||
)}
|
||||
</PlacePhotoGallery>
|
||||
{category && (
|
||||
<span className="flex max-w-[76px] items-center gap-1 rounded-full border border-[color:var(--m-faint)] px-2 py-[2px] font-geist text-[0.625rem] font-semibold text-m-muted wide:max-w-[140px]">
|
||||
<CatIcon size={10} strokeWidth={2} className="flex-none" />
|
||||
<span className="truncate">{category.name}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -230,6 +226,14 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
{place.address && (
|
||||
<div className="mt-[2px] font-geist text-[0.6875rem] leading-[1.4] text-m-muted">{place.address}</div>
|
||||
)}
|
||||
{/* The category under the name and address, as wide as its name:
|
||||
squeezed under the photo it was cut to "Shoppi…". */}
|
||||
{category && (
|
||||
<span className="mt-[6px] inline-flex max-w-full items-center gap-1 rounded-full border border-[color:var(--m-faint)] px-2 py-[2px] font-geist text-[0.625rem] font-semibold text-m-muted">
|
||||
<CatIcon size={10} strokeWidth={2} className="flex-none" />
|
||||
<span className="truncate">{category.name}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -242,7 +246,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
|
||||
<FormSheetBody>
|
||||
{place.phone && (
|
||||
<a href={`tel:${place.phone}`} className="mt-3 flex items-center gap-[7px] text-[0.78125rem] font-medium">
|
||||
<Phone size={13} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
@@ -263,7 +267,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
|
||||
{place.notes && (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3">{t('mobileTrip.notes')}</Eyebrow>
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('mobileTrip.notes')}</Eyebrow>
|
||||
<div className={`rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
|
||||
<MarkdownText className="font-geist text-[0.75rem] leading-[1.5] text-m-muted [overflow-wrap:anywhere]">{place.notes}</MarkdownText>
|
||||
</div>
|
||||
@@ -273,7 +277,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
{/* ── Track colour (#776) ── */}
|
||||
{place.route_geometry && (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3">{t('inspector.trackColor')}</Eyebrow>
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('inspector.trackColor')}</Eyebrow>
|
||||
<div className={`rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
|
||||
{isTourPlace ? (
|
||||
<span aria-label={t('inspector.trackColor')} style={{ display: 'block', width: 24, height: 24, borderRadius: 12, background: resolveTrackColor(place) }} />
|
||||
@@ -312,7 +316,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
)}
|
||||
|
||||
{/* ── Day assignments ── */}
|
||||
<Eyebrow className="mb-[6px] mt-3">{t('mobileTrip.assignedDays')}</Eyebrow>
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('mobileTrip.assignedDays')}</Eyebrow>
|
||||
<div className="flex flex-wrap items-center gap-[6px]">
|
||||
{placeAssignments.map(({ day, assignment }) => (
|
||||
<span
|
||||
@@ -382,7 +386,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
only appears when the place sits on more than one day. ── */}
|
||||
{placeAssignments.some(r => r.assignment.notes) && (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3">{t('mobileTrip.assignmentNotes')}</Eyebrow>
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('mobileTrip.assignmentNotes')}</Eyebrow>
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
{placeAssignments.filter(r => r.assignment.notes).map(({ day, assignment }) => (
|
||||
<div key={assignment.id} className={`rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
|
||||
@@ -401,7 +405,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
{/* ── The bookings attached to this stop (#2012, #2201) ── */}
|
||||
{linkedReservations.length > 0 && (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3">{t('reservations.title')}</Eyebrow>
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('reservations.title')}</Eyebrow>
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
{linkedReservations.map(res => (
|
||||
<button
|
||||
@@ -430,7 +434,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
{/* ── Participants of the selected day's assignment ── */}
|
||||
{!isTourPlace && assignmentInDay && members.length > 1 && (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3">{t('inspector.participants')}</Eyebrow>
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('inspector.participants')}</Eyebrow>
|
||||
<div className="flex flex-wrap items-center gap-[6px]">
|
||||
{activeMembers.map(m => (
|
||||
<button
|
||||
@@ -591,7 +595,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
</ActionCircle>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormSheetBody>
|
||||
</>
|
||||
)}
|
||||
</MSheet>
|
||||
|
||||
@@ -7,8 +7,7 @@ import { useAddonStore } from '../../../../store/addonStore'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import CustomSelect from '../../../../components/shared/CustomSelect'
|
||||
import CustomTimePicker from '../../../../components/shared/CustomTimePicker'
|
||||
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
|
||||
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import { Eyebrow, FIELD_CLS, FORM_PAIR_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader, SheetDateField } from './PlSheetChrome'
|
||||
import MBookingFilesCosts from './MBookingFilesCosts'
|
||||
import { expenseRequestAfterSave, travelerIdsOf, filesNeedConnection } from '../../../../components/Planner/bookingFormModel'
|
||||
import { useBookingFollowUp } from '../../../../components/Planner/useBookingFollowUp'
|
||||
@@ -179,7 +178,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
<MSheet
|
||||
open={showReservationModal}
|
||||
onClose={handleClose}
|
||||
material="glass"
|
||||
form
|
||||
ariaLabel={res ? t('reservations.editTitle') : t('reservations.newTitle')}
|
||||
discardGuard={showReservationModal ? { form, files: pendingFiles.length, travelers: [...travelerIds] } : undefined}
|
||||
>
|
||||
@@ -190,7 +189,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-[2px]">
|
||||
<FormSheetBody>
|
||||
{/* A saved booking goes into Apple Wallet (iOS app only). */}
|
||||
<MWalletButton tripId={tripId} reservationId={savedId(res)} onError={toast.error} className="mb-1 mt-2 flex" />
|
||||
|
||||
@@ -230,7 +229,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="min-w-0 flex-[1.2]">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('reservations.date')}</Eyebrow>
|
||||
<CustomDatePicker
|
||||
<SheetDateField
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
min={tripMinDate}
|
||||
@@ -248,7 +247,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
<div className="mt-2 flex gap-2">
|
||||
<div className="min-w-0 flex-[1.2]">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('reservations.endDate')}</Eyebrow>
|
||||
<CustomDatePicker value={form.end_date} onChange={d => set('end_date', d || '')} min={tripMinDate} max={tripMaxDate} />
|
||||
<SheetDateField value={form.end_date} onChange={d => set('end_date', d || '')} min={tripMinDate} max={tripMaxDate} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('reservations.endTime')}</Eyebrow>
|
||||
@@ -301,8 +300,8 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`mt-3 ${FORM_PAIR_CLS}`}>
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.fromDay')}</Eyebrow>
|
||||
<CustomSelect
|
||||
value={form.hotel_start_day}
|
||||
@@ -312,7 +311,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.toDay')}</Eyebrow>
|
||||
<CustomSelect
|
||||
value={form.hotel_end_day}
|
||||
@@ -414,7 +413,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
onCreate={expense.create}
|
||||
onEdit={item => onOpenExpense({ editItem: item })}
|
||||
/>
|
||||
</div>
|
||||
</FormSheetBody>
|
||||
|
||||
<FormSheetFooter
|
||||
onCancel={handleClose}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useTransportForm } from '../../../../components/Planner/useTransportFor
|
||||
import { expenseRequestAfterSave, travelerIdsOf, filesNeedConnection, type BookingSaveSteps } from '../../../../components/Planner/bookingFormModel'
|
||||
import { useBookingFollowUp } from '../../../../components/Planner/useBookingFollowUp'
|
||||
import { useBookingExpenseIntent } from '../../../../components/Planner/useBookingExpenseIntent'
|
||||
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import { Eyebrow, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import MBookingFilesCosts from './MBookingFilesCosts'
|
||||
import type { Place, Reservation } from '../../../../types'
|
||||
import type { BookingReviewDraft } from '../../../../components/Planner/parsedItemToDraft'
|
||||
@@ -163,7 +163,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
<MSheet
|
||||
open={showTransportModal}
|
||||
onClose={handleClose}
|
||||
material="glass"
|
||||
form
|
||||
ariaLabel={headerTitle}
|
||||
discardGuard={showTransportModal ? { form, waypoints, trainWaypoints, carStops, files: pendingFiles.length, travelers: [...travelerIds] } : undefined}
|
||||
>
|
||||
@@ -199,7 +199,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-[2px]">
|
||||
<FormSheetBody>
|
||||
{automated ? (
|
||||
/* ── Automated: public transit search ── */
|
||||
<>
|
||||
@@ -307,15 +307,15 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<div className="min-w-0 flex-[1.2]">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.airline')}</Eyebrow>
|
||||
<Eyebrow className="mb-[5px] truncate uppercase">{t('reservations.meta.airline')}</Eyebrow>
|
||||
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={FIELD_CLS} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.flightNumber')}</Eyebrow>
|
||||
<Eyebrow className="mb-[5px] truncate uppercase">{t('reservations.meta.flightNumber')}</Eyebrow>
|
||||
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={FIELD_CLS} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-[0.7]">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.seat')}</Eyebrow>
|
||||
<Eyebrow className="mb-[5px] truncate uppercase">{t('reservations.meta.seat')}</Eyebrow>
|
||||
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={FIELD_CLS} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -372,15 +372,15 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
>
|
||||
{!cruise && <div className="mt-2 flex gap-2">
|
||||
<div className="min-w-0 flex-[1.2]">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.trainNumber')}</Eyebrow>
|
||||
<Eyebrow className="mb-[5px] truncate uppercase">{t('reservations.meta.trainNumber')}</Eyebrow>
|
||||
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={FIELD_CLS} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.platform')}</Eyebrow>
|
||||
<Eyebrow className="mb-[5px] truncate uppercase">{t('reservations.meta.platform')}</Eyebrow>
|
||||
<input type="text" value={wp.platform} onChange={e => updateWp({ platform: e.target.value })} placeholder="12" className={FIELD_CLS} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-[0.7]">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.seat')}</Eyebrow>
|
||||
<Eyebrow className="mb-[5px] truncate uppercase">{t('reservations.meta.seat')}</Eyebrow>
|
||||
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={FIELD_CLS} />
|
||||
</div>
|
||||
</div>}
|
||||
@@ -540,10 +540,10 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FormSheetBody>
|
||||
|
||||
{automated ? (
|
||||
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<div className="m-fs-foot flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { announceNoteFilesChanged, useCollabNotesData } from '../../../../compon
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import { safeExternalHref } from '../../../../utils/safeUrl'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import { TabScroller } from './tabChrome'
|
||||
@@ -423,7 +423,7 @@ function NoteFormSheet({ open, target, categories, colorMap, canUploadFiles, onC
|
||||
}
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} ariaLabel={isEdit ? t('collab.notes.edit') : t('collab.notes.new')}>
|
||||
<MSheet open={open} onClose={onClose} form ariaLabel={isEdit ? t('collab.notes.edit') : t('collab.notes.new')}>
|
||||
<FormSheetHeader
|
||||
icon={StickyNote}
|
||||
title={isEdit ? t('collab.notes.edit') : t('collab.notes.new')}
|
||||
@@ -431,8 +431,8 @@ function NoteFormSheet({ open, target, categories, colorMap, canUploadFiles, onC
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('collab.notes.title')} *</Eyebrow>
|
||||
<FormSheetBody>
|
||||
<Eyebrow className="mb-[5px] mt-2 uppercase">{t('common.title')} *</Eyebrow>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
@@ -573,7 +573,7 @@ function NoteFormSheet({ open, target, categories, colorMap, canUploadFiles, onC
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FormSheetBody>
|
||||
|
||||
<FormSheetFooter
|
||||
onCancel={onClose}
|
||||
|
||||
@@ -11,7 +11,7 @@ import ToggleSwitch from '../../../../components/Settings/ToggleSwitch'
|
||||
import { CustomDateTimePicker } from '../../../../components/shared/CustomDateTimePicker'
|
||||
import { localIsoDate } from '../../../../utils/localDate'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import { CardGrid, SectionHeader, TabScroller } from './tabChrome'
|
||||
@@ -360,10 +360,10 @@ function PollFormSheet({ open, onClose, onSubmit, t }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} ariaLabel={t('collab.polls.new')}>
|
||||
<MSheet open={open} onClose={onClose} form ariaLabel={t('collab.polls.new')}>
|
||||
<FormSheetHeader icon={BarChart3} title={t('collab.polls.new')} onClose={onClose} closeLabel={t('common.close')} />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
|
||||
<FormSheetBody>
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('collab.polls.question')} *</Eyebrow>
|
||||
<textarea
|
||||
rows={4}
|
||||
@@ -432,7 +432,7 @@ function PollFormSheet({ open, onClose, onSubmit, t }: {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormSheetBody>
|
||||
|
||||
<FormSheetFooter
|
||||
onCancel={onClose}
|
||||
|
||||
@@ -1,34 +1,51 @@
|
||||
import { ArrowLeftRight } from 'lucide-react';
|
||||
import { NOTE_MAX } from '../../../../components/Budget/CostsPanel.helpers';
|
||||
import { currencyOptions, type CostsSettlement } from '../../../../components/Budget/costsModel';
|
||||
import { useSettlementForm } from '../../../../components/Budget/useSettlementForm';
|
||||
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker';
|
||||
import CustomSelect from '../../../../components/shared/CustomSelect';
|
||||
import type { TripMember } from '../../../../types';
|
||||
import { localizeAmountInput, zeroAmountInput } from '../../../../utils/formatters';
|
||||
import MChip from '../../../components/MChip';
|
||||
import MSheet from '../../../components/MSheet';
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome';
|
||||
import {
|
||||
AMOUNT_FIELD_CLS,
|
||||
Eyebrow,
|
||||
FIELD_AREA_CLS,
|
||||
FormSheetBody,
|
||||
FormSheetFooter,
|
||||
FormSheetHeader,
|
||||
SheetDateField,
|
||||
} from '../sheets/PlSheetChrome';
|
||||
|
||||
type TFn = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
// The costs tab's member avatar and its payment sheet, apart from the tab so it
|
||||
// keeps to the size a source file may have.
|
||||
|
||||
/** Avatar or initials circle; `variant` picks the accent (member chips) or neutral (balances) tone. */
|
||||
const AVATAR_TONE = {
|
||||
accent: 'bg-m-act text-m-actfg',
|
||||
neutral: 'bg-[color:var(--m-ic)] text-m-ink',
|
||||
// On a chip that is itself filled with the accent: the circle turns over, or
|
||||
// it vanishes into the chip.
|
||||
onAccent: 'bg-m-actfg text-m-act',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Avatar or initials circle; `variant` picks the accent (member chips), the
|
||||
* neutral (balances) tone, or the inverse one for a selected chip. The initial
|
||||
* is the member's own, yours included: "You" is the label beside the circle,
|
||||
* and the word squeezed into it read "Du Du" or ran out of it in Arabic.
|
||||
*/
|
||||
export function MemberAvatar({
|
||||
name,
|
||||
avatarUrl,
|
||||
isMe,
|
||||
variant,
|
||||
size,
|
||||
t,
|
||||
}: {
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
isMe: boolean;
|
||||
variant: 'accent' | 'neutral';
|
||||
variant: keyof typeof AVATAR_TONE;
|
||||
size: number;
|
||||
t: TFn;
|
||||
}) {
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
@@ -40,11 +57,10 @@ export function MemberAvatar({
|
||||
/>
|
||||
);
|
||||
}
|
||||
const initial = isMe ? t('costs.youShort') : (name || '?').charAt(0).toUpperCase();
|
||||
const toneCls = variant === 'accent' ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink';
|
||||
const initial = (name || '?').charAt(0).toUpperCase();
|
||||
return (
|
||||
<span
|
||||
className={`flex flex-none items-center justify-center rounded-full font-geist font-extrabold ${toneCls}`}
|
||||
className={`flex flex-none items-center justify-center rounded-full font-geist font-extrabold ${AVATAR_TONE[variant]}`}
|
||||
style={{ width: size, height: size, fontSize: Math.max(8, size * 0.42) }}
|
||||
>
|
||||
{initial}
|
||||
@@ -122,20 +138,18 @@ export function AddPaymentSheet({
|
||||
const title = editing ? t('costs.editPayment') : t('costs.addPayment');
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} ariaLabel={title}>
|
||||
<FormSheetHeader title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
|
||||
<Eyebrow className="mb-[7px] uppercase">{t('costs.from')}</Eyebrow>
|
||||
<MSheet open={open} onClose={onClose} ariaLabel={title} form>
|
||||
<FormSheetHeader icon={ArrowLeftRight} title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<FormSheetBody>
|
||||
<Eyebrow className="mb-[7px] mt-2 uppercase">{t('costs.from')}</Eyebrow>
|
||||
<div className="flex flex-wrap gap-[6px]">
|
||||
{people.map((p) => (
|
||||
<MChip key={p.id} active={fromId === p.id} onClick={() => setFromId(p.id)}>
|
||||
<MemberAvatar
|
||||
name={p.username}
|
||||
avatarUrl={p.avatar_url}
|
||||
isMe={p.id === me}
|
||||
variant={fromId === p.id ? 'accent' : 'neutral'}
|
||||
variant={fromId === p.id ? 'onAccent' : 'neutral'}
|
||||
size={16}
|
||||
t={t}
|
||||
/>
|
||||
{p.id === me ? t('costs.you') : p.username}
|
||||
</MChip>
|
||||
@@ -149,10 +163,8 @@ export function AddPaymentSheet({
|
||||
<MemberAvatar
|
||||
name={p.username}
|
||||
avatarUrl={p.avatar_url}
|
||||
isMe={p.id === me}
|
||||
variant={toId === p.id ? 'accent' : 'neutral'}
|
||||
variant={toId === p.id ? 'onAccent' : 'neutral'}
|
||||
size={16}
|
||||
t={t}
|
||||
/>
|
||||
{p.id === me ? t('costs.you') : p.username}
|
||||
</MChip>
|
||||
@@ -160,20 +172,23 @@ export function AddPaymentSheet({
|
||||
</div>
|
||||
|
||||
<Eyebrow className="mb-[7px] mt-[14px] uppercase">{t('costs.amount')}</Eyebrow>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* The currency sits inside the field, after the amount, like the
|
||||
expense's total: one box, not a field with a code floating beside it. */}
|
||||
<div className={AMOUNT_FIELD_CLS}>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={localizeAmountInput(amount, currency)}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder={zeroAmountInput(currency)}
|
||||
className={FIELD_CLS}
|
||||
aria-label={t('costs.amount')}
|
||||
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-[0.84375rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums] placeholder:font-normal placeholder:text-m-faint"
|
||||
/>
|
||||
<span className="flex-none font-geist text-caption font-bold text-m-faint">{currency}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-[14px] flex gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mt-[14px] grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-2">
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[7px] uppercase">{t('costs.currency')}</Eyebrow>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
@@ -184,9 +199,9 @@ export function AddPaymentSheet({
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[7px] uppercase">{t('costs.day')}</Eyebrow>
|
||||
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
|
||||
<SheetDateField value={day} onChange={setDay} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +214,7 @@ export function AddPaymentSheet({
|
||||
placeholder={t('costs.paymentNotePlaceholder')}
|
||||
className={FIELD_AREA_CLS}
|
||||
/>
|
||||
</div>
|
||||
</FormSheetBody>
|
||||
<FormSheetFooter
|
||||
onCancel={onClose}
|
||||
cancelLabel={t('common.cancel')}
|
||||
|
||||
@@ -243,7 +243,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
const neg = balance < -0.01
|
||||
return (
|
||||
<div key={p.id} className="flex items-center gap-[9px] border-b border-[color:var(--m-rowbr)] py-[7px] last:border-b-0">
|
||||
<MemberAvatar name={p.username} avatarUrl={p.avatar_url} isMe={p.id === me} variant="neutral" size={24} t={t} />
|
||||
<MemberAvatar name={p.username} avatarUrl={p.avatar_url} variant="neutral" size={24} />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold text-m-ink">{p.id === me ? t('costs.you') : p.username}</span>
|
||||
<span className="ms-auto flex-none font-geist text-[0.75rem] font-extrabold tabular-nums" style={{ color: pos ? STATUS_COLOR.confirmed : neg ? STATUS_COLOR.danger : 'var(--m-faint)' }}>
|
||||
{pos ? '+' : neg ? '−' : ''}
|
||||
@@ -272,7 +272,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
onClick={() => setExpandedFinalId(open ? null : p.id)}
|
||||
className="flex w-full items-center gap-[9px] py-[7px] text-start font-[inherit]"
|
||||
>
|
||||
<MemberAvatar name={p.username} avatarUrl={p.avatar_url} isMe={p.id === me} variant="neutral" size={24} t={t} />
|
||||
<MemberAvatar name={p.username} avatarUrl={p.avatar_url} variant="neutral" size={24} />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold text-m-ink">{personName(p.id)}</span>
|
||||
<span className="ms-auto flex-none font-geist text-[0.75rem] font-extrabold tabular-nums text-m-ink">{formatMoney(row.final, base, locale)}</span>
|
||||
{open
|
||||
@@ -637,7 +637,7 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
m.paid ? 'border-2 border-[color:var(--m-st-confirmed)]' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]'
|
||||
}`}
|
||||
>
|
||||
<MemberAvatar name={m.username} avatarUrl={m.avatar_url} isMe={m.user_id === ctx.me} variant="accent" size={14} t={t} />
|
||||
<MemberAvatar name={m.username} avatarUrl={m.avatar_url} variant="accent" size={14} />
|
||||
<span className="font-geist text-[0.5625rem] font-bold tabular-nums text-m-ink">
|
||||
{formatMoney(memberShareOf(item, m.user_id, ctx), base, locale)}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, Copy, HandHelping, Share2, UserRound, Users } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import { Eyebrow, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import { avatarSrc } from '../../../../utils/avatarSrc'
|
||||
import type { PackingItem, TripMember } from '../../../../types'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
@@ -109,7 +109,7 @@ export default function MPackItemSheet({
|
||||
}`
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} ariaLabel={t('packing.editItem')}>
|
||||
<MSheet open={open} onClose={onClose} form ariaLabel={t('packing.editItem')}>
|
||||
<FormSheetHeader
|
||||
title={t('packing.editItem')}
|
||||
subtitle={item.category || t('packing.defaultCategory')}
|
||||
@@ -117,7 +117,7 @@ export default function MPackItemSheet({
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
|
||||
<FormSheetBody>
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('packing.itemName')}</Eyebrow>
|
||||
<input
|
||||
type="text"
|
||||
@@ -228,7 +228,7 @@ export default function MPackItemSheet({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FormSheetBody>
|
||||
|
||||
<FormSheetFooter
|
||||
onCancel={onClose}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef } from 'react'
|
||||
import { FileDown } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import { usePackingImport } from '../../../../components/Packing/usePackingImport'
|
||||
import { PACKING_IMPORT_ACCEPT } from '../../../../components/Packing/packingListPanel.constants'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
@@ -25,10 +25,10 @@ export default function MPackingImportSheet({ planner, open, onClose }: MPacking
|
||||
usePackingImport({ tripId, t, toast, onImported: onClose, oneAtATime: true })
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} ariaLabel={t('packing.importTitle')}>
|
||||
<MSheet open={open} onClose={onClose} form ariaLabel={t('packing.importTitle')}>
|
||||
<FormSheetHeader title={t('packing.importTitle')} onClose={onClose} closeLabel={t('common.close')} />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
|
||||
<FormSheetBody>
|
||||
<p className="mb-1 font-geist text-[0.71875rem] leading-[1.5] text-m-muted">{t('packing.importHint')}</p>
|
||||
<p className="mb-3 font-geist text-[0.71875rem] leading-[1.5] text-m-muted">{t('packing.importHintMarkdown')}</p>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function MPackingImportSheet({ planner, open, onClose }: MPacking
|
||||
{parsed.length === 0 && text.trim() !== '' && (
|
||||
<Eyebrow className="mt-3">{t('packing.importEmpty')}</Eyebrow>
|
||||
)}
|
||||
</div>
|
||||
</FormSheetBody>
|
||||
|
||||
<FormSheetFooter
|
||||
onCancel={onClose}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { TodoCreateItemRequest } from '@trek/shared'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
|
||||
import MarkdownEditable from '../../../../components/shared/MarkdownEditable'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import { avatarSrc } from '../../../../utils/avatarSrc'
|
||||
import type { TodoItem, TripMember } from '../../../../types'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
@@ -91,10 +91,10 @@ export default function MTaskSheet({ planner, open, itemId, categories, members,
|
||||
}`
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} ariaLabel={item ? t('todo.detail.title') : t('todo.newItem')}>
|
||||
<MSheet open={open} onClose={onClose} form ariaLabel={item ? t('todo.detail.title') : t('todo.newItem')}>
|
||||
<FormSheetHeader title={item ? t('todo.detail.title') : t('todo.newItem')} onClose={onClose} closeLabel={t('common.close')} />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
|
||||
<FormSheetBody>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
@@ -220,7 +220,7 @@ export default function MTaskSheet({ planner, open, itemId, categories, members,
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</FormSheetBody>
|
||||
|
||||
<FormSheetFooter
|
||||
onDelete={item ? handleDelete : undefined}
|
||||
|
||||
@@ -97,6 +97,18 @@ describe('MNewTripSheet', () => {
|
||||
expect(screen.getByText('Add cover image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-095: the form uses the shared form chrome: icon header, flat fields, no frame on the focused sheet', () => {
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
|
||||
const dialog = screen.getByRole('dialog', { name: 'Create New Trip' });
|
||||
expect(dialog).toHaveClass('m-form-sheet');
|
||||
expect(dialog.querySelector('.m-fs-head')).not.toBeNull();
|
||||
expect(dialog.querySelector('.m-fs-foot')).not.toBeNull();
|
||||
// No section cards inside the card.
|
||||
expect(dialog.querySelector('section')).toBeNull();
|
||||
// Each date has its field to itself: the keyboard entry waits in the picker.
|
||||
expect(screen.queryByRole('button', { name: /enter date manually/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOB-NTSH-001b: create mode defaults the currency to the user\'s default_currency (#1784)', () => {
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, default_currency: 'USD' } }));
|
||||
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
|
||||
|
||||
@@ -392,9 +392,11 @@ describe('MJourneyEntrySheet full editor', () => {
|
||||
await user.type(screen.getByPlaceholderText('Give this moment a name...'), 'Rome');
|
||||
await user.type(screen.getByPlaceholderText('Write your story...'), 'Great day');
|
||||
// Through the picker's manual entry — the calendar's own path would depend
|
||||
// on which month it opens on.
|
||||
// on which month it opens on. The keyboard button sits in the picker's
|
||||
// popup, so the date keeps the field's width: open the calendar first.
|
||||
// The keyboard button and the input it opens share a name — they never
|
||||
// exist at the same time, so the same query reaches first one, then the other.
|
||||
fireEvent.click(screen.getAllByRole('button').find(b => b.getAttribute('aria-haspopup') === 'dialog')!);
|
||||
fireEvent.click(screen.getByLabelText('Enter date manually'));
|
||||
const manual = screen.getByLabelText('Enter date manually');
|
||||
fireEvent.change(manual, { target: { value: '18.03.2026' } });
|
||||
|
||||
@@ -244,6 +244,9 @@ describe('MCollabNotes', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'collab.notes.new' })
|
||||
expect(screen.getByRole('button', { name: 'collab.notes.create' })).toBeDisabled()
|
||||
// The title field is labelled "Title", not "Notes" as if it were a second content field.
|
||||
expect(within(dialog).getByText('common.title *')).toBeInTheDocument()
|
||||
expect(within(dialog).queryByText('collab.notes.title *')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: ' Insurance ' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.contentPlaceholder'), { target: { value: ' call the hotline ' } })
|
||||
|
||||
@@ -102,7 +102,8 @@ describe('MCostSheet', () => {
|
||||
expect(screen.getByRole('dialog', { name: 'Add expense' })).toBeInTheDocument()
|
||||
expect(nameField()).toHaveValue('')
|
||||
expect(catTrigger()).toHaveTextContent('Food & drink')
|
||||
expect(screen.getByRole('button', { name: 'Y You' })).toBeInTheDocument()
|
||||
// Your row shows your own initial ("alice"), the label beside it says "You".
|
||||
expect(screen.getByRole('button', { name: 'A You' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'B bob' })).toBeInTheDocument()
|
||||
expect(submit()).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
||||
@@ -224,7 +225,7 @@ describe('MCostSheet', () => {
|
||||
it('FE-MOB-COSTSH-005: an expense nobody shares is saved without members but keeps its payer', async () => {
|
||||
renderSheet()
|
||||
fillBasics('Souvenir', '12')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'A You' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
|
||||
|
||||
fireEvent.click(submit())
|
||||
@@ -332,7 +333,7 @@ describe('MCostSheet', () => {
|
||||
fillBasics('Group ticket', '100')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Y You' })[0])
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'A You' })[0])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'One person paid' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument()
|
||||
@@ -371,7 +372,7 @@ describe('MCostSheet', () => {
|
||||
fireEvent.change(share, { target: { value: '30,5' } })
|
||||
expect(screen.getByDisplayValue('30,5')).toBe(share)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'A You' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tap to include' }))
|
||||
expect(screen.getAllByPlaceholderText('50,00')[0]).toHaveValue('')
|
||||
})
|
||||
@@ -412,7 +413,7 @@ describe('MCostSheet', () => {
|
||||
expect(footerSubmit()).toBeEnabled()
|
||||
|
||||
// Un-ticking everyone makes the item unsplittable again.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'A You' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
|
||||
expect(footerSubmit()).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -299,7 +299,8 @@ describe('MCostsTab', () => {
|
||||
expect(chips).toHaveLength(2)
|
||||
expect(chips[0]).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(chips[1]).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(chips.map(c => c.textContent)).toEqual(['costs.youShort$40.00', '$40.00'])
|
||||
// Your own chip carries your initial too, not the word "You" squeezed into the circle.
|
||||
expect(chips.map(c => c.textContent)).toEqual(['M$40.00', '$40.00'])
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-017: toggles a member chip to paid', async () => {
|
||||
|
||||
@@ -173,10 +173,19 @@ describe('MPlaceSheet', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-033: the category pill keeps to the photo on a phone and takes the room a wide sheet has', () => {
|
||||
it('FE-MOB-PLSH-033: the category pill stands under the name and address, as wide as its name', () => {
|
||||
renderSheet()
|
||||
const pill = screen.getByText('Museum').parentElement as HTMLElement
|
||||
expect(pill).toHaveClass('max-w-[76px]', 'wide:max-w-[140px]')
|
||||
// No cap of its own any more: under the photo it was cut to "Shoppi…".
|
||||
expect(pill.className).not.toMatch(/max-w-\[/)
|
||||
expect(pill).toHaveClass('max-w-full')
|
||||
// In the column with the name, so the photo keeps the leading edge of the cards below.
|
||||
expect(pill.parentElement).toContainElement(screen.getByText('Kunsthistorisches Museum'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-034: the section labels are the small capitals every sheet uses', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByText('Assigned Days')).toHaveClass('uppercase')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-003: clears the place selection when closed', () => {
|
||||
|
||||
@@ -413,6 +413,21 @@ describe('MTripShell', () => {
|
||||
expect(shellApi.sheet).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-097: while More is open the dock marks More, not the tab under it', () => {
|
||||
renderShell()
|
||||
const more = screen.getByRole('button', { name: 'mobileTrip.more' })
|
||||
const plan = screen.getByRole('button', { name: 'Plan' })
|
||||
expect(plan).toHaveAttribute('aria-current', 'page')
|
||||
expect(more).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(more)
|
||||
expect(more).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(more).toHaveClass('bg-m-act')
|
||||
expect(plan).not.toHaveAttribute('aria-current')
|
||||
act(() => { shellApi.closeSheet() })
|
||||
expect(plan).toHaveAttribute('aria-current', 'page')
|
||||
expect(more).not.toHaveClass('bg-m-act')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-026: a non-plan tab replaces the plan chrome with its panel', () => {
|
||||
renderShell({ activeTab: 'dateien' } as Partial<TripPlanner>)
|
||||
expect(screen.getByTestId('tab-panel')).toHaveTextContent('dateien')
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'ما أدخلته هنا لم يُحفظ بعد.',
|
||||
'common.keepEditing': 'متابعة التعديل',
|
||||
'common.name': 'الاسم',
|
||||
'common.title': 'العنوان',
|
||||
'common.email': 'البريد الإلكتروني',
|
||||
'common.password': 'كلمة المرور',
|
||||
'common.saving': 'جارٍ الحفظ...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Burada daxil etdikləriniz hələ yadda saxlanmayıb.',
|
||||
'common.keepEditing': 'Redaktəyə davam et',
|
||||
'common.name': 'Ad',
|
||||
'common.title': 'Başlıq',
|
||||
'common.email': 'E-poçt',
|
||||
'common.password': 'Parol',
|
||||
'common.saving': 'Saxlanılır...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'O que você inseriu aqui ainda não foi salvo.',
|
||||
'common.keepEditing': 'Continuar editando',
|
||||
'common.name': 'Nome',
|
||||
'common.title': 'Título',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Senha',
|
||||
'common.saving': 'Salvando...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': "El que has introduït aquí encara no s'ha desat.",
|
||||
'common.keepEditing': 'Continua editant',
|
||||
'common.name': 'Nom',
|
||||
'common.title': 'Títol',
|
||||
'common.email': 'Correu',
|
||||
'common.password': 'Contrasenya',
|
||||
'common.saving': 'Desant...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'To, co jste zde zadali, ještě není uložené.',
|
||||
'common.keepEditing': 'Pokračovat v úpravách',
|
||||
'common.name': 'Jméno',
|
||||
'common.title': 'Název',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Heslo',
|
||||
'common.saving': 'Ukládání...',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const airport: TranslationStrings = {
|
||||
'airport.searchPlaceholder': 'Flughafencode oder Stadt (z. B. FRA)',
|
||||
'airport.searchPlaceholder': 'Flughafen oder Stadt',
|
||||
};
|
||||
export default airport;
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Was du hier eingegeben hast, ist noch nicht gespeichert.',
|
||||
'common.keepEditing': 'Weiter bearbeiten',
|
||||
'common.name': 'Name', // same-as-en
|
||||
'common.title': 'Titel',
|
||||
'common.email': 'E-Mail',
|
||||
'common.password': 'Passwort',
|
||||
'common.saving': 'Speichern...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'What you entered here has not been saved yet.',
|
||||
'common.keepEditing': 'Keep editing',
|
||||
'common.name': 'Name',
|
||||
'common.title': 'Title',
|
||||
'common.email': 'Email',
|
||||
'common.password': 'Password',
|
||||
'common.saving': 'Saving...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Lo que has introducido aquí aún no se ha guardado.',
|
||||
'common.keepEditing': 'Seguir editando',
|
||||
'common.name': 'Nombre',
|
||||
'common.title': 'Título',
|
||||
'common.email': 'Correo',
|
||||
'common.password': 'Contraseña',
|
||||
'common.saving': 'Guardando...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Siia sisestatu pole veel salvestatud.',
|
||||
'common.keepEditing': 'Jätka muutmist',
|
||||
'common.name': 'Nimi',
|
||||
'common.title': 'Pealkiri',
|
||||
'common.email': 'E-post',
|
||||
'common.password': 'Parool',
|
||||
'common.saving': 'Salvestamine...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': "Ce que vous avez saisi ici n'est pas encore enregistré.",
|
||||
'common.keepEditing': 'Continuer la modification',
|
||||
'common.name': 'Nom',
|
||||
'common.title': 'Titre',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Mot de passe',
|
||||
'common.saving': 'Enregistrement…',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Όσα καταχωρίσατε εδώ δεν έχουν αποθηκευτεί ακόμη.',
|
||||
'common.keepEditing': 'Συνέχεια επεξεργασίας',
|
||||
'common.name': 'Όνομα',
|
||||
'common.title': 'Τίτλος',
|
||||
'common.email': 'Email',
|
||||
'common.password': 'Κωδικός',
|
||||
'common.saving': 'Αποθήκευση...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'מה שהזנתם כאן עדיין לא נשמר.',
|
||||
'common.keepEditing': 'להמשיך לערוך',
|
||||
'common.name': 'שם',
|
||||
'common.title': 'כותרת',
|
||||
'common.email': 'אימייל',
|
||||
'common.password': 'סיסמה',
|
||||
'common.saving': 'שומר...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Amit itt megadtál, még nincs elmentve.',
|
||||
'common.keepEditing': 'Szerkesztés folytatása',
|
||||
'common.name': 'Név',
|
||||
'common.title': 'Cím',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Jelszó',
|
||||
'common.saving': 'Mentés...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Yang kamu masukkan di sini belum disimpan.',
|
||||
'common.keepEditing': 'Lanjut mengedit',
|
||||
'common.name': 'Nama',
|
||||
'common.title': 'Judul',
|
||||
'common.email': 'Email', // same-as-en
|
||||
'common.password': 'Kata sandi',
|
||||
'common.saving': 'Menyimpan...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Quello che hai inserito qui non è ancora stato salvato.',
|
||||
'common.keepEditing': 'Continua a modificare',
|
||||
'common.name': 'Nome',
|
||||
'common.title': 'Titolo',
|
||||
'common.email': 'Email', // same-as-en
|
||||
'common.password': 'Password', // same-as-en
|
||||
'common.saving': 'Salvataggio...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'ここで入力した内容はまだ保存されていません。',
|
||||
'common.keepEditing': '編集を続ける',
|
||||
'common.name': '名前',
|
||||
'common.title': 'タイトル',
|
||||
'common.email': 'メールアドレス',
|
||||
'common.password': 'パスワード',
|
||||
'common.saving': '保存中…',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': '여기에 입력한 내용은 아직 저장되지 않았습니다.',
|
||||
'common.keepEditing': '계속 편집',
|
||||
'common.name': '이름',
|
||||
'common.title': '제목',
|
||||
'common.email': '이메일',
|
||||
'common.password': '비밀번호',
|
||||
'common.saving': '저장 중...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Wat je hier hebt ingevuld, is nog niet opgeslagen.',
|
||||
'common.keepEditing': 'Verder bewerken',
|
||||
'common.name': 'Naam',
|
||||
'common.title': 'Titel',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Wachtwoord',
|
||||
'common.saving': 'Opslaan...',
|
||||
|
||||
@@ -36,6 +36,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'To, co tu wpisano, nie zostało jeszcze zapisane.',
|
||||
'common.keepEditing': 'Kontynuuj edycję',
|
||||
'common.name': 'Nazwa',
|
||||
'common.title': 'Tytuł',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Hasło',
|
||||
'common.saving': 'Zapisywanie...',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const airport: TranslationStrings = {
|
||||
'airport.searchPlaceholder': 'Код аэропорта или город (напр. FRA)',
|
||||
'airport.searchPlaceholder': 'Аэропорт или город',
|
||||
};
|
||||
export default airport;
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Введённое здесь ещё не сохранено.',
|
||||
'common.keepEditing': 'Продолжить',
|
||||
'common.name': 'Имя',
|
||||
'common.title': 'Название',
|
||||
'common.email': 'Эл. почта',
|
||||
'common.password': 'Пароль',
|
||||
'common.saving': 'Сохранение...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'To, čo ste tu zadali, ešte nie je uložené.',
|
||||
'common.keepEditing': 'Pokračovať v úpravách',
|
||||
'common.name': 'Meno',
|
||||
'common.title': 'Názov',
|
||||
'common.email': 'E-mail',
|
||||
'common.password': 'Heslo',
|
||||
'common.saving': 'Ukladá sa...',
|
||||
|
||||
@@ -52,6 +52,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Det du har fyllt i här är inte sparat än.',
|
||||
'common.keepEditing': 'Fortsätt redigera',
|
||||
'common.name': 'Namn',
|
||||
'common.title': 'Titel',
|
||||
'common.email': 'E-post',
|
||||
'common.password': 'Lösenord',
|
||||
'common.saving': 'Sparar...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'สิ่งที่คุณกรอกไว้ที่นี่ยังไม่ได้บันทึก',
|
||||
'common.keepEditing': 'แก้ไขต่อ',
|
||||
'common.name': 'ชื่อ',
|
||||
'common.title': 'ชื่อเรื่อง',
|
||||
'common.email': 'อีเมล',
|
||||
'common.password': 'รหัสผ่าน',
|
||||
'common.saving': 'กำลังบันทึก...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Buraya girdikleriniz henüz kaydedilmedi.',
|
||||
'common.keepEditing': 'Düzenlemeye devam et',
|
||||
'common.name': 'Ad',
|
||||
'common.title': 'Başlık',
|
||||
'common.email': 'E-posta',
|
||||
'common.password': 'Şifre',
|
||||
'common.saving': 'Kaydediliyor...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Введене тут ще не збережено.',
|
||||
'common.keepEditing': 'Продовжити редагування',
|
||||
'common.name': "Ім'я",
|
||||
'common.title': 'Назва',
|
||||
'common.email': 'Ел. пошта',
|
||||
'common.password': 'Пароль',
|
||||
'common.saving': 'Збереження...',
|
||||
|
||||
@@ -52,6 +52,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': 'Những gì bạn nhập ở đây chưa được lưu.',
|
||||
'common.keepEditing': 'Tiếp tục chỉnh sửa',
|
||||
'common.name': 'Tên',
|
||||
'common.title': 'Tiêu đề',
|
||||
'common.email': 'Email', // same-as-en
|
||||
'common.password': 'Mật khẩu',
|
||||
'common.saving': 'Đang lưu...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': '你在這裡輸入的內容尚未儲存。',
|
||||
'common.keepEditing': '繼續編輯',
|
||||
'common.name': '名稱',
|
||||
'common.title': '標題',
|
||||
'common.email': '郵箱',
|
||||
'common.password': '密碼',
|
||||
'common.saving': '儲存中...',
|
||||
|
||||
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
|
||||
'common.unsavedMessage': '你在这里输入的内容尚未保存。',
|
||||
'common.keepEditing': '继续编辑',
|
||||
'common.name': '名称',
|
||||
'common.title': '标题',
|
||||
'common.email': '邮箱',
|
||||
'common.password': '密码',
|
||||
'common.saving': '保存中...',
|
||||
|
||||
Reference in New Issue
Block a user