feat(trip-settings): edit the trip inside its settings, in one look across the areas and the trip form

Edit trip is an area of the settings dialog, beside People, Sharing and
Journal, instead of a second dialog. The side column names the areas only, the
dialog keeps one height, and every area uses the same cards, fields and
buttons; the trip form on its own takes the same head, cards and buttons. A
trip that loads quickly opens without the splash on the desktop too.
This commit is contained in:
Maurice
2026-11-13 12:40:16 +01:00
parent 721a610a70
commit bd58f17ec0
47 changed files with 892 additions and 522 deletions
+353 -330
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal'
import { Calendar, Camera, Search, X, UserPlus, Bell } from 'lucide-react'
import { useId, useState, useEffect, useRef } from 'react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
import { CardHead, INPUT, LABEL, PANEL } from '../shared/dialogParts'
import { CalendarDays, Camera, Image as ImageIcon, Pencil, Plane, Search, Type, X } from 'lucide-react'
import { tripsApi, authApi } from '../../api/client'
import CustomSelect from '../shared/CustomSelect'
import { useAuthStore } from '../../store/authStore'
@@ -43,10 +44,17 @@ interface TripFormModalProps {
onSave: (data: TripCreateRequest & { date_shift_mode?: DateShiftMode }) => Promise<{ trip?: Trip } | void> | void
trip: Trip | null
onCoverUpdate?: (tripId: number, coverUrl: string | null) => void
/**
* Drawn inside the trip settings rather than as a dialog of its own (#2811): no
* frame and no Cancel, the settings around it close it. One column there, since
* beside the settings' own side column two would cut the dates and wrap the chips.
*/
embedded?: boolean
}
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }: TripFormModalProps) {
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate, embedded = false }: TripFormModalProps) {
const isEditing = !!trip
const labelId = useId()
const fileRef = useRef<HTMLInputElement>(null)
const toast = useToast()
const { t } = useTranslation()
@@ -256,352 +264,367 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
variables are reachable from here — what carries over is the geometry:
generous radii, grouped panels instead of a single stack of labelled
fields, and overline captions rather than sentence-case labels. */
const inputCls = "w-full px-3.5 py-2.5 border border-edge rounded-xl bg-surface-input text-content placeholder:text-content-faint focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent text-body transition-shadow"
const labelCls = "flex items-center gap-1 text-caption font-semibold uppercase tracking-[0.14em] text-content-faint mb-2"
const ghostBtnCls = "px-4 py-2.5 text-body font-medium text-content-secondary hover:text-content border border-edge rounded-xl hover:bg-surface-hover transition-colors"
const primaryBtnCls = "px-5 py-2.5 text-body font-medium bg-accent hover:bg-accent-hover disabled:opacity-50 text-accent-text rounded-xl shadow-card transition-colors flex items-center gap-2"
const dangerBtnCls = "px-5 py-2.5 text-body font-medium whitespace-nowrap bg-danger hover:opacity-90 disabled:opacity-50 text-white rounded-xl shadow-card transition-opacity flex items-center gap-2"
// The dialogs' own language: their fields, eyebrows, panels and buttons, so the
// form reads alike on its own and inside the trip settings.
const inputCls = INPUT
const labelCls = `${LABEL.replace('block ', '')} flex items-center gap-1`
/* Two columns once there is room for them: the form had grown to eight stacked
blocks and the create button sat a full screen below the title. Left is what
the trip looks like, right is when and with whom. One column below md. */
const columnCls = "flex flex-col gap-4"
const panelCls = "rounded-2xl border border-edge bg-surface-secondary p-4 space-y-3.5"
const panelCls = PANEL
return (
<Modal
isOpen={isOpen}
// In the review step Escape, the close button and a click beside the
// dialog go back to the form, like Back, instead of throwing the whole
// edit away. Outside that step they close the dialog as before.
onClose={pendingReview ? () => { if (!busy) setPendingReview(null) } : onClose}
title={pendingReview
? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle')
: isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
/* The review step is a few radio buttons and a short list; alone, either
takes the width of the day dialog, where the same list stands. With both,
they stand side by side at the form's own width. */
size={pendingReview && !dateReviewIsWide(pendingReview.askShift, pendingReview.removal) ? 'lg' : '2xl'}
footer={
<div className="flex gap-3 justify-end">
{pendingReview ? (
<>
<button type="button" onClick={() => setPendingReview(null)} disabled={busy} className={ghostBtnCls}>
{t('common.back')}
</button>
{/* Removing days with something on them is the one save here that
loses work, so it asks in the danger tone and says so. */}
<button type="button" onClick={() => performSave(pendingReview.askShift ? { ...pendingReview.payload, date_shift_mode: dateShiftMode } : pendingReview.payload)} disabled={busy}
className={pendingReview.removal ? dangerBtnCls : primaryBtnCls}>
{isLoading
? <><div className={`w-4 h-4 border-2 rounded-full animate-spin ${pendingReview.removal ? 'border-white/30 border-t-white' : 'border-accent-text/30 border-t-accent-text'}`} />{t('common.saving')}</>
: t(pendingReview.removal ? 'dashboard.shrinkConfirm' : 'common.update')}
</button>
</>
) : (
<>
<button type="button" onClick={onClose} className={ghostBtnCls}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSubmit} disabled={busy} className={primaryBtnCls}>
{busy
? <><div className="w-4 h-4 border-2 border-accent-text/30 border-t-accent-text rounded-full animate-spin" />{t('common.saving')}</>
: isEditing ? t('common.update') : t('dashboard.createTrip')}
</button>
</>
)}
</div>
}
>
{pendingReview && (
<TripDateReview
askShift={pendingReview.askShift}
shiftMode={dateShiftMode}
onShiftMode={setDateShiftMode}
removal={pendingReview.removal}
error={error}
/>
const reviewTitle = pendingReview ? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle') : ''
const buttons = pendingReview ? (
<>
<DialogButton onClick={() => setPendingReview(null)} disabled={busy}>{t('common.back')}</DialogButton>
{/* Removing days with something on them is the one save here that
loses work, so it asks in the danger tone and says so. */}
<DialogButton variant={pendingReview.removal ? 'danger' : 'primary'} disabled={busy}
onClick={() => performSave(pendingReview.askShift ? { ...pendingReview.payload, date_shift_mode: dateShiftMode } : pendingReview.payload)}>
{isLoading ? t('common.saving') : t(pendingReview.removal ? 'dashboard.shrinkConfirm' : 'common.update')}
</DialogButton>
</>
) : (
<>
{!embedded && <DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>}
<DialogButton variant="primary" onClick={handleSubmit} disabled={busy}>
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
</DialogButton>
</>
)
const content = (
<>
{pendingReview && (
<TripDateReview
askShift={pendingReview.askShift}
shiftMode={dateShiftMode}
onShiftMode={setDateShiftMode}
removal={pendingReview.removal}
error={error}
/>
)}
<form onSubmit={handleSubmit} className={pendingReview ? 'hidden' : 'space-y-4'} onPaste={handlePaste}>
{error && (
<div className="p-3 bg-danger-soft border border-danger/30 rounded-xl text-body text-danger">{error}</div>
)}
<form onSubmit={handleSubmit} className={pendingReview ? 'hidden' : 'space-y-4'} onPaste={handlePaste}>
{error && (
<div className="p-3 bg-danger-soft border border-danger/30 rounded-xl text-body text-danger">{error}</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 items-start">
<div className={`grid grid-cols-1 gap-x-6 gap-y-4 items-start ${embedded ? '' : 'md:grid-cols-2'}`}>
{/* Left column — what the trip is: its picture, its name, what it is about. */}
<div className={columnCls}>
{/* Left column — what the trip is: its picture, its name, what it is about. */}
<div className={columnCls}>
{/* Cover image — gated by trip_cover_upload permission */}
{canUploadCover && <div className={panelCls}>
<label className={labelCls}>{t('dashboard.coverImage')}</label>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleCoverChange} />
{coverPreview ? (
<div className="relative h-[130px] rounded-xl overflow-hidden">
<img src={coverPreview} alt="" className="w-full h-full object-cover" />
<div className="absolute bottom-2 end-2 flex gap-1.5">
{/* Chrome sitting on top of a photo, so it is deliberately dark in
both themes rather than following the surface tokens. */}
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
className="flex items-center gap-1 px-2.5 py-[5px] rounded-lg bg-black/55 backdrop-blur-sm text-white text-caption font-semibold">
<Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')}
</button>
<button type="button" onClick={handleRemoveCover} aria-label={t('common.remove')}
className="flex items-center px-2 py-[5px] rounded-lg bg-black/55 backdrop-blur-sm text-white">
<X size={12} />
</button>
</div>
{/* Cover image — gated by trip_cover_upload permission */}
{canUploadCover && <div className={panelCls}>
<CardHead icon={<ImageIcon size={16} strokeWidth={2} />} title={t('trip.form.coverTitle')} hint={t('trip.form.coverHint')} />
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleCoverChange} />
{coverPreview ? (
<div className={`relative h-[110px] rounded-xl overflow-hidden`}>
<img src={coverPreview} alt="" className="w-full h-full object-cover" />
<div className="absolute bottom-2 end-2 flex gap-1.5">
{/* Chrome sitting on top of a photo, so it is deliberately dark in
both themes rather than following the surface tokens. */}
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
className="flex items-center gap-1 px-2.5 py-[5px] rounded-lg bg-black/55 backdrop-blur-sm text-white text-caption font-semibold">
<Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')}
</button>
<button type="button" onClick={handleRemoveCover} aria-label={t('common.remove')}
className="flex items-center px-2 py-[5px] rounded-lg bg-black/55 backdrop-blur-sm text-white">
<X size={12} />
</button>
</div>
) : (
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
onDragOver={e => { e.preventDefault(); setCoverDragActive(true) }}
onDragLeave={() => setCoverDragActive(false)}
onDrop={e => { e.preventDefault(); setCoverDragActive(false); const file = e.dataTransfer.files?.[0]; if (file?.type.startsWith('image/')) void handleCoverSelect(file) }}
className={`w-full h-[130px] px-4 border-2 border-dashed rounded-xl flex items-center justify-center gap-1.5 text-body transition-colors ${
coverDragActive
? 'border-accent bg-accent-subtle text-content'
: 'border-edge text-content-faint hover:border-edge-secondary hover:text-content-muted'
}`}>
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
</button>
)}
<div className="mt-2 flex gap-2">
<input
type="text"
value={coverSearchQuery}
onChange={e => setCoverSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void handleCoverSearch() } }}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
className={inputCls}
/>
<button type="button" onClick={handleCoverSearch} disabled={searchingCover || (!coverSearchQuery.trim() && !formData.title.trim())}
className="px-3 py-2 text-body text-content-secondary border border-edge rounded-xl hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5 whitespace-nowrap">
{searchingCover ? <div className="w-4 h-4 border-2 border-edge border-t-content-muted rounded-full animate-spin" /> : <Search size={14} />}
{t('dashboard.searchUnsplash')}
</button>
</div>
{coverSearchError && <p className="text-caption text-danger mt-1.5">{coverSearchError}</p>}
{coverSearchResults.length > 0 && (
<div className="grid grid-cols-3 gap-2 mt-2">
{coverSearchResults.map(photo => (
<button
type="button"
key={photo.id}
onClick={() => handleUnsplashSelect(photo)}
aria-label={t('dashboard.useUnsplashPhoto', { photographer: photo.photographer || 'Unsplash' })}
className={`relative h-20 overflow-hidden rounded-xl border transition-colors ${coverPreview === photo.url ? 'border-accent ring-2 ring-accent/20' : 'border-edge hover:border-content-faint'}`}
>
<img src={photo.thumb} alt={photo.description || ''} loading="lazy" className="w-full h-full object-cover" />
{photo.photographer && (
<span className="absolute inset-x-0 bottom-0 truncate bg-black/55 px-1.5 py-1 text-caption text-white">
{photo.photographer}
</span>
)}
) : (
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
onDragOver={e => { e.preventDefault(); setCoverDragActive(true) }}
onDragLeave={() => setCoverDragActive(false)}
onDrop={e => { e.preventDefault(); setCoverDragActive(false); const file = e.dataTransfer.files?.[0]; if (file?.type.startsWith('image/')) void handleCoverSelect(file) }}
className={`w-full h-11 border-[1.5px] rounded-[10px] text-[length:calc(13px*var(--fs-scale-body,1))] px-4 border-dashed flex items-center justify-center gap-1.5 transition-colors ${
coverDragActive
? 'border-accent bg-accent-subtle text-content'
: 'border-edge text-content-faint hover:border-edge-secondary hover:text-content-muted'
}`}>
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
</button>
)}
<div className="mt-2 flex gap-2">
<input
type="text"
value={coverSearchQuery}
onChange={e => setCoverSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void handleCoverSearch() } }}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
className={inputCls}
/>
<button type="button" onClick={handleCoverSearch} disabled={searchingCover || (!coverSearchQuery.trim() && !formData.title.trim())}
className='inline-flex flex-none items-center gap-1.5 whitespace-nowrap rounded-[10px] bg-accent px-4 py-2 text-[length:calc(13px*var(--fs-scale-body,1))] font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50'>
{searchingCover ? <div className="w-4 h-4 border-2 border-edge border-t-content-muted rounded-full animate-spin" /> : <Search size={14} />}
{t('dashboard.searchUnsplash')}
</button>
</div>
{coverSearchError && <p className="text-caption text-danger mt-1.5">{coverSearchError}</p>}
{coverSearchResults.length > 0 && (
<div className="grid grid-cols-3 gap-2 mt-2">
{coverSearchResults.map(photo => (
<button
type="button"
key={photo.id}
onClick={() => handleUnsplashSelect(photo)}
aria-label={t('dashboard.useUnsplashPhoto', { photographer: photo.photographer || 'Unsplash' })}
className={`relative h-20 overflow-hidden rounded-xl border transition-colors ${coverPreview === photo.url ? 'border-accent ring-2 ring-accent/20' : 'border-edge hover:border-content-faint'}`}
>
<img src={photo.thumb} alt={photo.description || ''} loading="lazy" className="w-full h-full object-cover" />
{photo.photographer && (
<span className="absolute inset-x-0 bottom-0 truncate bg-black/55 px-1.5 py-1 text-caption text-white">
{photo.photographer}
</span>
)}
</button>
))}
</div>
)}
</div>}
<div className={`${panelCls} flex-1`}>
<CardHead icon={<Type size={16} strokeWidth={2} />} title={t('trip.form.aboutTitle')} hint={t('trip.form.aboutHint')} />
<div>
<label className={labelCls}>
{t('dashboard.tripTitle')} <span className="text-danger">*</span>
</label>
<input type="text" value={formData.title} onChange={e => canEditTrip && update('title', e.target.value)}
required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
</div>
<div>
<label className={labelCls}>{t('dashboard.tripDescription')}</label>
<textarea value={formData.description} onChange={e => canEditTrip && update('description', e.target.value)}
readOnly={!canEditTrip} placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
className={`${inputCls} resize-none`} />
</div>
</div>
</div>{/* /left column */}
{/* Right column — when it happens, in what currency, with whom. */}
<div className={columnCls}>
<div className={panelCls}>
<CardHead icon={<CalendarDays size={16} strokeWidth={2} />} title={t('trip.form.whenTitle')} hint={t('trip.form.whenHint')} />
{/* min-w-0 on both cells: a grid item defaults to min-width:auto, so a
long date ("12. Sept. 2026" and the keyboard button beside it) pushed
the cell past its track and the two columns overlapped (#2267). */}
<div className="grid grid-cols-2 gap-3">
<div className="min-w-0">
<label className={labelCls}>
{t('dashboard.startDate')}
</label>
<CustomDatePicker value={formData.start_date} onChange={v => update('start_date', v)} placeholder={t('dashboard.startDate')} />
</div>
<div className="min-w-0">
<label className={labelCls}>
{t('dashboard.endDate')}
</label>
<CustomDatePicker value={formData.end_date} onChange={v => update('end_date', v)} placeholder={t('dashboard.endDate')} />
</div>
</div>
{!formData.start_date && !formData.end_date && (
<div>
<label className={labelCls}>
{t('dashboard.dayCount')}
</label>
<NumericInput min={1} max={MAX_TRIP_DAYS} value={formData.day_count}
onValueChange={raw => {
if (raw === '') { update('day_count', ''); return }
const n = Math.floor(Number(raw))
if (Number.isFinite(n)) update('day_count', Math.min(MAX_TRIP_DAYS, Math.max(1, n)))
}}
className={inputCls} />
<p className="text-caption text-content-faint mt-1.5">{t('dashboard.dayCountHint')}</p>
</div>
)}
<div>
<label className={labelCls}>{t('dashboard.currency')}</label>
<CustomSelect
value={formData.currency}
onChange={v => canEditTrip && update('currency', v)}
disabled={!canEditTrip}
options={currenciesWith(formData.currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
</div>
{/* Reminder — only visible to owner (or when creating) */}
{(!isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin') && (
<div className={!tripRemindersEnabled ? 'opacity-50' : ''}>
<label className={labelCls}>
{t('trips.reminder')}
</label>
{!tripRemindersEnabled ? (
<p className="text-caption text-content-faint bg-surface rounded-xl p-3">
{t('trips.reminderDisabledHint')}
</p>
) : (
<>
<div className="flex flex-wrap gap-2">
{[
{ value: 0, label: t('trips.reminderNone') },
{ value: 1, label: `1 ${t('trips.reminderDay')}` },
{ value: 3, label: `3 ${t('trips.reminderDays')}` },
{ value: 9, label: `9 ${t('trips.reminderDays')}` },
].map(opt => (
<button key={opt.value} type="button"
onClick={() => { update('reminder_days', opt.value); setCustomReminder(false) }}
className={`px-3 py-1.5 text-caption font-medium rounded-full border transition-colors ${
!customReminder && formData.reminder_days === opt.value
? 'bg-accent text-accent-text border-accent'
: 'bg-surface text-content-secondary border-edge hover:border-content-faint'
}`}>
{opt.label}
</button>
))}
<button type="button"
onClick={() => { setCustomReminder(true); if ([0, 1, 3, 9].includes(formData.reminder_days)) update('reminder_days', 7) }}
className={`px-3 py-1.5 text-caption font-medium rounded-full border transition-colors ${
customReminder
? 'bg-accent text-accent-text border-accent'
: 'bg-surface text-content-secondary border-edge hover:border-content-faint'
}`}>
{t('trips.reminderCustom')}
</button>
</div>
{customReminder && (
<div className="flex items-center gap-2 mt-2">
<NumericInput min={1} max={30}
value={formData.reminder_days}
onValueChange={raw => update('reminder_days', Math.max(1, Math.min(30, Number(raw) || 1)))}
className="w-20 px-3 py-1.5 border border-edge rounded-xl bg-surface-input text-body text-content focus:outline-none focus:ring-2 focus:ring-accent/40" />
<span className="text-caption text-content-muted">{t('trips.reminderDaysBefore')}</span>
</div>
)}
</>
)}
</div>
)}
{/* Members. Not inside the trip settings, whose People area is where they are managed. */}
{!embedded && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
<div>
<label className={labelCls}>
{t('dashboard.addMembers')}
</label>
{/* Existing members (editing mode) */}
{isEditing && existingMembers.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{existingMembers.map(m => (
<button type="button" key={m.id} disabled={m.id === currentUser?.id}
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-[3px] text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content shadow-sm ring-1 ring-edge-faint"
onClick={async () => {
if (m.id === currentUser?.id) return
if (isEffectivelyOffline()) { toast.error(t('common.needsConnection')); return }
try {
await tripsApi.removeMember(trip!.id, m.id)
setExistingMembers(prev => prev.filter(x => x.id !== m.id))
toast.success(t('trips.memberRemoved', { username: m.username }))
} catch { toast.error(t('trips.memberRemoveError')) }
}}
style={{ cursor: m.id === currentUser?.id ? 'default' : 'pointer' }}>
{m.username}
{m.id !== currentUser?.id && <X size={11} className="text-content-faint" />}
</button>
))}
</div>
)}
</div>}
<div className={`${panelCls} flex-1`}>
<div>
<label className={labelCls}>
{t('dashboard.tripTitle')} <span className="text-danger">*</span>
</label>
<input type="text" value={formData.title} onChange={e => canEditTrip && update('title', e.target.value)}
required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
</div>
<div>
<label className={labelCls}>{t('dashboard.tripDescription')}</label>
<textarea value={formData.description} onChange={e => canEditTrip && update('description', e.target.value)}
readOnly={!canEditTrip} placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
className={`${inputCls} resize-none`} />
</div>
</div>
</div>{/* /left column */}
{/* Right column — when it happens, in what currency, with whom. */}
<div className={columnCls}>
<div className={panelCls}>
{/* min-w-0 on both cells: a grid item defaults to min-width:auto, so a
long date ("12. Sept. 2026" and the keyboard button beside it) pushed
the cell past its track and the two columns overlapped (#2267). */}
<div className="grid grid-cols-2 gap-3">
<div className="min-w-0">
<label className={labelCls}>
<Calendar className="w-3.5 h-3.5" />{t('dashboard.startDate')}
</label>
<CustomDatePicker value={formData.start_date} onChange={v => update('start_date', v)} placeholder={t('dashboard.startDate')} />
</div>
<div className="min-w-0">
<label className={labelCls}>
<Calendar className="w-3.5 h-3.5" />{t('dashboard.endDate')}
</label>
<CustomDatePicker value={formData.end_date} onChange={v => update('end_date', v)} placeholder={t('dashboard.endDate')} />
</div>
</div>
{!formData.start_date && !formData.end_date && (
<div>
<label className={labelCls}>
{t('dashboard.dayCount')}
</label>
<NumericInput min={1} max={MAX_TRIP_DAYS} value={formData.day_count}
onValueChange={raw => {
if (raw === '') { update('day_count', ''); return }
const n = Math.floor(Number(raw))
if (Number.isFinite(n)) update('day_count', Math.min(MAX_TRIP_DAYS, Math.max(1, n)))
}}
className={inputCls} />
<p className="text-caption text-content-faint mt-1.5">{t('dashboard.dayCountHint')}</p>
</div>
)}
<div>
<label className={labelCls}>{t('dashboard.currency')}</label>
<CustomSelect
value={formData.currency}
onChange={v => canEditTrip && update('currency', v)}
disabled={!canEditTrip}
options={currenciesWith(formData.currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
</div>
{/* Reminder — only visible to owner (or when creating) */}
{(!isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin') && (
<div className={!tripRemindersEnabled ? 'opacity-50' : ''}>
<label className={labelCls}>
<Bell className="w-3.5 h-3.5" />{t('trips.reminder')}
</label>
{!tripRemindersEnabled ? (
<p className="text-caption text-content-faint bg-surface rounded-xl p-3">
{t('trips.reminderDisabledHint')}
</p>
) : (
<>
<div className="flex flex-wrap gap-2">
{[
{ value: 0, label: t('trips.reminderNone') },
{ value: 1, label: `1 ${t('trips.reminderDay')}` },
{ value: 3, label: `3 ${t('trips.reminderDays')}` },
{ value: 9, label: `9 ${t('trips.reminderDays')}` },
].map(opt => (
<button key={opt.value} type="button"
onClick={() => { update('reminder_days', opt.value); setCustomReminder(false) }}
className={`px-3 py-1.5 text-caption font-medium rounded-full border transition-colors ${
!customReminder && formData.reminder_days === opt.value
? 'bg-accent text-accent-text border-accent'
: 'bg-surface text-content-secondary border-edge hover:border-content-faint'
}`}>
{opt.label}
{/* Newly selected members (both modes) */}
{selectedMembers.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{selectedMembers.map(uid => {
const user = allUsers.find(u => u.id === uid)!
return (
<button type="button" key={uid} onClick={() => setSelectedMembers(prev => prev.filter(id => id !== uid))}
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-[3px] text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content shadow-sm ring-1 ring-edge-faint cursor-pointer">
{user.username}
<X size={11} className="text-content-faint" />
</button>
))}
<button type="button"
onClick={() => { setCustomReminder(true); if ([0, 1, 3, 9].includes(formData.reminder_days)) update('reminder_days', 7) }}
className={`px-3 py-1.5 text-caption font-medium rounded-full border transition-colors ${
customReminder
? 'bg-accent text-accent-text border-accent'
: 'bg-surface text-content-secondary border-edge hover:border-content-faint'
}`}>
{t('trips.reminderCustom')}
</button>
</div>
{customReminder && (
<div className="flex items-center gap-2 mt-2">
<NumericInput min={1} max={30}
value={formData.reminder_days}
onValueChange={raw => update('reminder_days', Math.max(1, Math.min(30, Number(raw) || 1)))}
className="w-20 px-3 py-1.5 border border-edge rounded-xl bg-surface-input text-body text-content focus:outline-none focus:ring-2 focus:ring-accent/40" />
<span className="text-caption text-content-muted">{t('trips.reminderDaysBefore')}</span>
</div>
)}
</>
)}
</div>
)}
{/* Members */}
{allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
<div>
<label className={labelCls}>
<UserPlus className="w-3.5 h-3.5" />{t('dashboard.addMembers')}
</label>
{/* Existing members (editing mode) */}
{isEditing && existingMembers.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{existingMembers.map(m => (
<button type="button" key={m.id} disabled={m.id === currentUser?.id}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-body font-medium bg-surface text-content border border-edge"
onClick={async () => {
if (m.id === currentUser?.id) return
if (isEffectivelyOffline()) { toast.error(t('common.needsConnection')); return }
try {
await tripsApi.removeMember(trip!.id, m.id)
setExistingMembers(prev => prev.filter(x => x.id !== m.id))
toast.success(t('trips.memberRemoved', { username: m.username }))
} catch { toast.error(t('trips.memberRemoveError')) }
}}
style={{ cursor: m.id === currentUser?.id ? 'default' : 'pointer' }}>
{m.username}
{m.id !== currentUser?.id && <X size={11} className="text-content-faint" />}
</button>
))}
</div>
)}
{/* Newly selected members (both modes) */}
{selectedMembers.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{selectedMembers.map(uid => {
const user = allUsers.find(u => u.id === uid)!
return (
<button type="button" key={uid} onClick={() => setSelectedMembers(prev => prev.filter(id => id !== uid))}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-body font-medium bg-surface text-content border border-edge cursor-pointer">
{user.username}
<X size={11} className="text-content-faint" />
</button>
)
})}
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<CustomSelect
value={memberSelectValue}
onChange={async value => {
if (isEditing && trip?.id) {
const user = allUsers.find(u => u.id === Number(value))
if (user && isEffectivelyOffline()) {
toast.error(t('common.needsConnection'))
} else if (user) {
try {
await tripsApi.addMember(trip.id, user.username)
setExistingMembers(prev => [...prev, { id: user.id, username: user.username }])
toast.success(t('trips.memberAdded', { username: user.username }))
} catch { toast.error(t('trips.memberAddError')) }
}
} else {
setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)])
}
setMemberSelectValue('')
}}
placeholder={t('dashboard.addMember')}
options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id) && !existingMembers.some(m => m.id === u.id)).map(u => ({ value: u.id, label: u.username }))}
searchable
size="sm"
/>
)
})}
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<CustomSelect
value={memberSelectValue}
onChange={async value => {
if (isEditing && trip?.id) {
const user = allUsers.find(u => u.id === Number(value))
if (user && isEffectivelyOffline()) {
toast.error(t('common.needsConnection'))
} else if (user) {
try {
await tripsApi.addMember(trip.id, user.username)
setExistingMembers(prev => [...prev, { id: user.id, username: user.username }])
toast.success(t('trips.memberAdded', { username: user.username }))
} catch { toast.error(t('trips.memberAddError')) }
}
} else {
setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)])
}
setMemberSelectValue('')
}}
placeholder={t('dashboard.addMember')}
options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id) && !existingMembers.some(m => m.id === u.id)).map(u => ({ value: u.id, label: u.username }))}
searchable
size="sm"
/>
</div>
)}
</div>
)}
</div>{/* /right panel */}
</div>{/* /right panel */}
</div>{/* /right column */}
</div>{/* /right column */}
</div>{/* /two-column grid */}
</form>
</Modal>
</div>{/* /two-column grid */}
</form>
</>
)
if (embedded) {
return (
<div className="flex flex-col gap-4">
{pendingReview && <h4 className="m-0 text-subtitle font-semibold text-content">{reviewTitle}</h4>}
{content}
<div className="flex justify-end gap-2">{buttons}</div>
</div>
)
}
return (
<DialogShell
open={isOpen}
// In the review step Escape, the close button and a click beside the
// dialog go back to the form, like Back, instead of throwing the whole
// edit away. Outside that step they close the dialog as before.
onClose={pendingReview ? () => { if (!busy) setPendingReview(null) } : onClose}
labelledBy={labelId}
/* The review step is a few radio buttons and a short list; alone, either
takes the width of the day dialog, where the same list stands. With both,
they stand side by side at the form's own width. */
width={pendingReview && !dateReviewIsWide(pendingReview.askShift, pendingReview.removal) ? 'detail' : 'wide'}
header={(
<DialogHeader
tile={<DialogTile>{isEditing ? <Pencil size={20} strokeWidth={1.9} className="text-content-muted" /> : <Plane size={20} strokeWidth={1.9} className="text-content-muted" />}</DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={pendingReview ? () => { if (!busy) setPendingReview(null) } : onClose}
eyebrow={pendingReview ? undefined : isEditing ? t('dashboard.editTrip') : undefined}
title={pendingReview ? reviewTitle : isEditing ? (trip?.title || t('dashboard.editTrip')) : t('dashboard.createTrip')}
/>
)}
footer={<DialogFooter><FooterSpacer />{buttons}</DialogFooter>}
>
{content}
</DialogShell>
)
}
@@ -80,7 +80,8 @@ describe('TripJournalLinkSection', () => {
it('FE-TJL-SECTION-002: with no journal linked it offers to link one, in its own card', async () => {
render(<TripJournalLinkSection tripId={TRIP} />);
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
expect(screen.getByText('Journal')).toBeInTheDocument();
// The phone's page is titled Journal already; the card opens with what linking does.
expect(screen.getByText(/^Link one of your journals/)).toBeInTheDocument();
});
it('FE-TJL-SECTION-003: picking a journal lists the trip and warns about who gets in for the first time', async () => {
@@ -5,8 +5,7 @@ import { useTranslation } from '../../i18n';
import ConfirmDialog from '../shared/ConfirmDialog';
import CustomSelect from '../shared/CustomSelect';
import { DialogButton, fs } from '../shared/DialogShell';
import { AddRowButton } from '../shared/dialogParts';
import { LinkedJournalIcon } from './LinkedJournalIcon';
import { AddRowButton, PANEL } from '../shared/dialogParts';
import { TripMemberAvatar } from './TripMemberAvatar';
import { LinkCard } from './TripShareDialog';
import { useTripJournalLink, type JournalMemberChoice } from './useTripJournalLink';
@@ -14,6 +13,9 @@ import { useTripJournalLink, type JournalMemberChoice } from './useTripJournalLi
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5';
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5';
const SEGMENT = 'rounded-full px-2.5 py-1 font-semibold';
/** The phone sheet's buttons, the round 38px ones its other areas use. */
const SHEET_BUTTON =
'flex h-[38px] min-w-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-full px-3 [&>svg]:flex-none text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold disabled:opacity-40';
const ROLES: { value: TripJournalLinkRole | null; labelKey: string }[] = [
{ value: null, labelKey: 'trip.journal.roleNone' },
@@ -38,36 +40,7 @@ export function TripJournalLinkSection({ tripId, framed = true }: { tripId: numb
return (
<>
<Frame>
{j.mode === 'view' && j.link && <LinkedJournal j={j} />}
{j.mode === 'view' && !j.link && j.canLink && (
<AddRowButton onClick={j.startPick}>{t('trip.journal.link')}</AddRowButton>
)}
{j.mode === 'view' && j.link && (
<div className="flex flex-wrap gap-2">
{j.canEditMembers && (
<DialogButton onClick={j.editMembers} icon={<Users size={14} strokeWidth={2.2} />}>
{t('trip.journal.editMembers')}
</DialogButton>
)}
{j.canLink && (
<DialogButton onClick={j.startPick} icon={<BookOpen size={14} strokeWidth={2.2} />}>
{t('trip.journal.change')}
</DialogButton>
)}
{j.canUnlink && (
<button
type="button"
onClick={() => setAskUnlink(true)}
disabled={j.busy}
className="inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:opacity-50"
style={fs(12.5, 'body')}
>
<Unlink size={13} strokeWidth={2.2} />
{t('trip.journal.unlink')}
</button>
)}
</div>
)}
{j.mode === 'view' && <JournalView j={j} framed={framed} onUnlink={() => setAskUnlink(true)} />}
{j.mode === 'pick' && <JournalPicker j={j} />}
{(j.mode === 'members' || (j.mode === 'pick' && j.pickedId != null)) && <MemberChooser j={j} />}
{j.mode !== 'view' && (
@@ -101,16 +74,70 @@ export function TripJournalLinkSection({ tripId, framed = true }: { tripId: numb
);
}
/**
* The journal as it stands: the linked one, in the dialog's cards or the phone
* card with its buttons, or the way to link one.
*/
function JournalView({ j, framed, onUnlink }: { j: JournalLink; framed: boolean; onUnlink: () => void }) {
const { t } = useTranslation();
if (!j.link) return j.canLink ? <AddRowButton onClick={j.startPick}>{t('trip.journal.link')}</AddRowButton> : null;
if (!framed) return <LinkedJournalCards j={j} onUnlink={onUnlink} />;
return (
<>
<LinkedJournal j={j} />
<SheetActions j={j} onUnlink={onUnlink} />
</>
);
}
/** The phone's ways to change who may open the journal, pick another or unlink it. */
function SheetActions({ j, onUnlink }: { j: JournalLink; onUnlink: () => void }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
{j.canEditMembers && (
<button type="button" onClick={j.editMembers} className={`${SHEET_BUTTON} bg-m-act text-m-actfg`}>
<Users size={14} strokeWidth={2.2} />
{t('trip.journal.editMembers')}
</button>
)}
{(j.canLink || j.canUnlink) && (
<div className="flex gap-2">
{j.canLink && (
<button
type="button"
onClick={j.startPick}
className={`${SHEET_BUTTON} flex-1 bg-[color:var(--m-ic)] text-m-ink`}
>
<BookOpen size={14} strokeWidth={2.2} />
{t('trip.journal.change')}
</button>
)}
{j.canUnlink && (
<button
type="button"
onClick={onUnlink}
disabled={j.busy}
className={`${SHEET_BUTTON} flex-1 bg-[color:var(--m-ic)] text-[color:var(--m-st-danger)]`}
>
<Unlink size={14} strokeWidth={2.2} />
{t('trip.journal.unlink')}
</button>
)}
</div>
)}
</div>
);
}
/** The phone sheet's card: what a linked journal does, then the journal and its people. */
function JournalCard({ children }: { children: ReactNode }) {
const { t } = useTranslation();
return (
<LinkCard
icon={<LinkedJournalIcon size={15} strokeWidth={2.2} />}
title={t('trip.journal.title')}
hint={t('trip.journal.hint')}
>
<section className="flex flex-col gap-3 rounded-[18px] border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] p-3">
<p className="m-0 font-geist text-caption leading-snug text-m-muted">{t('trip.journal.hint')}</p>
{children}
</LinkCard>
</section>
);
}
@@ -120,15 +147,114 @@ function Bare({ children }: { children: ReactNode }) {
type JournalLink = ReturnType<typeof useTripJournalLink>;
/** The linked journal: cover, title, what the reader may do in it, and who it is open to. */
/**
* The linked journal in the desktop settings, in cards like the Sharing area's:
* the journal itself with the ways to change or unlink it, then who of the trip
* may open it, as rows like the People area's.
*/
function LinkedJournalCards({ j, onUnlink }: { j: JournalLink; onUnlink: () => void }) {
const { t } = useTranslation();
const link = j.link;
if (!link) return null;
const members = link.members ?? [];
return (
<>
<section className={PANEL}>
<div className="flex items-center gap-3">
<span className="grid h-11 w-11 flex-none place-items-center overflow-hidden rounded-[10px] bg-surface-card text-content-muted shadow-sm">
{link.cover_image ? (
<img src={`/uploads/${link.cover_image}`} alt="" className="h-full w-full object-cover" />
) : (
<BookOpen size={18} strokeWidth={2} />
)}
</span>
<div className="min-w-0 flex-1">
<h3 className="m-0 truncate font-semibold text-content" style={fs(13.5, 'body')}>
{link.title}
</h3>
{link.my_role && (
<p className="m-0 mt-0.5 truncate text-content-faint" style={fs(11.5)}>
{t(`trip.journal.yourRole.${link.my_role}`)}
</p>
)}
</div>
<span
className="inline-flex flex-none items-center gap-1 rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success"
style={fs(10.5)}
>
<Link2 size={10} strokeWidth={2.6} />
{t('trip.journal.linkedBadge')}
</span>
</div>
{(j.canLink || j.canUnlink) && (
<div className="flex flex-wrap gap-2">
{j.canLink && (
<DialogButton variant="primary" onClick={j.startPick} icon={<BookOpen size={14} strokeWidth={2.2} />}>
{t('trip.journal.change')}
</DialogButton>
)}
{j.canUnlink && (
<DialogButton
variant="danger"
onClick={onUnlink}
disabled={j.busy}
icon={<Unlink size={14} strokeWidth={2.2} />}
>
{t('trip.journal.unlink')}
</DialogButton>
)}
</div>
)}
</section>
<LinkCard
icon={<Users size={15} strokeWidth={2.2} />}
title={t('trip.journal.members')}
hint={t('trip.journal.membersHint')}
>
{members.length > 0 ? (
<div className={ROWS}>
{members.map((m) => (
<div key={m.user_id} className={ROW}>
<TripMemberAvatar username={m.username} avatarUrl={m.avatar_url} />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
{m.username}
</span>
<span
className="flex-none rounded-full bg-surface-tertiary px-2 py-[2px] font-semibold text-content-muted"
style={fs(10.5)}
>
{t(m.role === 'editor' ? 'trip.journal.roleEditor' : 'trip.journal.roleViewer')}
</span>
</div>
))}
</div>
) : (
<p className="m-0 text-content-faint" style={fs(12)}>
{t('trip.journal.onlyYou')}
</p>
)}
{j.canEditMembers && (
<div>
<DialogButton variant="primary" onClick={j.editMembers} icon={<Users size={14} strokeWidth={2.2} />}>
{t('trip.journal.editMembers')}
</DialogButton>
</div>
)}
</LinkCard>
</>
);
}
/** The linked journal on the phone: cover, title and role, then who of the trip may open it, as rows. */
function LinkedJournal({ j }: { j: JournalLink }) {
const { t } = useTranslation();
const link = j.link;
if (!link) return null;
const members = link.members ?? [];
return (
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card p-2.5">
<span className="grid h-11 w-11 flex-none place-items-center overflow-hidden rounded-[10px] bg-surface-tertiary text-content-muted">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3 rounded-[14px] bg-[color:var(--m-ic)] p-2">
<span className="grid h-11 w-11 flex-none place-items-center overflow-hidden rounded-[11px] bg-m-card text-m-muted">
{link.cover_image ? (
<img src={`/uploads/${link.cover_image}`} alt="" className="h-full w-full object-cover" />
) : (
@@ -136,44 +262,32 @@ function LinkedJournal({ j }: { j: JournalLink }) {
)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13.5, 'body')}>
{link.title}
</div>
<div className="truncate text-body font-semibold text-m-ink">{link.title}</div>
{link.my_role && (
<div className="truncate text-content-faint" style={fs(11.5)}>
<div className="truncate font-geist text-caption text-m-muted">
{t(`trip.journal.yourRole.${link.my_role}`)}
</div>
)}
</div>
<span
className="inline-flex flex-none items-center gap-1 rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success"
style={fs(10.5)}
>
<Link2 size={10} strokeWidth={2.6} />
<span className="flex flex-none items-center gap-1 rounded-full bg-[color:color-mix(in_srgb,var(--m-st-confirmed)_14%,transparent)] px-2 py-[2px] font-geist text-caption font-semibold text-[color:var(--m-st-confirmed)]">
<Link2 size={11} strokeWidth={2.6} />
{t('trip.journal.linkedBadge')}
</span>
</div>
{link.members && link.members.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{link.members.map((m) => (
<span
key={m.user_id}
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card py-[3px] pe-2.5 ps-[3px] shadow-sm ring-1 ring-edge-faint"
style={fs(11.5)}
>
<TripMemberAvatar username={m.username} avatarUrl={m.avatar_url} size={20} />
<span className="font-medium text-content">{m.username}</span>
<span className="text-content-faint">
{members.length > 0 ? (
<div className="flex flex-col">
{members.map((m) => (
<div key={m.user_id} className="flex items-center gap-2.5 py-1.5">
<TripMemberAvatar username={m.username} avatarUrl={m.avatar_url} size={30} />
<span className="min-w-0 flex-1 truncate text-body font-semibold text-m-ink">{m.username}</span>
<span className="flex-none rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-caption font-semibold text-m-muted">
{t(m.role === 'editor' ? 'trip.journal.roleEditor' : 'trip.journal.roleViewer')}
</span>
</span>
</div>
))}
</div>
)}
{link.members && link.members.length === 0 && (
<p className="m-0 text-content-faint" style={fs(11.5)}>
{t('trip.journal.onlyYou')}
</p>
) : (
<p className="m-0 font-geist text-caption text-m-muted">{t('trip.journal.onlyYou')}</p>
)}
</div>
);
@@ -1108,17 +1108,21 @@ describe('TripMembersModal', () => {
view.unmount();
});
it('FE-COMP-MEMBERS-062: Edit Trip in the side column hands over to the trip form (#2811)', async () => {
it('FE-COMP-MEMBERS-062: Edit Trip is an area of the dialog that shows the trip form (#2811)', async () => {
const user = userEvent.setup();
const onEditTrip = vi.fn();
render(<TripMembersModal {...defaultProps} onEditTrip={onEditTrip} />);
const trip = buildTrip({ id: 1, title: 'Lisbon in spring', user_id: ownerUser.id });
render(<TripMembersModal {...defaultProps} editTrip={{ trip, onSave: vi.fn() }} />);
// The column stands for it even where People is the only area.
const nav = await screen.findByRole('navigation', { name: 'Trip settings' });
await user.click(within(nav).getByRole('button', { name: 'Edit Trip' }));
expect(onEditTrip).toHaveBeenCalledTimes(1);
expect(within(nav).getByText('Title, dates, cover, description and currency.')).toBeInTheDocument();
// The trip's own form, in place: its title field holds the trip's title.
expect(await screen.findByDisplayValue('Lisbon in spring')).toBeVisible();
expect(within(nav).getByRole('button', { name: 'Edit Trip' })).toHaveAttribute('aria-current', 'page');
// The area itself says what it is for; the side column carries the names alone.
expect(within(nav).queryByText('Title, dates, cover, description and currency.')).toBeNull();
expect(screen.getByText('Title, dates, cover, description and currency.')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-063: without the right to edit the trip, the dialog offers no way to its form', async () => {
@@ -1,4 +1,4 @@
import TripShareDialog from './TripShareDialog'
import TripShareDialog, { type TripSettingsForm } from './TripShareDialog'
interface TripMembersModalProps {
isOpen: boolean
@@ -8,8 +8,8 @@ interface TripMembersModalProps {
/** Called after the roster changes (guest/member added, renamed or removed) so the
* planner can refresh its members for Costs participants, Collab, etc. */
onMembersChanged?: () => void
/** Opens the trip's form from the dialog, for someone who may edit the trip (#2811). */
onEditTrip?: () => void
/** The trip's form as the dialog's last area, for someone who may edit the trip (#2811). */
editTrip?: TripSettingsForm
}
/** Share: the trip's members, guests and links. The phone has its own sheet (MTripSettingsSheet) on the same hooks. */
+53 -48
View File
@@ -1,12 +1,14 @@
import { useId, useState, type ReactNode } from 'react'
import { useId, useState, type ComponentProps, type ReactNode } from 'react'
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Settings, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts'
import { AddRowButton, CardHead, INPUT, LABEL, PANEL } from '../shared/dialogParts'
import { LinkedJournalIcon } from './LinkedJournalIcon'
import type { Trip } from '../../types'
import TripFormModal from './TripFormModal'
import { TripJournalLinkSection } from './TripJournalLinkSection'
import { TripMemberAvatar } from './TripMemberAvatar'
import { useTripJournalSectionVisible } from './useTripJournalLink'
@@ -18,8 +20,18 @@ interface TripShareDialogProps {
tripId: number
tripTitle: string
onMembersChanged?: () => void
/** Hands over to the trip's own form; left out for someone who may not edit the trip (#2811). */
onEditTrip?: () => void
/**
* What the trip's own form needs, shown as the dialog's last area (#2811);
* left out for someone who may not edit the trip.
*/
editTrip?: TripSettingsForm
}
/** The trip and how its form saves, for the Edit trip area. */
export interface TripSettingsForm {
trip: Trip
onSave: ComponentProps<typeof TripFormModal>['onSave']
onCoverUpdate?: ComponentProps<typeof TripFormModal>['onCoverUpdate']
}
/** A question the dialog asks before it acts, answered in a ConfirmDialog over it. */
@@ -34,12 +46,14 @@ const COUNT = 'rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bo
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card'
const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold'
/** A fixed height on a wide screen, so the dialog does not jump when another area is picked. */
const BODY = 'flex min-h-0 flex-1 max-md:flex-col md:h-[min(640px,calc(100dvh-240px))]'
/**
* A fixed height on a wide screen, so the dialog does not jump when another area is picked.
* Not flex-1 there: its zero basis won over the height and every area sized the dialog anew.
*/
const BODY = 'flex min-h-0 flex-1 max-md:flex-col md:h-[min(640px,calc(100dvh-240px))] md:flex-none'
const NAV = 'flex flex-none gap-1 border-edge-faint bg-surface-secondary p-3 md:w-[236px] md:flex-col md:border-e max-md:overflow-x-auto max-md:border-b'
const NAV_ENTRY = 'flex flex-none items-center gap-2.5 rounded-[12px] px-2.5 py-2 text-start transition-colors'
const NAV_ENTRY_OFF = 'text-content-muted hover:bg-surface-hover hover:text-content'
const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
/**
* The trip's Settings on the desktop: who is on the trip, its guests, the two
@@ -49,28 +63,29 @@ export default function TripShareDialog(props: TripShareDialogProps) {
return props.isOpen ? <OpenDialog {...props} /> : null
}
type Area = 'people' | 'sharing' | 'journal'
type Area = 'people' | 'sharing' | 'journal' | 'trip'
/** The areas of the dialog, each with its icon, its name and the line under the name. */
const AREAS: { id: Area; icon: (on: boolean) => ReactNode; labelKey: string; hintKey: string }[] = [
{ id: 'people', icon: () => <Users size={16} strokeWidth={2} />, labelKey: 'trip.settings.people', hintKey: 'trip.settings.peopleHint' },
{ id: 'sharing', icon: () => <Link2 size={16} strokeWidth={2} />, labelKey: 'trip.settings.sharing', hintKey: 'trip.settings.sharingHint' },
{ id: 'journal', icon: () => <LinkedJournalIcon size={16} />, labelKey: 'trip.journal.title', hintKey: 'trip.journal.hint' },
{ id: 'trip', icon: () => <Pencil size={16} strokeWidth={2} />, labelKey: 'dashboard.editTrip', hintKey: 'trip.settings.editTripHint' },
]
function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged, onEditTrip }: TripShareDialogProps) {
function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged, editTrip }: TripShareDialogProps) {
const { t } = useTranslation()
const labelId = useId()
const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged })
const [ask, setAsk] = useState<Ask | null>(null)
const [area, setArea] = useState<Area>('people')
const journalSection = useTripJournalSectionVisible(tripId)
const areas = AREAS.filter(a => a.id === 'people' || (a.id === 'sharing' ? m.canManageShare : journalSection))
const shows: Record<Area, boolean> = { people: true, sharing: m.canManageShare, journal: journalSection, trip: !!editTrip }
const areas = AREAS.filter(a => shows[a.id])
const current = areas.find(a => a.id === area) ?? areas[0]
const panelOf = (id: Area) => areas.find(a => a.id === id)
const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}`
// The way to the trip's form needs the side nav even where People is the only area.
const withNav = areas.length > 1 || !!onEditTrip
const withNav = areas.length > 1
return (
<>
@@ -97,7 +112,7 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged, onEditTrip }
/>
)}
>
{withNav && <AreaNav areas={areas} current={current.id} onPick={setArea} onEditTrip={onEditTrip} />}
{withNav && <AreaNav areas={areas} current={current.id} onPick={setArea} />}
<div className="min-h-0 flex-1 overflow-y-auto px-7 pb-7 pt-6 max-sm:px-5">
{/* Every area stays mounted, so its links and lists are loaded by the time it is picked. */}
<AreaPanel area={panelOf('people')} shown={current.id === 'people'} titled={withNav}>
@@ -116,6 +131,11 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged, onEditTrip }
<TripJournalLinkSection tripId={tripId} framed={false} />
</AreaPanel>
)}
{editTrip && (
<AreaPanel area={panelOf('trip')} shown={current.id === 'trip'} titled>
<TripFormModal embedded isOpen onClose={onClose} {...editTrip} />
</AreaPanel>
)}
</div>
</DialogShell>
<ConfirmDialog
@@ -132,9 +152,9 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged, onEditTrip }
/**
* The areas down the side on a wide screen, a row of pills above the content on a narrow one.
* Edit trip sits at the foot of the column: it opens the trip's form rather than an area of its own.
* Edit trip is one of them, after the areas about who is on the trip.
*/
function AreaNav({ areas, current, onPick, onEditTrip }: { areas: typeof AREAS; current: Area; onPick: (area: Area) => void; onEditTrip?: () => void }) {
function AreaNav({ areas, current, onPick }: { areas: typeof AREAS; current: Area; onPick: (area: Area) => void }) {
const { t } = useTranslation()
return (
<nav aria-label={t('trip.settings.title')} className={NAV}>
@@ -143,28 +163,20 @@ function AreaNav({ areas, current, onPick, onEditTrip }: { areas: typeof AREAS;
return (
<button key={a.id} type="button" aria-current={on ? 'page' : undefined} aria-label={t(a.labelKey)} onClick={() => onPick(a.id)}
className={`${NAV_ENTRY} ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : NAV_ENTRY_OFF}`}>
<NavEntryText icon={a.icon(on)} on={on} label={t(a.labelKey)} hint={t(a.hintKey)} />
<NavEntryText icon={a.icon(on)} on={on} label={t(a.labelKey)} />
</button>
)
})}
{onEditTrip && (
<button type="button" aria-label={t('dashboard.editTrip')} onClick={onEditTrip} className={`${NAV_ENTRY} ${NAV_ENTRY_OFF} md:mt-auto`}>
<NavEntryText icon={<Pencil size={16} strokeWidth={2} />} on={false} label={t('dashboard.editTrip')} hint={t('trip.settings.editTripHint')} />
</button>
)}
</nav>
)
}
/** An entry's tile, its name and, on a wide screen, the line under the name. */
function NavEntryText({ icon, on, label, hint }: { icon: ReactNode; on: boolean; label: string; hint: string }) {
/** An entry's tile and its name; what the area is for heads the area itself. */
function NavEntryText({ icon, on, label }: { icon: ReactNode; on: boolean; label: string }) {
return (
<>
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[9px] ${on ? 'bg-accent text-accent-text' : 'bg-surface-tertiary'}`}>{icon}</span>
<span className="min-w-0">
<span className="block truncate font-semibold" style={fs(13, 'body')}>{label}</span>
<span className="block truncate text-content-faint max-md:hidden" style={fs(11)}>{hint}</span>
</span>
<span className="min-w-0 truncate font-semibold" style={fs(13, 'body')}>{label}</span>
</>
)
}
@@ -358,7 +370,7 @@ function GuestsList({ m, onAsk }: { m: TripMembersState; onAsk: (ask: Ask) => vo
maxLength={50}
className={`${INPUT} flex-1`}
/>
<DialogButton onClick={() => void m.addGuest()} disabled={m.addingGuest || !m.newGuestName.trim()} icon={<Plus size={14} strokeWidth={2.2} />}>
<DialogButton variant="primary" onClick={() => void m.addGuest()} disabled={m.addingGuest || !m.newGuestName.trim()} icon={<Plus size={14} strokeWidth={2.2} />}>
{m.addingGuest ? '...' : t('members.addGuest')}
</DialogButton>
</div>
@@ -387,13 +399,7 @@ function LinkLine({ url, copied, onCopy }: { url: string; copied: boolean; onCop
export function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
return (
<section className={PANEL}>
<div className="flex items-start gap-2.5">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card text-content-muted shadow-sm">{icon}</span>
<div className="min-w-0">
<h3 className="m-0 font-semibold text-content" style={fs(13, 'body')}>{title}</h3>
<p className="m-0 mt-0.5 leading-normal text-content-faint" style={fs(11.5)}>{hint}</p>
</div>
</div>
<CardHead icon={icon} title={title} hint={hint} />
{children}
</section>
)
@@ -418,15 +424,14 @@ function PublicLink({ tripId }: { tripId: number }) {
if (link.loading) return null
return (
<LinkCard icon={<Link2 size={15} strokeWidth={2.2} />} title={t('share.linkTitle')} hint={t('share.linkHint')}>
<div className="flex flex-wrap gap-1.5">
{SHARE_SECTIONS.map(opt => (
<PermChip key={opt.key} on={link.perms[opt.key]} disabled={opt.always} label={t(opt.label)}
onToggle={() => void link.setPerm(opt.key, !link.perms[opt.key])} />
))}
</div>
{/* What the link shows and how, in one group under one eyebrow. */}
<div>
<span className={LABEL}>{t('share.options')}</span>
<div className="flex flex-wrap gap-1.5">
{SHARE_SECTIONS.map(opt => (
<PermChip key={opt.key} on={link.perms[opt.key]} disabled={opt.always} label={t(opt.label)}
onToggle={() => void link.setPerm(opt.key, !link.perms[opt.key])} />
))}
{SHARE_OPTIONS.map(opt => (
<PermChip key={opt.key} on={link.perms[opt.key]} label={t(opt.label)} hint={t(`${opt.label}Hint`)}
onToggle={() => void link.setPerm(opt.key, !link.perms[opt.key])} />
@@ -437,9 +442,9 @@ function PublicLink({ tripId }: { tripId: number }) {
<>
<LinkLine url={link.url} copied={link.copied} onCopy={() => void link.copy()} />
<div>
<button type="button" onClick={() => void link.remove()} className={DANGER_BUTTON} style={fs(12.5, 'body')}>
<Trash2 size={13} strokeWidth={2.2} />{t('share.deleteLink')}
</button>
<DialogButton variant="danger" onClick={() => void link.remove()} icon={<Trash2 size={14} strokeWidth={2.2} />}>
{t('share.deleteLink')}
</DialogButton>
</div>
</>
) : (
@@ -460,12 +465,12 @@ function InviteLink({ tripId }: { tripId: number }) {
<>
<LinkLine url={link.url} copied={link.copied} onCopy={() => void link.copy()} />
<div className="flex flex-wrap gap-2">
<DialogButton onClick={() => void link.create()} disabled={link.busy} icon={<Link2 size={14} strokeWidth={2.2} />}>
<DialogButton variant="primary" onClick={() => void link.create()} disabled={link.busy} icon={<Link2 size={14} strokeWidth={2.2} />}>
{t('trip.invite.regenerate')}
</DialogButton>
<button type="button" onClick={() => void link.remove()} disabled={link.busy} className={DANGER_BUTTON} style={fs(12.5, 'body')}>
<Trash2 size={13} strokeWidth={2.2} />{t('trip.invite.disable')}
</button>
<DialogButton variant="danger" onClick={() => void link.remove()} disabled={link.busy} icon={<Trash2 size={14} strokeWidth={2.2} />}>
{t('trip.invite.disable')}
</DialogButton>
</div>
</>
) : (
+14 -5
View File
@@ -296,12 +296,19 @@ export function DialogHeader(p: DialogHeaderProps) {
export const PILL = 'inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-1 font-semibold text-content shadow-sm'
/** A labelled block of the body, with room for an action on the right of its label. */
export function DialogSection({ label, action, className, children }: { label: ReactNode; action?: ReactNode; className?: string; children: ReactNode }) {
export function DialogSection({ label, action, tallAction = false, className, children }: {
label: ReactNode
action?: ReactNode
/** The action is a control as tall as a button (a Segmented): the head grows to hold it instead of letting it overhang the card. */
tallAction?: boolean
className?: string
children: ReactNode
}) {
return (
<section className={className}>
{/* A fixed height, so a section with an action (an upload pill) starts its content on the
same line as its neighbour without one; the action is centred and may overhang. */}
<div className="mb-2 flex h-4 items-center gap-2">
same line as its neighbour without one; the pill is centred and may overhang. */}
<div className={`flex items-center gap-2 ${tallAction ? 'mb-3 min-h-4 flex-wrap gap-y-2' : 'mb-2 h-4'}`}>
<div className={EYEBROW} style={fs(9.5)}>{label}</div>
{action && <span className="ms-auto flex items-center">{action}</span>}
</div>
@@ -323,10 +330,12 @@ const BUTTON_LOOK = {
secondary: 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50',
active: 'inline-flex items-center gap-1.5 rounded-[10px] bg-accent px-3.5 py-2 font-medium text-accent-text disabled:cursor-default disabled:opacity-50',
primary: 'inline-flex items-center gap-1.5 rounded-[10px] bg-accent px-4 py-2 font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50',
danger: 'inline-flex items-center gap-1.5 rounded-[10px] bg-danger px-4 py-2 font-medium text-[color:var(--danger-text)] hover:opacity-90 disabled:cursor-default disabled:opacity-50',
} as const
export type DialogButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'style'> & {
variant?: 'primary' | 'secondary'
/** danger: the same filled button in red, for an action that removes or cuts a tie. */
variant?: 'primary' | 'secondary' | 'danger'
/** A secondary button that is switched on (shown on the map) takes the accent fill. */
active?: boolean
icon?: ReactNode
@@ -338,7 +347,7 @@ export type DialogButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'c
*/
export function DialogButton({ variant = 'secondary', active = false, icon, children, type = 'button', ...rest }: DialogButtonProps) {
const primary = variant === 'primary'
const look = primary ? BUTTON_LOOK.primary : active ? BUTTON_LOOK.active : BUTTON_LOOK.secondary
const look = primary ? BUTTON_LOOK.primary : variant === 'danger' ? BUTTON_LOOK.danger : active ? BUTTON_LOOK.active : BUTTON_LOOK.secondary
return (
<button {...rest} type={type} className={look} style={fs(13, 'body')} data-dialog-primary={primary ? '' : undefined}>
{icon}{children}
@@ -183,3 +183,19 @@ export function AddRowButton({ onClick, disabled, children }: { onClick: () => v
</button>
)
}
/**
* The head of a card in a dialog: an icon tile, the card's name and a line on
* what it is for. The trip settings' cards all open with it.
*/
export function CardHead({ icon, title, hint }: { icon: ReactNode; title: string; hint: string }) {
return (
<div className="flex items-start gap-2.5">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card text-content-muted shadow-sm">{icon}</span>
<div className="min-w-0">
<h3 className="m-0 font-semibold text-content" style={fs(13, 'body')}>{title}</h3>
<p className="m-0 mt-0.5 leading-normal text-content-faint" style={fs(11.5)}>{hint}</p>
</div>
</div>
)
}
@@ -53,3 +53,11 @@ export function useTripSplashBeats({ liveReducedMotion }: TripSplashBeatsOptions
const activeIndex = reduceMotion ? SPLASH_STILL_INDEX : index;
return { reduceMotion, activeIndex, step: SPLASH_STEPS[activeIndex], steps: SPLASH_STEPS };
}
/**
* How long a trip may take to open before the splash comes up, on the phone and
* the desktop alike. A trip that loads in that time opens straight away; a
* slower one gets the splash right after, so a slow connection never sees the
* app stand still.
*/
export const TRIP_SPLASH_AFTER_MS = 400
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useTranslation } from '../../../i18n'
import MDancingTrek from '../../components/MDancingTrek'
import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeats'
import { TRIP_SPLASH_AFTER_MS, useTripSplashBeats } from '../../../components/shared/useTripSplashBeats'
/**
* Trip-open splash — the TREK mascot acts out a little journey while the trip
@@ -11,14 +11,10 @@ import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeat
* progress dots track which beat is on. Under reduced motion it holds a single
* frame (the real "loading photos" one) with no transitions.
*/
/**
* How long a trip may take to open before the splash comes up. The page
* transition holds the page the trip was opened from for up to 350ms
* (mobile/routeTransition.ts), so a trip that loads in that time slides in
* whole, the way the dashboard slides back in on the way out. A slower one gets
* the splash right after, so a slow connection never sees the app stand still.
*/
export const TRIP_SPLASH_AFTER_MS = 400
// The page transition holds the page the trip was opened from for up to 350ms
// (mobile/routeTransition.ts), so a trip that loads within TRIP_SPLASH_AFTER_MS
// slides in whole, the way the dashboard slides back in on the way out.
export { TRIP_SPLASH_AFTER_MS }
/**
* While a trip opens. At first nothing but the screen's own background, marked
+7 -7
View File
@@ -92,7 +92,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
highlightedAlternative, setHighlightedAlternative,
moveRoadtripStopToDay,
dropPoiOnRoute,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, editTripFromSettings,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind, bookingImportAvailable,
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
@@ -123,7 +123,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
selectedPlace, dayOrderMap, dayPlaces,
toursEnabled, toursMode, setToursMode, tours, toursLoading, tourDataReady, tourPlaceIds,
invalidateTourPlaceIds, selectedTour,
mapTileUrl, fontStyle, splashDone, assistantMap,
mapTileUrl, fontStyle, slowOpen, saveTrip, assistantMap,
} = pageState.planner
const {
poi,
@@ -183,13 +183,13 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
// the day-header tram button and the modal's mode switch — is off without one.
const tripHasDates = Boolean(trip?.start_date && trip?.end_date)
if (isLoading || !splashDone) {
if (isLoading) {
return (
<div className="bg-surface" style={{
<div className="bg-surface" aria-busy="true" style={{
minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
...fontStyle,
}}>
<TripLoadingSplash title={trip?.title} />
{slowOpen && <TripLoadingSplash title={trip?.title} />}
</div>
)
}
@@ -826,11 +826,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
<TripFormModal
isOpen={showTripForm}
onClose={() => setShowTripForm(false)}
onSave={async (data) => { await tripActions.updateTrip(tripId, data); loadAccommodations(); toast.success(t('trip.toast.tripUpdated')) }}
onSave={saveTrip}
trip={trip}
onCoverUpdate={applyTripCoverUpdate}
/>
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} onMembersChanged={refreshMembers} onEditTrip={can('trip_edit', trip) ? editTripFromSettings : undefined} />
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} onMembersChanged={refreshMembers} editTrip={trip && can('trip_edit', trip) ? { trip, onSave: saveTrip, onCoverUpdate: applyTripCoverUpdate } : undefined} />
<ReservationModal isOpen={showReservationModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data, steps) => { const r = await handleSaveReservation(data, steps); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} tripMembers={tripMembers} />
{showTransportModal && (
<ErrorBoundary boundaryId="planner-panel:transport" fallback={null}>
@@ -328,6 +328,7 @@ function mapState(): HookState {
selectedTour: null,
mapTileUrl: 'https://tiles/{z}/{x}/{y}.png',
fontStyle: { fontFamily: 'var(--font-system)' },
splashDone: true,
slowOpen: false,
saveTrip: vi.fn(),
};
}
@@ -638,21 +638,25 @@ describe('TripPlannerPage — shell', () => {
expect(plannerActions.viewGpxTour).toHaveBeenCalledOnce()
})
it('FE-PAGE-TPW-002: the splash holds the page until the trip finished loading', () => {
renderPage({ isLoading: true, splashDone: false })
it('FE-PAGE-TPW-002: a slow open shows the splash until the trip finished loading', () => {
renderPage({ isLoading: true, slowOpen: true })
expect(screen.getByRole('status')).toHaveTextContent('Kyoto')
expect(screen.queryByTestId('map-view')).not.toBeInTheDocument()
})
it('FE-PAGE-TPW-003: the splash also covers the gap between load and splashDone', () => {
renderPage({ isLoading: false, splashDone: false })
it('FE-PAGE-TPW-003: a quick open shows no splash, only the empty page while it loads, and the trip at once', () => {
const view = renderPage({ isLoading: true, slowOpen: false })
expect(screen.queryByTestId('splash')).not.toBeInTheDocument()
expect(screen.queryByTestId('map-view')).not.toBeInTheDocument()
view.unmount()
expect(screen.getByTestId('splash')).toBeInTheDocument()
renderPage({ isLoading: false, slowOpen: false })
expect(screen.queryByTestId('splash')).not.toBeInTheDocument()
})
it('FE-PAGE-TPW-004: a trip that never arrived renders nothing at all', () => {
const { container } = renderPage({ trip: null, isLoading: false, splashDone: true })
const { container } = renderPage({ trip: null, isLoading: false, slowOpen: false })
expect(container).toBeEmptyDOMElement()
})
@@ -1518,13 +1522,10 @@ describe('TripPlannerPage — modals', () => {
expect((props('placeForm').dayAssignments as unknown as unknown[]).length).toBe(1)
})
it('FE-PAGE-TPW-047: saving the trip form reloads the accommodations and confirms', async () => {
it('FE-PAGE-TPW-047: the trip form saves through the hook, the same save the trip settings use', () => {
renderPage()
await act(async () => { await props('tripForm').onSave({ title: 'Kyoto 2' }) })
const tripActions = hookState.tripActions as Record<string, ReturnType<typeof vi.fn>>
expect(tripActions.updateTrip).toHaveBeenCalledWith(42, { title: 'Kyoto 2' })
expect(hookState.loadAccommodations).toHaveBeenCalled()
expect(props('tripForm').onSave).toBe(hookState.saveTrip)
act(() => { props('tripForm').onClose() })
expect(hookState.setShowTripForm).toHaveBeenCalledWith(false)
@@ -1552,12 +1553,12 @@ describe('TripPlannerPage — modals', () => {
expect(hookState.setShowMembersModal).toHaveBeenCalledWith(false)
})
it('FE-PAGE-TPW-075: the members modal leads to the trip form only for someone who may edit the trip (#2811)', () => {
it('FE-PAGE-TPW-075: the trip settings hold the trip form only for someone who may edit the trip (#2811)', () => {
renderPage()
expect(props('membersModal').onEditTrip).toBe(hookState.editTripFromSettings)
expect(props('membersModal').editTrip).toBeTruthy()
renderPage({ can: vi.fn((action: string) => action !== 'trip_edit') })
expect(props('membersModal').onEditTrip).toBeUndefined()
expect(props('membersModal').editTrip).toBeUndefined()
})
it('FE-PAGE-TPW-050: closing the booking modal outside a review resets the editor', async () => {
@@ -248,14 +248,4 @@ describe('usePlannerDialogs', () => {
act(() => { result.current.changeTransitRoute(buildReservation({ id: 15, type: 'transit' })) })
expect(result.current.transitPrefill).toEqual({ from: null, to: null })
})
it('FE-TP-DIALOGS-013: editing the trip from its settings swaps the settings for the trip form (#2811)', () => {
const { result } = renderDialogs()
act(() => { result.current.setShowMembersModal(true) })
act(() => { result.current.editTripFromSettings() })
expect(result.current.showMembersModal).toBe(false)
expect(result.current.showTripForm).toBe(true)
})
})
@@ -104,12 +104,6 @@ export function usePlannerDialogs(options: PlannerDialogsOptions) {
}, [searchParams])
const [showTripForm, setShowTripForm] = useState<boolean>(false)
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
// The trip settings hand over to the trip's form rather than stack a second dialog
// on their own: the planner's way to the title, dates, cover and currency (#2811).
const editTripFromSettings = useCallback(() => {
setShowMembersModal(false)
setShowTripForm(true)
}, [])
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
const [showBookingImport, setShowBookingImport] = useState<boolean>(false)
@@ -173,7 +167,7 @@ export function usePlannerDialogs(options: PlannerDialogsOptions) {
editingAssignmentId, setEditingAssignmentId, placeFormDayId, setPlaceFormDayId,
placeFormPosition, setPlaceFormPosition, serviceStopForm, setServiceStopForm, serviceStopKind, setServiceStopKind,
stopDraft, setStopDraft, stayRelease, setStayRelease, reservationModalDayId, setReservationModalDayId,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, editTripFromSettings,
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind,
bookingImportAvailable, setBookingImportAvailable, airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
@@ -296,15 +296,14 @@ describe('useTripPlanner — bootstrap', () => {
expect(accommodationRepo.list).toHaveBeenCalledTimes(2)
})
it('FE-TP-HOOK-012: the splash gate opens once the trip finished loading', async () => {
it('FE-TP-HOOK-012: a trip that opens at once never asks for the splash, and holds no minimum', async () => {
vi.useFakeTimers()
seedTrip()
const { result } = await renderPlanner()
expect(result.current.splashDone).toBe(false)
await act(async () => { vi.advanceTimersByTime(1600) })
expect(result.current.splashDone).toBe(true)
expect(result.current.slowOpen).toBe(false)
vi.useRealTimers()
})
+17 -8
View File
@@ -68,6 +68,7 @@ import { usePlannerTripView } from './usePlannerTripView'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useIsWide } from '../../hooks/sizeClass'
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
import { TRIP_SPLASH_AFTER_MS } from '../../components/shared/useTripSplashBeats'
/**
* The trip's roster from the server: the owner and the members. Null offline
@@ -698,14 +699,22 @@ export function useTripPlanner() {
const fontStyle = { fontFamily: "var(--font-system)" }
// Splash screen — show for initial load + a brief moment for photos to start loading
const [splashDone, setSplashDone] = useState(false)
// The trip's form saves alike from its own dialog and from the trip settings (#2811).
const saveTrip = useCallback(async (data: Parameters<typeof tripActions.updateTrip>[1]) => {
await tripActions.updateTrip(tripId, data)
loadAccommodations()
toast.success(t('trip.toast.tripUpdated'))
}, [tripActions, tripId, loadAccommodations, toast, t])
// The splash only for a slow open, as on the phone (MTripOpening): a trip that
// loads within TRIP_SPLASH_AFTER_MS opens straight away, with no splash and no
// minimum on screen.
const [slowOpen, setSlowOpen] = useState(false)
useEffect(() => {
if (!isLoading && trip) {
const timer = setTimeout(() => setSplashDone(true), 1500)
return () => clearTimeout(timer)
}
}, [isLoading, trip])
if (!isLoading) return
const timer = setTimeout(() => setSlowOpen(true), TRIP_SPLASH_AFTER_MS)
return () => clearTimeout(timer)
}, [isLoading])
return {
tripId, navigate, toast, t, language, locale, settings, placesPhotosEnabled,
@@ -779,7 +788,7 @@ export function useTripPlanner() {
...dayClear,
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
mapTileUrl, fontStyle, splashDone,
mapTileUrl, fontStyle, slowOpen, saveTrip,
// The assistant on the map, handed to MapViewAuto whole by both shells.
assistantMap,
}
+1 -1
View File
@@ -324,7 +324,7 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
mapTileUrl: 'https://tile.example/{z}/{x}/{y}.png',
fontStyle: {},
splashDone: true,
slowOpen: false,
};
const planner = { ...base, ...overrides } as unknown as TripPlanner;
@@ -154,8 +154,8 @@ describe('MTripShell', () => {
}
})
it('FE-MOB-SHELL-002: the trip shows as soon as it has loaded, without the desktop splash minimum', () => {
renderShell({ splashDone: false } as Partial<TripPlanner>)
it('FE-MOB-SHELL-002: the trip shows as soon as it has loaded, whatever the desktop splash gate says', () => {
renderShell({ slowOpen: true } as Partial<TripPlanner>)
expect(screen.getByTestId('map-area')).toBeInTheDocument()
})
+7
View File
@@ -128,6 +128,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'المشاركة',
'trip.settings.sharingHint': 'رابط للقراءة فقط لأي شخص، ورابط دعوة للانضمام.',
'trip.settings.editTripHint': 'العنوان والتواريخ والغلاف والوصف والعملة.',
'trip.form.coverTitle': 'صورة الغلاف',
'trip.form.coverHint': 'تظهر على بطاقة الرحلة وفي أعلى المخطط.',
'trip.form.aboutTitle': 'الاسم والوصف',
'trip.form.aboutHint': 'اسم الرحلة وموضوعها.',
'trip.form.whenTitle': 'التواريخ والعملة',
'trip.form.whenHint': 'متى تكون الرحلة، وبأي عملة تدفعون، ومتى يتم تذكيرك.',
'trip.tabs.journal': 'اليوميات',
'trip.journal.title': 'اليوميات',
'trip.journal.hint': 'اربط إحدى يومياتك بهذه الرحلة. من تمنحه الوصول يفتحها هنا مباشرة في تبويب اليوميات، بشكل حي.',
@@ -148,6 +154,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'اختر يوميات',
'trip.journal.noMembers': 'لا يوجد أحد غيرك في هذه الرحلة بعد.',
'trip.journal.members': 'وصول أعضاء الرحلة',
'trip.journal.membersHint': 'من من هذه الرحلة يفتح اليوميات في تبويب اليوميات، وهل يمكنه الكتابة فيها.',
'trip.journal.allRead': 'الجميع يقرأ',
'trip.journal.allNone': 'لا أحد',
'trip.journal.roleNone': 'بلا وصول',
+7
View File
@@ -119,6 +119,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Paylaşma',
'trip.settings.sharingHint': 'Hər kəs üçün yalnız oxunan keçid və qoşulmaq üçün dəvət keçidi.',
'trip.settings.editTripHint': 'Ad, tarixlər, üz qabığı, təsvir və valyuta.',
'trip.form.coverTitle': 'Örtük şəkli',
'trip.form.coverHint': 'Səfərin kartında və planlayıcının yuxarısında göstərilir.',
'trip.form.aboutTitle': 'Ad və təsvir',
'trip.form.aboutHint': 'Səfərin adı və nə haqqında olduğu.',
'trip.form.whenTitle': 'Tarixlər və valyuta',
'trip.form.whenHint': 'Səfərin nə vaxt olduğu, hansı valyutada ödənildiyi və nə vaxt xatırladılacağın.',
'trip.tabs.journal': 'Gündəlik',
'trip.journal.title': 'Gündəlik',
'trip.journal.hint':
@@ -140,6 +146,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Gündəlik seçin',
'trip.journal.noMembers': 'Bu səyahətdə hələ başqa heç kim yoxdur.',
'trip.journal.members': 'Səyahət üzvləri üçün giriş',
'trip.journal.membersHint': 'Bu səfərdən kimin jurnalı Jurnal sekməsində açdığı və ona yaza bilib-bilməyəcəyi.',
'trip.journal.allRead': 'Hamı oxuya bilər',
'trip.journal.allNone': 'Heç kim',
'trip.journal.roleNone': 'Giriş yoxdur',
+7
View File
@@ -108,6 +108,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Compartilhar',
'trip.settings.sharingHint': 'Um link somente leitura para qualquer pessoa e um link de convite para entrar.',
'trip.settings.editTripHint': 'Título, datas, capa, descrição e moeda.',
'trip.form.coverTitle': 'Imagem de capa',
'trip.form.coverHint': 'Aparece no cartão da viagem e no topo do planejador.',
'trip.form.aboutTitle': 'Nome e descrição',
'trip.form.aboutHint': 'Como a viagem se chama e do que se trata.',
'trip.form.whenTitle': 'Datas e moeda',
'trip.form.whenHint': 'Quando é a viagem, em que moeda se paga e quando você é lembrado.',
'trip.tabs.journal': 'Diário',
'trip.journal.title': 'Diário',
'trip.journal.hint':
@@ -129,6 +135,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Escolha um diário',
'trip.journal.noMembers': 'Ainda não há mais ninguém nesta viagem.',
'trip.journal.members': 'Acesso para membros da viagem',
'trip.journal.membersHint': 'Quem desta viagem abre o diário na aba Diário e se pode escrever nele.',
'trip.journal.allRead': 'Todos podem ler',
'trip.journal.allNone': 'Ninguém',
'trip.journal.roleNone': 'Sem acesso',
+7
View File
@@ -108,6 +108,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Compartir',
'trip.settings.sharingHint': "Un enllaç de només lectura per a tothom i un enllaç d'invitació per unir-se.",
'trip.settings.editTripHint': 'Títol, dates, portada, descripció i moneda.',
'trip.form.coverTitle': 'Imatge de portada',
'trip.form.coverHint': 'Apareix a la targeta del viatge i a dalt del planificador.',
'trip.form.aboutTitle': 'Nom i descripció',
'trip.form.aboutHint': 'Com es diu el viatge i de què tracta.',
'trip.form.whenTitle': 'Dates i moneda',
'trip.form.whenHint': "Quan és el viatge, en quina moneda es paga i quan se t'avisa.",
'trip.tabs.journal': 'Diari',
'trip.journal.title': 'Diari',
'trip.journal.hint':
@@ -129,6 +135,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Tria un diari',
'trip.journal.noMembers': 'Encara no hi ha ningú més en aquest viatge.',
'trip.journal.members': 'Accés per als membres del viatge',
'trip.journal.membersHint': "Qui d'aquest viatge obre el diari a la pestanya Diari i si hi pot escriure.",
'trip.journal.allRead': 'Tothom pot llegir',
'trip.journal.allNone': 'Ningú',
'trip.journal.roleNone': 'Sense accés',
+7
View File
@@ -113,6 +113,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Sdílení',
'trip.settings.sharingHint': 'Odkaz jen pro čtení pro kohokoli a pozvánka k připojení.',
'trip.settings.editTripHint': 'Název, termíny, obálka, popis a měna.',
'trip.form.coverTitle': 'Titulní obrázek',
'trip.form.coverHint': 'Zobrazí se na kartě cesty a nahoře v plánovači.',
'trip.form.aboutTitle': 'Název a popis',
'trip.form.aboutHint': 'Jak se cesta jmenuje a o čem je.',
'trip.form.whenTitle': 'Data a měna',
'trip.form.whenHint': 'Kdy cesta probíhá, v jaké měně se platí a kdy dostaneš připomínku.',
'trip.tabs.journal': 'Deník',
'trip.journal.title': 'Deník',
'trip.journal.hint':
@@ -134,6 +140,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Vyberte deník',
'trip.journal.noMembers': 'Na této cestě zatím nikdo další není.',
'trip.journal.members': 'Přístup pro členy cesty',
'trip.journal.membersHint': 'Kdo z této cesty otevírá deník na kartě Deník a zda do něj smí psát.',
'trip.journal.allRead': 'Všichni čtou',
'trip.journal.allNone': 'Nikdo',
'trip.journal.roleNone': 'Bez přístupu',
+7
View File
@@ -109,6 +109,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Teilen',
'trip.settings.sharingHint': 'Ein Nur-Lesen-Link für alle und ein Einladungslink zum Beitreten.',
'trip.settings.editTripHint': 'Titel, Daten, Titelbild, Beschreibung und Währung.',
'trip.form.coverTitle': 'Titelbild',
'trip.form.coverHint': 'Erscheint auf der Karte der Reise und oben im Planer.',
'trip.form.aboutTitle': 'Name und Beschreibung',
'trip.form.aboutHint': 'Wie die Reise heißt und worum es geht.',
'trip.form.whenTitle': 'Daten und Währung',
'trip.form.whenHint': 'Wann die Reise ist, in welcher Währung bezahlt wird und wann du erinnert wirst.',
'trip.tabs.journal': 'Journal', // same-as-en
'trip.journal.title': 'Journal', // same-as-en
'trip.journal.hint':
@@ -130,6 +136,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Journal auswählen',
'trip.journal.noMembers': 'Außer dir ist noch niemand auf dieser Reise.',
'trip.journal.members': 'Zugriff für Reisemitglieder',
'trip.journal.membersHint': 'Wer von dieser Reise das Journal im Journal-Tab öffnet und ob er darin schreiben darf.',
'trip.journal.allRead': 'Alle lesen',
'trip.journal.allNone': 'Niemand',
'trip.journal.roleNone': 'Kein Zugriff',
+8
View File
@@ -108,6 +108,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Sharing',
'trip.settings.sharingHint': 'A read-only link for anyone, and an invite link to join.',
'trip.settings.editTripHint': 'Title, dates, cover, description and currency.',
'trip.form.coverTitle': 'Cover image',
'trip.form.coverHint': "Shown on the trip's card and at the top of the planner.",
'trip.form.aboutTitle': 'Name and description',
'trip.form.aboutHint': 'What the trip is called and what it is about.',
'trip.form.whenTitle': 'Dates and currency',
'trip.form.whenHint': 'When the trip runs, what it is paid in, and when you are reminded.',
'trip.tabs.journal': 'Journal',
'trip.journal.title': 'Journal',
'trip.journal.hint':
@@ -129,6 +135,8 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Choose a journal',
'trip.journal.noMembers': 'Nobody else is on this trip yet.',
'trip.journal.members': 'Access for trip members',
'trip.journal.membersHint':
'Who from this trip opens the journal in its Journal tab, and whether they may write in it.',
'trip.journal.allRead': 'Everyone can read',
'trip.journal.allNone': 'Nobody',
'trip.journal.roleNone': 'No access',
+7
View File
@@ -108,6 +108,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Compartir',
'trip.settings.sharingHint': 'Un enlace de solo lectura para cualquiera y un enlace de invitación para unirse.',
'trip.settings.editTripHint': 'Título, fechas, portada, descripción y moneda.',
'trip.form.coverTitle': 'Imagen de portada',
'trip.form.coverHint': 'Aparece en la tarjeta del viaje y arriba en el planificador.',
'trip.form.aboutTitle': 'Nombre y descripción',
'trip.form.aboutHint': 'Cómo se llama el viaje y de qué trata.',
'trip.form.whenTitle': 'Fechas y moneda',
'trip.form.whenHint': 'Cuándo es el viaje, en qué moneda se paga y cuándo se te recuerda.',
'trip.tabs.journal': 'Diario',
'trip.journal.title': 'Diario',
'trip.journal.hint':
@@ -129,6 +135,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Elige un diario',
'trip.journal.noMembers': 'Todavía no hay nadie más en este viaje.',
'trip.journal.members': 'Acceso para los miembros del viaje',
'trip.journal.membersHint': 'Quién de este viaje abre el diario en su pestaña Diario y si puede escribir en él.',
'trip.journal.allRead': 'Todos pueden leer',
'trip.journal.allNone': 'Nadie',
'trip.journal.roleNone': 'Sin acceso',
+7
View File
@@ -108,6 +108,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Jagamine',
'trip.settings.sharingHint': 'Kirjutuskaitstud link kõigile ja kutselink liitumiseks.',
'trip.settings.editTripHint': 'Pealkiri, kuupäevad, kaanepilt, kirjeldus ja valuuta.',
'trip.form.coverTitle': 'Kaanepilt',
'trip.form.coverHint': 'Kuvatakse reisi kaardil ja planeerija ülaosas.',
'trip.form.aboutTitle': 'Nimi ja kirjeldus',
'trip.form.aboutHint': 'Kuidas reis on nimetatud ja millest see räägib.',
'trip.form.whenTitle': 'Kuupäevad ja valuuta',
'trip.form.whenHint': 'Millal reis toimub, mis valuutas makstakse ja millal sulle meelde tuletatakse.',
'trip.tabs.journal': 'Päevik',
'trip.journal.title': 'Päevik',
'trip.journal.hint':
@@ -129,6 +135,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Vali päevik',
'trip.journal.noMembers': 'Sellel reisil pole veel kedagi teist.',
'trip.journal.members': 'Ligipääs reisi liikmetele',
'trip.journal.membersHint': 'Kes sellelt reisilt avab päeviku vahekaardil Päevik ja kas ta tohib sinna kirjutada.',
'trip.journal.allRead': 'Kõik saavad lugeda',
'trip.journal.allNone': 'Mitte keegi',
'trip.journal.roleNone': 'Ligipääs puudub',
+7
View File
@@ -109,6 +109,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Partage',
'trip.settings.sharingHint': "Un lien en lecture seule pour tous, et un lien d'invitation pour rejoindre.",
'trip.settings.editTripHint': 'Titre, dates, couverture, description et devise.',
'trip.form.coverTitle': 'Image de couverture',
'trip.form.coverHint': 'Affichée sur la carte du voyage et en haut du planificateur.',
'trip.form.aboutTitle': 'Nom et description',
'trip.form.aboutHint': 'Le nom du voyage et son sujet.',
'trip.form.whenTitle': 'Dates et devise',
'trip.form.whenHint': 'Quand le voyage a lieu, dans quelle devise et quand vous êtes rappelé.',
'trip.tabs.journal': 'Journal', // same-as-en
'trip.journal.title': 'Journal', // same-as-en
'trip.journal.hint':
@@ -130,6 +136,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Choisir un journal',
'trip.journal.noMembers': "Personne d'autre ne participe encore à ce voyage.",
'trip.journal.members': 'Accès des membres du voyage',
'trip.journal.membersHint': "Qui, parmi ce voyage, ouvre le journal dans son onglet Journal, et s'il peut y écrire.",
'trip.journal.allRead': 'Tous en lecture',
'trip.journal.allNone': 'Personne',
'trip.journal.roleNone': 'Aucun accès',
+8
View File
@@ -111,6 +111,12 @@ const trip: TranslationStrings = {
'trip.settings.sharingHint':
'Ένας σύνδεσμος μόνο για ανάγνωση για όλους και ένας σύνδεσμος πρόσκλησης για συμμετοχή.',
'trip.settings.editTripHint': 'Τίτλος, ημερομηνίες, εξώφυλλο, περιγραφή και νόμισμα.',
'trip.form.coverTitle': 'Εικόνα εξωφύλλου',
'trip.form.coverHint': 'Εμφανίζεται στην κάρτα του ταξιδιού και στην κορυφή του σχεδιαστή.',
'trip.form.aboutTitle': 'Όνομα και περιγραφή',
'trip.form.aboutHint': 'Πώς λέγεται το ταξίδι και τι αφορά.',
'trip.form.whenTitle': 'Ημερομηνίες και νόμισμα',
'trip.form.whenHint': 'Πότε γίνεται το ταξίδι, σε ποιο νόμισμα πληρώνετε και πότε θα σου υπενθυμιστεί.',
'trip.tabs.journal': 'Ημερολόγιο',
'trip.journal.title': 'Ημερολόγιο',
'trip.journal.hint':
@@ -132,6 +138,8 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Επιλέξτε ημερολόγιο',
'trip.journal.noMembers': 'Δεν υπάρχει ακόμη κανείς άλλος σε αυτό το ταξίδι.',
'trip.journal.members': 'Πρόσβαση για τα μέλη του ταξιδιού',
'trip.journal.membersHint':
'Ποιοι από το ταξίδι ανοίγουν το ημερολόγιο στην καρτέλα Ημερολόγιο και αν μπορούν να γράψουν σε αυτό.',
'trip.journal.allRead': 'Όλοι διαβάζουν',
'trip.journal.allNone': 'Κανείς',
'trip.journal.roleNone': 'Χωρίς πρόσβαση',
+7
View File
@@ -112,6 +112,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'שיתוף',
'trip.settings.sharingHint': 'קישור לקריאה בלבד לכל אחד, וקישור הזמנה להצטרפות.',
'trip.settings.editTripHint': 'כותרת, תאריכים, תמונת שער, תיאור ומטבע.',
'trip.form.coverTitle': 'תמונת שער',
'trip.form.coverHint': 'מוצגת בכרטיס הטיול ובראש המתכנן.',
'trip.form.aboutTitle': 'שם ותיאור',
'trip.form.aboutHint': 'איך הטיול נקרא ובמה הוא עוסק.',
'trip.form.whenTitle': 'תאריכים ומטבע',
'trip.form.whenHint': 'מתי הטיול, באיזה מטבע משלמים ומתי תקבל תזכורת.',
'trip.tabs.journal': 'יומן',
'trip.journal.title': 'יומן',
'trip.journal.hint':
@@ -133,6 +139,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'בחירת יומן',
'trip.journal.noMembers': 'עדיין אין אף אחד אחר בטיול.',
'trip.journal.members': 'גישה לחברי הטיול',
'trip.journal.membersHint': 'מי מהטיול פותח את היומן בלשונית היומן, והאם מותר לו לכתוב בו.',
'trip.journal.allRead': 'כולם יכולים לקרוא',
'trip.journal.allNone': 'אף אחד',
'trip.journal.roleNone': 'ללא גישה',
+7
View File
@@ -111,6 +111,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Megosztás',
'trip.settings.sharingHint': 'Csak olvasható link bárkinek, és meghívó link a csatlakozáshoz.',
'trip.settings.editTripHint': 'Cím, dátumok, borítókép, leírás és pénznem.',
'trip.form.coverTitle': 'Borítókép',
'trip.form.coverHint': 'Az utazás kártyáján és a tervező tetején jelenik meg.',
'trip.form.aboutTitle': 'Név és leírás',
'trip.form.aboutHint': 'Hogy hívják az utazást, és miről szól.',
'trip.form.whenTitle': 'Dátumok és pénznem',
'trip.form.whenHint': 'Mikor van az utazás, milyen pénznemben fizettek, és mikor kapsz emlékeztetőt.',
'trip.tabs.journal': 'Napló',
'trip.journal.title': 'Napló',
'trip.journal.hint':
@@ -132,6 +138,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Válassz naplót',
'trip.journal.noMembers': 'Rajtad kívül még senki sincs ezen az utazáson.',
'trip.journal.members': 'Hozzáférés az utazás tagjainak',
'trip.journal.membersHint': 'Ki nyitja meg az utazásból a naplót a Napló fülön, és írhat-e bele.',
'trip.journal.allRead': 'Mindenki olvashatja',
'trip.journal.allNone': 'Senki',
'trip.journal.roleNone': 'Nincs hozzáférés',
+8
View File
@@ -104,6 +104,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Berbagi',
'trip.settings.sharingHint': 'Tautan hanya-baca untuk siapa saja, dan tautan undangan untuk bergabung.',
'trip.settings.editTripHint': 'Judul, tanggal, sampul, deskripsi, dan mata uang.',
'trip.form.coverTitle': 'Gambar sampul',
'trip.form.coverHint': 'Tampil di kartu perjalanan dan di bagian atas perencana.',
'trip.form.aboutTitle': 'Nama dan deskripsi',
'trip.form.aboutHint': 'Apa nama perjalanan dan tentang apa.',
'trip.form.whenTitle': 'Tanggal dan mata uang',
'trip.form.whenHint': 'Kapan perjalanan berlangsung, mata uang yang dipakai, dan kapan kamu diingatkan.',
'trip.tabs.journal': 'Jurnal',
'trip.journal.title': 'Jurnal',
'trip.journal.hint':
@@ -125,6 +131,8 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Pilih jurnal',
'trip.journal.noMembers': 'Belum ada orang lain dalam perjalanan ini.',
'trip.journal.members': 'Akses untuk anggota perjalanan',
'trip.journal.membersHint':
'Siapa dari perjalanan ini yang membuka jurnal di tab Jurnal, dan apakah mereka boleh menulis di dalamnya.',
'trip.journal.allRead': 'Semua bisa membaca',
'trip.journal.allNone': 'Tidak ada',
'trip.journal.roleNone': 'Tanpa akses',
+7
View File
@@ -108,6 +108,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Condivisione',
'trip.settings.sharingHint': 'Un link di sola lettura per tutti e un link di invito per unirsi.',
'trip.settings.editTripHint': 'Titolo, date, copertina, descrizione e valuta.',
'trip.form.coverTitle': 'Immagine di copertina',
'trip.form.coverHint': 'Appare sulla scheda del viaggio e in alto nel planner.',
'trip.form.aboutTitle': 'Nome e descrizione',
'trip.form.aboutHint': 'Come si chiama il viaggio e di cosa tratta.',
'trip.form.whenTitle': 'Date e valuta',
'trip.form.whenHint': 'Quando si svolge il viaggio, in che valuta si paga e quando ricevi il promemoria.',
'trip.tabs.journal': 'Diario',
'trip.journal.title': 'Diario',
'trip.journal.hint':
@@ -129,6 +135,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Scegli un diario',
'trip.journal.noMembers': 'Non c’è ancora nessun altro in questo viaggio.',
'trip.journal.members': 'Accesso per i membri del viaggio',
'trip.journal.membersHint': 'Chi di questo viaggio apre il diario nella sua scheda Diario e se può scriverci.',
'trip.journal.allRead': 'Tutti in lettura',
'trip.journal.allNone': 'Nessuno',
'trip.journal.roleNone': 'Nessun accesso',
+7
View File
@@ -101,6 +101,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': '共有',
'trip.settings.sharingHint': '誰でも見られる閲覧専用リンクと、参加用の招待リンク。',
'trip.settings.editTripHint': 'タイトル、日程、カバー画像、説明、通貨。',
'trip.form.coverTitle': 'カバー画像',
'trip.form.coverHint': '旅行のカードとプランナーの上部に表示されます。',
'trip.form.aboutTitle': '名前と説明',
'trip.form.aboutHint': '旅行の名前と内容。',
'trip.form.whenTitle': '日付と通貨',
'trip.form.whenHint': '旅行の期間、支払う通貨、リマインドのタイミング。',
'trip.tabs.journal': 'ジャーナル',
'trip.journal.title': 'ジャーナル',
'trip.journal.hint':
@@ -122,6 +128,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'ジャーナルを選択',
'trip.journal.noMembers': 'この旅行にはまだ他に誰もいません。',
'trip.journal.members': '旅行メンバーのアクセス',
'trip.journal.membersHint': 'この旅行の誰がジャーナルタブでジャーナルを開けるか、そして書き込めるか。',
'trip.journal.allRead': '全員閲覧',
'trip.journal.allNone': 'なし',
'trip.journal.roleNone': 'アクセスなし',
+7
View File
@@ -101,6 +101,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': '공유',
'trip.settings.sharingHint': '누구나 볼 수 있는 읽기 전용 링크와 참여용 초대 링크.',
'trip.settings.editTripHint': '제목, 날짜, 커버 이미지, 설명, 통화.',
'trip.form.coverTitle': '커버 이미지',
'trip.form.coverHint': '여행 카드와 플래너 상단에 표시됩니다.',
'trip.form.aboutTitle': '이름과 설명',
'trip.form.aboutHint': '여행의 이름과 내용.',
'trip.form.whenTitle': '날짜와 통화',
'trip.form.whenHint': '여행 기간, 결제 통화, 알림 시점.',
'trip.tabs.journal': '저널',
'trip.journal.title': '저널',
'trip.journal.hint':
@@ -122,6 +128,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': '저널 선택',
'trip.journal.noMembers': '이 여행에는 아직 다른 사람이 없습니다.',
'trip.journal.members': '여행 멤버 접근 권한',
'trip.journal.membersHint': '이 여행에서 누가 저널 탭에서 저널을 열 수 있고 작성할 수 있는지.',
'trip.journal.allRead': '모두 읽기',
'trip.journal.allNone': '없음',
'trip.journal.roleNone': '접근 불가',
+8
View File
@@ -108,6 +108,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Delen',
'trip.settings.sharingHint': 'Een alleen-lezen-link voor iedereen en een uitnodigingslink om mee te doen.',
'trip.settings.editTripHint': 'Titel, data, omslagfoto, beschrijving en valuta.',
'trip.form.coverTitle': 'Omslagfoto',
'trip.form.coverHint': 'Staat op de kaart van de reis en bovenaan de planner.',
'trip.form.aboutTitle': 'Naam en beschrijving',
'trip.form.aboutHint': 'Hoe de reis heet en waar hij over gaat.',
'trip.form.whenTitle': 'Data en valuta',
'trip.form.whenHint': 'Wanneer de reis is, in welke valuta wordt betaald en wanneer je een herinnering krijgt.',
'trip.tabs.journal': 'Dagboek',
'trip.journal.title': 'Dagboek',
'trip.journal.hint':
@@ -129,6 +135,8 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Kies een dagboek',
'trip.journal.noMembers': 'Verder is er nog niemand op deze reis.',
'trip.journal.members': 'Toegang voor reisleden',
'trip.journal.membersHint':
'Wie van deze reis het dagboek opent in het tabblad Dagboek, en of die erin mag schrijven.',
'trip.journal.allRead': 'Iedereen lezen',
'trip.journal.allNone': 'Niemand',
'trip.journal.roleNone': 'Geen toegang',
+7
View File
@@ -118,6 +118,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Udostępnianie',
'trip.settings.sharingHint': 'Link tylko do odczytu dla każdego i link z zaproszeniem do dołączenia.',
'trip.settings.editTripHint': 'Tytuł, daty, okładka, opis i waluta.',
'trip.form.coverTitle': 'Zdjęcie okładki',
'trip.form.coverHint': 'Widoczne na karcie podróży i na górze planera.',
'trip.form.aboutTitle': 'Nazwa i opis',
'trip.form.aboutHint': 'Jak nazywa się podróż i czego dotyczy.',
'trip.form.whenTitle': 'Daty i waluta',
'trip.form.whenHint': 'Kiedy jest podróż, w jakiej walucie się płaci i kiedy dostaniesz przypomnienie.',
'trip.tabs.journal': 'Dziennik',
'trip.journal.title': 'Dziennik',
'trip.journal.hint':
@@ -139,6 +145,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Wybierz dziennik',
'trip.journal.noMembers': 'Nikogo innego nie ma jeszcze w tej podróży.',
'trip.journal.members': 'Dostęp dla członków podróży',
'trip.journal.membersHint': 'Kto z tej podróży otwiera dziennik w karcie Dziennik i czy może w nim pisać.',
'trip.journal.allRead': 'Wszyscy czytają',
'trip.journal.allNone': 'Nikt',
'trip.journal.roleNone': 'Brak dostępu',
+7
View File
@@ -118,6 +118,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Доступ по ссылке',
'trip.settings.sharingHint': 'Ссылка только для чтения для всех и ссылка-приглашение для участия.',
'trip.settings.editTripHint': 'Название, даты, обложка, описание и валюта.',
'trip.form.coverTitle': 'Обложка',
'trip.form.coverHint': 'Показывается на карточке поездки и вверху планировщика.',
'trip.form.aboutTitle': 'Название и описание',
'trip.form.aboutHint': 'Как называется поездка и о чём она.',
'trip.form.whenTitle': 'Даты и валюта',
'trip.form.whenHint': 'Когда поездка, в какой валюте платите и когда придёт напоминание.',
'trip.tabs.journal': 'Дневник',
'trip.journal.title': 'Дневник',
'trip.journal.hint':
@@ -139,6 +145,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Выберите дневник',
'trip.journal.noMembers': 'В этой поездке пока больше никого нет.',
'trip.journal.members': 'Доступ для участников поездки',
'trip.journal.membersHint': 'Кто из этой поездки открывает журнал во вкладке «Журнал» и может ли в нём писать.',
'trip.journal.allRead': 'Все читают',
'trip.journal.allNone': 'Никто',
'trip.journal.roleNone': 'Нет доступа',
+7
View File
@@ -113,6 +113,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Zdieľanie',
'trip.settings.sharingHint': 'Odkaz len na čítanie pre kohokoľvek a pozvánka na pripojenie.',
'trip.settings.editTripHint': 'Názov, termíny, obálka, popis a mena.',
'trip.form.coverTitle': 'Titulný obrázok',
'trip.form.coverHint': 'Zobrazí sa na karte cesty a hore v plánovači.',
'trip.form.aboutTitle': 'Názov a popis',
'trip.form.aboutHint': 'Ako sa cesta volá a o čom je.',
'trip.form.whenTitle': 'Dátumy a mena',
'trip.form.whenHint': 'Kedy cesta prebieha, v akej mene sa platí a kedy dostaneš pripomienku.',
'trip.tabs.journal': 'Denník',
'trip.journal.title': 'Denník',
'trip.journal.hint':
@@ -134,6 +140,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Vyberte denník',
'trip.journal.noMembers': 'Na tejto ceste zatiaľ nikto ďalší nie je.',
'trip.journal.members': 'Prístup pre členov cesty',
'trip.journal.membersHint': 'Kto z tejto cesty otvára denník na karte Denník a či doň smie písať.',
'trip.journal.allRead': 'Všetci čítajú',
'trip.journal.allNone': 'Nikto',
'trip.journal.roleNone': 'Bez prístupu',
+7
View File
@@ -108,6 +108,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Dela',
'trip.settings.sharingHint': 'En skrivskyddad länk för alla och en inbjudningslänk för att gå med.',
'trip.settings.editTripHint': 'Titel, datum, omslagsbild, beskrivning och valuta.',
'trip.form.coverTitle': 'Omslagsbild',
'trip.form.coverHint': 'Visas på resans kort och högst upp i planeraren.',
'trip.form.aboutTitle': 'Namn och beskrivning',
'trip.form.aboutHint': 'Vad resan heter och vad den handlar om.',
'trip.form.whenTitle': 'Datum och valuta',
'trip.form.whenHint': 'När resan är, vilken valuta ni betalar i och när du påminns.',
'trip.tabs.journal': 'Dagbok',
'trip.journal.title': 'Dagbok',
'trip.journal.hint':
@@ -129,6 +135,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Välj en dagbok',
'trip.journal.noMembers': 'Ingen annan är med på resan än.',
'trip.journal.members': 'Åtkomst för resans medlemmar',
'trip.journal.membersHint': 'Vem från resan som öppnar journalen under fliken Journal, och om de får skriva i den.',
'trip.journal.allRead': 'Alla kan läsa',
'trip.journal.allNone': 'Ingen',
'trip.journal.roleNone': 'Ingen åtkomst',
+7
View File
@@ -103,6 +103,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'การแชร์',
'trip.settings.sharingHint': 'ลิงก์อ่านอย่างเดียวสำหรับทุกคน และลิงก์เชิญเพื่อเข้าร่วม',
'trip.settings.editTripHint': 'ชื่อ วันที่ ภาพปก คำอธิบาย และสกุลเงิน',
'trip.form.coverTitle': 'ภาพปก',
'trip.form.coverHint': 'แสดงบนการ์ดทริปและด้านบนของตัววางแผน',
'trip.form.aboutTitle': 'ชื่อและคำอธิบาย',
'trip.form.aboutHint': 'ทริปชื่ออะไรและเกี่ยวกับอะไร',
'trip.form.whenTitle': 'วันที่และสกุลเงิน',
'trip.form.whenHint': 'ทริปมีขึ้นเมื่อไร จ่ายด้วยสกุลเงินใด และจะเตือนคุณเมื่อไร',
'trip.tabs.journal': 'บันทึก',
'trip.journal.title': 'บันทึก',
'trip.journal.hint': 'เชื่อมบันทึกของคุณเล่มหนึ่งเข้ากับทริปนี้ ผู้ที่คุณให้สิทธิ์จะเปิดได้ที่แท็บบันทึกตรงนี้ แบบสด',
@@ -123,6 +129,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'เลือกบันทึก',
'trip.journal.noMembers': 'ยังไม่มีใครอื่นในทริปนี้',
'trip.journal.members': 'สิทธิ์สำหรับสมาชิกทริป',
'trip.journal.membersHint': 'ใครในทริปนี้เปิดบันทึกในแท็บบันทึกได้ และเขียนลงไปได้หรือไม่',
'trip.journal.allRead': 'ทุกคนอ่านได้',
'trip.journal.allNone': 'ไม่มีใคร',
'trip.journal.roleNone': 'ไม่มีสิทธิ์',
+7
View File
@@ -109,6 +109,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Paylaşım',
'trip.settings.sharingHint': 'Herkes için salt okunur bir bağlantı ve katılmak için davet bağlantısı.',
'trip.settings.editTripHint': 'Başlık, tarihler, kapak, açıklama ve para birimi.',
'trip.form.coverTitle': 'Kapak görseli',
'trip.form.coverHint': 'Seyahat kartında ve planlayıcının üstünde görünür.',
'trip.form.aboutTitle': 'Ad ve açıklama',
'trip.form.aboutHint': 'Seyahatin adı ve neyle ilgili olduğu.',
'trip.form.whenTitle': 'Tarihler ve para birimi',
'trip.form.whenHint': 'Seyahatin ne zaman olduğu, hangi para biriminde ödendiği ve ne zaman hatırlatılacağın.',
'trip.tabs.journal': 'Günlük',
'trip.journal.title': 'Günlük',
'trip.journal.hint':
@@ -130,6 +136,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Bir günlük seç',
'trip.journal.noMembers': 'Bu seyahatte henüz başka kimse yok.',
'trip.journal.members': 'Seyahat üyeleri için erişim',
'trip.journal.membersHint': 'Bu seyahatten kimin günlüğü Günlük sekmesinde açtığı ve içine yazıp yazamayacağı.',
'trip.journal.allRead': 'Herkes okuyabilir',
'trip.journal.allNone': 'Hiç kimse',
'trip.journal.roleNone': 'Erişim yok',
+7
View File
@@ -118,6 +118,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Поширення',
'trip.settings.sharingHint': 'Посилання лише для читання для всіх і посилання-запрошення, щоб приєднатися.',
'trip.settings.editTripHint': 'Назва, дати, обкладинка, опис і валюта.',
'trip.form.coverTitle': 'Обкладинка',
'trip.form.coverHint': 'Показується на картці подорожі та вгорі планувальника.',
'trip.form.aboutTitle': 'Назва й опис',
'trip.form.aboutHint': 'Як називається подорож і про що вона.',
'trip.form.whenTitle': 'Дати й валюта',
'trip.form.whenHint': 'Коли подорож, у якій валюті платите і коли прийде нагадування.',
'trip.tabs.journal': 'Щоденник',
'trip.journal.title': 'Щоденник',
'trip.journal.hint':
@@ -139,6 +145,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Виберіть щоденник',
'trip.journal.noMembers': 'У цій подорожі поки більше нікого немає.',
'trip.journal.members': 'Доступ для учасників подорожі',
'trip.journal.membersHint': 'Хто з цієї подорожі відкриває журнал у вкладці «Журнал» і чи може в ньому писати.',
'trip.journal.allRead': 'Усі читають',
'trip.journal.allNone': 'Ніхто',
'trip.journal.roleNone': 'Без доступу',
+7
View File
@@ -103,6 +103,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': 'Chia sẻ',
'trip.settings.sharingHint': 'Liên kết chỉ đọc cho mọi người và liên kết mời để tham gia.',
'trip.settings.editTripHint': 'Tiêu đề, ngày, ảnh bìa, mô tả và tiền tệ.',
'trip.form.coverTitle': 'Ảnh bìa',
'trip.form.coverHint': 'Hiển thị trên thẻ chuyến đi và ở đầu trình lập kế hoạch.',
'trip.form.aboutTitle': 'Tên và mô tả',
'trip.form.aboutHint': 'Chuyến đi tên gì và nói về điều gì.',
'trip.form.whenTitle': 'Ngày và tiền tệ',
'trip.form.whenHint': 'Chuyến đi diễn ra khi nào, thanh toán bằng tiền gì và khi nào bạn được nhắc.',
'trip.tabs.journal': 'Nhật ký',
'trip.journal.title': 'Nhật ký',
'trip.journal.hint':
@@ -124,6 +130,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': 'Chọn nhật ký',
'trip.journal.noMembers': 'Chưa có ai khác trong chuyến đi này.',
'trip.journal.members': 'Quyền truy cập cho thành viên chuyến đi',
'trip.journal.membersHint': 'Ai trong chuyến đi mở nhật ký trong thẻ Nhật ký và họ có được viết vào đó không.',
'trip.journal.allRead': 'Mọi người đều đọc',
'trip.journal.allNone': 'Không ai',
'trip.journal.roleNone': 'Không có quyền',
+7
View File
@@ -100,6 +100,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': '分享',
'trip.settings.sharingHint': '任何人可用的唯讀連結,以及用於加入的邀請連結。',
'trip.settings.editTripHint': '標題、日期、封面、描述和貨幣。',
'trip.form.coverTitle': '封面圖片',
'trip.form.coverHint': '顯示在旅行卡片與規劃器頂部。',
'trip.form.aboutTitle': '名稱與描述',
'trip.form.aboutHint': '旅行的名稱與內容。',
'trip.form.whenTitle': '日期與貨幣',
'trip.form.whenHint': '旅行的時間、使用的貨幣以及何時提醒你。',
'trip.tabs.journal': '日誌',
'trip.journal.title': '日誌',
'trip.journal.hint': '將你的一本日誌連結到此旅行。獲得存取權限的人可在此處的日誌分頁中即時開啟它。',
@@ -120,6 +126,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': '選擇日誌',
'trip.journal.noMembers': '此旅行中還沒有其他人。',
'trip.journal.members': '旅行成員的存取權限',
'trip.journal.membersHint': '此旅行中誰可以在日誌分頁開啟日誌,以及能否在其中撰寫。',
'trip.journal.allRead': '全部可讀',
'trip.journal.allNone': '無人',
'trip.journal.roleNone': '無權限',
+7
View File
@@ -100,6 +100,12 @@ const trip: TranslationStrings = {
'trip.settings.sharing': '分享',
'trip.settings.sharingHint': '任何人可用的只读链接,以及用于加入的邀请链接。',
'trip.settings.editTripHint': '标题、日期、封面、描述和货币。',
'trip.form.coverTitle': '封面图片',
'trip.form.coverHint': '显示在旅行卡片和规划器顶部。',
'trip.form.aboutTitle': '名称和描述',
'trip.form.aboutHint': '旅行的名称和内容。',
'trip.form.whenTitle': '日期和货币',
'trip.form.whenHint': '旅行的时间、使用的货币以及何时提醒你。',
'trip.tabs.journal': '日志',
'trip.journal.title': '日志',
'trip.journal.hint': '将你的一本日志关联到此旅行。获得访问权限的人可在此处的日志标签中实时打开它。',
@@ -120,6 +126,7 @@ const trip: TranslationStrings = {
'trip.journal.pickPlaceholder': '选择日志',
'trip.journal.noMembers': '此旅行中还没有其他人。',
'trip.journal.members': '旅行成员的访问权限',
'trip.journal.membersHint': '此旅行中谁可以在日志标签页打开日志,以及能否在其中写作。',
'trip.journal.allRead': '全部可读',
'trip.journal.allNone': '无人',
'trip.journal.roleNone': '无权限',