From d1e3d8a75a3481587fc07de7f54617d7a311b5ff Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:00:03 +0200 Subject: [PATCH] feat(places): offer Waze and Apple Maps beside Google Maps (#1885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Offer Waze and Apple Maps beside Google Maps A place could only be opened in Google Maps, and the request in #919 was for the apps people actually drive with. Google keeps the precise link it always had: ftid, then place id, then the details URL, and coordinates only as a last resort, so it still lands on the entry inside a mall rather than on the roof. Waze and Apple Maps take coordinates and nothing else, which is why they are absent for a place with no position, and why they open with navigation armed instead of on a place view. No setting. Every app that can resolve the place is offered, except Apple Maps, which is dead weight on Android and Windows and therefore only appears on Apple platforms. iPadOS reports itself as a Mac; both sides of that ambiguity have the app, so the check does not need to tell them apart. The picker is a new component rather than the context menu, which has no notion of a submenu. It is portalled because both call sites sit in panels that clip their overflow, and it is measured before it is painted: near the bottom of the window it flips above the trigger instead of running off the screen, and it is pulled back inside when it would overflow to the right. With a single app left to offer there is no menu at all, the button opens it directly, exactly as it behaved before. The three surfaces take it the way each has room for. The inspector button collects them behind one entry, the day-plan context menu lists them flat because a menu can grow, and the phone keeps its single circle and opens the picker as a sheet. * Move OpenStreetMap into the navigation menu, and name the destination OSM was still a button of its own beside the one that now collects the map apps, which is the arrangement the menu exists to end. It belongs with the others. The bigger correction is what Waze and Apple Maps are handed. Both document a query parameter next to the position, and the pair is what makes a destination legible: coordinates say which place is meant, the name is what the driver reads instead of a number. Neither gets a name without a position, because there are a lot of places called "Bahnhof". Google is unchanged; it can already do better than a name through its place id. * Stop drawing the section count as a pill inside a pill SectionHeader wraps its count in a CountPill, and all three callers passed one in already, so every section header carried two stacked pills: a lighter rounded background peeking out from behind the real one. Pass the number and let the header do the wrapping. * Give the traveler filter a label and a way back The avatars floated between the toolbar and the first section with nothing to say what they were, and once a filter was on the only way out was remembering which face had been tapped. It is a labelled bar now, closed off by the same divider the rows below use, with an explicit reset that appears while a filter is active. Both texts already exist in all 23 languages. * Line the traveler filter up with the section titles It carried the sheet's usual 18px of horizontal padding while SectionHeader right below it starts at 2, so the two labels sat on different left edges. * Filter the place list by stars instead of sorting by them Sorting put the best-rated place first and left every other one on the list, which does not help when the question is "what does the group actually want to do". It is a floor now, the same one the collections bar uses: All, or 5+ down to 1+, with unrated places dropping out once a floor is set. The button carries the active floor so the list is never quietly shortened. The row it sits in used to render only when the trip had categories, which hid the star control on a fresh trip. That is exactly where someone collects places to rate, so the row is always there now and only the category dropdown depends on there being categories. places.sortByRating is replaced by places.filterByRating in all 23 languages, since the old label described a behaviour that no longer exists. * Keep the collections map when a filter matches nothing Clicking a label with no places behind it made the map disappear and the page reflow to full width. The layout hung off the FILTERED set: no visible places meant nothing mappable meant no split. A filter should empty the map, not tear it out, so the layout now asks whether the list has anything mappable at all, while the markers keep following the filter. * Give the label filters a tray of their own Two changes to the filter row. Select and manage-labels drop their captions and keep icon, title and aria-label, which puts them on the same footing as the add-place button that was already icon-only. The labels themselves were pill-shaped buttons with their own borders sitting loose among square-cornered dropdowns, so a list with a handful of them read as a pile rather than as one control. They share the filter buttons' radius and type now, and sit in a recessed strip led by a tag glyph: the chips carry no border of their own, because a frame inside a frame was half the noise. Manage stays quiet at the end of the strip until hovered. * Put the collections controls on one line The filter row sat at about 31px while the buttons floating over the map are 38, and the icon-only add button was the odd one out even within its own row. Scrolled to the top, where the row and the map's top bar are side by side, that reads as three different heights. One --col-ctl-h for both, fixed rather than derived from padding so a button with an icon and one with a word cannot drift apart, and the row starts at the same offset the map's own bar does. Phones raise the variable instead of patching individual min-heights, which keeps the square buttons square. * Offer Apple Maps on desktops that are not Apple Apple Maps has had a web version since 2024, so the link resolves to a usable map on Windows and Linux rather than to nothing. Hiding it there was a guess about a limitation that no longer exists. Android keeps the exemption: the web version works there too, but nobody navigating from an Android phone reaches for Apple Maps, and the row of choices is short on purpose. * Move the label filter into the map's top bar The filter row was running out of room while the map's own bar had space to spare, so the labels ride up there next to the collapse button, in the same glass as the controls beside them and scrolling sideways rather than wrapping. They fall back into the filter row whenever no map is on screen — a narrow window in list view, or a list with nothing mappable in it — so they can never end up somewhere the user cannot reach. Both places render the same component now instead of two copies drifting apart. * Stop the active select button drawing a dark icon on a dark fill Two rules with the same specificity claimed the same class. On a dropdown `.open` means the panel is down, so the label stays dark; on the select button it meant the mode is on, which fills it with the accent and needs the light text. The dropdown rule sits later in the file, so it won, and the icon disappeared into the fill the moment the button was clicked. The state is `.on` now, which is what the other toggles on this page (col-map-btn, col-iconbtn) already call it. * Open the place in Google Maps, not the coordinates Reported in #1278 and still reproducible: without a Google identifier the link carried nothing but latitude and longitude, so Maps dropped an unlabelled pin instead of opening the entry. The name and the address were both on hand and both thrown away. Google documents query=PLACE_NAME,ADDRESS for exactly this case, so that is what the fallback sends now. A name with no address still falls through to coordinates, because "Central Station" on its own would confidently open the wrong city, and a pin in the right spot beats a link to the wrong place. The precise paths are untouched: an ftid or a place id still wins, and they are what a place searched through Google carries. This only changes what happens for the ones that carry neither, which is every place added from OpenStreetMap, by hand, or before those fields existed. --- .../Collections/CollectionFilterBar.test.tsx | 12 ++ .../Collections/CollectionFilterBar.tsx | 47 +++---- .../Collections/CollectionLabelFilter.tsx | 72 ++++++++++ .../Collections/CollectionMapPanel.tsx | 26 +++- .../src/components/Planner/DayPlanSidebar.tsx | 9 +- .../Planner/PlaceInspector.test.tsx | 35 +++-- .../src/components/Planner/PlaceInspector.tsx | 47 +++++-- .../components/Planner/PlacesSidebar.test.tsx | 22 +++ .../Planner/PlacesSidebarHeader.tsx | 75 +++++++--- .../Planner/placeGoogleMaps.test.ts | 16 ++- .../src/components/Planner/placeGoogleMaps.ts | 15 +- .../Planner/placeNavigation.test.ts | 101 ++++++++++++++ .../src/components/Planner/placeNavigation.ts | 95 +++++++++++++ .../Planner/usePlacesSidebar.test.tsx | 20 +-- .../components/Planner/usePlacesSidebar.ts | 18 +-- .../components/shared/NavigationMenu.test.tsx | 89 ++++++++++++ .../src/components/shared/NavigationMenu.tsx | 131 ++++++++++++++++++ .../screens/trip/sheets/MPlaceSheet.tsx | 35 ++++- .../screens/trip/sheets/MTripSheetUi.tsx | 7 +- .../mobile/screens/trip/tabs/MBookingsTab.tsx | 13 +- .../mobile/screens/trip/tabs/MCollabPolls.tsx | 4 +- .../screens/trip/tabs/MTransportsTab.tsx | 13 +- .../mobile/screens/trip/tabs/tabChrome.tsx | 72 +++++++--- client/src/pages/CollectionsPage.test.tsx | 20 ++- client/src/pages/CollectionsPage.tsx | 15 +- .../src/pages/CollectionsPage.wiring.test.tsx | 33 +++-- .../src/pages/collections/useCollections.ts | 7 +- client/src/styles/collections.css | 64 +++++++-- .../unit/mobile/trip/MPlaceSheet.test.tsx | 11 +- .../tests/unit/mobile/trip/tabChrome.test.tsx | 50 +++++++ shared/src/i18n/ar/inspector.ts | 2 + shared/src/i18n/ar/places.ts | 2 +- shared/src/i18n/br/inspector.ts | 2 + shared/src/i18n/br/places.ts | 2 +- shared/src/i18n/ca/inspector.ts | 2 + shared/src/i18n/ca/places.ts | 2 +- shared/src/i18n/cs/inspector.ts | 2 + shared/src/i18n/cs/places.ts | 2 +- shared/src/i18n/de/inspector.ts | 2 + shared/src/i18n/de/places.ts | 2 +- shared/src/i18n/en/inspector.ts | 2 + shared/src/i18n/en/places.ts | 2 +- shared/src/i18n/es/inspector.ts | 2 + shared/src/i18n/es/places.ts | 2 +- shared/src/i18n/fr/inspector.ts | 2 + shared/src/i18n/fr/places.ts | 2 +- shared/src/i18n/gr/inspector.ts | 2 + shared/src/i18n/gr/places.ts | 2 +- shared/src/i18n/hu/inspector.ts | 2 + shared/src/i18n/hu/places.ts | 2 +- shared/src/i18n/id/inspector.ts | 2 + shared/src/i18n/id/places.ts | 2 +- shared/src/i18n/it/inspector.ts | 2 + shared/src/i18n/it/places.ts | 2 +- shared/src/i18n/ja/inspector.ts | 2 + shared/src/i18n/ja/places.ts | 2 +- shared/src/i18n/ko/inspector.ts | 2 + shared/src/i18n/ko/places.ts | 2 +- shared/src/i18n/nl/inspector.ts | 2 + shared/src/i18n/nl/places.ts | 2 +- shared/src/i18n/pl/inspector.ts | 2 + shared/src/i18n/pl/places.ts | 2 +- shared/src/i18n/ru/inspector.ts | 2 + shared/src/i18n/ru/places.ts | 2 +- shared/src/i18n/sv/inspector.ts | 2 + shared/src/i18n/sv/places.ts | 2 +- shared/src/i18n/tr/inspector.ts | 2 + shared/src/i18n/tr/places.ts | 2 +- shared/src/i18n/uk/inspector.ts | 2 + shared/src/i18n/uk/places.ts | 2 +- shared/src/i18n/vi/inspector.ts | 2 + shared/src/i18n/vi/places.ts | 2 +- shared/src/i18n/zh-TW/inspector.ts | 2 + shared/src/i18n/zh-TW/places.ts | 2 +- shared/src/i18n/zh/inspector.ts | 2 + shared/src/i18n/zh/places.ts | 2 +- 76 files changed, 1087 insertions(+), 179 deletions(-) create mode 100644 client/src/components/Collections/CollectionLabelFilter.tsx create mode 100644 client/src/components/Planner/placeNavigation.test.ts create mode 100644 client/src/components/Planner/placeNavigation.ts create mode 100644 client/src/components/shared/NavigationMenu.test.tsx create mode 100644 client/src/components/shared/NavigationMenu.tsx diff --git a/client/src/components/Collections/CollectionFilterBar.test.tsx b/client/src/components/Collections/CollectionFilterBar.test.tsx index da13a5a21..bc63e3c55 100644 --- a/client/src/components/Collections/CollectionFilterBar.test.tsx +++ b/client/src/components/Collections/CollectionFilterBar.test.tsx @@ -131,4 +131,16 @@ describe('CollectionFilterBar', () => { render(); expect(screen.queryByRole('button', { name: 'Select' })).not.toBeInTheDocument(); }); + + it('FE-COMP-COLFILTERBAR-009: the active Select button marks itself with `on`, not `open`', () => { + // `.open` belongs to the dropdowns, where it means the panel is down and the + // label stays dark. Sharing that class made the active button paint a dark + // icon on its dark accent fill, because the dropdown rule sits later in the + // stylesheet and both have the same specificity. + render(); + const selectBtn = screen.getByRole('button', { name: 'Select' }); + expect(selectBtn).toHaveClass('on'); + expect(selectBtn).not.toHaveClass('open'); + expect(selectBtn).toHaveAttribute('aria-pressed', 'true'); + }); }); diff --git a/client/src/components/Collections/CollectionFilterBar.tsx b/client/src/components/Collections/CollectionFilterBar.tsx index 2dec032b7..21b262ff4 100644 --- a/client/src/components/Collections/CollectionFilterBar.tsx +++ b/client/src/components/Collections/CollectionFilterBar.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useRef, useState } from 'react' -import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react' +import { ChevronDown, Check, Layers, Tag, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react' import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore' import type { TranslationFn } from '../../types' import { getCategoryIcon } from '../shared/categoryIcons' import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel' import type { CategoryOption, LabelOption } from '../../pages/collections/collectionsModel' +import CollectionLabelFilter from './CollectionLabelFilter' interface Opt { key: string | number @@ -148,36 +149,26 @@ export default function CollectionFilterBar({ onRatingFilter(k as number | 'all')} lead={} /> onSortMode(k as CollectionSortMode)} lead={} /> {showSelect && ( - )} {showLabels && (labelOptions.length > 0 || canManageLabels) && ( -
- {labelOptions.map(l => { - const on = labelFilter.includes(l.id) - return ( - - ) - })} - {canManageLabels && ( - - )} -
+ )} ) diff --git a/client/src/components/Collections/CollectionLabelFilter.tsx b/client/src/components/Collections/CollectionLabelFilter.tsx new file mode 100644 index 000000000..f124b078d --- /dev/null +++ b/client/src/components/Collections/CollectionLabelFilter.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import { Plus, Settings2, Tags } from 'lucide-react' +import type { LabelOption } from '../../pages/collections/collectionsModel' +import type { TranslationFn } from '../../types' + +export type { LabelOption } + +interface CollectionLabelFilterProps { + labelOptions: LabelOption[] + labelFilter: number[] + onLabelFilter: (ids: number[]) => void + canManageLabels?: boolean + onManageLabels?: () => void + /** + * 'bar' is the filter row above the list, 'map' the strip floating over the + * map, which needs the same glass treatment as the buttons beside it. + */ + variant?: 'bar' | 'map' + t: TranslationFn +} + +/** + * The label filter, in one place because it has two homes. + * + * Labels are an axis of their own next to status, category and rating, so they + * are grouped into a tray instead of joining the row as more loose buttons. On + * a desktop it rides in the map's top bar, where there is room to spare; the + * filter row keeps it whenever no map is on screen, so it never becomes + * unreachable. + */ +export default function CollectionLabelFilter({ + labelOptions, labelFilter, onLabelFilter, canManageLabels = false, onManageLabels, + variant = 'bar', t, +}: CollectionLabelFilterProps): React.ReactElement { + return ( +
+
+ ) +} diff --git a/client/src/components/Collections/CollectionMapPanel.tsx b/client/src/components/Collections/CollectionMapPanel.tsx index 718f09de5..4658da62a 100644 --- a/client/src/components/Collections/CollectionMapPanel.tsx +++ b/client/src/components/Collections/CollectionMapPanel.tsx @@ -3,6 +3,7 @@ import { PanelLeftClose, PanelLeftOpen, Search } from 'lucide-react' import type { CollectionPlace } from '@trek/shared' import type { TranslationFn } from '../../types' import CollectionMap from './CollectionMap' +import CollectionLabelFilter, { type LabelOption } from './CollectionLabelFilter' interface CollectionMapPanelProps { places: CollectionPlace[] @@ -17,6 +18,16 @@ interface CollectionMapPanelProps { onToggleView: () => void search: string onSearch: (v: string) => void + /** + * The label filter rides along in the map's top bar, where there is room for + * it. The filter row keeps it whenever no map is on screen, so it can never + * become unreachable. + */ + labelOptions?: LabelOption[] + labelFilter?: number[] + onLabelFilter?: (ids: number[]) => void + canManageLabels?: boolean + onManageLabels?: () => void t: TranslationFn } @@ -27,8 +38,10 @@ interface CollectionMapPanelProps { */ export default function CollectionMapPanel({ places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView, - search, onSearch, t, + search, onSearch, labelOptions = [], labelFilter = [], onLabelFilter, canManageLabels = false, + onManageLabels, t, }: CollectionMapPanelProps): React.ReactElement { + const showLabels = onLabelFilter != null && (labelOptions.length > 0 || canManageLabels) return (
{view === 'map' ? : } + {showLabels && ( + + )}
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 6a1a9d04d..06b6d137b 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -43,7 +43,7 @@ import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDet import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay' import { DayPlanSidebarFooter } from './DayPlanSidebarFooter' import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types' -import { getGoogleMapsUrlForPlace } from './placeGoogleMaps' +import { getNavigationTargets, openNavigationTarget } from './placeNavigation' interface DayPlanSidebarProps { tripId: number @@ -1701,12 +1701,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }} onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }} onContextMenu={e => { - const googleMapsUrl = getGoogleMapsUrlForPlace(place) + // Flat entries, one per app: the menu has room to + // grow and a submenu would need a component that + // does not exist here. + const navTargets = getNavigationTargets(place) ctxMenu.open(e, [ canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) }, canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) }, place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, - googleMapsUrl && { label: t('inspector.google'), icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') }, + ...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })), collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) }, { divider: true }, canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index 8790ca3e8..0dcf4cc0a 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, fireEvent, act } from '../../../tests/helpers/render'; +import { render, screen, waitFor, fireEvent, act, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { buildUser, buildTrip, buildPlace, buildCategory, buildReservation } from '../../../tests/helpers/factories'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; @@ -632,13 +632,18 @@ describe('PlaceInspector', () => { // ── Google Maps URL action ───────────────────────────────────────────────── - it('FE-PLANNER-INSPECTOR-043: Google Maps lat/lng button visible when no google_maps_url', () => { + it('FE-PLANNER-INSPECTOR-043: the navigation button offers every app the place can be opened in', async () => { + const user = userEvent.setup(); render(); - // place has lat/lng so Google Maps button should appear with Navigation icon - const allButtons = screen.getAllByRole('button'); - // Find button containing "Google Maps" text - const mapsBtn = allButtons.find(btn => btn.textContent?.includes('Google Maps')); - expect(mapsBtn).toBeTruthy(); + // A place with coordinates reaches Google Maps and Waze (Apple Maps only on + // Apple platforms), so the button collects them behind one entry. + const navBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Navigation'))!; + expect(navBtn).toBeTruthy(); + + await user.click(navBtn); + const menu = await screen.findByRole('menu'); + expect(within(menu).getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument(); + expect(within(menu).getByRole('menuitem', { name: 'Waze' })).toBeInTheDocument(); }); it('FE-PLANNER-INSPECTOR-043b: Google Maps action uses google_ftid over coordinates', async () => { @@ -651,9 +656,10 @@ describe('PlaceInspector', () => { lng: -80.5542617, google_ftid: '0x882bf179e806d471:0x8591dde29c821a93', })} />); - const mapsBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Google Maps'))!; - await user.click(mapsBtn); - expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank'); + const navBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Navigation'))!; + await user.click(navBtn); + await user.click(await screen.findByRole('menuitem', { name: 'Google Maps' })); + expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank', 'noopener,noreferrer'); openSpy.mockRestore(); }); @@ -934,12 +940,17 @@ describe('PlaceInspector', () => { // ── Footer actions ─────────────────────────────────────────────────────────── - it('FE-PLANNER-INSPECTOR-069: the OpenStreetMap and website buttons open their targets in a new tab', () => { + it('FE-PLANNER-INSPECTOR-069: OpenStreetMap sits in the navigation menu, the website keeps its own button', async () => { + const user = userEvent.setup(); const open = vi.fn(); vi.stubGlobal('open', open); const p = buildPlace({ id: 705, name: 'Linked Place', osm_id: 'node/12345', website: 'https://example.org' }); render(); - fireEvent.click(screen.getByText('OpenStreetMap').closest('button')!); + + // OSM moved in with the other map apps rather than standing beside them. + await user.click(screen.getAllByRole('button').find(b => b.textContent?.includes('Navigation'))!); + await user.click(await screen.findByRole('menuitem', { name: 'OpenStreetMap' })); + fireEvent.click(screen.getByText('Open Website').closest('button')!); expect(open).toHaveBeenCalledTimes(2); expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank']); diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index d7ad08ffe..8d53e9567 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -27,8 +27,8 @@ import type { CollectionStatus } from '@trek/shared' import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters' import { useTripStore } from '../../store/tripStore' import { formatDistance, formatElevation } from '../../utils/units' -import { getGoogleMapsUrlForPlace } from './placeGoogleMaps' -import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap' +import { getNavigationTargets, openNavigationTarget } from './placeNavigation' +import { NavigationMenu } from '../shared/NavigationMenu' import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState' import { convertHoursLine } from './placeHoursFormat' @@ -178,6 +178,8 @@ export default function PlaceInspector({ // Extra native rows contributed by placeDetailProvider plugins (#1429). Fail-safe: // any provider error/timeout is dropped server-side, so this only ever adds rows. const [providerDetails, setProviderDetails] = useState }>>([]) + const [navOpen, setNavOpen] = useState(false) + const navBtnRef = useRef(null) const placeIdForDetails = mode === 'trip' ? place?.id : undefined useEffect(() => { if (placeIdForDetails == null) { setProviderDetails([]); return } @@ -316,11 +318,10 @@ export default function PlaceInspector({ googleDetails?.open_now, ) // Prefer the place's stored ftid; if it has none yet, use the one just fetched from Google. - const googleMapsUrl = getGoogleMapsUrlForPlace( + const navigationTargets = getNavigationTargets( place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null, googleDetails?.google_maps_url, ) - const openStreetMapUrl = getOpenStreetMapUrlForPlace(place) const selectedDay = days?.find(d => d.id === selectedDayId) const weekdayIndex = getWeekdayIndex(selectedDay?.date, placeTimeZone) @@ -484,13 +485,32 @@ export default function PlaceInspector({ icon={savedInCollection ? : } label={{savedInCollection ? t('inspector.savedToCollection') : t('inspector.saveToCollection')}} /> )} - {googleMapsUrl && ( - window.open(googleMapsUrl, '_blank')} variant="ghost" icon={} - label={{t('inspector.google')}} /> - )} - {openStreetMapUrl && ( - window.open(openStreetMapUrl, '_blank')} variant="ghost" icon={} - label={{t('inspector.openStreetMap')}} /> + {navigationTargets.length > 0 && ( + <> + {/* One target left (a place without coordinates) opens straight + away, exactly as this button always did. */} + { + if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0]) + else setNavOpen(o => !o) + }} + variant="ghost" + icon={} + label={ + + {navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')} + + } + /> + {navOpen && ( + setNavOpen(false)} + /> + )} + )} {(place.website || googleDetails?.website) && ( window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={} @@ -535,9 +555,11 @@ interface ActionButtonProps { variant: 'primary' | 'ghost' | 'danger' icon: React.ReactNode label: React.ReactNode + /** For callers that anchor a popup to the button. React 19 passes it through. */ + ref?: React.Ref } -export function ActionButton({ onClick, variant, icon, label }: ActionButtonProps) { +export function ActionButton({ onClick, variant, icon, label, ref }: ActionButtonProps) { const base = { primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' }, ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' }, @@ -546,6 +568,7 @@ export function ActionButton({ onClick, variant, icon, label }: ActionButtonProp const s = base[variant] || base.ghost return ( - {/* Star sort (#1435): order the list by average rating, best first. */} - + } + {/* Minimum-stars filter (#1435), same floors as the collections bar. */} + + {starDropOpen && ( +
+ {(['all', 5, 4, 3, 2, 1] as const).map(opt => { + const active = ratingFilter === opt + return ( + + ) + })} +
+ )} {canEditPlaces && ( + ))} +
, + document.body, + ) +} diff --git a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx index 3d8cb10e6..544a70b3b 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx @@ -18,7 +18,8 @@ import TrackColorPicker from '../../../../components/shared/TrackColorPicker' import { resolveTrackColor, inheritedTrackColor } from '../../../../components/Map/trackColors' import { avatarSrc } from '../../../../utils/avatarSrc' import { openFile } from '../../../../utils/fileDownload' -import { getGoogleMapsUrlForPlace } from '../../../../components/Planner/placeGoogleMaps' +import { getNavigationTargets, openNavigationTarget } from '../../../../components/Planner/placeNavigation' +import { NavigationMenu } from '../../../../components/shared/NavigationMenu' import type { Assignment, Day, TripMember } from '../../../../types' import { ActionCircle, Eyebrow, INNER_CLS } from './MTripSheetUi' @@ -43,6 +44,8 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { const [dayPickerOpen, setDayPickerOpen] = useState(false) const [participantPickerOpen, setParticipantPickerOpen] = useState(false) const [uploading, setUploading] = useState(false) + const [navOpen, setNavOpen] = useState(false) + const navBtnRef = useRef(null) const [imgBusy, setImgBusy] = useState(false) const [colorPickerOpen, setColorPickerOpen] = useState(false) const fileInputRef = useRef(null) @@ -199,9 +202,13 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { planner.setFitKey(k => k + 1) } + // One app installed-and-offered means no picker: the tap opens it, which is + // what this button has always done. The sheet only appears when there is an + // actual choice to make. + const navTargets = getNavigationTargets(place) const openDirections = () => { - const url = getGoogleMapsUrlForPlace(place) - if (url) window.open(url, '_blank', 'noopener,noreferrer') + if (navTargets.length === 1) openNavigationTarget(navTargets[0]) + else if (navTargets.length > 1) setNavOpen(true) } return ( @@ -496,10 +503,24 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { )} - {place.lat != null && place.lng != null && ( - - - + {navTargets.length > 0 && ( + <> + + + + {navOpen && ( + setNavOpen(false)} + title={t('inspector.openWith')} + /> + )} + )} {place.lat != null && place.lng != null && ( diff --git a/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx b/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx index 156587a5e..6ce637da2 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { ReactNode, Ref } from 'react' import { X } from 'lucide-react' import MIconBtn from '../../../components/MIconBtn' import { formatTime } from '../../../../utils/formatters' @@ -88,12 +88,15 @@ interface ActionCircleProps { danger?: boolean className?: string children: ReactNode + /** For callers that anchor a popup to the button. React 19 passes it through. */ + ref?: Ref } /** 38px round action button of the inspector footer rows. */ -export function ActionCircle({ onClick, label, primary = false, danger = false, className = '', children }: ActionCircleProps) { +export function ActionCircle({ onClick, label, primary = false, danger = false, className = '', children, ref }: ActionCircleProps) { return ( - ) - })} +
+ + {label} + +
+ {members.map(m => { + const on = active.has(m.id) + return ( + + ) + })} +
+ {filtering && allLabel && onClear && ( + + )}
) } diff --git a/client/src/pages/CollectionsPage.test.tsx b/client/src/pages/CollectionsPage.test.tsx index c67620953..1e7634f63 100644 --- a/client/src/pages/CollectionsPage.test.tsx +++ b/client/src/pages/CollectionsPage.test.tsx @@ -69,6 +69,7 @@ function makeHook(overrides: Record = {}) { places: [{ id: 10 }], visiblePlaces: [{ id: 10 }], mappable: [{ id: 10 }], + hasMappable: true, members: [], incomingInvites: [], counts: { all: 1, idea: 0, want: 1, visited: 0 }, @@ -124,8 +125,25 @@ describe('CollectionsPage — Add place button reachability (#1485)', () => { }) it('shows the Add button on an empty collection', () => { - mockUseCollections.mockReturnValue(makeHook({ places: [], visiblePlaces: [], mappable: [] })) + mockUseCollections.mockReturnValue(makeHook({ places: [], visiblePlaces: [], mappable: [], hasMappable: false })) render() expect(screen.getAllByRole('button', { name: 'collections.addPlace' })).toHaveLength(1) }) }) + +describe('CollectionsPage — the map survives a filter that matches nothing', () => { + it('keeps the map when a label filter empties the list', () => { + // Clicking a label with no places behind it left visiblePlaces empty, and + // the layout hung off exactly that: the map was torn out and the page + // reflowed to full width. A filter should empty the map, not remove it. + mockUseCollections.mockReturnValue(makeHook({ visiblePlaces: [], mappable: [], hasMappable: true })) + render() + expect(screen.getByTestId('map')).toBeInTheDocument() + }) + + it('still hides the map for a list with nothing mappable in it at all', () => { + mockUseCollections.mockReturnValue(makeHook({ mappable: [], hasMappable: false })) + render() + expect(screen.queryByTestId('map')).toBeNull() + }) +}) diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx index ccbe9c8a9..5ac3015f9 100644 --- a/client/src/pages/CollectionsPage.tsx +++ b/client/src/pages/CollectionsPage.tsx @@ -59,8 +59,8 @@ function CollectionsPageDesktop(): React.ReactElement { // + detail come into view alongside the map. const onMapSelect = (id: number) => { openPlace(id); if (c.view === 'map') c.setView('list') } - const desktopSplit = c.isWide && mappable.length > 0 - const mapShown = mappable.length > 0 && (c.view === 'map' || c.isWide) + const desktopSplit = c.isWide && c.hasMappable + const mapShown = c.hasMappable && (c.view === 'map' || c.isWide) const mapOverlay = c.isWide && mapShown // the map carries the toggle + search const canAddPlace = typeof c.activeId === 'number' && c.canEdit // a real list you can edit @@ -79,6 +79,11 @@ function CollectionsPageDesktop(): React.ReactElement { ) const mapPanel = (overlay: boolean) => ( c.setShowLabelManager(true)} places={mappable} selectedPlaceId={c.selectedPlaceId} onSelect={onMapSelect} @@ -109,7 +114,7 @@ function CollectionsPageDesktop(): React.ReactElement { onSortMode={c.setSortMode} canAddPlace={canAddPlace} onAddPlace={() => c.setShowAddPlace(true)} - showLabels={isRealList} + showLabels={isRealList && !mapShown} labelOptions={c.labelOptions} labelFilter={c.labelFilter} onLabelFilter={c.setLabelFilter} @@ -151,7 +156,7 @@ function CollectionsPageDesktop(): React.ReactElement {
{mapPanel(true)}
) - } else if (c.view === 'map' && mappable.length > 0) { + } else if (c.view === 'map' && c.hasMappable) { body =
{mapPanel(false)}
} else { body = listColumn @@ -212,7 +217,7 @@ function CollectionsPageDesktop(): React.ReactElement { - {!c.isWide && mappable.length > 0 && ( + {!c.isWide && c.hasMappable && (
} @@ -206,6 +208,7 @@ function makeHook(overrides: Hook = {}): Hook { places: [{ id: 10 }], visiblePlaces: [{ id: 10 }], mappable: [{ id: 10 }], + hasMappable: true, members: [], incomingInvites: [], counts: { all: 1, idea: 0, want: 1, visited: 0 }, @@ -340,7 +343,7 @@ describe('CollectionsPage — shell', () => { }) it('FE-PAGE-COLLPAGE-007: shows a spinner while the first places load', () => { - renderPage({ places: [], visiblePlaces: [], mappable: [], placesLoading: true }) + renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false, placesLoading: true }) expect(document.querySelector('.col-spinner')).not.toBeNull() expect(screen.queryByTestId('list')).toBeNull() }) @@ -348,7 +351,7 @@ describe('CollectionsPage — shell', () => { describe('CollectionsPage — body branches', () => { it('FE-PAGE-COLLPAGE-008: an empty list shows the empty state plus the add CTA', () => { - const hook = renderPage({ places: [], visiblePlaces: [], mappable: [] }) + const hook = renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false }) expect(screen.getByTestId('empty-collections')).toHaveTextContent('collections.empty.title') fireEvent.click(screen.getByRole('button', { name: /collections.addPlace/ })) @@ -356,7 +359,7 @@ describe('CollectionsPage — body branches', () => { }) it('FE-PAGE-COLLPAGE-009: a viewer on an empty list gets no add CTA', () => { - renderPage({ places: [], visiblePlaces: [], mappable: [], canEdit: false }) + renderPage({ places: [], visiblePlaces: [], mappable: [], hasMappable: false, canEdit: false }) expect(screen.getByTestId('empty-collections')).toBeInTheDocument() expect(screen.queryByRole('button', { name: /collections.addPlace/ })).toBeNull() }) @@ -384,13 +387,13 @@ describe('CollectionsPage — body branches', () => { }) it('FE-PAGE-COLLPAGE-013: places without coordinates keep the list column even on a wide layout', () => { - renderPage({ mappable: [] }) + renderPage({ mappable: [], hasMappable: false }) expect(screen.getByTestId('list')).toBeInTheDocument() expect(screen.queryByTestId('map-overlay')).toBeNull() }) it('FE-PAGE-COLLPAGE-014: a filter that matches nothing keeps the filter bar and shows the no-match state', () => { - renderPage({ visiblePlaces: [], mappable: [] }) + renderPage({ visiblePlaces: [], mappable: [], hasMappable: false }) expect(screen.getByTestId('filterbar')).toBeInTheDocument() expect(screen.getByTestId('empty-search')).toHaveTextContent('collections.empty.noMatchTitle') expect(screen.queryByTestId('list')).toBeNull() @@ -555,20 +558,32 @@ describe('CollectionsPage — child wiring', () => { expect(hook.setSelectedPlaceId).not.toHaveBeenCalled() }) - it('FE-PAGE-COLLPAGE-030: the filter bar drives add-place, label management and select mode', () => { + it('FE-PAGE-COLLPAGE-030: the filter bar drives add-place and select mode, the map takes the labels', () => { const hook = renderPage() - expect(screen.getByTestId('filter-flags')).toHaveTextContent('true|true|true') + // Labels ride in the map's top bar while a map is on screen, so the filter + // row is told not to draw them a second time. + expect(screen.getByTestId('filter-flags')).toHaveTextContent('false|true|true') + expect(screen.getByTestId('map-has-labels')).toHaveTextContent('true') fireEvent.click(screen.getByText('filter-add')) expect(hook.setShowAddPlace).toHaveBeenCalledWith(true) - fireEvent.click(screen.getByText('filter-labels')) + fireEvent.click(screen.getByText('map-labels')) expect(hook.setShowLabelManager).toHaveBeenCalledWith(true) fireEvent.click(screen.getByText('filter-select')) expect(hook.setSelectMode).toHaveBeenCalledWith(true) }) + it('FE-PAGE-COLLPAGE-030b: without a map the filter row keeps the labels, so they stay reachable', () => { + const hook = renderPage({ mappable: [], hasMappable: false }) + expect(screen.getByTestId('filter-flags')).toHaveTextContent('true|true|true') + expect(screen.queryByTestId('map-overlay')).toBeNull() + + fireEvent.click(screen.getByText('filter-labels')) + expect(hook.setShowLabelManager).toHaveBeenCalledWith(true) + }) + it('FE-PAGE-COLLPAGE-031: the All-saved union has no labels and no add button in the filter row', () => { renderPage({ isAllSaved: true, activeId: 'all', activeCollection: null }) expect(screen.getByTestId('filter-flags')).toHaveTextContent('false|false|true') diff --git a/client/src/pages/collections/useCollections.ts b/client/src/pages/collections/useCollections.ts index 86d53760c..0b11dc110 100644 --- a/client/src/pages/collections/useCollections.ts +++ b/client/src/pages/collections/useCollections.ts @@ -177,6 +177,11 @@ export function useCollections() { // Stable reference so the map doesn't tear down + rebuild every marker on each // unrelated re-render (which would swallow marker clicks mid-rebuild). const mappable = useMemo(() => mappablePlaces(visiblePlaces), [visiblePlaces]) + // Whether this list has anything mappable AT ALL, filters aside. The layout + // hangs off this rather than off `mappable`: a label with no places left the + // filtered set empty, which tore the map out and reflowed the page to full + // width. The filter should empty the map, not remove it. + const hasMappable = useMemo(() => mappablePlaces(places).length > 0, [places]) const counts = useMemo(() => statusCounts(places), [places]) // ── Handlers ──────────────────────────────────────────────────────── @@ -385,7 +390,7 @@ export function useCollections() { collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner, myRole, canEdit, canDelete, canShare, shareMemberCount, - activeId, places, visiblePlaces, mappable, members, incomingInvites, counts, + activeId, places, visiblePlaces, mappable, hasMappable, members, incomingInvites, counts, view, statusFilter, categoryFilter, categoryOptions, ratingFilter, sortMode, search, selectedPlaceId, selectMode, selectedIds, labels, labelFilter, labelOptions, loading, placesLoading, diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css index dc26384ce..0f98169a6 100644 --- a/client/src/styles/collections.css +++ b/client/src/styles/collections.css @@ -274,9 +274,31 @@ .trek-dash .col-lrow-label { display: inline-flex; align-items: center; gap: 5px; max-width: 120px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); } .trek-dash .col-lrow-label.more { color: var(--ink-3); background: var(--surface-2); } .trek-dash .col-labelchip-dot { width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0; background: var(--label, var(--accent)); } -.trek-dash .col-labelfilter { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; } -.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; } -.trek-dash .col-labelchip:hover { background: var(--surface-2); color: var(--ink); } +/* Labels are their own axis of filtering, so they read as one tray rather than + as more loose buttons: a recessed strip, led by a tag glyph, holding chips + that share the filter buttons' radius and type. The chips carry no border of + their own — a frame inside a frame is what made this look like a pile. */ +.trek-dash .col-labelfilter { + display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; + min-height: var(--col-ctl-h); padding: 3px 6px 3px 9px; border-radius: 11px; + background: color-mix(in oklch, var(--surface-2) 70%, transparent); + border: 1px solid var(--line-2); +} +.trek-dash .col-labelfilter-lead { color: var(--ink-3); flex-shrink: 0; margin-right: 2px; } +/* On the map the tray joins the floating controls, so it takes their glass, + their radius and their shadow instead of the row's flat surface. */ +.trek-dash .col-labelfilter.on-map { + pointer-events: auto; max-width: min(52ch, 100%); flex-wrap: nowrap; overflow-x: auto; + border-radius: 11px; border-color: var(--glass-border); box-shadow: var(--sh-md); + background: color-mix(in oklch, var(--surface) 92%, transparent); + backdrop-filter: blur(8px) saturate(1.2); -webkit-backdrop-filter: blur(8px) saturate(1.2); + scrollbar-width: none; +} +.trek-dash .col-labelfilter.on-map::-webkit-scrollbar { display: none; } +.trek-dash .col-labelfilter.on-map .col-labelchip { flex-shrink: 0; } +.trek-dash .col-labelfilter.on-map .col-labelchip:hover { background: var(--surface-2); } +.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 5px 9px; border-radius: 8px; font-size: 12.5px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: transparent; border: 1px solid transparent; transition: background .12s, border-color .12s, color .12s; } +.trek-dash .col-labelchip:hover { background: var(--surface); color: var(--ink); } .trek-dash .col-labelchip .col-filter-lbl { overflow: hidden; text-overflow: ellipsis; } .trek-dash .col-labelchip.on { color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--label, var(--accent)) 42%, transparent); } .trek-dash .col-labelchip.on .col-labelchip-dot { background: var(--label, var(--accent)); } @@ -284,14 +306,35 @@ .trek-dash .col-detail-labels { display: flex; flex-wrap: wrap; gap: 7px; margin: 2px 0 10px; } /* ----------------- filter bar (status + category dropdowns) ----------------- */ -.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; } +/* One control height for the whole page: the filter row and the buttons floating + over the map are read as a single band when the page is scrolled to the top, + so they share a height and a top offset instead of each having their own. */ +.trek-dash { --col-ctl-h: 38px; } +.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; } +/* Beside the map, the row starts where the map's own top bar does. */ +.trek-dash .col-split-list .col-filterbar { margin-top: 12px; } .trek-dash .col-filter { position: relative; } /* Icon-only add-place button: square it off so it matches the dropdowns' height. */ -.trek-dash .col-filter-add { padding: 7px; } +/* Square icon-only buttons: as wide as they are tall, so they sit in the row + without being the one element that is a few pixels off. */ +.trek-dash .col-filter-add, +.trek-dash .col-filter-icon { width: var(--col-ctl-h); padding: 0; justify-content: center; } +/* Manage/add labels sits inside the label tray, so it stays quiet until hovered. */ +.trek-dash .col-filter-addlabel { + display: inline-flex; align-items: center; justify-content: center; + padding: 5px; border-radius: 8px; color: var(--ink-3); + background: transparent; border: 1px solid transparent; + transition: background .12s, color .12s; +} +.trek-dash .col-filter-addlabel:hover { background: var(--surface); color: var(--ink); } .trek-dash .col-filter-select { margin-left: auto; } -.trek-dash .col-filter-select.open { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } -.trek-dash .col-filter-select.open:hover { background: var(--accent); color: var(--accent-text); } -.trek-dash .col-filter-btn { display: inline-flex; align-items: center; gap: 7px; padding: 7px 11px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; } +/* `.on`, not `.open`: on a dropdown "open" means the panel is down and the text + stays dark, and that rule sits later in this file, so sharing the name left + the active select button drawing a dark icon on its dark accent fill. The + other toggles in this page (col-map-btn, col-iconbtn) already use `.on`. */ +.trek-dash .col-filter-select.on { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } +.trek-dash .col-filter-select.on:hover { background: var(--accent); color: var(--accent-text); } +.trek-dash .col-filter-btn { display: inline-flex; align-items: center; gap: 7px; height: var(--col-ctl-h); padding: 0 11px; border-radius: 10px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); background: var(--surface); border: 1px solid var(--line-2); transition: background .12s, border-color .12s, color .12s; } .trek-dash .col-filter-btn:hover { background: var(--surface-2); color: var(--ink); } .trek-dash .col-filter-btn.open { border-color: var(--accent); color: var(--ink); } .trek-dash .col-filter-btn .col-filter-chev { color: var(--ink-3); transition: transform .15s; } @@ -461,9 +504,10 @@ .trek-dash .col-search { flex: 1; } .trek-dash .col-mapwrap { height: calc(100dvh - 260px); min-height: 320px; } - /* bigger touch targets on phones (visuals unchanged on desktop) */ + /* bigger touch targets on phones (visuals unchanged on desktop). Raising the + shared height keeps the square icon buttons square. */ + .trek-dash { --col-ctl-h: 40px; } .trek-dash .col-viewseg button { width: 44px; height: 38px; } - .trek-dash .col-filter-btn { min-height: 40px; } .trek-dash .col-filter-opt { min-height: 40px; } .trek-dash .col-selbar-btn { min-height: 40px; } .trek-dash .col-detail-close { width: 40px; height: 40px; } diff --git a/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx b/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx index cde8129d1..946244266 100644 --- a/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx @@ -368,9 +368,14 @@ describe('MPlaceSheet', () => { const shell = makeShell({ trTab: 'dateien', view: 'plan' }) const { planner } = renderSheet(makePlanner(), shell) - fireEvent.click(screen.getByRole('button', { name: 'Google Maps' })) + // A place with coordinates reaches more than one map app, so the circle + // opens the picker instead of guessing which one was meant. + fireEvent.click(screen.getByRole('button', { name: 'Navigation' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Google Maps' })) + // Name and address rather than bare coordinates (#1278): the place has both, + // so Google opens the entry instead of an unlabelled pin. expect(window.open).toHaveBeenCalledWith( - 'https://www.google.com/maps/search/?api=1&query=48.2038,16.3616', '_blank', 'noopener,noreferrer', + 'https://www.google.com/maps/search/?api=1&query=Kunsthistorisches%20Museum%2C%20Maria-Theresien-Platz', '_blank', 'noopener,noreferrer', ) fireEvent.click(screen.getByRole('button', { name: 'Open Website' })) @@ -405,7 +410,7 @@ describe('MPlaceSheet', () => { expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Save to Collection' })).not.toBeInTheDocument() // Read-only members still get the navigation actions. - expect(screen.getByRole('button', { name: 'Google Maps' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument() }) it('FE-MOB-PLSH-026: collapses the track colour picker again when the sheet closes', () => { diff --git a/client/tests/unit/mobile/trip/tabChrome.test.tsx b/client/tests/unit/mobile/trip/tabChrome.test.tsx index 8ab8f67a6..bd2c464bf 100644 --- a/client/tests/unit/mobile/trip/tabChrome.test.tsx +++ b/client/tests/unit/mobile/trip/tabChrome.test.tsx @@ -148,4 +148,54 @@ describe('tabChrome', () => { expect(screen.getByTitle('Ada').className).toContain('opacity-100') expect(screen.getByTitle('Bob').className).toContain('opacity-100') }) + + it('FE-MOB-TABCHR-015: TravelerFilterRow names itself, so the avatars are not left unexplained', () => { + render( + , + ) + expect(screen.getByRole('group', { name: 'Travelers' })).toBeInTheDocument() + expect(screen.getByText('Travelers')).toBeInTheDocument() + }) + + it('FE-MOB-TABCHR-016: the way out of a filter only appears once one is set', () => { + const onClear = vi.fn() + const { rerender } = render( + , + ) + expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument() + + rerender( + , + ) + fireEvent.click(screen.getByRole('button', { name: 'All' })) + expect(onClear).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-TABCHR-017: SectionHeader wraps its count exactly once', () => { + // The callers used to pass a into a prop that wraps one itself, + // which put a pill inside a pill and showed two stacked backgrounds. + render() + const pill = screen.getByText('2') + expect(pill.querySelector('span')).toBeNull() + expect(pill.parentElement?.tagName).toBe('BUTTON') + }) }) diff --git a/shared/src/i18n/ar/inspector.ts b/shared/src/i18n/ar/inspector.ts index ed125bd92..413aad04a 100644 --- a/shared/src/i18n/ar/inspector.ts +++ b/shared/src/i18n/ar/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'حجز مؤكد', 'inspector.pendingRes': 'حجز قيد الانتظار', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'التنقل', + 'inspector.openWith': 'الفتح باستخدام', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'فتح الموقع الإلكتروني', 'inspector.saveToCollection': 'حفظ في مجموعة', diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index cd2fe539f..197457b30 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'غير مخطط', 'places.planned': 'مخطط لها', 'places.filterTracks': 'المسارات', - 'places.sortByRating': 'الترتيب حسب التقييم', + 'places.filterByRating': 'تصفية حسب التقييم', 'places.yourRating': 'تقييمك', 'places.notRated': 'لم يُقيَّم بعد', 'places.search': 'ابحث عن أماكن...', diff --git a/shared/src/i18n/br/inspector.ts b/shared/src/i18n/br/inspector.ts index ea27087f6..531528444 100644 --- a/shared/src/i18n/br/inspector.ts +++ b/shared/src/i18n/br/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Reserva confirmada', 'inspector.pendingRes': 'Reserva pendente', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navegação', + 'inspector.openWith': 'Abrir com', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.saveToCollection': 'Salvar na Coleção', 'inspector.savedToCollection': 'Salvo', diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index 9a51323f7..c92fd76aa 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Não planejados', 'places.planned': 'Planejados', 'places.filterTracks': 'Trilhas', - 'places.sortByRating': 'Ordenar por avaliação', + 'places.filterByRating': 'Filtrar por avaliação', 'places.yourRating': 'Sua avaliação', 'places.notRated': 'Ainda sem avaliações', 'places.search': 'Buscar lugares...', diff --git a/shared/src/i18n/ca/inspector.ts b/shared/src/i18n/ca/inspector.ts index 9c27e5f96..df1893253 100644 --- a/shared/src/i18n/ca/inspector.ts +++ b/shared/src/i18n/ca/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Reserva confirmada', 'inspector.pendingRes': 'Reserva pendent', 'inspector.google': 'Obre a Google Maps', + 'inspector.navigation': 'Navegació', + 'inspector.openWith': 'Obre amb', 'inspector.website': 'Obre el lloc web', 'inspector.addRes': 'Reserva', 'inspector.editRes': 'Edita la reserva', diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index 7056bf151..56d3280f1 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -43,7 +43,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Sense planificar', 'places.planned': 'Planificats', 'places.filterTracks': 'Rutes', - 'places.sortByRating': 'Ordena per valoració', + 'places.filterByRating': 'Filtra per valoració', 'places.yourRating': 'La teva valoració', 'places.notRated': 'Encara sense valoració', 'places.search': 'Cerca llocs...', diff --git a/shared/src/i18n/cs/inspector.ts b/shared/src/i18n/cs/inspector.ts index f06d35e40..0ca346de8 100644 --- a/shared/src/i18n/cs/inspector.ts +++ b/shared/src/i18n/cs/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Potvrzená rezervace', 'inspector.pendingRes': 'Čekající rezervace', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigace', + 'inspector.openWith': 'Otevřít v', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Otevřít webové stránky', 'inspector.saveToCollection': 'Uložit do sbírky', diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index 5cd2df76d..e8073085d 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Nezařazené', 'places.planned': 'Naplánované', 'places.filterTracks': 'Trasy', - 'places.sortByRating': 'Seřadit podle hodnocení', + 'places.filterByRating': 'Filtrovat podle hodnocení', 'places.yourRating': 'Tvé hodnocení', 'places.notRated': 'Zatím bez hodnocení', 'places.search': 'Hledat místa...', diff --git a/shared/src/i18n/de/inspector.ts b/shared/src/i18n/de/inspector.ts index 3cb723e69..0dcb72c28 100644 --- a/shared/src/i18n/de/inspector.ts +++ b/shared/src/i18n/de/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Bestätigte Reservierung', 'inspector.pendingRes': 'Ausstehende Reservierung', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigation', + 'inspector.openWith': 'Öffnen mit', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Webseite öffnen', 'inspector.saveToCollection': 'In Sammlung speichern', diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index 90c2173ca..46f655dd8 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Ungeplant', 'places.planned': 'Geplant', 'places.filterTracks': 'Tracks', - 'places.sortByRating': 'Nach Bewertung sortieren', + 'places.filterByRating': 'Nach Bewertung filtern', 'places.yourRating': 'Deine Bewertung', 'places.notRated': 'Noch nicht bewertet', 'places.search': 'Orte suchen...', diff --git a/shared/src/i18n/en/inspector.ts b/shared/src/i18n/en/inspector.ts index a0d456eee..a70884d6a 100644 --- a/shared/src/i18n/en/inspector.ts +++ b/shared/src/i18n/en/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Confirmed Reservation', 'inspector.pendingRes': 'Pending Reservation', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigation', + 'inspector.openWith': 'Open with', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Open Website', 'inspector.saveToCollection': 'Save to Collection', diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index 90ea728c0..7795ee8a5 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Unplanned', 'places.planned': 'Planned', 'places.filterTracks': 'Tracks', - 'places.sortByRating': 'Sort by rating', + 'places.filterByRating': 'Filter by rating', 'places.yourRating': 'Your rating', 'places.notRated': 'Not rated yet', 'places.search': 'Search places...', diff --git a/shared/src/i18n/es/inspector.ts b/shared/src/i18n/es/inspector.ts index bd16e2e5e..f8dfaa26d 100644 --- a/shared/src/i18n/es/inspector.ts +++ b/shared/src/i18n/es/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Reserva confirmada', 'inspector.pendingRes': 'Reserva pendiente', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navegación', + 'inspector.openWith': 'Abrir con', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Abrir la web', 'inspector.saveToCollection': 'Guardar en colección', diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index 458f7774f..b405f2905 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Sin planificar', 'places.planned': 'Planificados', 'places.filterTracks': 'Rutas', - 'places.sortByRating': 'Ordenar por valoración', + 'places.filterByRating': 'Filtrar por valoración', 'places.yourRating': 'Tu valoración', 'places.notRated': 'Sin valorar todavía', 'places.search': 'Buscar lugares...', diff --git a/shared/src/i18n/fr/inspector.ts b/shared/src/i18n/fr/inspector.ts index 30ef5b160..f30c148df 100644 --- a/shared/src/i18n/fr/inspector.ts +++ b/shared/src/i18n/fr/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Réservation confirmée', 'inspector.pendingRes': 'Réservation en attente', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigation', + 'inspector.openWith': 'Ouvrir avec', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Ouvrir le site web', 'inspector.saveToCollection': 'Enregistrer dans une collection', diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index 190dd7a63..1b1c71851 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Non planifiés', 'places.planned': 'Planifiés', 'places.filterTracks': 'Traces', - 'places.sortByRating': 'Trier par note', + 'places.filterByRating': 'Filtrer par note', 'places.yourRating': 'Ta note', 'places.notRated': 'Pas encore noté', 'places.search': 'Rechercher des lieux…', diff --git a/shared/src/i18n/gr/inspector.ts b/shared/src/i18n/gr/inspector.ts index 409ba2dc8..b81a6c617 100644 --- a/shared/src/i18n/gr/inspector.ts +++ b/shared/src/i18n/gr/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Επιβεβαιωμένη Κράτηση', 'inspector.pendingRes': 'Εκκρεμής Κράτηση', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Πλοήγηση', + 'inspector.openWith': 'Άνοιγμα με', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.saveToCollection': 'Αποθήκευση στη Συλλογή', 'inspector.savedToCollection': 'Αποθηκεύτηκε', diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index d7cf13cbc..d721161c2 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Μη προγραμματισμένα', 'places.planned': 'Προγραμματισμένα', 'places.filterTracks': 'Ίχνη', - 'places.sortByRating': 'Ταξινόμηση κατά βαθμολογία', + 'places.filterByRating': 'Φιλτράρισμα κατά βαθμολογία', 'places.yourRating': 'Η βαθμολογία σου', 'places.notRated': 'Χωρίς βαθμολογία ακόμη', 'places.search': 'Αναζήτηση μερών...', diff --git a/shared/src/i18n/hu/inspector.ts b/shared/src/i18n/hu/inspector.ts index df5236f2c..6c4004134 100644 --- a/shared/src/i18n/hu/inspector.ts +++ b/shared/src/i18n/hu/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Megerősített foglalás', 'inspector.pendingRes': 'Függőben lévő foglalás', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigáció', + 'inspector.openWith': 'Megnyitás ezzel', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Weboldal megnyitása', 'inspector.saveToCollection': 'Mentés gyűjteménybe', diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index b9c736ea5..3cb41240c 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Nem tervezett', 'places.planned': 'Tervezett', 'places.filterTracks': 'Nyomvonalak', - 'places.sortByRating': 'Rendezés értékelés szerint', + 'places.filterByRating': 'Szűrés értékelés szerint', 'places.yourRating': 'Az értékelésed', 'places.notRated': 'Még nincs értékelve', 'places.search': 'Helyek keresése...', diff --git a/shared/src/i18n/id/inspector.ts b/shared/src/i18n/id/inspector.ts index 1df45f455..0a88e0146 100644 --- a/shared/src/i18n/id/inspector.ts +++ b/shared/src/i18n/id/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Reservasi Dikonfirmasi', 'inspector.pendingRes': 'Reservasi Menunggu', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigasi', + 'inspector.openWith': 'Buka dengan', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.saveToCollection': 'Simpan ke Koleksi', 'inspector.savedToCollection': 'Tersimpan', diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index 59170197b..ae57bb480 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -44,7 +44,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Belum direncanakan', 'places.planned': 'Direncanakan', 'places.filterTracks': 'Trek', - 'places.sortByRating': 'Urutkan berdasarkan rating', + 'places.filterByRating': 'Saring menurut penilaian', 'places.yourRating': 'Rating kamu', 'places.notRated': 'Belum dinilai', 'places.search': 'Cari tempat...', diff --git a/shared/src/i18n/it/inspector.ts b/shared/src/i18n/it/inspector.ts index 5fe161cd9..d8f914b6b 100644 --- a/shared/src/i18n/it/inspector.ts +++ b/shared/src/i18n/it/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Prenotazione confermata', 'inspector.pendingRes': 'Prenotazione in attesa', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigazione', + 'inspector.openWith': 'Apri con', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Apri sito web', 'inspector.saveToCollection': 'Salva nella raccolta', diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index b5e537728..812bb1272 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Non pianificati', 'places.planned': 'Pianificati', 'places.filterTracks': 'Tracce', - 'places.sortByRating': 'Ordina per valutazione', + 'places.filterByRating': 'Filtra per valutazione', 'places.yourRating': 'La tua valutazione', 'places.notRated': 'Non ancora valutato', 'places.search': 'Cerca luoghi...', diff --git a/shared/src/i18n/ja/inspector.ts b/shared/src/i18n/ja/inspector.ts index 86f467e7e..13380f861 100644 --- a/shared/src/i18n/ja/inspector.ts +++ b/shared/src/i18n/ja/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': '確定済み予約', 'inspector.pendingRes': '保留中の予約', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'ナビゲーション', + 'inspector.openWith': 'アプリで開く', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Webサイトを開く', 'inspector.saveToCollection': 'コレクションに保存', diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index ddc2ad4c2..c358d1d1c 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': '未計画', 'places.planned': '計画済み', 'places.filterTracks': 'トラック', - 'places.sortByRating': '評価順に並べ替え', + 'places.filterByRating': '評価で絞り込む', 'places.yourRating': 'あなたの評価', 'places.notRated': '未評価', 'places.search': '場所を検索…', diff --git a/shared/src/i18n/ko/inspector.ts b/shared/src/i18n/ko/inspector.ts index fb00c9f7f..0a2f3a706 100644 --- a/shared/src/i18n/ko/inspector.ts +++ b/shared/src/i18n/ko/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': '확정된 예약', 'inspector.pendingRes': '대기 중인 예약', 'inspector.google': 'Google Maps', + 'inspector.navigation': '내비게이션', + 'inspector.openWith': '다음으로 열기', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': '웹사이트 열기', 'inspector.saveToCollection': '컬렉션에 저장', diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index 81b40c34a..03139024f 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -44,7 +44,7 @@ const places: TranslationStrings = { 'places.unplanned': '미계획', 'places.planned': '계획됨', 'places.filterTracks': '트랙', - 'places.sortByRating': '평점순 정렬', + 'places.filterByRating': '평점으로 필터링', 'places.yourRating': '내 평점', 'places.notRated': '아직 평점 없음', 'places.search': '장소 검색...', diff --git a/shared/src/i18n/nl/inspector.ts b/shared/src/i18n/nl/inspector.ts index 6cd2a3479..838afd9b2 100644 --- a/shared/src/i18n/nl/inspector.ts +++ b/shared/src/i18n/nl/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Bevestigde reservering', 'inspector.pendingRes': 'Reservering in behandeling', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigatie', + 'inspector.openWith': 'Openen met', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Website openen', 'inspector.saveToCollection': 'In collectie opslaan', diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index b8fa93959..6c0c6f008 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Ongepland', 'places.planned': 'Gepland', 'places.filterTracks': 'Tracks', - 'places.sortByRating': 'Sorteren op beoordeling', + 'places.filterByRating': 'Filteren op beoordeling', 'places.yourRating': 'Jouw beoordeling', 'places.notRated': 'Nog niet beoordeeld', 'places.search': 'Plaatsen zoeken...', diff --git a/shared/src/i18n/pl/inspector.ts b/shared/src/i18n/pl/inspector.ts index b0fe05a29..b5dafafe2 100644 --- a/shared/src/i18n/pl/inspector.ts +++ b/shared/src/i18n/pl/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Potwierdzona rezerwacja', 'inspector.pendingRes': 'Oczekująca rezerwacja', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Nawigacja', + 'inspector.openWith': 'Otwórz w', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Otwórz stronę internetową', 'inspector.saveToCollection': 'Zapisz w kolekcji', diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index 21a103ca1..224be55ab 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -36,7 +36,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Niezaplanowane', 'places.planned': 'Zaplanowane', 'places.filterTracks': 'Trasy', - 'places.sortByRating': 'Sortuj według oceny', + 'places.filterByRating': 'Filtruj według oceny', 'places.yourRating': 'Twoja ocena', 'places.notRated': 'Jeszcze nie oceniono', 'places.search': 'Szukaj miejsc...', diff --git a/shared/src/i18n/ru/inspector.ts b/shared/src/i18n/ru/inspector.ts index bb95d7d86..1f5a28940 100644 --- a/shared/src/i18n/ru/inspector.ts +++ b/shared/src/i18n/ru/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Подтверждённое бронирование', 'inspector.pendingRes': 'Ожидающее бронирование', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Навигация', + 'inspector.openWith': 'Открыть в', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Открыть сайт', 'inspector.saveToCollection': 'Сохранить в коллекцию', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index e2392bf37..d154d1eb2 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Незапланированные', 'places.planned': 'Запланированные', 'places.filterTracks': 'Треки', - 'places.sortByRating': 'Сортировать по рейтингу', + 'places.filterByRating': 'Фильтр по оценке', 'places.yourRating': 'Твоя оценка', 'places.notRated': 'Ещё нет оценок', 'places.search': 'Поиск мест...', diff --git a/shared/src/i18n/sv/inspector.ts b/shared/src/i18n/sv/inspector.ts index ec94f53a6..d578fa6de 100644 --- a/shared/src/i18n/sv/inspector.ts +++ b/shared/src/i18n/sv/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Bekräftad bokning', 'inspector.pendingRes': 'Pendlande bokning', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Navigation', + 'inspector.openWith': 'Öppna med', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Öppna hemsida', 'inspector.saveToCollection': 'Spara i samling', diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index b03db9923..4e29e62c8 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Oplanerat', 'places.planned': 'Planerat', 'places.filterTracks': 'Spår', - 'places.sortByRating': 'Sortera efter betyg', + 'places.filterByRating': 'Filtrera efter betyg', 'places.yourRating': 'Ditt betyg', 'places.notRated': 'Inte betygsatt ännu', 'places.search': 'Sök efter platser...', diff --git a/shared/src/i18n/tr/inspector.ts b/shared/src/i18n/tr/inspector.ts index 9ca66ae1b..d3067940c 100644 --- a/shared/src/i18n/tr/inspector.ts +++ b/shared/src/i18n/tr/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Onaylanmış Rezervasyon', 'inspector.pendingRes': 'Bekleyen Rezervasyon', 'inspector.google': "Google Haritalar'da aç", + 'inspector.navigation': 'Navigasyon', + 'inspector.openWith': 'Şununla aç', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.saveToCollection': 'Koleksiyona kaydet', 'inspector.savedToCollection': 'Kaydedildi', diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index cd58fd6d3..b6dc3cfe6 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -47,7 +47,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Planlanmamış', 'places.planned': 'Planlanmış', 'places.filterTracks': 'Parçalar', - 'places.sortByRating': 'Puana göre sırala', + 'places.filterByRating': 'Puana göre filtrele', 'places.yourRating': 'Senin puanın', 'places.notRated': 'Henüz puanlanmadı', 'places.search': 'Yer ara...', diff --git a/shared/src/i18n/uk/inspector.ts b/shared/src/i18n/uk/inspector.ts index 04b16ee23..d59e79bf9 100644 --- a/shared/src/i18n/uk/inspector.ts +++ b/shared/src/i18n/uk/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Підтверджене бронювання', 'inspector.pendingRes': 'Очікуване бронювання', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Навігація', + 'inspector.openWith': 'Відкрити в', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': 'Відкрити сайт', 'inspector.saveToCollection': 'Зберегти в колекцію', diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index 1f569f769..efde4db2e 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Незаплановані', 'places.planned': 'Заплановані', 'places.filterTracks': 'Треки', - 'places.sortByRating': 'Сортувати за рейтингом', + 'places.filterByRating': 'Фільтр за оцінкою', 'places.yourRating': 'Твоя оцінка', 'places.notRated': 'Ще не оцінено', 'places.search': 'Пошук місць...', diff --git a/shared/src/i18n/vi/inspector.ts b/shared/src/i18n/vi/inspector.ts index 377cbb99e..e63cb2b75 100644 --- a/shared/src/i18n/vi/inspector.ts +++ b/shared/src/i18n/vi/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': 'Đã xác nhận đặt chỗ', 'inspector.pendingRes': 'Đang chờ đặt chỗ', 'inspector.google': 'Google Maps', + 'inspector.navigation': 'Điều hướng', + 'inspector.openWith': 'Mở bằng', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.saveToCollection': 'Lưu vào Bộ sưu tập', 'inspector.savedToCollection': 'Đã lưu', diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index f0f4b174b..6cbee89bf 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -45,7 +45,7 @@ const places: TranslationStrings = { 'places.unplanned': 'Không có kế hoạch', 'places.planned': 'Có kế hoạch', 'places.filterTracks': 'Bài hát', - 'places.sortByRating': 'Sắp xếp theo đánh giá', + 'places.filterByRating': 'Lọc theo đánh giá', 'places.yourRating': 'Đánh giá của bạn', 'places.notRated': 'Chưa có đánh giá', 'places.search': 'Tìm kiếm địa điểm...', diff --git a/shared/src/i18n/zh-TW/inspector.ts b/shared/src/i18n/zh-TW/inspector.ts index 2fc9be63b..ad4a2444c 100644 --- a/shared/src/i18n/zh-TW/inspector.ts +++ b/shared/src/i18n/zh-TW/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': '已確認預訂', 'inspector.pendingRes': '待確認預訂', 'inspector.google': 'Google Maps', + 'inspector.navigation': '導航', + 'inspector.openWith': '開啟方式', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': '開啟網站', 'inspector.saveToCollection': '儲存到收藏', diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index b4cf00fba..b05b4dfcd 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -44,7 +44,7 @@ const places: TranslationStrings = { 'places.unplanned': '未規劃', 'places.planned': '已規劃', 'places.filterTracks': '路線', - 'places.sortByRating': '依評分排序', + 'places.filterByRating': '依評分篩選', 'places.yourRating': '你的評分', 'places.notRated': '尚未評分', 'places.search': '搜尋地點...', diff --git a/shared/src/i18n/zh/inspector.ts b/shared/src/i18n/zh/inspector.ts index daae1d81a..052298667 100644 --- a/shared/src/i18n/zh/inspector.ts +++ b/shared/src/i18n/zh/inspector.ts @@ -13,6 +13,8 @@ const inspector: TranslationStrings = { 'inspector.confirmedRes': '已确认预订', 'inspector.pendingRes': '待确认预订', 'inspector.google': 'Google 地图', + 'inspector.navigation': '导航', + 'inspector.openWith': '打开方式', 'inspector.openStreetMap': 'OpenStreetMap', 'inspector.website': '打开网站', 'inspector.saveToCollection': '保存到收藏', diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index df5478bc2..acc141c99 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -44,7 +44,7 @@ const places: TranslationStrings = { 'places.unplanned': '未规划', 'places.planned': '已规划', 'places.filterTracks': '路线', - 'places.sortByRating': '按评分排序', + 'places.filterByRating': '按评分筛选', 'places.yourRating': '你的评分', 'places.notRated': '暂无评分', 'places.search': '搜索地点...',