mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 788b1f63d0 | |||
| bfb1b04f8d | |||
| a77ec6e225 | |||
| 2851eb7afe | |||
| 6c7605b4a6 | |||
| 3b286d04c3 | |||
| 21afe89b13 | |||
| 060a43ba64 | |||
| dedbf16f03 |
@@ -5,6 +5,7 @@ import { StatusBar, Style } from '@capacitor/status-bar';
|
||||
import logoUrl from '../public/icons/icon-white.svg';
|
||||
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
|
||||
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
|
||||
import { opensSoon, refundedBeforeOpening } from '../src/native/cloudOrders';
|
||||
import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
|
||||
import { CLOUD_TERMS_URL, CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links';
|
||||
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
|
||||
@@ -429,12 +430,14 @@ function offerFor(offer: Offer, plan: CloudPlan, interval: 'month' | 'year'): {
|
||||
|
||||
async function loadOffer(list: CloudPlans): Promise<Offer> {
|
||||
const available = await iapReady;
|
||||
// Closed: a store purchase would be refused at the cloud, so only a test build may buy.
|
||||
const opensSoon = available && list.closed && !(await iap.isSandbox().catch(() => false));
|
||||
const canBuy = available && !opensSoon;
|
||||
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon };
|
||||
// Closed: a real purchase is refused at the cloud, so only a test build may buy on iOS;
|
||||
// Android keeps the button for the licence testers (see cloudOrders.ts).
|
||||
const sandbox = list.closed && !onPlay ? await iap.isSandbox().catch(() => false) : false;
|
||||
const soon = opensSoon({ available, closed: list.closed, sandbox, onPlay });
|
||||
const canBuy = available && !soon;
|
||||
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null, opensSoon: soon };
|
||||
const ids = list.plans.flatMap((p) => [productIdFor(p.id, 'month'), productIdFor(p.id, 'year')]);
|
||||
return { canBuy, storePrices: await iap.prices(ids), opensSoon };
|
||||
return { canBuy, storePrices: await iap.prices(ids), opensSoon: soon };
|
||||
}
|
||||
|
||||
function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HTMLElement {
|
||||
@@ -526,7 +529,10 @@ function planFooter(offer: Offer, plans: CloudPlan[]): Node[] {
|
||||
void notify(NotificationType.Error);
|
||||
restore.disabled = false;
|
||||
status.className = 'hint bad';
|
||||
status.textContent = t('native.plans.restoreFailed');
|
||||
// Bought before the opening: refused, unacknowledged, and refunded by Google Play.
|
||||
status.textContent = refundedBeforeOpening(redeemed.code, onPlay)
|
||||
? t('native.claim.closedRefund')
|
||||
: t('native.plans.restoreFailed');
|
||||
});
|
||||
|
||||
// A payment that was pending (cash, some carrier billing) and has cleared since:
|
||||
@@ -838,7 +844,9 @@ function showClaim(
|
||||
slugNote.textContent = t('native.claim.taken');
|
||||
slugNote.className = 'hint bad';
|
||||
} else {
|
||||
error.textContent = t('native.claim.redeemFailed');
|
||||
error.textContent = refundedBeforeOpening(redeemed.code, onPlay)
|
||||
? t('native.claim.closedRefund')
|
||||
: t('native.claim.redeemFailed');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -669,6 +669,12 @@
|
||||
"desktop": ["src/components/Roadtrip/RoadtripStopPopup.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Road-trip Google Maps route import",
|
||||
"hooks": ["src/components/Roadtrip/useGoogleRouteImport.ts"],
|
||||
"desktop": ["src/components/Roadtrip/GoogleRouteImport.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtRouteImportSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Road-trip stay length draft",
|
||||
"hooks": ["src/components/Roadtrip/useStayDraft.ts"],
|
||||
@@ -1045,8 +1051,10 @@
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx": "Large screen form inside MRtInfoSheet over the planner's saveRoadtripLimit, the desktop dialog's own save, and the shared dayWindow, parseAvoid and rangeFromSpec.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.",
|
||||
"src/mobile/screens/trip/roadtrip/MRtTrackSheet.tsx": "Large screen sheet over the planner's followTrack, the same hook RoadtripTrackModal runs on; the phone has no entry to it.",
|
||||
"src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.",
|
||||
"src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.",
|
||||
"src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.",
|
||||
|
||||
@@ -1,60 +1,17 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useId } from 'react'
|
||||
import { MoreHorizontal, Link2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { googleRouteRepo } from '../../repo/googleRouteRepo'
|
||||
import { generateUUID } from '../../sync/mutationQueue'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { GoogleRoutePreview } from '@trek/shared'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useGoogleRouteImport } from './useGoogleRouteImport'
|
||||
|
||||
export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; dayId?: number }) {
|
||||
const { t } = useTranslation()
|
||||
const days = useTripStore(s => s.days)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [preview, setPreview] = useState<GoogleRoutePreview | null>(null)
|
||||
const [target, setTarget] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
const requestId = useRef('')
|
||||
const { days, open, url, setUrl, preview, target, pickDay, busy, failure, saved, show, close, canSubmit, submit } = useGoogleRouteImport(tripId, dayId)
|
||||
const menu = useContextMenu()
|
||||
const labelId = useId()
|
||||
useEffect(() => () => controller.current?.abort(), [])
|
||||
const inspect = async () => {
|
||||
controller.current?.abort()
|
||||
const abort = new AbortController()
|
||||
controller.current = abort
|
||||
setBusy(true); setFailure(''); setPreview(null)
|
||||
try { const parsed = await googleRouteRepo.preview(url, abort.signal); if (!abort.signal.aborted) setPreview(parsed) }
|
||||
catch (error) { if (!abort.signal.aborted) setFailure(getApiErrorMessage(error, t('common.error'))) }
|
||||
finally { if (!abort.signal.aborted) setBusy(false) }
|
||||
}
|
||||
const save = async () => {
|
||||
if (!preview || !target) return
|
||||
setBusy(true); setFailure('')
|
||||
try {
|
||||
const stops = preview.stops.filter(s => s.lat != null && s.lng != null).map(s => ({ name: s.name, lat: s.lat!, lng: s.lng! }))
|
||||
await googleRouteRepo.append(tripId, { dayId: Number(target), stops }, requestId.current)
|
||||
setSaved(true)
|
||||
await useTripStore.getState().loadTrip(tripId)
|
||||
setOpen(false)
|
||||
} catch (error) { setFailure(getApiErrorMessage(error, t('common.error'))) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const show = () => {
|
||||
setUrl(''); setPreview(null); setFailure(''); setSaved(false)
|
||||
setTarget(String(dayId ?? days[0]?.id ?? ''))
|
||||
requestId.current = generateUUID(); setOpen(true)
|
||||
}
|
||||
// Every way out is held while a preview or an import is in flight, as before.
|
||||
const close = () => { if (!busy) setOpen(false) }
|
||||
const resolved = preview?.stops.filter(s => s.lat != null && s.lng != null) ?? []
|
||||
return <>
|
||||
<Tooltip label={t('roadtrip.import.title')}>
|
||||
<button type="button" aria-label={t('roadtrip.import.title')} className="shrink-0 rounded-lg p-2 text-content-muted hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||
@@ -84,7 +41,7 @@ export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; d
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton disabled={busy} onClick={close}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" disabled={busy || saved || (!preview ? !url.trim() : resolved.length < 2 || !target)} onClick={() => void (preview ? save() : inspect())}>
|
||||
<DialogButton variant="primary" disabled={!canSubmit} onClick={submit}>
|
||||
{busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
@@ -94,7 +51,7 @@ export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; d
|
||||
className="w-full rounded-[10px] border border-edge bg-surface-input px-3 py-2.5 text-content outline-none placeholder:text-content-faint focus:ring-2 focus:ring-[color:var(--text-primary)] disabled:opacity-50"
|
||||
style={fs(13, 'body')} /> : <>
|
||||
<DialogSection label={t('roadtrip.import.day')}>
|
||||
<CustomSelect value={target} onChange={value => { setTarget(String(value)); requestId.current = generateUUID() }}
|
||||
<CustomSelect value={target} onChange={value => pickDay(String(value))}
|
||||
options={days.map(day => ({ value: String(day.id), label: day.title || `${t('roadtrip.import.day')} ${day.day_number}` }))} disabled={busy || saved} />
|
||||
</DialogSection>
|
||||
<ol className="m-0 flex max-h-64 list-none flex-col gap-2.5 overflow-auto rounded-[14px] border border-edge-faint bg-surface-secondary p-3.5">
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { GoogleRoutePreview } from '@trek/shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import { googleRouteRepo } from '../../repo/googleRouteRepo';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { generateUUID } from '../../sync/mutationQueue';
|
||||
import { getApiErrorMessage } from '../../types';
|
||||
|
||||
/**
|
||||
* A route shared from Google Maps, read and appended to a day of the trip: the logic
|
||||
* behind the desktop's import dialog (GoogleRouteImport) and the large screens' sheet
|
||||
* (MRtRouteImportSheet), so the two cannot drift.
|
||||
*
|
||||
* A link is previewed first, the stops it names in order, and only the ones that resolved
|
||||
* to a place are appended, at least two of them. One request id per opening and per day
|
||||
* picked, so a retried append lands once. Every way out is held while a preview or an
|
||||
* import is in flight.
|
||||
*/
|
||||
export function useGoogleRouteImport(tripId: number, dayId?: number) {
|
||||
const { t } = useTranslation();
|
||||
const days = useTripStore((s) => s.days);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const [preview, setPreview] = useState<GoogleRoutePreview | null>(null);
|
||||
const [target, setTarget] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failure, setFailure] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
const requestId = useRef('');
|
||||
useEffect(() => () => controller.current?.abort(), []);
|
||||
|
||||
const inspect = async () => {
|
||||
controller.current?.abort();
|
||||
const abort = new AbortController();
|
||||
controller.current = abort;
|
||||
setBusy(true);
|
||||
setFailure('');
|
||||
setPreview(null);
|
||||
try {
|
||||
const parsed = await googleRouteRepo.preview(url, abort.signal);
|
||||
if (!abort.signal.aborted) setPreview(parsed);
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) setFailure(getApiErrorMessage(error, t('common.error')));
|
||||
} finally {
|
||||
if (!abort.signal.aborted) setBusy(false);
|
||||
}
|
||||
};
|
||||
const save = async () => {
|
||||
if (!preview || !target) return;
|
||||
setBusy(true);
|
||||
setFailure('');
|
||||
try {
|
||||
const stops = preview.stops
|
||||
.filter((s) => s.lat != null && s.lng != null)
|
||||
.map((s) => ({ name: s.name, lat: s.lat!, lng: s.lng! }));
|
||||
await googleRouteRepo.append(tripId, { dayId: Number(target), stops }, requestId.current);
|
||||
setSaved(true);
|
||||
await useTripStore.getState().loadTrip(tripId);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
setFailure(getApiErrorMessage(error, t('common.error')));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const show = () => {
|
||||
setUrl('');
|
||||
setPreview(null);
|
||||
setFailure('');
|
||||
setSaved(false);
|
||||
setTarget(String(dayId ?? days[0]?.id ?? ''));
|
||||
requestId.current = generateUUID();
|
||||
setOpen(true);
|
||||
};
|
||||
const close = () => {
|
||||
if (!busy) setOpen(false);
|
||||
};
|
||||
/** Another day is another append, so it gets a request id of its own. */
|
||||
const pickDay = (value: string) => {
|
||||
setTarget(value);
|
||||
requestId.current = generateUUID();
|
||||
};
|
||||
const resolved = preview?.stops.filter((s) => s.lat != null && s.lng != null) ?? [];
|
||||
const canSubmit = !busy && !saved && (!preview ? !!url.trim() : resolved.length >= 2 && !!target);
|
||||
/** The one button: the preview while there is none, the import once there is. */
|
||||
const submit = () => {
|
||||
void (preview ? save() : inspect());
|
||||
};
|
||||
|
||||
return { days, open, url, setUrl, preview, target, pickDay, busy, failure, saved, show, close, canSubmit, submit };
|
||||
}
|
||||
@@ -97,6 +97,11 @@ export function MRailSpacer() {
|
||||
interface MRailButtonProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
/**
|
||||
* Opens a sheet rather than a section (More): while that sheet is open the button wears
|
||||
* the ring the phone dock's More wears, so the rail says where the sheet came from.
|
||||
*/
|
||||
expanded?: boolean;
|
||||
onClick: () => void;
|
||||
/** The icon, drawn at 20px. */
|
||||
children: ReactNode;
|
||||
@@ -108,19 +113,24 @@ interface MRailButtonProps {
|
||||
* and never shrunk: it wraps into a second, balanced line where its language
|
||||
* needs one, and only a word longer than the rail is hyphenated.
|
||||
*/
|
||||
export function MRailButton({ label, active, onClick, children }: MRailButtonProps) {
|
||||
export function MRailButton({ label, active, expanded, onClick, children }: MRailButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
aria-expanded={expanded}
|
||||
className="group flex w-full flex-none flex-col items-center gap-[3px] rounded-[18px] py-[5px] outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full transition-colors duration-150 ${
|
||||
active ? 'bg-m-act text-m-actfg' : 'text-m-muted group-hover:bg-[color:var(--m-ic)] group-hover:text-m-ink'
|
||||
active
|
||||
? 'bg-m-act text-m-actfg'
|
||||
: expanded
|
||||
? 'bg-[color:var(--m-ic)] text-m-ink ring-[1.5px] ring-inset ring-[color:var(--m-act)]'
|
||||
: 'text-m-muted group-hover:bg-[color:var(--m-ic)] group-hover:text-m-ink'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2, X, Route, ListChecks } from 'lucide-react'
|
||||
import { Outlet, useSearchParams } from 'react-router'
|
||||
import { BookOpen, ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2, X, Route, ListChecks } from 'lucide-react'
|
||||
import JourneyLayeredMap from '../../../components/Journey/JourneyLayeredMap'
|
||||
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
|
||||
import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog'
|
||||
@@ -324,8 +324,9 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
// keeps a single "more" affordance.
|
||||
//
|
||||
// There is no book export here any more: the book is a Studio document now,
|
||||
// and Studio is a desktop editor. A phone-sized PDF of a book laid out for
|
||||
// print was the old export's answer to a question nobody was asking.
|
||||
// and Studio needs the room of a desktop, an iPad on its side or an unfolded
|
||||
// Duo (split), where the header offers it. A phone-sized PDF of a book laid
|
||||
// out for print was the old export's answer to a question nobody was asking.
|
||||
const toggleSkeletons = async (next: boolean) => {
|
||||
setHideSkeletons(next)
|
||||
try {
|
||||
@@ -498,6 +499,16 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
</span>
|
||||
)}
|
||||
{split && !embedded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate(`/journey/${current.id}/studio`)}
|
||||
aria-label={t('journey.studio.openAria')}
|
||||
className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
|
||||
>
|
||||
<BookOpen size={17} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowActionMenu(true)}
|
||||
@@ -805,6 +816,10 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
onClose={() => setLightbox(null)}
|
||||
/>
|
||||
)}
|
||||
{/* TREK Studio, the book designer, opens over the journey on a screen with room for
|
||||
two panes, as on the desktop (its route is a child of this one). The phone keeps
|
||||
the journey alone, whatever the address says. */}
|
||||
{split && !embedded && <Outlet />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ interface MTripRailProps {
|
||||
onSelect: (id: string) => void;
|
||||
/** Opens the More sheet: trip settings, export, the assistant. */
|
||||
onMore: () => void;
|
||||
/** Whether the More sheet is open, which the More button shows with a ring. */
|
||||
moreOpen?: boolean;
|
||||
labelOf: (id: string) => string;
|
||||
}
|
||||
|
||||
@@ -27,7 +29,7 @@ const DOCK_ICONS = new Map(DOCK_PRIORITY.map((tab) => [tab.id, tab.icon]));
|
||||
* collaboration and plugin pages sit in it too, each with its name, and More
|
||||
* keeps only the trip's actions.
|
||||
*/
|
||||
export default function MTripRail({ tabs, active, onSelect, onMore, labelOf }: MTripRailProps) {
|
||||
export default function MTripRail({ tabs, active, onSelect, onMore, moreOpen = false, labelOf }: MTripRailProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MRail label={t('mobileTrip.sections')}>
|
||||
@@ -41,7 +43,7 @@ export default function MTripRail({ tabs, active, onSelect, onMore, labelOf }: M
|
||||
);
|
||||
})}
|
||||
<MRailSpacer />
|
||||
<MRailButton label={t('mobileTrip.more')} active={false} onClick={onMore}>
|
||||
<MRailButton label={t('mobileTrip.more')} active={false} expanded={moreOpen} onClick={onMore}>
|
||||
<MoreHorizontal size={20} strokeWidth={2.2} />
|
||||
</MRailButton>
|
||||
</MRail>
|
||||
|
||||
@@ -901,6 +901,7 @@ export default function MTripShell({
|
||||
active={trTab}
|
||||
onSelect={setTrTab}
|
||||
onMore={() => openSheet('mehr')}
|
||||
moreOpen={sheet?.id === 'mehr'}
|
||||
labelOf={tabLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useMRtCorridor } from './useMRtCorridor'
|
||||
import { useMRtAlternatives } from './useMRtAlternatives'
|
||||
import MRtCorridorBar from './MRtCorridorBar'
|
||||
import MRtAlternativesBar from './MRtAlternativesBar'
|
||||
import { MRtTrackEntry } from './MRtTrackSheet'
|
||||
import MRtRouteImport from './MRtRouteImportSheet'
|
||||
import { RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
|
||||
import MBadge from '../../../components/MBadge'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
@@ -289,6 +291,12 @@ function RoadtripStagePage({ planner, shell, dayId: pageDayId, alts, chrome }: O
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<MRtTrackEntry planner={planner} dayId={stage.dayId} />
|
||||
<MRtRouteImport
|
||||
tripId={planner.tripId}
|
||||
dayId={stage.dayId > 0 ? stage.dayId : undefined}
|
||||
canEdit={canEditPlaces && planner.can('day_edit', planner.trip)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{rt.upNext && <UpNext planner={planner} shell={shell} rt={rt} stageDayId={stage.dayId} onOpen={openStop} />}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { formatDurationShort, parseAvoid } from '../../../../components/Roadtrip
|
||||
import { valhallaAvailable } from '../../../../components/Map/valhallaRoute'
|
||||
import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../../../utils/units'
|
||||
import { Eyebrow, TileHeader } from '../sheets/MTripSheetUi'
|
||||
import { useIsWide } from '../../../../hooks/sizeClass'
|
||||
import MRtLimitsEditor from './MRtLimitsEditor'
|
||||
import type { DistanceUnit } from '../../../../types'
|
||||
|
||||
/**
|
||||
@@ -104,6 +106,9 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const degradation = useRoadtripSettings(s => s.roadtrip_battery_degradation, tripId)
|
||||
const avoidRaw = useRoadtripSettings(s => s.roadtrip_avoid, tripId)
|
||||
const bookends = useHotelBookends(planner.saveRoadtripLimit, tripId)
|
||||
// An iPad or an unfolded Duo is the table the figures are set at: there the limits are a
|
||||
// form (MRtLimitsEditor). The phone and the folded Duo keep the read-out below.
|
||||
const large = useIsWide()
|
||||
|
||||
const electric = vehicleKind === 'electric'
|
||||
const VehicleIcon = electric ? Zap : vehicleKind === 'combustion' ? Fuel : Car
|
||||
@@ -225,26 +230,41 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
|
||||
</div>
|
||||
|
||||
{/* ── Limits: what every warning on the stage is measured against ── */}
|
||||
<div className="mt-2.5">
|
||||
<InfoCard label={t('roadtrip.limit.title')}>
|
||||
<div className="mt-1">
|
||||
{lines.map((line, i) => (
|
||||
<LimitLine
|
||||
key={line.key}
|
||||
label={line.label}
|
||||
value={line.value}
|
||||
fallback={line.fallback}
|
||||
first={i === 0}
|
||||
/>
|
||||
))}
|
||||
{large ? (
|
||||
<div className="mt-2.5">
|
||||
<MRtLimitsEditor
|
||||
tripId={tripId}
|
||||
// No save is the planner saying the reader may not edit days, or that the
|
||||
// trip's settings are not in yet: the form shows its figures, disabled.
|
||||
readOnly={!planner.saveRoadtripLimit}
|
||||
onSave={(key, value) => void planner.saveRoadtripLimit?.(key, value)}
|
||||
onResetDayBoundaries={planner.resetDayBoundaries}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-2.5">
|
||||
<InfoCard label={t('roadtrip.limit.title')}>
|
||||
<div className="mt-1">
|
||||
{lines.map((line, i) => (
|
||||
<LimitLine
|
||||
key={line.key}
|
||||
label={line.label}
|
||||
value={line.value}
|
||||
fallback={line.fallback}
|
||||
first={i === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</InfoCard>
|
||||
</div>
|
||||
</InfoCard>
|
||||
</div>
|
||||
|
||||
{/* Under the figures it speaks of, and above the switch it does not. */}
|
||||
<p className="mt-2.5 font-geist text-[0.6875rem] leading-snug text-m-faint">
|
||||
{t('mobileTrip.rtDesktopNote')}
|
||||
</p>
|
||||
{/* Under the figures it speaks of, and above the switch it does not. */}
|
||||
<p className="mt-2.5 font-geist text-[0.6875rem] leading-snug text-m-faint">
|
||||
{t('mobileTrip.rtDesktopNote')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── The one switch: whether a day starts and ends at the stay ── */}
|
||||
<div className="mt-3">
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { valhallaAvailable } from '../../../../components/Map/valhallaRoute';
|
||||
import { dayWindow } from '../../../../components/Roadtrip/dayWindow';
|
||||
import { parseAvoid, serializeAvoid, type VehicleKind } from '../../../../components/Roadtrip/roadtripModel';
|
||||
import { rangeFromSpec, type VehicleSpec } from '../../../../components/Roadtrip/vehicleRange';
|
||||
import CustomTimePicker from '../../../../components/shared/CustomTimePicker';
|
||||
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import type { RouteAvoidClass } from '../../../../types';
|
||||
import { convertDistance } from '../../../../utils/units';
|
||||
import MSegmented from '../../../components/MSegmented';
|
||||
import MToggle from '../../../components/MToggle';
|
||||
import { Eyebrow } from '../sheets/MTripSheetUi';
|
||||
|
||||
/**
|
||||
* The driving figures as a form, for an iPad or an unfolded Duo.
|
||||
*
|
||||
* The phone keeps them read-only (MRtInfoSheet says why: a form in the passenger seat breaks
|
||||
* a trip while it is happening). A large screen is the table the figures are set at, so
|
||||
* there the same sheet edits what the desktop's limits dialog edits most: the two driving
|
||||
* limits, the day's window and where it ends, the vehicle with its range and fill, and what
|
||||
* the road may avoid. The vehicle's own figures (tank, consumption, battery) stay on the
|
||||
* desktop; once they are stored the range is theirs and shows read-only here, as it does
|
||||
* there.
|
||||
*
|
||||
* Every change goes through the planner's `saveRoadtripLimit`, the desktop dialog's own
|
||||
* save, which checks the right to edit days itself. `readOnly` disables the controls for a
|
||||
* reader without that right, so nothing takes a value only to drop it.
|
||||
*/
|
||||
|
||||
/** Empty means no limit, and so does zero: both are stored as 0 and read as off. */
|
||||
function parseFigure(raw: string): number {
|
||||
const n = Number(raw.replace(',', '.'));
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
|
||||
}
|
||||
|
||||
/** One card of the form: eyebrow plus its rows. */
|
||||
function EditCard({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-[16px] bg-[color:var(--m-ic)] px-3.5 py-3">
|
||||
<Eyebrow>{label}</Eyebrow>
|
||||
<div className="mt-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A label and its control on one line, with the row rule above every row but the first. */
|
||||
function EditRow({ label, first, children }: { label: string; first?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex min-h-11 items-center justify-between gap-3 py-1.5 ${first ? '' : 'border-t border-[color:var(--m-rowbr)]'}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-body text-m-muted">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A number typed in and saved when the field is left or Enter is pressed, not per
|
||||
* keystroke: a settings write per digit would plan the trip against "4" on the way to "45".
|
||||
*/
|
||||
function FigureField({
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
placeholder,
|
||||
disabled,
|
||||
testId,
|
||||
onCommit,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | undefined;
|
||||
suffix: string;
|
||||
placeholder: string;
|
||||
disabled: boolean;
|
||||
testId: string;
|
||||
onCommit: (value: number) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const shown = draft ?? (value ? String(value) : '');
|
||||
const commit = () => {
|
||||
if (draft === null) return;
|
||||
setDraft(null);
|
||||
const next = parseFigure(draft);
|
||||
if (next !== (value ?? 0)) onCommit(next);
|
||||
};
|
||||
return (
|
||||
<span className="flex flex-none items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
value={shown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setDraft(e.target.value.replace(/[^0-9.,]/g, ''))}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
className="h-9 w-[5.5rem] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 text-end text-body font-semibold tabular-nums text-m-ink outline-none placeholder:font-medium placeholder:text-m-faint disabled:opacity-60"
|
||||
/>
|
||||
<span className="w-7 text-caption text-m-faint">{suffix}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
const HINT = 'font-geist text-caption leading-snug text-m-faint';
|
||||
|
||||
interface CardProps {
|
||||
tripId: number;
|
||||
readOnly: boolean;
|
||||
onSave: (key: string, value: number | string | boolean) => void;
|
||||
}
|
||||
|
||||
/** How long one stretch and one day may be, in minutes. */
|
||||
function DrivingCard({ tripId, readOnly, onSave }: CardProps) {
|
||||
const { t } = useTranslation();
|
||||
const leg = useRoadtripSettings((all) => all.roadtrip_leg_minutes, tripId);
|
||||
const day = useRoadtripSettings((all) => all.roadtrip_day_minutes, tripId);
|
||||
const rows = [
|
||||
{ key: 'roadtrip_leg_minutes', label: t('roadtrip.limit.legLabel'), value: leg, testId: 'rt-edit-leg' },
|
||||
{ key: 'roadtrip_day_minutes', label: t('roadtrip.limit.dayLabel'), value: day, testId: 'rt-edit-day' },
|
||||
];
|
||||
return (
|
||||
<EditCard label={t('roadtrip.limit.sectionDriving')}>
|
||||
{rows.map((row, i) => (
|
||||
<EditRow key={row.key} label={row.label} first={i === 0}>
|
||||
<FigureField
|
||||
label={row.label}
|
||||
value={row.value}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
disabled={readOnly}
|
||||
testId={row.testId}
|
||||
onCommit={(v) => onSave(row.key, v)}
|
||||
/>
|
||||
</EditRow>
|
||||
))}
|
||||
<p className={`pb-1 ${HINT}`}>{t('roadtrip.limit.hint')}</p>
|
||||
</EditCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** A choice between a few words, faded and out of reach for a reader. */
|
||||
function Choice<T extends string>({
|
||||
readOnly,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
readOnly: boolean;
|
||||
value: T;
|
||||
options: { value: T; label: string }[];
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={readOnly ? 'pointer-events-none opacity-60' : ''} aria-disabled={readOnly || undefined}>
|
||||
<MSegmented value={value} onChange={onChange} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** When the day may start and end, and whether it ends on the road or at a stop. */
|
||||
function WindowCard({
|
||||
tripId,
|
||||
readOnly,
|
||||
onSave,
|
||||
onResetDayBoundaries,
|
||||
}: CardProps & {
|
||||
onResetDayBoundaries?: () => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const storedStart = useRoadtripSettings((all) => all.roadtrip_day_start, tripId) ?? '';
|
||||
const storedEnd = useRoadtripSettings((all) => all.roadtrip_day_end, tripId) ?? '';
|
||||
const endMode = useRoadtripSettings((all) => all.roadtrip_day_end_mode, tripId) === 'stop' ? 'stop' : 'route';
|
||||
// What is in the pickers, ahead of a time that is whole enough to save.
|
||||
const [times, setTimes] = useState<{ start?: string; end?: string }>({});
|
||||
const shown = { start: times.start ?? storedStart, end: times.end ?? storedEnd };
|
||||
const stored = { start: storedStart, end: storedEnd };
|
||||
const invalid = !!shown.start && !!shown.end && !dayWindow(shown.start, shown.end);
|
||||
const setTime = (field: 'start' | 'end', value: string) => {
|
||||
setTimes((prev) => ({ ...prev, [field]: value }));
|
||||
if (value !== '' && !TIME_RE.test(value)) return;
|
||||
if (value !== stored[field]) onSave(`roadtrip_day_${field}`, value);
|
||||
};
|
||||
return (
|
||||
<EditCard label={t('roadtrip.window.title')}>
|
||||
{(['start', 'end'] as const).map((field, i) => (
|
||||
<EditRow key={field} label={t(`roadtrip.window.${field}`)} first={i === 0}>
|
||||
<span className="w-[7rem] flex-none">
|
||||
<CustomTimePicker
|
||||
value={shown[field]}
|
||||
disabled={readOnly}
|
||||
aria-label={t(`roadtrip.window.${field}`)}
|
||||
aria-invalid={invalid || undefined}
|
||||
onChange={(value) => setTime(field, value)}
|
||||
/>
|
||||
</span>
|
||||
</EditRow>
|
||||
))}
|
||||
<p
|
||||
role={invalid ? 'alert' : undefined}
|
||||
className={`pb-2 ${invalid ? 'font-geist text-caption leading-snug text-danger' : HINT}`}
|
||||
>
|
||||
{t(invalid ? 'roadtrip.window.invalid' : 'roadtrip.window.hint')}
|
||||
</p>
|
||||
<div className="border-t border-[color:var(--m-rowbr)] pt-2.5">
|
||||
<div className="mb-2 text-body text-m-muted">{t('roadtrip.window.endAt')}</div>
|
||||
<Choice
|
||||
readOnly={readOnly}
|
||||
value={endMode}
|
||||
onChange={(mode) => onSave('roadtrip_day_end_mode', mode)}
|
||||
options={[
|
||||
{ value: 'route', label: t('roadtrip.window.onRoute') },
|
||||
{ value: 'stop', label: t('roadtrip.window.atStop') },
|
||||
]}
|
||||
/>
|
||||
<p className={`mt-2 ${HINT}`}>
|
||||
{t(endMode === 'stop' ? 'roadtrip.window.stopHint' : 'roadtrip.window.routeHint')}
|
||||
</p>
|
||||
{onResetDayBoundaries && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onResetDayBoundaries()}
|
||||
className="mt-2 min-h-9 text-start text-caption font-semibold text-m-ink underline-offset-2 active:underline"
|
||||
>
|
||||
{t('roadtrip.window.resetBoundaries')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</EditCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The vehicle, how far it goes, and how full a stop fills it. */
|
||||
function VehicleCard({ tripId, readOnly, onSave }: CardProps) {
|
||||
const { t } = useTranslation();
|
||||
const imperial = useSettingsStore((st) => st.settings.distance_unit) === 'imperial';
|
||||
const s = useRoadtripSettings((all) => all, tripId);
|
||||
const vehicle = s.roadtrip_vehicle ?? '';
|
||||
const vehicleKind: VehicleKind | null = vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null;
|
||||
const computedKm = rangeFromSpec(vehicleKind, {
|
||||
tankLitres: s.roadtrip_tank_litres,
|
||||
litresPer100: s.roadtrip_litres_per_100,
|
||||
batteryKwh: s.roadtrip_battery_kwh,
|
||||
kwhPer100: s.roadtrip_kwh_per_100,
|
||||
degradationPercent: s.roadtrip_battery_degradation,
|
||||
} satisfies VehicleSpec);
|
||||
const rangeKm = computedKm ?? s.roadtrip_range_km;
|
||||
// Kilometres in storage, the traveller's own unit on screen, as on the desktop.
|
||||
const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, imperial ? 'imperial' : 'metric')) : undefined;
|
||||
const rangeLabel = t(vehicleKind === 'electric' ? 'roadtrip.limit.rangeLabelCharge' : 'roadtrip.limit.rangeLabel');
|
||||
return (
|
||||
<EditCard label={t('roadtrip.limit.vehicleLabel')}>
|
||||
<div className="pb-1.5 pt-1">
|
||||
<Choice
|
||||
readOnly={readOnly}
|
||||
value={vehicleKind ?? 'any'}
|
||||
onChange={(kind) => onSave('roadtrip_vehicle', kind === 'any' ? '' : kind)}
|
||||
options={[
|
||||
{ value: 'any', label: t('roadtrip.limit.vehicleAny') },
|
||||
{ value: 'combustion', label: t('roadtrip.limit.vehicleCombustion') },
|
||||
{ value: 'electric', label: t('roadtrip.limit.vehicleElectric') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<EditRow label={rangeLabel}>
|
||||
<FigureField
|
||||
label={rangeLabel}
|
||||
value={rangeShown}
|
||||
suffix={imperial ? 'mi' : 'km'}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
// Worked out from the vehicle's own figures: those are set on the desktop, and a
|
||||
// second answer here would leave nobody knowing which one the warnings use.
|
||||
disabled={readOnly || computedKm !== null}
|
||||
testId="rt-edit-range"
|
||||
onCommit={(v) => onSave('roadtrip_range_km', Math.round(imperial ? v / 0.621371 : v))}
|
||||
/>
|
||||
</EditRow>
|
||||
<EditRow label={t('roadtrip.limit.fillLabel')}>
|
||||
<FigureField
|
||||
label={t('roadtrip.limit.fillLabel')}
|
||||
value={s.roadtrip_fill_percent}
|
||||
suffix="%"
|
||||
placeholder={t('roadtrip.limit.fillFull')}
|
||||
disabled={readOnly}
|
||||
testId="rt-edit-fill"
|
||||
onCommit={(v) => onSave('roadtrip_fill_percent', Math.min(100, v))}
|
||||
/>
|
||||
</EditRow>
|
||||
{computedKm !== null && <p className={`pb-1 ${HINT}`}>{t('roadtrip.limit.computed')}</p>}
|
||||
</EditCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the road may avoid: one stored list that each switch adds to or takes from. */
|
||||
function AvoidCard({ tripId, readOnly, onSave }: CardProps) {
|
||||
const { t } = useTranslation();
|
||||
const avoiding = parseAvoid(useRoadtripSettings((all) => all.roadtrip_avoid, tripId));
|
||||
// No second engine, no avoidance: the switches stay, disabled, and the hint says why.
|
||||
const canAvoid = valhallaAvailable();
|
||||
const rows: { cls: RouteAvoidClass; label: string }[] = [
|
||||
{ cls: 'toll', label: t('roadtrip.avoid.toll') },
|
||||
{ cls: 'motorway', label: t('roadtrip.avoid.motorway') },
|
||||
{ cls: 'ferry', label: t('roadtrip.avoid.ferry') },
|
||||
];
|
||||
const toggle = (cls: RouteAvoidClass) =>
|
||||
onSave(
|
||||
'roadtrip_avoid',
|
||||
serializeAvoid(avoiding.includes(cls) ? avoiding.filter((c) => c !== cls) : [...avoiding, cls])
|
||||
);
|
||||
return (
|
||||
<EditCard label={t('roadtrip.avoid.section')}>
|
||||
{rows.map(({ cls, label }, i) => (
|
||||
<EditRow key={cls} label={label} first={i === 0}>
|
||||
<MToggle
|
||||
checked={avoiding.includes(cls)}
|
||||
onChange={() => toggle(cls)}
|
||||
ariaLabel={label}
|
||||
disabled={readOnly || !canAvoid}
|
||||
/>
|
||||
</EditRow>
|
||||
))}
|
||||
<p className={`pb-1 ${HINT}`}>{t(canAvoid ? 'roadtrip.avoid.hint' : 'roadtrip.avoid.unavailable')}</p>
|
||||
</EditCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MRtLimitsEditor({
|
||||
onResetDayBoundaries,
|
||||
...card
|
||||
}: CardProps & {
|
||||
onResetDayBoundaries?: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5" data-testid="rt-limits-editor">
|
||||
<DrivingCard {...card} />
|
||||
<WindowCard {...card} onResetDayBoundaries={onResetDayBoundaries} />
|
||||
<VehicleCard {...card} />
|
||||
<AvoidCard {...card} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Link2 } from 'lucide-react';
|
||||
import { useGoogleRouteImport } from '../../../../components/Roadtrip/useGoogleRouteImport';
|
||||
import { useIsWide } from '../../../../hooks/sizeClass';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import MSheet from '../../../components/MSheet';
|
||||
import { Eyebrow, SHEET_PRIMARY, SHEET_SECONDARY, TileHeader } from '../sheets/MTripSheetUi';
|
||||
import { FIELD_CLS } from '../sheets/PlSheetChrome';
|
||||
|
||||
const NOTE = 'font-geist text-caption leading-snug';
|
||||
|
||||
/**
|
||||
* A route shared from Google Maps, appended to a day, on an iPad or an unfolded Duo.
|
||||
*
|
||||
* The desktop's import dialog (GoogleRouteImport) as the stage card's second offer under the
|
||||
* track one, over the same useGoogleRouteImport: a link is previewed, its stops listed in
|
||||
* order with the ones that did not resolve said so, and the resolved ones appended to the
|
||||
* day picked. Absent on the phone, where the stage stays a plan read on the road, and for a
|
||||
* reader who may not add places to days, since the import does both.
|
||||
*/
|
||||
export default function MRtRouteImport({
|
||||
tripId,
|
||||
dayId,
|
||||
canEdit,
|
||||
}: {
|
||||
tripId: number;
|
||||
dayId?: number;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const large = useIsWide();
|
||||
const flow = useGoogleRouteImport(tripId, dayId);
|
||||
if (!large || !canEdit) return null;
|
||||
const { days, open, url, setUrl, preview, target, pickDay, busy, failure, saved, show, close, canSubmit, submit } =
|
||||
flow;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={show}
|
||||
className="mt-2 flex min-h-10 w-full items-center gap-2 rounded-[13px] bg-[color:var(--m-ic)] px-3 py-2 text-start"
|
||||
>
|
||||
<Link2 size={15} strokeWidth={1.9} className="flex-none text-m-muted" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-m-ink">
|
||||
{t('roadtrip.import.title')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<MSheet open={open} onClose={close} variant="bottom" material="glass" ariaLabel={t('roadtrip.import.title')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Link2 size={19} strokeWidth={1.8} />}
|
||||
title={<span className="truncate">{t('roadtrip.import.title')}</span>}
|
||||
sub={t('roadtrip.import.note')}
|
||||
subWrap
|
||||
onClose={close}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-3 pt-[14px]">
|
||||
{!preview ? (
|
||||
<input
|
||||
type="url"
|
||||
dir="ltr"
|
||||
value={url}
|
||||
disabled={busy}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
aria-label="Google Maps URL"
|
||||
placeholder="https://maps.app.goo.gl/…"
|
||||
className={`${FIELD_CLS} disabled:opacity-50`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Eyebrow className="mb-[5px]">{t('roadtrip.import.day')}</Eyebrow>
|
||||
<select
|
||||
value={target}
|
||||
disabled={busy || saved}
|
||||
onChange={(e) => pickDay(e.target.value)}
|
||||
aria-label={t('roadtrip.import.day')}
|
||||
className={`${FIELD_CLS} appearance-none`}
|
||||
>
|
||||
{days.map((day) => (
|
||||
<option key={day.id} value={String(day.id)}>
|
||||
{day.title || `${t('roadtrip.import.day')} ${day.day_number}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ol
|
||||
className="m-0 mt-3 flex list-none flex-col gap-2.5 rounded-[16px] bg-[color:var(--m-ic)] p-3.5"
|
||||
data-testid="rt-import-stops"
|
||||
>
|
||||
{preview.stops.map((stop, index) => {
|
||||
const unresolved = stop.lat == null || stop.lng == null;
|
||||
return (
|
||||
<li key={index} className="flex items-baseline gap-3">
|
||||
<span className="grid h-5 w-5 flex-none place-items-center rounded-full bg-[color:var(--m-card)] text-caption font-semibold tabular-nums text-m-muted">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 break-words text-body text-m-ink">
|
||||
<span dir="auto">{stop.name}</span>
|
||||
{unresolved && (
|
||||
<span className={`block ${NOTE} text-m-faint`}>{t('roadtrip.import.unresolved')}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
{failure && (
|
||||
<p role="alert" className={`mt-2.5 ${NOTE} text-danger`}>
|
||||
{saved ? `${t('common.saved')}: ` : ''}
|
||||
{failure}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none items-center justify-end gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<button type="button" disabled={busy} onClick={close} className={SHEET_SECONDARY}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" disabled={!canSubmit} onClick={submit} className={SHEET_PRIMARY}>
|
||||
{busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')}
|
||||
</button>
|
||||
</div>
|
||||
</MSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { AlertTriangle, Check, RotateCw, Spline } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { FollowTrackOutcome } from '../../../../components/Roadtrip/useFollowTrack';
|
||||
import { useIsWide } from '../../../../hooks/sizeClass';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import type { DistanceUnit } from '../../../../types';
|
||||
import { formatDistance } from '../../../../utils/units';
|
||||
import MSheet from '../../../components/MSheet';
|
||||
import type { MTripSheetsProps } from '../MTripShell';
|
||||
import { SHEET_PRIMARY, SHEET_SECONDARY, TileHeader } from '../sheets/MTripSheetUi';
|
||||
|
||||
/**
|
||||
* Which imported track a day drives along, on an iPad or an unfolded Duo.
|
||||
*
|
||||
* The desktop's track dialog (RoadtripTrackModal) as a sheet, over the same `followTrack`
|
||||
* of the planner: it lists the trip's tracks nearest to the day first, fits the drive to
|
||||
* the one picked with via points, and drops those vias again. The phone has no entry to
|
||||
* it; the stage there stays a plan read on the road, and reshaping a whole day's drive
|
||||
* is work for the larger screen. The sheet opens when the planner's `followTrack` has a
|
||||
* day, which only the large screen's stage card asks for.
|
||||
*/
|
||||
|
||||
/** Within this many kilometres a track counts as running along the day. */
|
||||
const ON_THIS_DAY_KM = 2;
|
||||
|
||||
/** The sentence for a finished fit: nothing to do, as close as asked, or as close as it got. */
|
||||
function outcomeKey(outcome: FollowTrackOutcome): string {
|
||||
if (outcome.vias === 0) return 'roadtrip.track.already';
|
||||
return outcome.capped ? 'roadtrip.track.capped' : 'roadtrip.track.done';
|
||||
}
|
||||
|
||||
const NOTE = 'font-geist text-caption leading-snug';
|
||||
|
||||
/**
|
||||
* The stage card's way into the sheet, on a large screen only: the track the day follows,
|
||||
* or the offer to follow one. Absent on the phone, for a day not stored yet, for a trip
|
||||
* without tracks (its only answer would be "no tracks") and for a reader who may not edit
|
||||
* days, since following a track writes via points onto the day.
|
||||
*/
|
||||
export function MRtTrackEntry({ planner, dayId }: { planner: MTripSheetsProps['planner']; dayId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const large = useIsWide();
|
||||
const follow = planner.followTrack;
|
||||
if (!large || dayId < 0 || !follow.available || !planner.can('day_edit', planner.trip)) return null;
|
||||
const name = follow.namesByDay[dayId];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => follow.open(dayId)}
|
||||
className="mt-2.5 flex min-h-10 w-full items-center gap-2 rounded-[13px] bg-[color:var(--m-ic)] px-3 py-2 text-start"
|
||||
>
|
||||
<Spline size={15} strokeWidth={1.9} className="flex-none text-m-muted" aria-hidden />
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-caption font-semibold text-m-ink">
|
||||
{name ? t('roadtrip.track.current', { name }) : t('roadtrip.track.hint')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MRtTrackSheet({ planner }: MTripSheetsProps) {
|
||||
const { t } = useTranslation();
|
||||
const unit: DistanceUnit = useSettingsStore((s) => s.settings.distance_unit) === 'imperial' ? 'imperial' : 'metric';
|
||||
const large = useIsWide();
|
||||
const follow = planner.followTrack;
|
||||
const { dayId, busy, round, error, outcome, tracks, viaCount, current } = follow;
|
||||
// The pick belongs to the day it was made on: a track picked on one day means nothing
|
||||
// on the next, so it is kept together with that day and read only while they match.
|
||||
const [pick, setPick] = useState<{ dayId: number; trackId: number } | null>(null);
|
||||
const picked = pick && pick.dayId === dayId ? pick.trackId : null;
|
||||
const chosen = tracks.find((track) => track.id === picked) ?? null;
|
||||
const open = large && dayId !== null;
|
||||
const dayNumber = planner.roadtripRoutes.days.find((d) => d.dayId === dayId)?.dayNumber ?? 0;
|
||||
|
||||
return (
|
||||
<MSheet
|
||||
open={open}
|
||||
onClose={follow.close}
|
||||
variant="bottom"
|
||||
material="glass"
|
||||
ariaLabel={t('roadtrip.track.title', { number: dayNumber })}
|
||||
>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Spline size={19} strokeWidth={1.8} />}
|
||||
title={<span className="truncate">{t('roadtrip.track.title', { number: dayNumber })}</span>}
|
||||
sub={current ? t('roadtrip.track.current', { name: current.name }) : undefined}
|
||||
subWrap
|
||||
onClose={follow.close}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-3 pt-[14px]">
|
||||
{tracks.length === 0 ? (
|
||||
<div className="rounded-[16px] bg-[color:var(--m-ic)] px-4 py-5 text-center">
|
||||
<div className="text-body font-semibold text-m-ink">{t('roadtrip.track.empty')}</div>
|
||||
<p className={`mt-1 ${NOTE} text-m-faint`}>{t('roadtrip.track.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul
|
||||
className="m-0 flex list-none flex-col gap-1 rounded-[16px] bg-[color:var(--m-ic)] p-1.5"
|
||||
data-testid="rt-track-list"
|
||||
>
|
||||
{tracks.map((track) => {
|
||||
const on = track.id === picked;
|
||||
return (
|
||||
<li key={track.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
aria-pressed={on}
|
||||
onClick={() => setPick(on || dayId === null ? null : { dayId, trackId: track.id })}
|
||||
className={`flex min-h-12 w-full items-center gap-3 rounded-[12px] px-3 py-2 text-start disabled:opacity-50 ${
|
||||
on ? 'bg-[color:var(--m-card)] ring-1 ring-[color:var(--m-cbr)]' : ''
|
||||
}`}
|
||||
>
|
||||
{/* The line's own colour as a short stroke: it is a line on the map. */}
|
||||
<span
|
||||
className="h-1 w-6 flex-none rounded-full"
|
||||
style={{ background: track.color || 'var(--m-faint)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span dir="auto" className="truncate text-body font-semibold text-m-ink">
|
||||
{track.name || t('roadtrip.track.untitled')}
|
||||
</span>
|
||||
<span className={`${NOTE} text-m-faint`}>
|
||||
{t('roadtrip.track.length', { distance: formatDistance(track.lengthKm, unit) })}
|
||||
{' · '}
|
||||
{track.gapKm <= ON_THIS_DAY_KM
|
||||
? t('roadtrip.track.onDay')
|
||||
: t('roadtrip.track.gap', { distance: formatDistance(track.gapKm, unit) })}
|
||||
</span>
|
||||
</span>
|
||||
{on && <Check size={16} className="flex-none text-m-ink" aria-hidden />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* What the pick will do, before it is done: a track becomes via points on the day. */}
|
||||
{chosen && <p className={`mt-2.5 ${NOTE} text-m-muted`}>{t('roadtrip.track.explain')}</p>}
|
||||
|
||||
{busy && (
|
||||
<p role="status" className={`mt-2.5 flex items-center gap-2 ${NOTE} text-m-muted`}>
|
||||
<RotateCw size={13} className="flex-none animate-spin" aria-hidden />
|
||||
{t('roadtrip.track.working', { round })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className={`mt-2.5 flex items-start gap-1.5 ${NOTE} text-danger`}>
|
||||
<AlertTriangle size={13} className="mt-0.5 flex-none" aria-hidden />
|
||||
{t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{outcome && !busy && !error && (
|
||||
<p role="status" className={`mt-2.5 flex items-start gap-1.5 ${NOTE} text-m-muted`}>
|
||||
<Check size={13} className="mt-0.5 flex-none" aria-hidden />
|
||||
{t(outcomeKey(outcome), { count: outcome.vias, distance: formatDistance(outcome.strayKm, unit) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none flex-wrap items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
{/* Whenever the day carries vias, whoever put them there: back to the router's way. */}
|
||||
{viaCount > 0 && (
|
||||
<button type="button" disabled={busy} onClick={() => void follow.clear()} className={SHEET_SECONDARY}>
|
||||
{t('roadtrip.track.clear', { count: viaCount })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!chosen || busy}
|
||||
onClick={() => {
|
||||
if (chosen) void follow.apply(chosen.id);
|
||||
}}
|
||||
className={`ms-auto ${SHEET_PRIMARY}`}
|
||||
>
|
||||
{t('roadtrip.track.action')}
|
||||
</button>
|
||||
</div>
|
||||
</MSheet>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { buildPlace } from '../../../../../tests/helpers/factories'
|
||||
import { resetAllStores, seedStore } from '../../../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import { MSelectedTourDetail } from './MTripSheets'
|
||||
import { _pinSizeClass } from '../../../../hooks/sizeClass'
|
||||
|
||||
const place = buildPlace({
|
||||
id: 42,
|
||||
@@ -58,6 +59,10 @@ function planner(overrides: Partial<TripPlanner> = {}): TripPlanner {
|
||||
} as unknown as TripPlanner
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
_pinSizeClass(null)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
@@ -108,4 +113,31 @@ describe('MSelectedTourDetail', () => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(document.querySelector('#tour-elevation-42')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-TOURDETAIL-001: the phone reads the tour, with no way to rename it', () => {
|
||||
render(<MSelectedTourDetail planner={planner({ can: vi.fn<TripPlanner['can']>(() => true) })} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TOURDETAIL-002: an iPad edits the tour as the desktop card does, through the shared actions', async () => {
|
||||
_pinSizeClass('wide')
|
||||
const p = planner({ can: vi.fn<TripPlanner['can']>(() => true) })
|
||||
render(<MSelectedTourDetail planner={p} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
const field = screen.getByRole('textbox', { name: 'Name' })
|
||||
fireEvent.change(field, { target: { value: 'Ridge walk long' } })
|
||||
fireEvent.keyDown(field, { key: 'Enter' })
|
||||
|
||||
await vi.waitFor(() => expect(p.tripActions.updatePlace).toHaveBeenCalledWith(7, 42, { name: 'Ridge walk long' }))
|
||||
await vi.waitFor(() => expect(p.reloadTourPlaceIds).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-MOB-TOURDETAIL-003: on an iPad a member who may not edit places still only reads', () => {
|
||||
_pinSizeClass('split')
|
||||
render(<MSelectedTourDetail planner={planner()} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import TourDetailDialog from '../../../../components/Tours/TourDetailDialog'
|
||||
import { useBookingExpenseEditor } from '../../../../pages/tripPlanner/useBookingExpenseEditor'
|
||||
import { applyTripCoverUpdate } from '../../../../pages/tripPlanner/tripCover'
|
||||
import { latestReservation } from '../../../../pages/tripPlanner/transportEditorOpeners'
|
||||
import { useTourDetailActions } from '../../../../pages/tripPlanner/useTourDetailActions'
|
||||
import { useIsWide } from '../../../../hooks/sizeClass'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import MDayImpactList from '../../../components/MDayImpactList'
|
||||
import MDaySheet from './MDaySheet'
|
||||
@@ -29,6 +31,7 @@ import MRtStopSheet from '../roadtrip/MRtStopSheet'
|
||||
import MRtStaySheet from '../roadtrip/MRtStaySheet'
|
||||
import MRtKindSheet from '../roadtrip/MRtKindSheet'
|
||||
import MRtInfoSheet from '../roadtrip/MRtInfoSheet'
|
||||
import MRtTrackSheet from '../roadtrip/MRtTrackSheet'
|
||||
import MRtCorridorSheet from '../roadtrip/MRtCorridorSheet'
|
||||
import MRtDraftSheet from '../roadtrip/MRtDraftSheet'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
@@ -40,11 +43,18 @@ import { useLastOpen } from '../../../../components/shared/usePresence'
|
||||
* The one global mobile tour-detail owner, independent of which surface
|
||||
* selected it. The tour shows in a sheet like every other detail
|
||||
* (MTourDetailShell); it keeps the tour it showed last while it slides away.
|
||||
*
|
||||
* On the phone it reads, and plans the tour onto a day. On an iPad or an
|
||||
* unfolded Duo it edits what the desktop's tour card edits, through the same
|
||||
* useTourDetailActions: the name and colour, a copy saved as a new tour, the
|
||||
* recorded tracks, the packing list it points at and its files.
|
||||
*/
|
||||
export function MSelectedTourDetail({ planner }: Pick<MTripSheetsProps, 'planner'>) {
|
||||
const open = !!(planner.selectedTour && planner.selectedPlace)
|
||||
const tour = useLastOpen(planner.selectedTour, open)
|
||||
const place = useLastOpen(planner.selectedPlace, open)
|
||||
const actions = useTourDetailActions(planner)
|
||||
const canEdit = useIsWide() && planner.can('place_edit', planner.trip)
|
||||
if (!tour || !place) return null
|
||||
|
||||
return (
|
||||
@@ -58,9 +68,11 @@ export function MSelectedTourDetail({ planner }: Pick<MTripSheetsProps, 'planner
|
||||
selectedAssignmentId={planner.selectedAssignmentId}
|
||||
assignments={planner.assignments}
|
||||
files={planner.files}
|
||||
readOnly
|
||||
canEdit={false}
|
||||
{...(canEdit ? actions : {})}
|
||||
readOnly={!canEdit}
|
||||
canEdit={canEdit}
|
||||
canAssign={planner.can('day_edit', planner.trip)}
|
||||
onFileUpload={canEdit && planner.canUploadFiles ? (fd: FormData) => planner.tripActions.addFile(planner.tripId, fd) : undefined}
|
||||
onClose={() => planner.setSelectedPlaceId(null)}
|
||||
onAssignToDay={planner.handleAssignToDay}
|
||||
onRemoveAssignment={planner.handleRemoveAssignment}
|
||||
@@ -109,6 +121,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
<MRtStaySheet planner={planner} shell={shell} />
|
||||
<MRtKindSheet planner={planner} shell={shell} />
|
||||
<MRtInfoSheet planner={planner} shell={shell} />
|
||||
<MRtTrackSheet planner={planner} shell={shell} />
|
||||
{/* Before the draft sheet, not after: both sit at the same z, so the one mounted
|
||||
later paints on top, and taking a hit onto the trip opens the draft OVER the
|
||||
search it came from. */}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { opensSoon, refundedBeforeOpening } from './cloudOrders';
|
||||
|
||||
describe('opensSoon', () => {
|
||||
const base = { available: true, closed: true, sandbox: false, onPlay: false };
|
||||
|
||||
it('FE-NATIVE-ORDERS-001: closed orders hide the purchase from an App Store build', () => {
|
||||
expect(opensSoon(base)).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-ORDERS-002: a TestFlight build may buy while orders are closed', () => {
|
||||
expect(opensSoon({ ...base, sandbox: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-ORDERS-003: an Android release build keeps the purchase while orders are closed', () => {
|
||||
expect(opensSoon({ ...base, onPlay: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-ORDERS-004: open orders and a binary without a store never say "opens soon"', () => {
|
||||
expect(opensSoon({ ...base, closed: false })).toBe(false);
|
||||
expect(opensSoon({ ...base, available: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refundedBeforeOpening', () => {
|
||||
it('FE-NATIVE-ORDERS-005: a purchase refused as ordering-closed on Android is the refunded one', () => {
|
||||
expect(refundedBeforeOpening('ordering-closed', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-ORDERS-006: other refusals, and ordering-closed on iOS, keep the general message', () => {
|
||||
expect(refundedBeforeOpening('ordering-closed', false)).toBe(false);
|
||||
expect(refundedBeforeOpening('redeem-failed', true)).toBe(false);
|
||||
expect(refundedBeforeOpening(undefined, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* What the app's plan screens do while TREK Cloud's orders are closed, apart
|
||||
* from the screens themselves so it can be tested.
|
||||
*
|
||||
* On iOS only a test build (TestFlight, App Review) may buy then: the App Store
|
||||
* charges it nothing and the cloud serves it. On Android the button shows in
|
||||
* every build, so the licence testers of the internal test can buy from a
|
||||
* release build; their purchases are Play test purchases, which the cloud
|
||||
* serves. A real purchase made before the opening is refused at the cloud with
|
||||
* `ordering-closed` and never acknowledged, so Google Play refunds it after
|
||||
* three days, and the app says so instead of showing a general error.
|
||||
*/
|
||||
|
||||
/** Whether the plans show without a way to buy: orders are closed and this build may not buy before the opening. */
|
||||
export function opensSoon(opts: { available: boolean; closed: boolean; sandbox: boolean; onPlay: boolean }): boolean {
|
||||
return opts.available && opts.closed && !opts.sandbox && !opts.onPlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cloud refused a purchase because it came before the opening, on
|
||||
* Android, where Google Play then refunds it: the app says so rather than
|
||||
* showing a general error. On iOS no real purchase gets this far.
|
||||
*/
|
||||
export function refundedBeforeOpening(code: string | undefined, onPlay: boolean): boolean {
|
||||
return onPlay && code === 'ordering-closed';
|
||||
}
|
||||
@@ -232,6 +232,14 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
|
||||
totalDistance: 0, totalDuration: 0, totalStops: 0, loading: false,
|
||||
},
|
||||
|
||||
// Following an imported track, closed. The sheets mount the track sheet on every
|
||||
// render, so its closed shape belongs in the base.
|
||||
followTrack: {
|
||||
dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null,
|
||||
outcome: null, apply: vi.fn(async () => {}), clear: vi.fn(async () => {}), viaCount: 0,
|
||||
current: null, namesByDay: {}, available: false,
|
||||
},
|
||||
|
||||
// The corridor search, idle. Both the search bar over the stage and the search
|
||||
// sheet read it on every render of the road trip tab, addon on or off, so the
|
||||
// empty-but-valid shape belongs here rather than in one suite's overrides.
|
||||
|
||||
@@ -190,6 +190,24 @@ describe('MTripRail', () => {
|
||||
fireEvent.click(within(nav).getByRole('button', { name: 'More' }));
|
||||
expect(onMore).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('FE-MOB-RAIL-014: More wears a ring while its sheet is open, and says it is expanded', () => {
|
||||
const props = {
|
||||
tabs,
|
||||
active: 'plan',
|
||||
onSelect: vi.fn(),
|
||||
onMore: vi.fn(),
|
||||
labelOf: (id: string) => tabs.find((x) => x.id === id)?.label ?? id,
|
||||
};
|
||||
const { rerender } = render(<MTripRail {...props} />);
|
||||
const more = () => screen.getByRole('button', { name: 'More' });
|
||||
expect(more()).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(more().firstElementChild?.className).not.toContain('ring-');
|
||||
rerender(<MTripRail {...props} moreOpen />);
|
||||
expect(more()).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(more().firstElementChild?.className).toContain('ring-[color:var(--m-act)]');
|
||||
expect(more()).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileShell on a wide window', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-MOB-JDET-001 to FE-MOB-JDET-050
|
||||
// FE-MOB-JDET-001 to FE-MOB-JDET-052
|
||||
import { describe, it, expect, beforeEach, afterEach, onTestFinished, vi } from 'vitest';
|
||||
import { setSizeClass } from '../../../helpers/sizeClass';
|
||||
import { render, screen, fireEvent, waitFor, act } from '../../../helpers/render';
|
||||
@@ -830,4 +830,16 @@ describe('MJourneyDetail', () => {
|
||||
setup({ view: 'gallery' });
|
||||
expect(screen.queryByText('journey.detail.day')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-051: with room for two panes the header opens Studio over the journey', () => {
|
||||
setSizeClass('split');
|
||||
const { hook } = setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.openAria' }));
|
||||
expect(hook.navigate).toHaveBeenCalledWith(expect.stringMatching(/^\/journey\/\d+\/studio$/));
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-052: the phone and a single pane offer no Studio', () => {
|
||||
setup();
|
||||
expect(screen.queryByRole('button', { name: 'journey.studio.openAria' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { RoadtripPreferences } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { _pinSizeClass, type SizeClass } from '../../../../src/hooks/sizeClass';
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell';
|
||||
import MRtInfoSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtInfoSheet';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { useRoadtripPreferencesStore } from '../../../../src/store/roadtripPreferencesStore';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip';
|
||||
import { fireEvent, render, screen } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
|
||||
// FE-MOB-RTEDIT-001 to FE-MOB-RTEDIT-012
|
||||
//
|
||||
// The driving figures as a form on an iPad or an unfolded Duo, inside the real
|
||||
// MRtInfoSheet and TranslationProvider. The phone keeps the read-out
|
||||
// (MRtInfoSheet.test.tsx); these pin the large screen's form.
|
||||
|
||||
const USER_ID = 7;
|
||||
const TRIP_ID = 4;
|
||||
|
||||
const TYPED: RoadtripPreferences = {
|
||||
roadtrip_vehicle: 'combustion',
|
||||
roadtrip_range_km: 600,
|
||||
roadtrip_fill_percent: 80,
|
||||
roadtrip_leg_minutes: 240,
|
||||
roadtrip_day_minutes: 480,
|
||||
roadtrip_day_start: '08:00',
|
||||
roadtrip_day_end: '19:00',
|
||||
roadtrip_avoid: 'toll',
|
||||
};
|
||||
|
||||
function renderSheet(
|
||||
preferences: RoadtripPreferences = TYPED,
|
||||
size: SizeClass = 'wide',
|
||||
plannerOverrides: Record<string, unknown> = {}
|
||||
) {
|
||||
_pinSizeClass(size);
|
||||
useRoadtripPreferencesStore.setState({ byTrip: { [`${USER_ID}:${TRIP_ID}`]: preferences } });
|
||||
const saveRoadtripLimit = vi.fn(async () => {});
|
||||
const planner = buildPlanner({
|
||||
tripId: TRIP_ID,
|
||||
saveRoadtripLimit,
|
||||
...plannerOverrides,
|
||||
} as unknown as Partial<TripPlanner>);
|
||||
const shell = buildShell({ sheet: { id: 'rtinfo' } } as unknown as Partial<MTripShellApi>);
|
||||
render(<MRtInfoSheet planner={planner} shell={shell} />);
|
||||
return { saveRoadtripLimit };
|
||||
}
|
||||
|
||||
/** Types a figure into a field and leaves it, which is when the form saves. */
|
||||
function typeInto(testId: string, value: string): void {
|
||||
const field = screen.getByTestId(testId);
|
||||
fireEvent.change(field, { target: { value } });
|
||||
fireEvent.blur(field);
|
||||
}
|
||||
|
||||
describe('MRtLimitsEditor', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
useRoadtripPreferencesStore.setState({ byTrip: {} });
|
||||
seedStore(useAuthStore, { user: { id: USER_ID } as never });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_pinSizeClass(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-RTEDIT-001: the phone keeps the read-out and its desktop footnote', () => {
|
||||
renderSheet(TYPED, 'compact');
|
||||
expect(screen.queryByTestId('rt-limits-editor')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/desktop/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-RTEDIT-002: an iPad gets the form instead, without the footnote', () => {
|
||||
renderSheet();
|
||||
expect(screen.getByTestId('rt-limits-editor')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/desktop/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('rt-edit-leg')).toHaveValue('240');
|
||||
expect(screen.getByTestId('rt-edit-day')).toHaveValue('480');
|
||||
expect(screen.getByTestId('rt-edit-range')).toHaveValue('600');
|
||||
expect(screen.getByTestId('rt-edit-fill')).toHaveValue('80');
|
||||
});
|
||||
|
||||
it('FE-MOB-RTEDIT-003: a driving limit is saved when the field is left, not per keystroke', () => {
|
||||
const { saveRoadtripLimit } = renderSheet();
|
||||
const field = screen.getByTestId('rt-edit-leg');
|
||||
fireEvent.change(field, { target: { value: '3' } });
|
||||
fireEvent.change(field, { target: { value: '300' } });
|
||||
expect(saveRoadtripLimit).not.toHaveBeenCalled();
|
||||
fireEvent.blur(field);
|
||||
expect(saveRoadtripLimit).toHaveBeenCalledTimes(1);
|
||||
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_leg_minutes', 300);
|
||||
});
|
||||
|
||||
it('FE-MOB-RTEDIT-004: an emptied field switches the limit off, an unchanged one saves nothing', () => {
|
||||
const { saveRoadtripLimit } = renderSheet();
|
||||
typeInto('rt-edit-day', '480');
|
||||
expect(saveRoadtripLimit).not.toHaveBeenCalled();
|
||||
typeInto('rt-edit-day', '');
|
||||
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_day_minutes', 0);
|
||||
});
|
||||
|
||||
it('FE-MOB-RTEDIT-005: the fill is capped at a full tank', () => {
|
||||
const { saveRoadtripLimit } = renderSheet();
|
||||
typeInto('rt-edit-fill', '140');
|
||||
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_fill_percent', 100);
|
||||
});
|
||||
|
||||
it('FE-MOB-RTEDIT-006: an imperial traveller types miles and kilometres are stored', () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, distance_unit: 'imperial' } });
|
||||
const { saveRoadtripLimit } = renderSheet();
|
||||
expect(screen.getByTestId('rt-edit-range')).toHaveValue('373');
|
||||
typeInto('rt-edit-range', '310');
|
||||
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_range_km', 499);
|
||||
});
|
||||
|
||||
it('FE-MOB-RTEDIT-007: a range worked out from the battery is read-only here', () => {
|
||||
renderSheet({
|
||||
roadtrip_vehicle: 'electric',
|
||||
roadtrip_battery_kwh: 77,
|
||||
roadtrip_kwh_per_100: 18,
|
||||
roadtrip_battery_degradation: 8,
|
||||
});
|
||||
expect(screen.getByTestId('rt-edit-range')).toBeDisabled();
|
||||
expect(screen.getByTestId('rt-edit-range')).toHaveValue('394');
|
||||
});
|
||||
|
||||
it('FE-MOB-RTEDIT-008: the vehicle is chosen in the form, none stored as empty', () => {
|
||||
const { saveRoadtripLimit } = renderSheet();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Electric' }));
|
||||
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_vehicle', 'electric');
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Either' }));
|
||||
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_vehicle', '');
|
||||
});
|
||||
|
||||
it('FE-MOB-RTEDIT-009: the avoidance is one stored list that each switch adds to or takes from', () => {
|
||||
const { saveRoadtripLimit } = renderSheet();
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Ferries' }));
|
||||
expect(saveRoadtripLimit).toHaveBeenLastCalledWith('roadtrip_avoid', 'toll,ferry');
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Toll roads' }));
|
||||
expect(saveRoadtripLimit).toHaveBeenLastCalledWith('roadtrip_avoid', '');
|
||||
});
|
||||
|
||||
it('FE-MOB-RTEDIT-010: where the day ends is a choice between the route and a stop', () => {
|
||||
const { saveRoadtripLimit } = renderSheet();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'At the last place' }));
|
||||
expect(saveRoadtripLimit).toHaveBeenCalledWith('roadtrip_day_end_mode', 'stop');
|
||||
});
|
||||
|
||||
it('FE-MOB-RTEDIT-011: a reader without a save sees the figures, every field disabled', () => {
|
||||
renderSheet(TYPED, 'split', { saveRoadtripLimit: undefined });
|
||||
for (const id of ['rt-edit-leg', 'rt-edit-day', 'rt-edit-range', 'rt-edit-fill']) {
|
||||
expect(screen.getByTestId(id)).toBeDisabled();
|
||||
}
|
||||
expect(screen.getByRole('switch', { name: 'Toll roads' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-RTEDIT-012: a decimal comma is read and rounded, junk switches the limit off', () => {
|
||||
const { saveRoadtripLimit } = renderSheet();
|
||||
typeInto('rt-edit-leg', '245,6');
|
||||
expect(saveRoadtripLimit).toHaveBeenLastCalledWith('roadtrip_leg_minutes', 246);
|
||||
typeInto('rt-edit-day', '.');
|
||||
expect(saveRoadtripLimit).toHaveBeenLastCalledWith('roadtrip_day_minutes', 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { _pinSizeClass, type SizeClass } from '../../../../src/hooks/sizeClass';
|
||||
import MRtRouteImport from '../../../../src/mobile/screens/trip/roadtrip/MRtRouteImportSheet';
|
||||
import { googleRouteRepo } from '../../../../src/repo/googleRouteRepo';
|
||||
import { useTripStore } from '../../../../src/store/tripStore';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
|
||||
// FE-MOB-RTIMPORT-001 to FE-MOB-RTIMPORT-005
|
||||
//
|
||||
// A Google Maps route appended to a day, as the stage card's offer on an iPad or
|
||||
// an unfolded Duo, over the same useGoogleRouteImport as the desktop dialog.
|
||||
|
||||
vi.mock('../../../../src/repo/googleRouteRepo', () => ({ googleRouteRepo: { preview: vi.fn(), append: vi.fn() } }));
|
||||
|
||||
const loadTrip = vi.fn(async () => {});
|
||||
|
||||
function renderImport(size: SizeClass = 'wide', canEdit = true) {
|
||||
_pinSizeClass(size);
|
||||
return render(<MRtRouteImport tripId={1} dayId={3} canEdit={canEdit} />);
|
||||
}
|
||||
|
||||
async function previewRoute() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Google Maps route' }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Google Maps URL' }), {
|
||||
target: { value: 'https://google.com/maps/dir/A/B' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
|
||||
await screen.findByText('Munich');
|
||||
}
|
||||
|
||||
describe('MRtRouteImport', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useTripStore, {
|
||||
days: [
|
||||
{ id: 3, day_number: 1, title: 'First day' },
|
||||
{ id: 4, day_number: 2, title: null },
|
||||
] as never,
|
||||
loadTrip,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_pinSizeClass(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-RTIMPORT-001: is absent on the phone and for a reader', () => {
|
||||
const phone = renderImport('compact');
|
||||
expect(phone.container).toBeEmptyDOMElement();
|
||||
phone.unmount();
|
||||
const reader = renderImport('split', false);
|
||||
expect(reader.container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-MOB-RTIMPORT-002: previews the stops in order and says which did not resolve', async () => {
|
||||
vi.mocked(googleRouteRepo.preview).mockResolvedValue({
|
||||
stops: [
|
||||
{ name: 'Munich', lat: 48, lng: 11 },
|
||||
{ name: 'Unknown', lat: null, lng: null },
|
||||
{ name: 'Rome', lat: 41, lng: 12 },
|
||||
],
|
||||
});
|
||||
renderImport();
|
||||
await previewRoute();
|
||||
const items = screen.getAllByRole('listitem').map((item) => item.textContent);
|
||||
expect(items).toEqual([
|
||||
expect.stringContaining('Munich'),
|
||||
expect.stringContaining('Unknown'),
|
||||
expect.stringContaining('Rome'),
|
||||
]);
|
||||
expect(screen.getByText('Location unresolved; this stop will be skipped.')).toBeInTheDocument();
|
||||
expect(googleRouteRepo.append).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-RTIMPORT-003: appends only the resolved stops to the day picked', async () => {
|
||||
vi.mocked(googleRouteRepo.preview).mockResolvedValue({
|
||||
stops: [
|
||||
{ name: 'Munich', lat: 48, lng: 11 },
|
||||
{ name: 'Unknown', lat: null, lng: null },
|
||||
{ name: 'Rome', lat: 41, lng: 12 },
|
||||
],
|
||||
});
|
||||
vi.mocked(googleRouteRepo.append).mockResolvedValue(undefined);
|
||||
renderImport();
|
||||
await previewRoute();
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Day' }), { target: { value: '4' } });
|
||||
expect(screen.getByRole('option', { name: 'Day 2' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import' }));
|
||||
await waitFor(() => expect(loadTrip).toHaveBeenCalledWith(1));
|
||||
expect(googleRouteRepo.append).toHaveBeenCalledWith(
|
||||
1,
|
||||
{
|
||||
dayId: 4,
|
||||
stops: [
|
||||
{ name: 'Munich', lat: 48, lng: 11 },
|
||||
{ name: 'Rome', lat: 41, lng: 12 },
|
||||
],
|
||||
},
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-MOB-RTIMPORT-004: holds the import while fewer than two stops resolve', async () => {
|
||||
vi.mocked(googleRouteRepo.preview).mockResolvedValue({
|
||||
stops: [
|
||||
{ name: 'Munich', lat: 48, lng: 11 },
|
||||
{ name: 'Unknown', lat: null, lng: null },
|
||||
],
|
||||
});
|
||||
renderImport();
|
||||
await previewRoute();
|
||||
expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-MOB-RTIMPORT-005: a link that cannot be read is an alert, and the preview waits for a link', async () => {
|
||||
vi.mocked(googleRouteRepo.preview).mockRejectedValue(new Error('Not a Google Maps route'));
|
||||
renderImport();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Google Maps route' }));
|
||||
expect(screen.getByRole('button', { name: 'Preview' })).toBeDisabled();
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Google Maps URL' }), { target: { value: 'nonsense' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
|
||||
expect(await screen.findByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { FollowTrack } from '../../../../src/components/Roadtrip/useFollowTrack';
|
||||
import { _pinSizeClass, type SizeClass } from '../../../../src/hooks/sizeClass';
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell';
|
||||
import MRtTrackSheet, { MRtTrackEntry } from '../../../../src/mobile/screens/trip/roadtrip/MRtTrackSheet';
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip';
|
||||
import { fireEvent, render, screen } from '../../../helpers/render';
|
||||
import { resetAllStores } from '../../../helpers/store';
|
||||
|
||||
// FE-MOB-RTTRACK-001 to FE-MOB-RTTRACK-010
|
||||
//
|
||||
// The track a day follows, as a sheet on an iPad or an unfolded Duo, over the
|
||||
// planner's followTrack (the desktop's RoadtripTrackModal runs on the same hook).
|
||||
|
||||
const COAST = { id: 11, name: 'Coast road', color: '#3366ff', lengthKm: 120, gapKm: 0.5 };
|
||||
const PASS = { id: 12, name: '', color: '', lengthKm: 48, gapKm: 30 };
|
||||
|
||||
function follow(overrides: Partial<FollowTrack> = {}): FollowTrack {
|
||||
return {
|
||||
dayId: 5,
|
||||
open: vi.fn(),
|
||||
close: vi.fn(),
|
||||
tracks: [COAST, PASS],
|
||||
busy: false,
|
||||
round: 0,
|
||||
error: null,
|
||||
outcome: null,
|
||||
apply: vi.fn(async () => {}),
|
||||
clear: vi.fn(async () => {}),
|
||||
viaCount: 0,
|
||||
current: null,
|
||||
namesByDay: {},
|
||||
available: true,
|
||||
...overrides,
|
||||
} as FollowTrack;
|
||||
}
|
||||
|
||||
function planner(track: FollowTrack, overrides: Record<string, unknown> = {}): TripPlanner {
|
||||
return buildPlanner({
|
||||
followTrack: track,
|
||||
roadtripRoutes: { ...buildPlanner().roadtripRoutes, days: [{ dayId: 5, dayNumber: 3 }] },
|
||||
...overrides,
|
||||
} as unknown as Partial<TripPlanner>);
|
||||
}
|
||||
|
||||
function renderSheet(track: FollowTrack, size: SizeClass = 'wide') {
|
||||
_pinSizeClass(size);
|
||||
const shell = buildShell({} as Partial<MTripShellApi>);
|
||||
render(<MRtTrackSheet planner={planner(track)} shell={shell} />);
|
||||
}
|
||||
|
||||
describe('MRtTrackSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_pinSizeClass(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-RTTRACK-001: opens for the day the planner names, on a large screen', () => {
|
||||
renderSheet(follow());
|
||||
expect(screen.getByRole('dialog', { name: 'Day 3 follows a track' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Coast road')).toBeInTheDocument();
|
||||
expect(screen.getByText('Unnamed track')).toBeInTheDocument();
|
||||
expect(screen.getByText(/along this day/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-RTTRACK-002: stays closed on the phone', () => {
|
||||
renderSheet(follow(), 'compact');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-RTTRACK-003: stays closed while no day is open', () => {
|
||||
renderSheet(follow({ dayId: null }));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-RTTRACK-004: a track is picked, explained, then followed', () => {
|
||||
const track = follow();
|
||||
renderSheet(track);
|
||||
const action = screen.getByRole('button', { name: 'Follow this track' });
|
||||
expect(action).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Coast road/ }));
|
||||
expect(screen.getByRole('button', { name: /Coast road/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(action).toBeEnabled();
|
||||
fireEvent.click(action);
|
||||
expect(track.apply).toHaveBeenCalledWith(11);
|
||||
});
|
||||
|
||||
it('FE-MOB-RTTRACK-005: dropping the vias is offered only when the day carries some', () => {
|
||||
const track = follow({ viaCount: 4 });
|
||||
renderSheet(track);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Drop 4/ }));
|
||||
expect(track.clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-RTTRACK-006: says what the day follows today, and an empty trip says so', () => {
|
||||
renderSheet(follow({ current: { name: 'Coast road', strayKm: 1 }, tracks: [] }));
|
||||
expect(screen.getByText('Currently following Coast road')).toBeInTheDocument();
|
||||
expect(screen.getByText('No tracks in this trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-RTTRACK-007: tells the round while it works', () => {
|
||||
renderSheet(follow({ busy: true, round: 2 }));
|
||||
expect(screen.getByText('Fitting the drive to the track, round 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-RTTRACK-008: a routing failure is an alert', () => {
|
||||
renderSheet(follow({ error: 'route' }));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('The routing service did not answer.');
|
||||
});
|
||||
|
||||
it('FE-MOB-RTTRACK-009: a fit that placed nothing says the drive already followed the track', () => {
|
||||
renderSheet(follow({ outcome: { vias: 0, strayKm: 0, capped: false } }));
|
||||
expect(screen.getByText('The drive already followed this track.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MRtTrackEntry', () => {
|
||||
afterEach(() => {
|
||||
_pinSizeClass(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-RTTRACK-010: offers the sheet on a large screen to an editor of a trip with tracks only', () => {
|
||||
const track = follow({ namesByDay: { 5: 'Coast road' } });
|
||||
_pinSizeClass('split');
|
||||
const { unmount } = render(<MRtTrackEntry planner={planner(track)} dayId={5} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Currently following Coast road' }));
|
||||
expect(track.open).toHaveBeenCalledWith(5);
|
||||
unmount();
|
||||
|
||||
const hidden: [SizeClass, TripPlanner][] = [
|
||||
['wide', planner(follow({ available: false }))],
|
||||
['compact', planner(follow())],
|
||||
['wide', planner(follow(), { can: vi.fn(() => false) })],
|
||||
];
|
||||
for (const [size, p] of hidden) {
|
||||
_pinSizeClass(size);
|
||||
const view = render(<MRtTrackEntry planner={p} dayId={5} />);
|
||||
expect(view.container).toBeEmptyDOMElement();
|
||||
view.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -143,7 +143,6 @@
|
||||
"client/src/components/Roadtrip/nightSpill.ts": 1,
|
||||
"client/src/components/Roadtrip/refuelSuggestion.ts": 1,
|
||||
"client/src/components/Roadtrip/roadtripModel.ts": 4,
|
||||
"client/src/components/Roadtrip/useFollowTrack.ts": 1,
|
||||
"client/src/components/Roadtrip/useRoadtripRoutes.ts": 3,
|
||||
"client/src/components/Studio/StudioCanvas.tsx": 1,
|
||||
"client/src/components/Studio/mapTiles.ts": 1,
|
||||
|
||||
@@ -67,6 +67,7 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'تم إلغاء الشراء.',
|
||||
'native.claim.failed': 'لم تتم عملية الشراء. حاول مرة أخرى.',
|
||||
'native.claim.redeemFailed': 'تعذّر علينا إعداد TREK الخاص بك. حاول مرة أخرى.',
|
||||
'native.claim.closedRefund': 'يفتح TREK Cloud قريبًا. سيرد Google Play ثمن هذا الشراء تلقائيًا خلال ثلاثة أيام.',
|
||||
'native.plans.restore': 'استعادة المشتريات',
|
||||
'native.plans.restoring': 'جارٍ التحقق من مشترياتك…',
|
||||
'native.plans.restoreNone': 'لم يُعثر على اشتراك نشط لمعرّف Apple هذا.',
|
||||
|
||||
@@ -69,6 +69,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Alış ləğv edildi.',
|
||||
'native.claim.failed': 'Alış baş tutmadı. Zəhmət olmasa yenidən cəhd edin.',
|
||||
'native.claim.redeemFailed': 'TREK-inizi hazırlaya bilmədik. Zəhmət olmasa yenidən cəhd edin.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud tezliklə açılır. Google Play bu alışın pulunu üç gün ərzində avtomatik qaytarır.',
|
||||
'native.plans.restore': 'Alışları bərpa et',
|
||||
'native.plans.restoring': 'Alışlarınız yoxlanılır…',
|
||||
'native.plans.restoreNone': 'Bu Apple ID üçün aktiv abunə tapılmadı.',
|
||||
|
||||
@@ -68,6 +68,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Compra cancelada.',
|
||||
'native.claim.failed': 'A compra não foi concluída. Tente novamente.',
|
||||
'native.claim.redeemFailed': 'Não conseguimos preparar seu TREK. Tente novamente.',
|
||||
'native.claim.closedRefund':
|
||||
'O TREK Cloud abre em breve. O Google Play reembolsa esta compra automaticamente em até três dias.',
|
||||
'native.plans.restore': 'Restaurar compras',
|
||||
'native.plans.restoring': 'Verificando suas compras…',
|
||||
'native.plans.restoreNone': 'Nenhuma assinatura ativa encontrada neste ID Apple.',
|
||||
|
||||
@@ -69,6 +69,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Compra cancel·lada.',
|
||||
'native.claim.failed': 'La compra no s’ha completat. Torna-ho a provar.',
|
||||
'native.claim.redeemFailed': 'No hem pogut preparar el teu TREK. Torna-ho a provar.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud obre aviat. Google Play et reemborsa aquesta compra automàticament en un termini de tres dies.',
|
||||
'native.plans.restore': 'Restaura les compres',
|
||||
'native.plans.restoring': 'Comprovant les teves compres…',
|
||||
'native.plans.restoreNone': 'No s’ha trobat cap subscripció activa en aquest ID d’Apple.',
|
||||
|
||||
@@ -68,6 +68,7 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Nákup zrušen.',
|
||||
'native.claim.failed': 'Nákup se nezdařil. Zkuste to znovu.',
|
||||
'native.claim.redeemFailed': 'Nepodařilo se připravit váš TREK. Zkuste to znovu.',
|
||||
'native.claim.closedRefund': 'TREK Cloud brzy otevírá. Google Play vám tento nákup automaticky vrátí do tří dnů.',
|
||||
'native.plans.restore': 'Obnovit nákupy',
|
||||
'native.plans.restoring': 'Kontrolujeme vaše nákupy…',
|
||||
'native.plans.restoreNone': 'Pro toto Apple ID nebylo nalezeno žádné aktivní předplatné.',
|
||||
|
||||
@@ -69,6 +69,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Kauf abgebrochen.',
|
||||
'native.claim.failed': 'Der Kauf hat nicht geklappt. Bitte versuch es erneut.',
|
||||
'native.claim.redeemFailed': 'Wir konnten deine TREK nicht einrichten. Bitte versuch es erneut.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud startet bald. Google Play erstattet dir diesen Kauf automatisch innerhalb von drei Tagen.',
|
||||
'native.plans.restore': 'Käufe wiederherstellen',
|
||||
'native.plans.restoring': 'Deine Käufe werden geprüft…',
|
||||
'native.plans.restoreNone': 'Für diese Apple-ID wurde kein aktives Abo gefunden.',
|
||||
|
||||
@@ -68,6 +68,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Purchase cancelled.',
|
||||
'native.claim.failed': 'The purchase did not go through. Please try again.',
|
||||
'native.claim.redeemFailed': 'We could not set up your TREK. Please try again.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud opens soon. Google Play refunds this purchase automatically within three days.',
|
||||
'native.plans.restore': 'Restore purchases',
|
||||
'native.plans.restoring': 'Checking your purchases…',
|
||||
'native.plans.restoreNone': 'No active subscription found on this Apple ID.',
|
||||
|
||||
@@ -70,6 +70,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Compra cancelada.',
|
||||
'native.claim.failed': 'La compra no se completó. Inténtalo de nuevo.',
|
||||
'native.claim.redeemFailed': 'No pudimos preparar tu TREK. Inténtalo de nuevo.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud abre pronto. Google Play te reembolsa esta compra automáticamente en un plazo de tres días.',
|
||||
'native.plans.restore': 'Restaurar compras',
|
||||
'native.plans.restoring': 'Comprobando tus compras…',
|
||||
'native.plans.restoreNone': 'No se encontró ninguna suscripción activa en este ID de Apple.',
|
||||
|
||||
@@ -68,6 +68,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Ost tühistatud.',
|
||||
'native.claim.failed': 'Ost ei õnnestunud. Proovi uuesti.',
|
||||
'native.claim.redeemFailed': 'Me ei saanud sinu TREK-i seadistada. Proovi uuesti.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud avatakse peagi. Google Play maksab selle ostu raha kolme päeva jooksul automaatselt tagasi.',
|
||||
'native.plans.restore': 'Taasta ostud',
|
||||
'native.plans.restoring': 'Kontrollime sinu oste…',
|
||||
'native.plans.restoreNone': 'Selle Apple ID jaoks ei leitud aktiivset tellimust.',
|
||||
|
||||
@@ -70,6 +70,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Achat annulé.',
|
||||
'native.claim.failed': 'L’achat n’a pas abouti. Réessayez.',
|
||||
'native.claim.redeemFailed': 'Nous n’avons pas pu préparer votre TREK. Réessayez.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud ouvre bientôt. Google Play vous rembourse cet achat automatiquement sous trois jours.',
|
||||
'native.plans.restore': 'Restaurer les achats',
|
||||
'native.plans.restoring': 'Vérification de vos achats…',
|
||||
'native.plans.restoreNone': 'Aucun abonnement actif trouvé pour cet identifiant Apple.',
|
||||
|
||||
@@ -71,6 +71,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Η αγορά ακυρώθηκε.',
|
||||
'native.claim.failed': 'Η αγορά δεν ολοκληρώθηκε. Δοκιμάστε ξανά.',
|
||||
'native.claim.redeemFailed': 'Δεν μπορέσαμε να ετοιμάσουμε το TREK σας. Δοκιμάστε ξανά.',
|
||||
'native.claim.closedRefund':
|
||||
'Το TREK Cloud ανοίγει σύντομα. Το Google Play σας επιστρέφει αυτόματα τα χρήματα αυτής της αγοράς μέσα σε τρεις ημέρες.',
|
||||
'native.plans.restore': 'Επαναφορά αγορών',
|
||||
'native.plans.restoring': 'Έλεγχος των αγορών σας…',
|
||||
'native.plans.restoreNone': 'Δεν βρέθηκε ενεργή συνδρομή για αυτό το Apple ID.',
|
||||
|
||||
@@ -67,6 +67,7 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'הרכישה בוטלה.',
|
||||
'native.claim.failed': 'הרכישה לא הושלמה. אנא נסו שוב.',
|
||||
'native.claim.redeemFailed': 'לא הצלחנו להכין את ה-TREK שלכם. אנא נסו שוב.',
|
||||
'native.claim.closedRefund': 'TREK Cloud ייפתח בקרוב. Google Play יחזיר לכם את הרכישה הזו אוטומטית תוך שלושה ימים.',
|
||||
'native.plans.restore': 'שחזור רכישות',
|
||||
'native.plans.restoring': 'בודקים את הרכישות שלך…',
|
||||
'native.plans.restoreNone': 'לא נמצא מינוי פעיל עבור Apple ID זה.',
|
||||
|
||||
@@ -69,6 +69,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Vásárlás megszakítva.',
|
||||
'native.claim.failed': 'A vásárlás nem sikerült. Próbáld újra.',
|
||||
'native.claim.redeemFailed': 'Nem sikerült beállítani a TREK-edet. Próbáld újra.',
|
||||
'native.claim.closedRefund':
|
||||
'A TREK Cloud hamarosan nyit. A Google Play három napon belül automatikusan visszatéríti ezt a vásárlást.',
|
||||
'native.plans.restore': 'Vásárlások visszaállítása',
|
||||
'native.plans.restoring': 'Vásárlásaid ellenőrzése…',
|
||||
'native.plans.restoreNone': 'Ehhez az Apple ID-hoz nem található aktív előfizetés.',
|
||||
|
||||
@@ -68,6 +68,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Pembelian dibatalkan.',
|
||||
'native.claim.failed': 'Pembelian tidak berhasil. Silakan coba lagi.',
|
||||
'native.claim.redeemFailed': 'Kami tidak dapat menyiapkan TREK Anda. Silakan coba lagi.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud segera dibuka. Google Play mengembalikan dana pembelian ini secara otomatis dalam tiga hari.',
|
||||
'native.plans.restore': 'Pulihkan pembelian',
|
||||
'native.plans.restoring': 'Memeriksa pembelian Anda…',
|
||||
'native.plans.restoreNone': 'Tidak ada langganan aktif untuk Apple ID ini.',
|
||||
|
||||
@@ -69,6 +69,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Acquisto annullato.',
|
||||
'native.claim.failed': 'L’acquisto non è andato a buon fine. Riprova.',
|
||||
'native.claim.redeemFailed': 'Non siamo riusciti a preparare il tuo TREK. Riprova.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud apre presto. Google Play ti rimborsa automaticamente questo acquisto entro tre giorni.',
|
||||
'native.plans.restore': 'Ripristina acquisti',
|
||||
'native.plans.restoring': 'Controllo dei tuoi acquisti…',
|
||||
'native.plans.restoreNone': 'Nessun abbonamento attivo trovato per questo ID Apple.',
|
||||
|
||||
@@ -68,6 +68,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': '購入をキャンセルしました。',
|
||||
'native.claim.failed': '購入できませんでした。もう一度お試しください。',
|
||||
'native.claim.redeemFailed': 'TREKを用意できませんでした。もう一度お試しください。',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloudはまもなく開始します。この購入はGoogle Playにより3日以内に自動で返金されます。',
|
||||
'native.plans.restore': '購入を復元',
|
||||
'native.plans.restoring': '購入を確認しています…',
|
||||
'native.plans.restoreNone': 'このApple IDに有効なサブスクリプションは見つかりませんでした。',
|
||||
|
||||
@@ -67,6 +67,7 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': '구매가 취소되었습니다.',
|
||||
'native.claim.failed': '구매가 완료되지 않았습니다. 다시 시도하세요.',
|
||||
'native.claim.redeemFailed': 'TREK을 준비하지 못했습니다. 다시 시도하세요.',
|
||||
'native.claim.closedRefund': 'TREK Cloud가 곧 문을 엽니다. Google Play에서 3일 이내에 이 구매를 자동으로 환불합니다.',
|
||||
'native.plans.restore': '구입 내역 복원',
|
||||
'native.plans.restoring': '구입 내역을 확인하는 중…',
|
||||
'native.plans.restoreNone': '이 Apple ID에서 활성 구독을 찾을 수 없습니다.',
|
||||
|
||||
@@ -68,6 +68,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Aankoop geannuleerd.',
|
||||
'native.claim.failed': 'De aankoop is niet gelukt. Probeer het opnieuw.',
|
||||
'native.claim.redeemFailed': 'We konden je TREK niet klaarzetten. Probeer het opnieuw.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud opent binnenkort. Google Play betaalt deze aankoop binnen drie dagen automatisch terug.',
|
||||
'native.plans.restore': 'Aankopen herstellen',
|
||||
'native.plans.restoring': 'Je aankopen worden gecontroleerd…',
|
||||
'native.plans.restoreNone': 'Geen actief abonnement gevonden voor deze Apple ID.',
|
||||
|
||||
@@ -68,6 +68,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Zakup anulowany.',
|
||||
'native.claim.failed': 'Zakup się nie powiódł. Spróbuj ponownie.',
|
||||
'native.claim.redeemFailed': 'Nie udało się przygotować Twojego TREK. Spróbuj ponownie.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud wkrótce startuje. Google Play automatycznie zwróci pieniądze za ten zakup w ciągu trzech dni.',
|
||||
'native.plans.restore': 'Przywróć zakupy',
|
||||
'native.plans.restoring': 'Sprawdzanie zakupów…',
|
||||
'native.plans.restoreNone': 'Nie znaleziono aktywnej subskrypcji dla tego Apple ID.',
|
||||
|
||||
@@ -68,6 +68,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Покупка отменена.',
|
||||
'native.claim.failed': 'Покупка не прошла. Повторите попытку.',
|
||||
'native.claim.redeemFailed': 'Не удалось настроить ваш TREK. Повторите попытку.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud скоро откроется. Google Play автоматически вернёт деньги за эту покупку в течение трёх дней.',
|
||||
'native.plans.restore': 'Восстановить покупки',
|
||||
'native.plans.restoring': 'Проверяем ваши покупки…',
|
||||
'native.plans.restoreNone': 'Для этого Apple ID не найдено активной подписки.',
|
||||
|
||||
@@ -68,6 +68,7 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Nákup zrušený.',
|
||||
'native.claim.failed': 'Nákup sa nepodaril. Skúste to znova.',
|
||||
'native.claim.redeemFailed': 'Nepodarilo sa pripraviť váš TREK. Skúste to znova.',
|
||||
'native.claim.closedRefund': 'TREK Cloud čoskoro otvára. Google Play vám tento nákup automaticky vráti do troch dní.',
|
||||
'native.plans.restore': 'Obnoviť nákupy',
|
||||
'native.plans.restoring': 'Kontrolujeme vaše nákupy…',
|
||||
'native.plans.restoreNone': 'Pre toto Apple ID sa nenašlo žiadne aktívne predplatné.',
|
||||
|
||||
@@ -69,6 +69,7 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Köpet avbröts.',
|
||||
'native.claim.failed': 'Köpet gick inte igenom. Försök igen.',
|
||||
'native.claim.redeemFailed': 'Vi kunde inte göra i ordning din TREK. Försök igen.',
|
||||
'native.claim.closedRefund': 'TREK Cloud öppnar snart. Google Play återbetalar köpet automatiskt inom tre dagar.',
|
||||
'native.plans.restore': 'Återställ köp',
|
||||
'native.plans.restoring': 'Kontrollerar dina köp…',
|
||||
'native.plans.restoreNone': 'Ingen aktiv prenumeration hittades för detta Apple-ID.',
|
||||
|
||||
@@ -67,6 +67,7 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'ยกเลิกการซื้อแล้ว',
|
||||
'native.claim.failed': 'การซื้อไม่สำเร็จ โปรดลองอีกครั้ง',
|
||||
'native.claim.redeemFailed': 'เราตั้งค่า TREK ของคุณไม่สำเร็จ โปรดลองอีกครั้ง',
|
||||
'native.claim.closedRefund': 'TREK Cloud จะเปิดเร็วๆ นี้ Google Play จะคืนเงินการซื้อนี้ให้โดยอัตโนมัติภายในสามวัน',
|
||||
'native.plans.restore': 'กู้คืนการซื้อ',
|
||||
'native.plans.restoring': 'กำลังตรวจสอบการซื้อของคุณ…',
|
||||
'native.plans.restoreNone': 'ไม่พบการสมัครสมาชิกที่ใช้งานอยู่สำหรับ Apple ID นี้',
|
||||
|
||||
@@ -69,6 +69,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Satın alma iptal edildi.',
|
||||
'native.claim.failed': 'Satın alma tamamlanamadı. Lütfen tekrar deneyin.',
|
||||
'native.claim.redeemFailed': 'TREK’inizi hazırlayamadık. Lütfen tekrar deneyin.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud yakında açılıyor. Google Play bu satın alımı üç gün içinde otomatik olarak iade eder.',
|
||||
'native.plans.restore': 'Satın alımları geri yükle',
|
||||
'native.plans.restoring': 'Satın alımlarınız kontrol ediliyor…',
|
||||
'native.plans.restoreNone': 'Bu Apple Kimliği için etkin abonelik bulunamadı.',
|
||||
|
||||
@@ -68,6 +68,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Покупку скасовано.',
|
||||
'native.claim.failed': 'Покупка не пройшла. Повторіть спробу.',
|
||||
'native.claim.redeemFailed': 'Не вдалося налаштувати ваш TREK. Повторіть спробу.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud скоро відкриється. Google Play автоматично поверне кошти за цю покупку протягом трьох днів.',
|
||||
'native.plans.restore': 'Відновити покупки',
|
||||
'native.plans.restoring': 'Перевіряємо ваші покупки…',
|
||||
'native.plans.restoreNone': 'Для цього Apple ID не знайдено активної підписки.',
|
||||
|
||||
@@ -70,6 +70,8 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': 'Đã hủy mua.',
|
||||
'native.claim.failed': 'Giao dịch mua không thành công. Vui lòng thử lại.',
|
||||
'native.claim.redeemFailed': 'Chúng tôi không thể thiết lập TREK của bạn. Vui lòng thử lại.',
|
||||
'native.claim.closedRefund':
|
||||
'TREK Cloud sắp ra mắt. Google Play sẽ tự động hoàn tiền giao dịch mua này trong vòng ba ngày.',
|
||||
'native.plans.restore': 'Khôi phục giao dịch mua',
|
||||
'native.plans.restoring': 'Đang kiểm tra giao dịch mua của bạn…',
|
||||
'native.plans.restoreNone': 'Không tìm thấy gói đăng ký đang hoạt động cho Apple ID này.',
|
||||
|
||||
@@ -67,6 +67,7 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': '已取消購買。',
|
||||
'native.claim.failed': '購買未成功。請重試。',
|
||||
'native.claim.redeemFailed': '我們無法為你設定 TREK。請重試。',
|
||||
'native.claim.closedRefund': 'TREK Cloud 即將開放。Google Play 會在三天內自動退還這筆購買的款項。',
|
||||
'native.plans.restore': '恢復購買項目',
|
||||
'native.plans.restoring': '正在檢查你的購買項目…',
|
||||
'native.plans.restoreNone': '此 Apple ID 沒有找到有效的訂閱。',
|
||||
|
||||
@@ -67,6 +67,7 @@ const native: TranslationStrings = {
|
||||
'native.claim.cancelled': '购买已取消。',
|
||||
'native.claim.failed': '购买未成功。请重试。',
|
||||
'native.claim.redeemFailed': '我们无法为你设置 TREK。请重试。',
|
||||
'native.claim.closedRefund': 'TREK Cloud 即将开放。Google Play 会在三天内自动退还这笔购买的款项。',
|
||||
'native.plans.restore': '恢复购买',
|
||||
'native.plans.restoring': '正在检查你的购买…',
|
||||
'native.plans.restoreNone': '此 Apple ID 下没有找到有效订阅。',
|
||||
|
||||
Reference in New Issue
Block a user