Compare commits

...

9 Commits

Author SHA1 Message Date
Maurice 788b1f63d0 fix(mobile): the trip rail's More wears the dock's ring while its sheet is open 2026-11-08 03:07:24 +00:00
Maurice bfb1b04f8d feat(mobile): TREK Studio opens over the journey on an iPad on its side and an unfolded Duo
Studio is a child route of the journey, opened on top of it. The touch
shell's journey now renders that child in the split size class, where the
header offers Studio beside the menu, as the desktop's bar does. The editor
is the desktop's: tapping a panel, adding an element and dragging it with a
finger work through its pointer events. On the phone and in a single pane
the journey stays alone, whatever the address says, and offers no Studio.
2026-11-08 03:05:56 +00:00
Maurice a77ec6e225 fix(ci): the track sheet reads the distance unit as a DistanceUnit, and knip's baseline drops the type useFollowTrack no longer leaves unused 2026-11-08 03:00:39 +00:00
Maurice 2851eb7afe feat(mobile): a Google Maps route is imported into a road trip day on an iPad and an unfolded Duo
The desktop's import dialog keeps its markup and moves its logic into
useGoogleRouteImport, unchanged: the link previewed, its stops in order with
the unresolved ones named, and the resolved ones appended to the day picked,
at least two, under one request id per opening and day. The large screens
get the same flow as a sheet, offered on the stage card under the track
offer to a member who may add places to days. The phone has no entry to it.
2026-11-08 02:57:43 +00:00
Maurice 6c7605b4a6 fix(mobile): the driving form's vehicle card says what you drive, not a second "Vehicle" under the range card 2026-11-08 02:54:39 +00:00
Maurice 3b286d04c3 feat(mobile): the tour detail edits on an iPad and an unfolded Duo
The phone's tour sheet reads the tour and plans it onto a day. In the wide
and split size classes it now edits what the desktop's tour card edits,
through the same useTourDetailActions: the name and the colour, a copy saved
as a new tour, the recorded tracks, the packing list it points at and its
files, for a member who may edit places. The phone is unchanged.
2026-11-08 02:45:34 +00:00
Maurice 21afe89b13 feat(mobile): a road trip day follows an imported track on an iPad and an unfolded Duo
The desktop's track dialog comes to the large screens as a sheet over the
planner's followTrack, the hook RoadtripTrackModal runs on: the trip's tracks
nearest to the day first, the drive fitted to the one picked with via points,
and those vias dropped again. The stage card offers it in the wide and split
size classes, to an editor of days, on a trip that holds a track, and names
the track the day follows. The phone and the folded Duo have no entry to it.
2026-11-08 02:42:37 +00:00
Maurice 060a43ba64 feat(mobile): the driving figures are a form on an iPad and an unfolded Duo
On the phone the road trip's driving figures stay a read-out, set at the
desktop. A large screen is that table, so in the wide and split size classes
the same sheet edits the two driving limits, the day's window and where it
ends, the vehicle with its range and fill, and what the route may avoid,
through the planner's saveRoadtripLimit, the desktop dialog's own save. A
range worked out from the vehicle's own figures stays read-only, as on the
desktop, and a reader without the right to edit days sees every field
disabled. The phone and the folded Duo are unchanged.
2026-11-08 02:37:26 +00:00
Maurice dedbf16f03 fix(native): Android offers TREK Cloud while orders are closed and says a refused purchase is refunded
The licence testers of the internal test buy from a release build, so the
plan screen no longer hides the purchase on Android before the opening.
Their purchases are Play test purchases, which the cloud serves. A real
purchase made before the opening is refused with ordering-closed and never
acknowledged, so Google Play refunds it after three days; the claim and the
restore now say that instead of a general error. The app still acknowledges
a purchase only after a redeem that returned an instance. iOS is unchanged.
2026-11-08 02:31:13 +00:00
52 changed files with 1505 additions and 87 deletions
+15 -7
View File
@@ -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');
}
});
+8
View File
@@ -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 };
}
+12 -2
View File
@@ -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>
)
}
+4 -2
View File
@@ -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. */}
+35
View File
@@ -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);
});
});
+26
View File
@@ -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';
}
+8
View File
@@ -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();
}
});
});
-1
View File
@@ -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,
+1
View File
@@ -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 هذا.',
+2
View File
@@ -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ı.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+1
View File
@@ -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é.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+1
View File
@@ -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 זה.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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に有効なサブスクリプションは見つかりませんでした。',
+1
View File
@@ -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에서 활성 구독을 찾을 수 없습니다.',
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -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 не найдено активной подписки.',
+1
View File
@@ -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é.',
+1
View File
@@ -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.',
+1
View File
@@ -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 นี้',
+2
View File
@@ -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ı.',
+2
View File
@@ -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 не знайдено активної підписки.',
+2
View File
@@ -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.',
+1
View File
@@ -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 沒有找到有效的訂閱。',
+1
View File
@@ -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 下没有找到有效订阅。',