mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
Compare commits
9 Commits
v5.1.3-tf.2
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 35e0613986 | |||
| ccfa92f650 | |||
| fdb2095ced | |||
| d6a6b49b6b | |||
| 0855bafca7 | |||
| a39f3fe40c | |||
| f13a0aa6ee | |||
| 10d1a61614 | |||
| 2bcfc1bfcb |
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 5.1.3
|
version: 5.1.4
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "5.1.3"
|
appVersion: "5.1.4"
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
for context in connectionOptions.urlContexts where context.url.isFileURL {
|
for context in connectionOptions.urlContexts where context.url.isFileURL {
|
||||||
TrekSharedFiles.receive(context.url)
|
TrekSharedFiles.receive(context.url)
|
||||||
}
|
}
|
||||||
|
// Launched by a tap on a widget or a Live Activity: held like a quick action.
|
||||||
|
// Capacitor still hears of the link, the UI ignores it there (feature appLinks).
|
||||||
|
for context in connectionOptions.urlContexts {
|
||||||
|
if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) }
|
||||||
|
}
|
||||||
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
|
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +45,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
// com.liketrek.trek://share: the share sheet left an item for the import.
|
// com.liketrek.trek://share: the share sheet left an item for the import.
|
||||||
let handoffs = URLContexts.filter { $0.url.scheme == "com.liketrek.trek" && $0.url.host == "share" }
|
let handoffs = URLContexts.filter { $0.url.scheme == "com.liketrek.trek" && $0.url.host == "share" }
|
||||||
if !handoffs.isEmpty { TrekSharedFiles.receiveFromShareExtension() }
|
if !handoffs.isEmpty { TrekSharedFiles.receiveFromShareExtension() }
|
||||||
let others = URLContexts.subtracting(files).subtracting(handoffs)
|
// com.liketrek.trek://navigate/<path>: a widget or Live Activity tap. Through
|
||||||
|
// Capacitor it was an event a page reloading after the system ended its process
|
||||||
|
// never heard, and that page then took the same link for one already followed.
|
||||||
|
let appLinks = URLContexts.filter { TrekQuickActions.appLinkPath($0.url) != nil }
|
||||||
|
for context in appLinks {
|
||||||
|
if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) }
|
||||||
|
}
|
||||||
|
let others = URLContexts.subtracting(files).subtracting(handoffs).subtracting(appLinks)
|
||||||
if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) }
|
if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,10 +116,25 @@ enum TrekQuickActions {
|
|||||||
|
|
||||||
static func open(_ item: UIApplicationShortcutItem) -> Bool {
|
static func open(_ item: UIApplicationShortcutItem) -> Bool {
|
||||||
guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false }
|
guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false }
|
||||||
if let onOpen { onOpen(path) } else { pending = path }
|
open(path: path)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A path to open: at once when the UI listens, else held for it to take.
|
||||||
|
/// Widget and Live Activity taps come this way too, so each is followed once.
|
||||||
|
static func open(path: String) {
|
||||||
|
if let onOpen { onOpen(path) } else { pending = path }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path in com.liketrek.trek://navigate/<path>, the link widgets and Live Activities open.
|
||||||
|
static func appLinkPath(_ url: URL) -> String? {
|
||||||
|
let prefix = "com.liketrek.trek://navigate"
|
||||||
|
guard url.absoluteString.hasPrefix(prefix) else { return nil }
|
||||||
|
let path = String(url.absoluteString.dropFirst(prefix.count))
|
||||||
|
if path.isEmpty { return "/" }
|
||||||
|
return path.hasPrefix("/") ? path : nil
|
||||||
|
}
|
||||||
|
|
||||||
/// The action the app was launched with, once.
|
/// The action the app was launched with, once.
|
||||||
static func take() -> String? {
|
static func take() -> String? {
|
||||||
defer { pending = nil }
|
defer { pending = nil }
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
|||||||
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
|
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
|
||||||
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
|
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
|
||||||
static var features: [String] {
|
static var features: [String] {
|
||||||
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate", "filePreview"]
|
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "appLinks", "clientCertificate", "filePreview"]
|
||||||
if canAddWalletPasses { names.append("wallet") }
|
if canAddWalletPasses { names.append("wallet") }
|
||||||
if TrekWatchLink.isAvailable { names.append("watch") }
|
if TrekWatchLink.isAvailable { names.append("watch") }
|
||||||
return names
|
return names
|
||||||
@@ -94,8 +94,9 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
|||||||
TrekKeyCommands.onPress = { [weak self] id in
|
TrekKeyCommands.onPress = { [weak self] id in
|
||||||
self?.notifyListeners("keyCommand", data: ["id": id])
|
self?.notifyListeners("keyCommand", data: ["id": id])
|
||||||
}
|
}
|
||||||
|
// Kept until a listener takes it: a tap while the page reloads reaches the new page.
|
||||||
TrekQuickActions.onOpen = { [weak self] path in
|
TrekQuickActions.onOpen = { [weak self] path in
|
||||||
DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path]) }
|
DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path], retainUntilConsumed: true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,7 +686,7 @@ extension TrekShellPlugin {
|
|||||||
// A held one that was delivered stops the purchase here. One that was paid
|
// A held one that was delivered stops the purchase here. One that was paid
|
||||||
// but never set up is handed back as bought, so its instance is set up now.
|
// but never set up is handed back as bought, so its instance is set up now.
|
||||||
if let held = await TrekStore.currentEntitlement(prefix: "cloud.") {
|
if let held = await TrekStore.currentEntitlement(prefix: "cloud.") {
|
||||||
if await TrekStore.isUnfinished(held.id) {
|
if await TrekStore.awaitingSetup(held) {
|
||||||
call.resolve(["kind": "bought", "jws": held.jws, "transactionId": String(held.id), "productId": held.productId])
|
call.resolve(["kind": "bought", "jws": held.jws, "transactionId": String(held.id), "productId": held.productId])
|
||||||
} else {
|
} else {
|
||||||
call.resolve(["kind": "subscribed"])
|
call.resolve(["kind": "subscribed"])
|
||||||
@@ -759,8 +760,8 @@ extension TrekShellPlugin {
|
|||||||
call.resolve(["held": false])
|
call.resolve(["held": false])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let unfinished = await TrekStore.isUnfinished(held.id)
|
let awaiting = await TrekStore.awaitingSetup(held)
|
||||||
call.resolve(["held": !unfinished])
|
call.resolve(["held": !awaiting])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -793,17 +794,54 @@ enum TrekStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64, productId: String)? {
|
typealias Held = (jws: String, id: UInt64, productId: String, firstPurchase: Bool)
|
||||||
var newest: (date: Date, jws: String, id: UInt64, productId: String)?
|
|
||||||
|
/// The subscription this Apple ID holds under `prefix`, newest first. The
|
||||||
|
/// entitlements on the device come first; on a fresh install they can still
|
||||||
|
/// be empty while the subscription runs, so the status of the subscription
|
||||||
|
/// group, which StoreKit asks the App Store for, decides then.
|
||||||
|
static func currentEntitlement(prefix: String) async -> Held? {
|
||||||
|
var newest: (date: Date, held: Held)?
|
||||||
for await result in Transaction.currentEntitlements {
|
for await result in Transaction.currentEntitlements {
|
||||||
guard case .verified(let transaction) = result,
|
guard case .verified(let transaction) = result,
|
||||||
transaction.productID.hasPrefix(prefix),
|
transaction.productID.hasPrefix(prefix),
|
||||||
transaction.revocationDate == nil else { continue }
|
transaction.revocationDate == nil else { continue }
|
||||||
if newest == nil || transaction.purchaseDate > newest!.date {
|
if newest == nil || transaction.purchaseDate > newest!.date {
|
||||||
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id, transaction.productID)
|
newest = (transaction.purchaseDate, held(result.jwsRepresentation, transaction))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newest.map { (jws: $0.jws, id: $0.id, productId: $0.productId) }
|
if let newest { return newest.held }
|
||||||
|
return await subscriptionGroupHeld(prefix: prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plans the control plane sells (`cloud.<plan>.<monthly|yearly>`, as
|
||||||
|
/// productIdFor in iap.ts); one of them is enough to find their group.
|
||||||
|
private static let plans = ["start", "basis", "plus", "pro"]
|
||||||
|
|
||||||
|
private static func subscriptionGroupHeld(prefix: String) async -> Held? {
|
||||||
|
let ids = plans.flatMap { ["\(prefix)\($0).monthly", "\(prefix)\($0).yearly"] }
|
||||||
|
guard let products = try? await Product.products(for: ids),
|
||||||
|
let group = products.compactMap({ $0.subscription?.subscriptionGroupID }).first,
|
||||||
|
let statuses = try? await Product.SubscriptionInfo.status(for: group) else { return nil }
|
||||||
|
let running: [Product.SubscriptionInfo.RenewalState] = [.subscribed, .inGracePeriod, .inBillingRetryPeriod]
|
||||||
|
for status in statuses where running.contains(status.state) {
|
||||||
|
if case .verified(let transaction) = status.transaction, transaction.revocationDate == nil {
|
||||||
|
return held(status.transaction.jwsRepresentation, transaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func held(_ jws: String, _ transaction: Transaction) -> Held {
|
||||||
|
(jws: jws, id: transaction.id, productId: transaction.productID, firstPurchase: transaction.originalID == transaction.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A first purchase that was paid but whose instance was never set up: handed
|
||||||
|
/// back as bought so it is set up now. A renewal the device has not finished
|
||||||
|
/// yet is no such purchase; its subscription is running and counts as held.
|
||||||
|
static func awaitingSetup(_ held: Held) async -> Bool {
|
||||||
|
guard held.firstPurchase else { return false }
|
||||||
|
return await isUnfinished(held.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paid but not delivered yet: the shell finishes a transaction once its instance exists.
|
/// Paid but not delivered yet: the shell finishes a transaction once its instance exists.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@trek/client",
|
"name": "@trek/client",
|
||||||
"version": "5.1.3",
|
"version": "5.1.4",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import L from 'leaflet';
|
|||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { act, render, waitFor } from '../../../tests/helpers/render';
|
import { act, render, waitFor } from '../../../tests/helpers/render';
|
||||||
import { resetAllStores } from '../../../tests/helpers/store';
|
import { resetAllStores } from '../../../tests/helpers/store';
|
||||||
|
import { withMarkerCategory } from '../../pages/collections/collectionsModel';
|
||||||
import CollectionMap from './CollectionMap';
|
import CollectionMap from './CollectionMap';
|
||||||
|
|
||||||
vi.mock('../../hooks/useGeolocation', () => ({
|
vi.mock('../../hooks/useGeolocation', () => ({
|
||||||
@@ -258,3 +259,26 @@ describe('CollectionMap framing', () => {
|
|||||||
expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true);
|
expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CollectionMap pins (#3073)', () => {
|
||||||
|
const MUSEUM = { id: 4, name: 'Museum', color: '#7c3aed', icon: 'Landmark' };
|
||||||
|
|
||||||
|
it('FE-COMP-COLMAP-008: a saved place carries its category where the markers read it', () => {
|
||||||
|
const pin = withMarkerCategory({ ...place(1, 'Belvedere', 48.1915, 16.3809), category_id: 4, category: MUSEUM });
|
||||||
|
expect(pin).toMatchObject({ category_name: 'Museum', category_color: '#7c3aed', category_icon: 'Landmark' });
|
||||||
|
expect(withMarkerCategory(place(2, 'Somewhere', 48.2, 16.37))).toMatchObject({
|
||||||
|
category_name: null,
|
||||||
|
category_color: null,
|
||||||
|
category_icon: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-COLMAP-009: the pin of a place with a category wears its colour, not the grey default', async () => {
|
||||||
|
const { view } = await renderMap([
|
||||||
|
{ ...place(1, 'Belvedere', 48.1915, 16.3809), category_id: 4, category: MUSEUM },
|
||||||
|
]);
|
||||||
|
await waitFor(() => expect(view.container.querySelector('.leaflet-marker-icon')).not.toBeNull());
|
||||||
|
const html = view.container.querySelector('.leaflet-marker-icon')?.innerHTML ?? '';
|
||||||
|
expect(html.toLowerCase()).toContain('#7c3aed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { MapViewAuto } from '../Map/MapViewAuto'
|
import { MapViewAuto } from '../Map/MapViewAuto'
|
||||||
import type { CollectionPlace } from '@trek/shared'
|
import type { CollectionPlace } from '@trek/shared'
|
||||||
import { mappablePlaces } from '../../pages/collections/collectionsModel'
|
import { mappablePlaces, withMarkerCategory } from '../../pages/collections/collectionsModel'
|
||||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
|
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
|
||||||
import type { ViewportPadding } from '../../utils/mapViewport'
|
import type { ViewportPadding } from '../../utils/mapViewport'
|
||||||
@@ -70,7 +70,7 @@ function framePoints(places: CollectionPlace[]): [number, number][] {
|
|||||||
* while the map is built rather than flying in.
|
* while the map is built rather than flying in.
|
||||||
*/
|
*/
|
||||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement {
|
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement {
|
||||||
const pts = useMemo(() => mappablePlaces(places), [places])
|
const pts = useMemo(() => mappablePlaces(places).map(withMarkerCategory), [places])
|
||||||
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
|
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
|
||||||
const focusPoints = useMemo(() => framePoints(pts), [pts])
|
const focusPoints = useMemo(() => framePoints(pts), [pts])
|
||||||
// The places as a set: another list, a filter or a new place frames again, while a
|
// The places as a set: another list, a filter or a new place frames again, while a
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { loadUnionPlaces } from '../../store/collectionUnion'
|
|||||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||||
import type { TranslationFn } from '../../types'
|
import type { TranslationFn } from '../../types'
|
||||||
|
import { textMatchesSearch } from '../../utils/placeSearch'
|
||||||
import { fs } from '../shared/DialogShell'
|
import { fs } from '../shared/DialogShell'
|
||||||
import { EditorField, INPUT, LABEL } from '../shared/dialogParts'
|
import { EditorField, INPUT, LABEL } from '../shared/dialogParts'
|
||||||
import { ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from '../Planner/placeDialogParts'
|
import { ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from '../Planner/placeDialogParts'
|
||||||
@@ -143,12 +144,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
|||||||
const [shown, setShown] = useState(PAGE)
|
const [shown, setShown] = useState(PAGE)
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase()
|
|
||||||
const list = places.filter(p => {
|
const list = places.filter(p => {
|
||||||
if (listFilter !== 'all' && p.collection_id !== listFilter) return false
|
if (listFilter !== 'all' && p.collection_id !== listFilter) return false
|
||||||
if (statusFilter !== 'all' && p.status !== statusFilter) return false
|
if (statusFilter !== 'all' && p.status !== statusFilter) return false
|
||||||
if (!q) return true
|
return textMatchesSearch([p.name, p.address], search)
|
||||||
return p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)
|
|
||||||
})
|
})
|
||||||
if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center))
|
if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center))
|
||||||
else list.sort((a, b) => a.name.localeCompare(b.name))
|
else list.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
|
|||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { formatDate } from '../../utils/formatters'
|
import { formatDate } from '../../utils/formatters'
|
||||||
|
import { textMatchesSearch } from '../../utils/placeSearch'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
import type { Category, TranslationFn } from '../../types'
|
import type { Category, TranslationFn } from '../../types'
|
||||||
import type { CollectionImportablePlace, Trip } from '@trek/shared'
|
import type { CollectionImportablePlace, Trip } from '@trek/shared'
|
||||||
@@ -128,10 +129,9 @@ export default function ImportFromTripModal({ isOpen, collectionId, collectionNa
|
|||||||
const savedCount = items.length - newCount
|
const savedCount = items.length - newCount
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase()
|
|
||||||
return items
|
return items
|
||||||
.filter(p => (onlyNew ? !p.already_in_list : true))
|
.filter(p => (onlyNew ? !p.already_in_list : true))
|
||||||
.filter(p => !q || p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q))
|
.filter(p => textMatchesSearch([p.name, p.address], query))
|
||||||
.sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name))
|
.sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name))
|
||||||
}, [items, onlyNew, query])
|
}, [items, onlyNew, query])
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
// FE-W4NAVH-001 to FE-W4NAVH-005
|
// FE-W4NAVH-001 to FE-W4NAVH-008
|
||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { CalendarDays, Globe, LayoutGrid } from 'lucide-react'
|
import { CalendarDays, Globe, LayoutGrid, Sparkles } from 'lucide-react'
|
||||||
import { renderHook } from '@testing-library/react'
|
import { renderHook } from '@testing-library/react'
|
||||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { usePluginStore } from '../../store/pluginStore'
|
import { usePluginStore } from '../../store/pluginStore'
|
||||||
import { useNavItems } from './navItems'
|
import { useAssistantStore } from '../../store/assistantStore'
|
||||||
|
import type { AiStatusResponse } from '@trek/shared'
|
||||||
|
import { useDockNavItems, useNavItems } from './navItems'
|
||||||
|
|
||||||
function wrapper({ children }: { children: React.ReactNode }) {
|
function wrapper({ children }: { children: React.ReactNode }) {
|
||||||
return <TranslationProvider>{children}</TranslationProvider>
|
return <TranslationProvider>{children}</TranslationProvider>
|
||||||
@@ -16,6 +18,7 @@ const render = () => renderHook(() => useNavItems(), { wrapper })
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useAddonStore.setState({ addons: [] })
|
useAddonStore.setState({ addons: [] })
|
||||||
usePluginStore.setState({ plugins: [] })
|
usePluginStore.setState({ plugins: [] })
|
||||||
|
useAssistantStore.setState({ status: null })
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useNavItems', () => {
|
describe('useNavItems', () => {
|
||||||
@@ -77,3 +80,33 @@ describe('useNavItems', () => {
|
|||||||
expect(result.current).toBe(first)
|
expect(result.current).toBe(first)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('useDockNavItems', () => {
|
||||||
|
const renderDock = () => renderHook(() => useDockNavItems(), { wrapper })
|
||||||
|
const assistantAddon = (enabled: boolean) =>
|
||||||
|
useAddonStore.setState({ addons: [{ id: 'ai_assistant', name: 'AI assistant', icon: 'Sparkles', type: 'integration', enabled }] as never })
|
||||||
|
|
||||||
|
it('FE-W4NAVH-006: adds the assistant after the pages while its addon is on and it can run', () => {
|
||||||
|
assistantAddon(true)
|
||||||
|
useAssistantStore.setState({ status: { available: true } as AiStatusResponse })
|
||||||
|
|
||||||
|
const { result } = renderDock()
|
||||||
|
|
||||||
|
expect(result.current.map(i => i.id)).toEqual(['dashboard', 'assistant'])
|
||||||
|
expect(result.current[1]).toMatchObject({ to: '', label: 'Assistant', icon: Sparkles })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-W4NAVH-007: leaves it out while the addon is off', () => {
|
||||||
|
assistantAddon(false)
|
||||||
|
useAssistantStore.setState({ status: { available: true } as AiStatusResponse })
|
||||||
|
|
||||||
|
expect(renderDock().result.current.map(i => i.id)).toEqual(['dashboard'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-W4NAVH-008: leaves it out while it cannot run for this user', () => {
|
||||||
|
assistantAddon(true)
|
||||||
|
useAssistantStore.setState({ status: { available: false } as AiStatusResponse })
|
||||||
|
|
||||||
|
expect(renderDock().result.current.map(i => i.id)).toEqual(['dashboard'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, type LucideIcon } from 'lucide-react'
|
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Sparkles, type LucideIcon } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
|
import { useAssistantStore } from '../../store/assistantStore'
|
||||||
import { usePluginStore } from '../../store/pluginStore'
|
import { usePluginStore } from '../../store/pluginStore'
|
||||||
import { resolvePluginIcon } from '../shared/PluginIcon'
|
import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||||
|
|
||||||
@@ -12,16 +13,21 @@ import { resolvePluginIcon } from '../shared/PluginIcon'
|
|||||||
*
|
*
|
||||||
* Stable ids used for persistence: `'dashboard'` (pinned), an enabled global
|
* Stable ids used for persistence: `'dashboard'` (pinned), an enabled global
|
||||||
* addon's id (vacay|atlas|journey|collections|future), or `'plugin:<id>'` for a
|
* addon's id (vacay|atlas|journey|collections|future), or `'plugin:<id>'` for a
|
||||||
* page plugin. Dashboard is always first and immovable.
|
* page plugin. Dashboard is always first and immovable. The mobile dock adds
|
||||||
|
* `'assistant'` (see useDockNavItems).
|
||||||
*/
|
*/
|
||||||
export interface NavItemDef {
|
export interface NavItemDef {
|
||||||
id: string
|
id: string
|
||||||
|
/** The page it opens; empty for the assistant, which opens the home chat instead. */
|
||||||
to: string
|
to: string
|
||||||
label: string
|
label: string
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
pinned?: boolean
|
pinned?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The home assistant's entry in the mobile dock: a chat it opens, not a page. */
|
||||||
|
export const ASSISTANT_NAV_ID = 'assistant'
|
||||||
|
|
||||||
// Signature glyphs for the four catalog global addons, keyed by id so the known
|
// Signature glyphs for the four catalog global addons, keyed by id so the known
|
||||||
// nav items keep their icon regardless of the (string) icon the addon row ships;
|
// nav items keep their icon regardless of the (string) icon the addon row ships;
|
||||||
// any future/unknown global addon resolves through its manifest icon name.
|
// any future/unknown global addon resolves through its manifest icon name.
|
||||||
@@ -33,7 +39,7 @@ const ADDON_ICONS: Record<string, LucideIcon> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The bar holds Dashboard + at most this many custom items; the rest go to More.
|
/** The bar holds Dashboard + at most this many custom items; the rest go to More.
|
||||||
* Two keeps the dock at 3 destinations (Dashboard + 2) so the More slot still fits. */
|
* Two keeps the dock at 3 entries (Dashboard + 2) so the More slot still fits. */
|
||||||
export const MOBILE_NAV_MAX_BAR = 2
|
export const MOBILE_NAV_MAX_BAR = 2
|
||||||
/**
|
/**
|
||||||
* The built-in dock next to Dashboard for an un-customised account, in priority
|
* The built-in dock next to Dashboard for an un-customised account, in priority
|
||||||
@@ -44,9 +50,11 @@ export const MOBILE_NAV_MAX_BAR = 2
|
|||||||
* to every day, not the map of countries it already visited. Journey ships
|
* to every day, not the map of countries it already visited. Journey ships
|
||||||
* disabled though, so an instance that never turned it on keeps the Vacay/Atlas
|
* disabled though, so an instance that never turned it on keeps the Vacay/Atlas
|
||||||
* dock it has today. Listing all four known global addons rather than just the
|
* dock it has today. Listing all four known global addons rather than just the
|
||||||
* top two also keeps the dock filled when one of them is switched off.
|
* top two also keeps the dock filled when one of them is switched off. The
|
||||||
|
* assistant comes last: it takes a slot only one of the addons left free, rather
|
||||||
|
* than sitting alone behind the More button.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections']
|
export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections', ASSISTANT_NAV_ID]
|
||||||
|
|
||||||
export function buildNavItems(
|
export function buildNavItems(
|
||||||
globalAddons: { id: string; name: string; icon: string }[],
|
globalAddons: { id: string; name: string; icon: string }[],
|
||||||
@@ -90,6 +98,26 @@ export function useNavItems(): NavItemDef[] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entries the mobile dock arranges: the nav items, and the home assistant
|
||||||
|
* while its addon is on and it can run for this user. The settings customizer
|
||||||
|
* sorts the same list, so the assistant can go into the bar or under More.
|
||||||
|
*/
|
||||||
|
export function useDockNavItems(): NavItemDef[] {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const items = useNavItems()
|
||||||
|
const addonOn = useAddonStore((s) => s.isEnabled('ai_assistant'))
|
||||||
|
const available = useAssistantStore((s) => s.status?.available === true)
|
||||||
|
const withAssistant = addonOn && available
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
withAssistant
|
||||||
|
? [...items, { id: ASSISTANT_NAV_ID, to: '', label: t('assistant.launcher.tooltip'), icon: Sparkles }]
|
||||||
|
: items,
|
||||||
|
[items, withAssistant, t],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export interface MobileNavSplit {
|
export interface MobileNavSplit {
|
||||||
/** Items shown directly in the dock, Dashboard first. */
|
/** Items shown directly in the dock, Dashboard first. */
|
||||||
bar: NavItemDef[]
|
bar: NavItemDef[]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useId, useRef, useState } from 'react'
|
import { useEffect, useId, useRef, useState } from 'react'
|
||||||
import { MapPin, Search } from 'lucide-react'
|
import { MapPin, Search } from 'lucide-react'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
|
import { textMatchesSearch } from '../../utils/placeSearch'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||||
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
|
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
|
||||||
@@ -196,7 +197,7 @@ function SourceMark({ label }: { label: string | null }) {
|
|||||||
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
|
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
|
||||||
const showPicks = !value && typed.length < 3 && !!places?.length
|
const showPicks = !value && typed.length < 3 && !!places?.length
|
||||||
const rows: (T | LocationPoint)[] = showPicks
|
const rows: (T | LocationPoint)[] = showPicks
|
||||||
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
|
? (places ?? []).filter(p => textMatchesSearch([p.name], typed))
|
||||||
: results
|
: results
|
||||||
return { showPicks, rows }
|
return { showPicks, rows }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// FE-PLANNER-PLACEFORM-125: a place picked from a collection inside a trip (#3074).
|
||||||
|
import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeFormSetup';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { collectionsApi } from '../../api/collections';
|
||||||
|
import { useAddonStore } from '../../store/addonStore';
|
||||||
|
import PlaceFormModal from './PlaceFormModal';
|
||||||
|
|
||||||
|
vi.mock('../shared/CustomTimePicker', async () => ({
|
||||||
|
default: (await import('../../../tests/helpers/placeForm')).TimePickerStub,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
seedPlaceFormStores();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PlaceFormModal with a place from a collection', () => {
|
||||||
|
it('FE-PLANNER-PLACEFORM-125: a place picked from a collection keeps the category it was saved under (#3074)', async () => {
|
||||||
|
seedStore(useAddonStore, {
|
||||||
|
addons: [{ id: 'collections', name: 'Collections', type: 'collections', icon: '', enabled: true }],
|
||||||
|
loaded: true,
|
||||||
|
});
|
||||||
|
vi.spyOn(collectionsApi, 'list').mockResolvedValue({
|
||||||
|
collections: [{ id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }],
|
||||||
|
incomingInvites: [],
|
||||||
|
});
|
||||||
|
vi.spyOn(collectionsApi, 'get').mockResolvedValue({
|
||||||
|
collection: { id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' },
|
||||||
|
places: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
collection_id: 1,
|
||||||
|
name: 'Zebra Cafe',
|
||||||
|
status: 'idea',
|
||||||
|
lat: 35.66,
|
||||||
|
lng: 139.7,
|
||||||
|
category_id: 7,
|
||||||
|
category: { id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
// A cafe saved as "Must see": the guess from its kind would be the cafe category.
|
||||||
|
const categories = [
|
||||||
|
{ id: 3, name: 'Cafe', color: '#7c3aed', icon: 'Coffee' },
|
||||||
|
{ id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' },
|
||||||
|
];
|
||||||
|
render(<PlaceFormModal {...placeFormProps} categories={categories} onSave={onSave} />);
|
||||||
|
fireEvent.click(await screen.findByText('Zebra Cafe'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Zebra Cafe', category_id: '7' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -410,3 +410,15 @@ export function formPin(form: Pick<PlaceFormData, 'lat' | 'lng'>): { lat: number
|
|||||||
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null
|
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null
|
||||||
return { lat, lng }
|
return { lat, lng }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The category a picked place already carries, as the form's value, when this server
|
||||||
|
* has it. A place saved in a collection keeps its category_id, and categories are the
|
||||||
|
* server's own, so it fits the trip as it stands. Guessing from the place's kind
|
||||||
|
* instead dropped it (#3074).
|
||||||
|
*/
|
||||||
|
export function savedCategoryId(result: Record<string, unknown>, categories: readonly { id: number }[]): string | null {
|
||||||
|
const id = result.category_id
|
||||||
|
if (typeof id !== 'number') return null
|
||||||
|
return categories.some(c => c.id === id) ? String(id) : null
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { weekFromPeriods } from './placeHours'
|
|||||||
import type { PlaceOpeningHours } from '@trek/shared'
|
import type { PlaceOpeningHours } from '@trek/shared'
|
||||||
import {
|
import {
|
||||||
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
|
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
|
||||||
prefillForm, type PlaceFormData, type PlacePrefill,
|
prefillForm, savedCategoryId, type PlaceFormData, type PlacePrefill,
|
||||||
} from './PlaceFormModal.helpers'
|
} from './PlaceFormModal.helpers'
|
||||||
import { useNewCategory, usePlaceForm } from './usePlaceForm'
|
import { useNewCategory, usePlaceForm } from './usePlaceForm'
|
||||||
import { usePlaceSearch } from './usePlaceSearch'
|
import { usePlaceSearch } from './usePlaceSearch'
|
||||||
@@ -135,9 +135,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
// the next pick may replace it and a hand choice is never overwritten (#2282).
|
// the next pick may replace it and a hand choice is never overwritten (#2282).
|
||||||
const categoryGuessedRef = useRef(false)
|
const categoryGuessedRef = useRef(false)
|
||||||
const guessedCategory = (result: Record<string, unknown>): string => {
|
const guessedCategory = (result: Record<string, unknown>): string => {
|
||||||
const id = guessCategoryId(result, categories || [])
|
const saved = savedCategoryId(result, categories || []) // a collection's place keeps its own (#3074)
|
||||||
|
const id = saved ? null : guessCategoryId(result, categories || [])
|
||||||
categoryGuessedRef.current = id != null
|
categoryGuessedRef.current = id != null
|
||||||
return id != null ? String(id) : ''
|
return saved ?? (id != null ? String(id) : '')
|
||||||
}
|
}
|
||||||
// The hours the details column looked up, as a week the contact block can take over (#2472).
|
// The hours the details column looked up, as a week the contact block can take over (#2472).
|
||||||
const [detailsHours, setDetailsHours] = useState<PlaceOpeningHours | null>(null)
|
const [detailsHours, setDetailsHours] = useState<PlaceOpeningHours | null>(null)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useNavItems, splitMobileNav, MOBILE_NAV_MAX_BAR, type NavItemDef } from '../Layout/navItems'
|
import { useDockNavItems, splitMobileNav, MOBILE_NAV_MAX_BAR, type NavItemDef } from '../Layout/navItems'
|
||||||
|
|
||||||
export interface MobileNavValue {
|
export interface MobileNavValue {
|
||||||
bar: string[]
|
bar: string[]
|
||||||
@@ -36,7 +36,7 @@ export function useMobileNavEditor(
|
|||||||
value: MobileNavValue,
|
value: MobileNavValue,
|
||||||
onChange: (next: MobileNavValue) => void,
|
onChange: (next: MobileNavValue) => void,
|
||||||
): MobileNavEditor {
|
): MobileNavEditor {
|
||||||
const items = useNavItems()
|
const items = useDockNavItems()
|
||||||
const split = splitMobileNav(items, value)
|
const split = splitMobileNav(items, value)
|
||||||
const dashboard = split.bar.find((i) => i.id === 'dashboard')
|
const dashboard = split.bar.find((i) => i.id === 'dashboard')
|
||||||
const barItems = split.bar.filter((i) => i.id !== 'dashboard')
|
const barItems = split.bar.filter((i) => i.id !== 'dashboard')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-008
|
// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-011
|
||||||
|
|
||||||
vi.mock('../../api/websocket', () => ({
|
vi.mock('../../api/websocket', () => ({
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
@@ -23,11 +23,19 @@ import { useAddonStore } from '../../store/addonStore';
|
|||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildUser } from '../../../tests/helpers/factories';
|
import { buildUser } from '../../../tests/helpers/factories';
|
||||||
import { useAssistantStore } from '../../store/assistantStore';
|
import { useAssistantStore } from '../../store/assistantStore';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
import type { AiStatusResponse } from '@trek/shared';
|
import type { AiStatusResponse } from '@trek/shared';
|
||||||
import MBottomNav from './MBottomNav';
|
import MBottomNav from './MBottomNav';
|
||||||
|
|
||||||
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
||||||
|
|
||||||
|
const vacay = { id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true };
|
||||||
|
const atlas = { id: 'atlas', name: 'Atlas', type: 'global', icon: 'globe', enabled: true };
|
||||||
|
const assistantAddon = { id: 'ai_assistant', name: 'AI assistant', type: 'integration', icon: 'sparkles', enabled: true };
|
||||||
|
|
||||||
|
const withAssistant = () =>
|
||||||
|
useAssistantStore.setState({ status: { available: true } as AiStatusResponse, window: { open: false, minimized: false } });
|
||||||
|
|
||||||
const withCollections = () =>
|
const withCollections = () =>
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled: true }],
|
addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled: true }],
|
||||||
@@ -40,6 +48,10 @@ beforeEach(() => {
|
|||||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
|
||||||
|
});
|
||||||
|
|
||||||
describe('MBottomNav', () => {
|
describe('MBottomNav', () => {
|
||||||
it('FE-COMP-MBOTTOMNAV-001: the dock "+" creates a trip on an unclaimed route', async () => {
|
it('FE-COMP-MBOTTOMNAV-001: the dock "+" creates a trip on an unclaimed route', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -100,14 +112,46 @@ describe('MBottomNav', () => {
|
|||||||
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-MBOTTOMNAV-008: the More list leads with the assistant while it can run, and opens the home chat', async () => {
|
it('FE-COMP-MBOTTOMNAV-008: the assistant takes a free dock slot, so More does not hold it alone', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
useAssistantStore.setState({ status: { available: true } as AiStatusResponse, window: { open: false, minimized: false } });
|
withAssistant();
|
||||||
|
seedStore(useAddonStore, { addons: [atlas, assistantAddon] });
|
||||||
|
render(<MBottomNav />, { initialEntries: ['/atlas'] });
|
||||||
|
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||||
|
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
|
||||||
|
expect(mockNavigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MBOTTOMNAV-009: with the dock full the assistant waits under More, and opens the home chat from there', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
withAssistant();
|
||||||
|
seedStore(useAddonStore, { addons: [vacay, atlas, assistantAddon] });
|
||||||
render(<MBottomNav />, { initialEntries: ['/atlas'] });
|
render(<MBottomNav />, { initialEntries: ['/atlas'] });
|
||||||
await user.click(screen.getByRole('button', { name: 'More' }));
|
await user.click(screen.getByRole('button', { name: 'More' }));
|
||||||
await user.click(screen.getByRole('button', { name: 'Assistant' }));
|
await user.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||||
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
|
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
|
||||||
expect(mockNavigate).not.toHaveBeenCalled();
|
expect(mockNavigate).not.toHaveBeenCalled();
|
||||||
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MBOTTOMNAV-010: the assistant goes where the settings put it', () => {
|
||||||
|
withAssistant();
|
||||||
|
seedStore(useAddonStore, { addons: [vacay, atlas, assistantAddon] });
|
||||||
|
seedStore(useSettingsStore, {
|
||||||
|
settings: { ...useSettingsStore.getState().settings, appearance: { mobileNav: { bar: ['assistant', 'atlas'], more: ['vacay'] } } },
|
||||||
|
});
|
||||||
|
render(<MBottomNav />, { initialEntries: ['/atlas'] });
|
||||||
|
expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Atlas' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Vacay' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-MBOTTOMNAV-011: no assistant entry while its addon is off', () => {
|
||||||
|
withAssistant();
|
||||||
|
seedStore(useAddonStore, { addons: [atlas, { ...assistantAddon, enabled: false }] });
|
||||||
|
render(<MBottomNav />, { initialEntries: ['/atlas'] });
|
||||||
|
expect(screen.queryByRole('button', { name: 'Assistant' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,17 +2,14 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useNavigate, useLocation, matchPath } from 'react-router'
|
import { useNavigate, useLocation, matchPath } from 'react-router'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { ChevronRight, MoreHorizontal, Plus, Search, Sparkles, Upload } from 'lucide-react'
|
import { ChevronRight, MoreHorizontal, Plus, Search, Upload } from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
|
||||||
import { normalizeAppearance } from '@trek/shared'
|
import { normalizeAppearance } from '@trek/shared'
|
||||||
import { useNavItems, splitMobileNav } from '../../components/Layout/navItems'
|
import { useDockNavItems, splitMobileNav, ASSISTANT_NAV_ID, type NavItemDef } from '../../components/Layout/navItems'
|
||||||
import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction'
|
import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction'
|
||||||
import MFab from './MFab'
|
import MFab from './MFab'
|
||||||
import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome'
|
import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome'
|
||||||
import MDockScrim from './MDockScrim'
|
import MDockScrim from './MDockScrim'
|
||||||
|
|
||||||
interface NavItem { to: string; label: string; icon: LucideIcon }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Floating glass dock of the mobile shell. Same tab/gating/"+" logic as the
|
* Floating glass dock of the mobile shell. Same tab/gating/"+" logic as the
|
||||||
* legacy BottomNav (addons, ?create= contract), redesigned as the demo's icon
|
* legacy BottomNav (addons, ?create= contract), redesigned as the demo's icon
|
||||||
@@ -28,7 +25,8 @@ export default function MBottomNav() {
|
|||||||
// the dock yields the slot instead (see screenFabSlot below, #1811).
|
// the dock yields the slot instead (see screenFabSlot below, #1811).
|
||||||
const create = useCreateAction(true)
|
const create = useCreateAction(true)
|
||||||
const [moreOpen, setMoreOpen] = useState(false)
|
const [moreOpen, setMoreOpen] = useState(false)
|
||||||
// The home chat leads the More list on every page outside a trip, as the assistant tile leads a trip's.
|
// The home chat, an entry of the dock like the pages (useDockNavItems): sorted
|
||||||
|
// into the bar or under More in the settings, there only while it can run.
|
||||||
const assistant = useAssistantHomeLauncher()
|
const assistant = useAssistantHomeLauncher()
|
||||||
|
|
||||||
// Close the popover when the route changes underneath it (browser back etc.).
|
// Close the popover when the route changes underneath it (browser back etc.).
|
||||||
@@ -37,20 +35,21 @@ export default function MBottomNav() {
|
|||||||
// The dock split + order is user-configurable (Settings → Appearance → Mobile);
|
// The dock split + order is user-configurable (Settings → Appearance → Mobile);
|
||||||
// an un-customised account falls back to the built-in Dashboard + vacay/atlas
|
// an un-customised account falls back to the built-in Dashboard + vacay/atlas
|
||||||
// dock with everything else under "More".
|
// dock with everything else under "More".
|
||||||
const navItems = useNavItems()
|
const navItems = useDockNavItems()
|
||||||
const appearance = useSettingsStore(s => s.settings.appearance)
|
const appearance = useSettingsStore(s => s.settings.appearance)
|
||||||
const split = splitMobileNav(navItems, normalizeAppearance(appearance).mobileNav)
|
const split = splitMobileNav(navItems, normalizeAppearance(appearance).mobileNav)
|
||||||
|
|
||||||
const dockItems: NavItem[] = split.bar
|
const dockItems = split.bar
|
||||||
const moreItems: NavItem[] = split.more
|
const moreItems = split.more
|
||||||
|
|
||||||
const isActive = (to: string) => isNavItemActive(location.pathname, to)
|
const isAssistant = (item: NavItemDef) => item.id === ASSISTANT_NAV_ID
|
||||||
const moreActive = moreItems.some(item => isActive(item.to))
|
const isActive = (item: NavItemDef) => !isAssistant(item) && isNavItemActive(location.pathname, item.to)
|
||||||
const moreRows = [
|
const open = (item: NavItemDef): void => {
|
||||||
...(assistant.available ? [{ key: 'assistant', label: t('assistant.launcher.tooltip'), icon: Sparkles, go: assistant.open }] : []),
|
if (isAssistant(item)) assistant.open()
|
||||||
...moreItems.map(({ to, label, icon }) => ({ key: to, label, icon, go: () => navigate(to) })),
|
else void navigate(item.to)
|
||||||
]
|
}
|
||||||
const hasMore = moreRows.length > 0
|
const moreActive = moreItems.some(isActive)
|
||||||
|
const hasMore = moreItems.length > 0
|
||||||
|
|
||||||
// The FAB gives way to a decorative logo slot on screens without an add
|
// The FAB gives way to a decorative logo slot on screens without an add
|
||||||
// action (settings/admin, demo Z. 1372/1429).
|
// action (settings/admin, demo Z. 1372/1429).
|
||||||
@@ -82,16 +81,17 @@ export default function MBottomNav() {
|
|||||||
// A map runs to the screen's edge on purpose; everything else fades out under the dock.
|
// A map runs to the screen's edge on purpose; everything else fades out under the dock.
|
||||||
const overMap = location.pathname.startsWith('/atlas') || matchPath('/journey/:id', location.pathname) !== null
|
const overMap = location.pathname.startsWith('/atlas') || matchPath('/journey/:id', location.pathname) !== null
|
||||||
|
|
||||||
const renderItem = ({ to, label, icon: Icon }: NavItem) => {
|
const renderItem = (item: NavItemDef) => {
|
||||||
const active = isActive(to)
|
const { id, label, icon: Icon } = item
|
||||||
|
const active = isActive(item)
|
||||||
// Fixed sizes per slot (demo): the dashboard grid is 18/2.1, every other
|
// Fixed sizes per slot (demo): the dashboard grid is 18/2.1, every other
|
||||||
// slot 21/1.9 — independent of the active state.
|
// slot 21/1.9 — independent of the active state.
|
||||||
const dash = to === '/dashboard'
|
const dash = id === 'dashboard'
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={to}
|
key={id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(to)}
|
onClick={() => open(item)}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
className={circleCls(active)}
|
className={circleCls(active)}
|
||||||
@@ -111,17 +111,17 @@ export default function MBottomNav() {
|
|||||||
role="presentation"
|
role="presentation"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{moreRows.map(({ key, label, icon: Icon, go }) => (
|
{moreItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setMoreOpen(false); go() }}
|
onClick={() => { setMoreOpen(false); open(item) }}
|
||||||
className="flex items-center gap-[13px] rounded-[18px] bg-[color:var(--m-ic)] px-4 py-[14px] text-start"
|
className="flex items-center gap-[13px] rounded-[18px] bg-[color:var(--m-ic)] px-4 py-[14px] text-start"
|
||||||
>
|
>
|
||||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-[color:var(--m-ic)] text-m-ink">
|
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-[color:var(--m-ic)] text-m-ink">
|
||||||
<Icon size={20} strokeWidth={2} />
|
<item.icon size={20} strokeWidth={2} />
|
||||||
</span>
|
</span>
|
||||||
<span className="block min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{label}</span>
|
<span className="block min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{item.label}</span>
|
||||||
<ChevronRight size={17} strokeWidth={2} className="flex-none text-m-faint" />
|
<ChevronRight size={17} strokeWidth={2} className="flex-none text-m-faint" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { StatusBar, Style } from '@capacitor/status-bar';
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { closeTopOverlay } from '../mobile/overlayStack';
|
import { closeTopOverlay } from '../mobile/overlayStack';
|
||||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||||
import { checkAppUpdate } from './capabilities';
|
import { checkAppUpdate, hasNativeFeature } from './capabilities';
|
||||||
import { installNativeGeolocation } from './geolocation';
|
import { installNativeGeolocation } from './geolocation';
|
||||||
import { prepareKeyboardFields } from './keyboardFields';
|
import { prepareKeyboardFields } from './keyboardFields';
|
||||||
import { scrollToTop, startNativeApp } from './nativeApp';
|
import { scrollToTop, startNativeApp } from './nativeApp';
|
||||||
@@ -28,6 +28,7 @@ vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn() }));
|
|||||||
vi.mock('./capabilities', () => ({
|
vi.mock('./capabilities', () => ({
|
||||||
checkAppUpdate: vi.fn(async () => false),
|
checkAppUpdate: vi.fn(async () => false),
|
||||||
loadNativeCapabilities: vi.fn(async () => ({})),
|
loadNativeCapabilities: vi.fn(async () => ({})),
|
||||||
|
hasNativeFeature: vi.fn(() => false),
|
||||||
}));
|
}));
|
||||||
vi.mock('./geolocation', () => ({ installNativeGeolocation: vi.fn() }));
|
vi.mock('./geolocation', () => ({ installNativeGeolocation: vi.fn() }));
|
||||||
vi.mock('./sharedFiles', () => ({ startSharedFiles: vi.fn(async () => {}) }));
|
vi.mock('./sharedFiles', () => ({ startSharedFiles: vi.fn(async () => {}) }));
|
||||||
@@ -51,6 +52,7 @@ beforeEach(() => {
|
|||||||
vi.mocked(isNativeIos).mockReturnValue(false);
|
vi.mocked(isNativeIos).mockReturnValue(false);
|
||||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
|
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
|
||||||
vi.mocked(keepOfflineShellFresh).mockResolvedValue();
|
vi.mocked(keepOfflineShellFresh).mockResolvedValue();
|
||||||
|
vi.mocked(hasNativeFeature).mockReturnValue(false);
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
document.documentElement.removeAttribute('data-native-app');
|
document.documentElement.removeAttribute('data-native-app');
|
||||||
document.documentElement.removeAttribute('data-native-ios');
|
document.documentElement.removeAttribute('data-native-ios');
|
||||||
@@ -210,6 +212,18 @@ describe('startNativeApp', () => {
|
|||||||
expect(openPushTarget).not.toHaveBeenCalled();
|
expect(openPushTarget).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves widget links to the binary when it hands them over as quick actions', async () => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.mocked(hasNativeFeature).mockImplementation((feature) => feature === 'appLinks');
|
||||||
|
vi.mocked(App.getLaunchUrl).mockResolvedValue({ url: 'com.liketrek.trek://navigate/trips/5?tab=listen' });
|
||||||
|
startNativeApp();
|
||||||
|
await vi.waitFor(() => expect(hasNativeFeature).toHaveBeenCalledWith('appLinks'));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(App.getLaunchUrl).not.toHaveBeenCalled();
|
||||||
|
expect(vi.mocked(App.addListener).mock.calls.map(([name]) => String(name))).not.toContain('appUrlOpen');
|
||||||
|
expect(openPushTarget).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves the start page alone for a launch without a TREK link', async () => {
|
it('leaves the start page alone for a launch without a TREK link', async () => {
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
vi.mocked(App.getLaunchUrl).mockResolvedValue({ url: 'https://example.com/elsewhere' });
|
vi.mocked(App.getLaunchUrl).mockResolvedValue({ url: 'https://example.com/elsewhere' });
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { StatusBar, Style } from '@capacitor/status-bar';
|
|||||||
import { closeTopOverlay } from '../mobile/overlayStack';
|
import { closeTopOverlay } from '../mobile/overlayStack';
|
||||||
import { useSettingsStore } from '../store/settingsStore';
|
import { useSettingsStore } from '../store/settingsStore';
|
||||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||||
import { checkAppUpdate, loadNativeCapabilities } from './capabilities';
|
import { checkAppUpdate, hasNativeFeature, loadNativeCapabilities } from './capabilities';
|
||||||
import { installNativeGeolocation } from './geolocation';
|
import { installNativeGeolocation } from './geolocation';
|
||||||
import { fadeInImages } from './imageFade';
|
import { fadeInImages } from './imageFade';
|
||||||
import { keepFieldInView } from './keepFieldInView';
|
import { keepFieldInView } from './keepFieldInView';
|
||||||
@@ -54,13 +54,18 @@ export function startNativeApp(): void {
|
|||||||
// A quick action on the app icon opens its page, also the one the app was launched with.
|
// A quick action on the app icon opens its page, also the one the app was launched with.
|
||||||
void loadNativeCapabilities().then(() => startQuickActions());
|
void loadNativeCapabilities().then(() => startQuickActions());
|
||||||
|
|
||||||
// A widget or Live Activity tap opens com.liketrek.trek://navigate/<path>.
|
// A widget or Live Activity tap opens com.liketrek.trek://navigate/<path>. A
|
||||||
void App.addListener('appUrlOpen', ({ url }) => {
|
// binary with the feature appLinks hands it over as a quick action instead,
|
||||||
const prefix = `${APP_URL_SCHEME}://navigate`;
|
// held until taken, so it is only followed here for older binaries.
|
||||||
if (url.startsWith(prefix)) openPushTarget(url.slice(prefix.length) || '/');
|
void loadNativeCapabilities().then(() => {
|
||||||
|
if (hasNativeFeature('appLinks')) return;
|
||||||
|
void App.addListener('appUrlOpen', ({ url }) => {
|
||||||
|
const prefix = `${APP_URL_SCHEME}://navigate`;
|
||||||
|
if (url.startsWith(prefix)) openPushTarget(url.slice(prefix.length) || '/');
|
||||||
|
});
|
||||||
|
// A tap that launched the app comes as the launch URL, not as appUrlOpen.
|
||||||
|
void openLaunchLink();
|
||||||
});
|
});
|
||||||
// A tap that launched the app comes as the launch URL, not as appUrlOpen.
|
|
||||||
void openLaunchLink();
|
|
||||||
|
|
||||||
// A tap on the status bar scrolls the list under it back to the top, as in
|
// A tap on the status bar scrolls the list under it back to the top, as in
|
||||||
// every iOS app (the status-bar plugin fires statusTap on iOS).
|
// every iOS app (the status-bar plugin fires statusTap on iOS).
|
||||||
|
|||||||
@@ -211,9 +211,12 @@ export interface TrekShellPlugin {
|
|||||||
): Promise<{ remove: () => Promise<void> }>;
|
): Promise<{ remove: () => Promise<void> }>;
|
||||||
/** Feature quickActions (iOS): the actions a long press on the app icon shows, in the UI's language. */
|
/** Feature quickActions (iOS): the actions a long press on the app icon shows, in the UI's language. */
|
||||||
setQuickActions(options: { items: NativeQuickAction[] }): Promise<void>;
|
setQuickActions(options: { items: NativeQuickAction[] }): Promise<void>;
|
||||||
/** Feature quickActions: the action the app was launched with, before the UI ran. */
|
/**
|
||||||
|
* Feature quickActions: the action the app was launched with, before the UI ran.
|
||||||
|
* With the feature appLinks also a widget or Live Activity tap, as its path.
|
||||||
|
*/
|
||||||
takeQuickAction(): Promise<{ path: string | null }>;
|
takeQuickAction(): Promise<{ path: string | null }>;
|
||||||
/** Feature quickActions: an action chosen while the app was running. */
|
/** Feature quickActions: an action chosen (with appLinks: a widget tapped) while the app was running. */
|
||||||
addListener(
|
addListener(
|
||||||
event: 'quickAction',
|
event: 'quickAction',
|
||||||
listener: (payload: { path: string }) => void
|
listener: (payload: { path: string }) => void
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ const HOLE = undefined as unknown as CollectionPlace;
|
|||||||
describe('collectionsModel', () => {
|
describe('collectionsModel', () => {
|
||||||
// ── filterPlaces ────────────────────────────────────────────────────────────
|
// ── filterPlaces ────────────────────────────────────────────────────────────
|
||||||
describe('filterPlaces', () => {
|
describe('filterPlaces', () => {
|
||||||
|
it('FE-COLLECTIONS-MODEL-045: the search finds Óbidos and São Brás typed without accents (#3075)', () => {
|
||||||
|
const places = [
|
||||||
|
cp({ id: 1, name: 'Óbidos', status: 'idea' }),
|
||||||
|
cp({ id: 2, name: 'São Brás de Alportel', status: 'idea' }),
|
||||||
|
cp({ id: 3, name: 'Lagos', status: 'idea', address: 'Rua de Évora' }),
|
||||||
|
];
|
||||||
|
expect(filterPlaces(places, 'all', 'obidos').map(p => p.id)).toEqual([1]);
|
||||||
|
expect(filterPlaces(places, 'all', 'Sao Bras').map(p => p.id)).toEqual([2]);
|
||||||
|
expect(filterPlaces(places, 'all', 'evora').map(p => p.id)).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-COLLECTIONS-MODEL-001: status filter keeps only the matching status', () => {
|
it('FE-COLLECTIONS-MODEL-001: status filter keeps only the matching status', () => {
|
||||||
const places = [
|
const places = [
|
||||||
cp({ id: 1, name: 'A', status: 'idea' }),
|
cp({ id: 1, name: 'A', status: 'idea' }),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { CollectionPlace, CollectionLabel } from '@trek/shared'
|
import type { CollectionPlace, CollectionLabel } from '@trek/shared'
|
||||||
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
|
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
|
||||||
|
import { textMatchesSearch } from '../../utils/placeSearch'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure data shaping + presentation metadata for the Collections page. No React
|
* Pure data shaping + presentation metadata for the Collections page. No React
|
||||||
@@ -36,7 +37,6 @@ export function filterPlaces(
|
|||||||
labelFilter: number[] = [],
|
labelFilter: number[] = [],
|
||||||
ratingFilter: number | 'all' = 'all',
|
ratingFilter: number | 'all' = 'all',
|
||||||
): CollectionPlace[] {
|
): CollectionPlace[] {
|
||||||
const q = search.trim().toLowerCase()
|
|
||||||
return places.filter(p => {
|
return places.filter(p => {
|
||||||
if (!p) return false
|
if (!p) return false
|
||||||
if (statusFilter !== 'all' && p.status !== statusFilter) return false
|
if (statusFilter !== 'all' && p.status !== statusFilter) return false
|
||||||
@@ -44,12 +44,7 @@ export function filterPlaces(
|
|||||||
// Minimum-average-stars filter (#1435): unrated places drop out once a floor is set.
|
// Minimum-average-stars filter (#1435): unrated places drop out once a floor is set.
|
||||||
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
|
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
|
||||||
if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false
|
if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false
|
||||||
if (!q) return true
|
return textMatchesSearch([p.name, p.address, p.notes], search)
|
||||||
return (
|
|
||||||
p.name.toLowerCase().includes(q) ||
|
|
||||||
(p.address ?? '').toLowerCase().includes(q) ||
|
|
||||||
(p.notes ?? '').toLowerCase().includes(q)
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +88,25 @@ export function mappablePlaces(places: CollectionPlace[]): CollectionPlace[] {
|
|||||||
return places.filter(p => p && typeof p.lat === 'number' && typeof p.lng === 'number')
|
return places.filter(p => p && typeof p.lat === 'number' && typeof p.lng === 'number')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A saved place with its category where the trip map reads it. The server nests a
|
||||||
|
* collection place's category (`category.color`), the markers and the hover card
|
||||||
|
* read the flat fields a trip place carries, so every pin here was the grey default
|
||||||
|
* (#3073).
|
||||||
|
*/
|
||||||
|
export function withMarkerCategory(place: CollectionPlace): CollectionPlace & {
|
||||||
|
category_name: string | null
|
||||||
|
category_color: string | null
|
||||||
|
category_icon: string | null
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
...place,
|
||||||
|
category_name: place.category?.name ?? null,
|
||||||
|
category_color: place.category?.color ?? null,
|
||||||
|
category_icon: place.category?.icon ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalise a user-typed link: prepend https:// when there's no scheme so the
|
* Normalise a user-typed link: prepend https:// when there's no scheme so the
|
||||||
* href is absolute (a bare "booking.com" would otherwise resolve relative to the
|
* href is absolute (a bare "booking.com" would otherwise resolve relative to the
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// FE-UTIL-PLACESEARCH-001 to FE-UTIL-PLACESEARCH-004
|
// FE-UTIL-PLACESEARCH-001 to FE-UTIL-PLACESEARCH-004
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { placeMatchesSearch } from './placeSearch'
|
import { foldForSearch, placeMatchesSearch, textMatchesSearch } from './placeSearch'
|
||||||
|
|
||||||
const place = {
|
const place = {
|
||||||
name: 'Café Central',
|
name: 'Café Central',
|
||||||
@@ -32,3 +32,25 @@ describe('placeMatchesSearch', () => {
|
|||||||
expect(placeMatchesSearch(place, 'pizza')).toBe(false)
|
expect(placeMatchesSearch(place, 'pizza')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('search without accents (#3075)', () => {
|
||||||
|
it('FE-UTIL-PLACESEARCH-005: a query without accents finds the accented name, and the other way round', () => {
|
||||||
|
for (const [query, name] of [['Obidos', 'Óbidos'], ['Evora', 'Évora'], ['Sao Bras', 'São Brás'], ['Loule', 'Loulé']]) {
|
||||||
|
expect(placeMatchesSearch({ name, address: null, description: null, notes: null }, query)).toBe(true)
|
||||||
|
expect(placeMatchesSearch({ name: query, address: null, description: null, notes: null }, name)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-UTIL-PLACESEARCH-006: letters with the mark built in fold to their plain spelling', () => {
|
||||||
|
expect(foldForSearch('Straße')).toBe('strasse')
|
||||||
|
expect(foldForSearch('Łódź')).toBe('lodz')
|
||||||
|
expect(foldForSearch('Ærø')).toBe('aero')
|
||||||
|
expect(textMatchesSearch(['Kraków'], 'krakow')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-UTIL-PLACESEARCH-007: marks that change a word outside Latin script stay, so が is not か', () => {
|
||||||
|
expect(foldForSearch('が')).toBe('が')
|
||||||
|
expect(textMatchesSearch(['かき'], 'がき')).toBe(false)
|
||||||
|
expect(foldForSearch('한국')).toBe('한국')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -10,9 +10,33 @@ type Searchable = Pick<Place, 'name' | 'address' | 'description' | 'notes'>
|
|||||||
* can never disagree about what a search finds.
|
* can never disagree about what a search finds.
|
||||||
*/
|
*/
|
||||||
export function placeMatchesSearch(place: Searchable, query: string): boolean {
|
export function placeMatchesSearch(place: Searchable, query: string): boolean {
|
||||||
const q = query.trim().toLowerCase()
|
return textMatchesSearch([place.name, place.address, place.description, place.notes], query)
|
||||||
if (!q) return true
|
}
|
||||||
return [place.name, place.address, place.description, place.notes].some(
|
|
||||||
field => !!field && field.toLowerCase().includes(q),
|
/** The combining accents of Latin script (U+0300 to U+036F), the marks NFD splits off a letter. */
|
||||||
)
|
const LATIN_MARKS = new RegExp(`[${String.fromCharCode(0x300)}-${String.fromCharCode(0x36f)}]`, 'g')
|
||||||
|
|
||||||
|
/** Letters that carry their mark in the letter itself, so taking the marks off leaves them as they are. */
|
||||||
|
const FOLDED_LETTERS: Record<string, string> = { ß: 'ss', ø: 'o', æ: 'ae', œ: 'oe', ł: 'l', đ: 'd', ð: 'd', þ: 'th', ı: 'i' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text as a search compares it: lower case and without accents, so "Obidos" finds
|
||||||
|
* "Óbidos", "Sao Bras" finds "São Brás" and the other way round (#3075). Only
|
||||||
|
* Latin marks go: a kana's voicing mark or a Hangul syllable stays whole, since
|
||||||
|
* there it changes the word.
|
||||||
|
*/
|
||||||
|
export function foldForSearch(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(LATIN_MARKS, '')
|
||||||
|
.replace(/[ßøæœłđðþı]/g, ch => FOLDED_LETTERS[ch] ?? ch)
|
||||||
|
.normalize('NFC')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether any of the fields holds the query, accents and case aside. An empty query matches everything. */
|
||||||
|
export function textMatchesSearch(fields: readonly (string | null | undefined)[], query: string): boolean {
|
||||||
|
const q = foldForSearch(query.trim())
|
||||||
|
if (!q) return true
|
||||||
|
return fields.some(field => !!field && foldForSearch(field).includes(q))
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@trek/root",
|
"name": "@trek/root",
|
||||||
"version": "5.1.3",
|
"version": "5.1.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@trek/root",
|
"name": "@trek/root",
|
||||||
"version": "5.1.3",
|
"version": "5.1.4",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"client",
|
"client",
|
||||||
"server",
|
"server",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"name": "@trek/client",
|
"name": "@trek/client",
|
||||||
"version": "5.1.3",
|
"version": "5.1.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@assistant-ui/react": "0.15.32",
|
"@assistant-ui/react": "0.15.32",
|
||||||
"@capacitor/action-sheet": "^8.1.1",
|
"@capacitor/action-sheet": "^8.1.1",
|
||||||
@@ -25676,7 +25676,7 @@
|
|||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"name": "@trek/server",
|
"name": "@trek/server",
|
||||||
"version": "5.1.3",
|
"version": "5.1.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-lite/client": "^0.24.1",
|
"@aws-lite/client": "^0.24.1",
|
||||||
"@aws-lite/s3": "^0.2.7",
|
"@aws-lite/s3": "^0.2.7",
|
||||||
@@ -26076,7 +26076,7 @@
|
|||||||
},
|
},
|
||||||
"shared": {
|
"shared": {
|
||||||
"name": "@trek/shared",
|
"name": "@trek/shared",
|
||||||
"version": "5.1.3",
|
"version": "5.1.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"isomorphic-dompurify": "^3.23.0",
|
"isomorphic-dompurify": "^3.23.0",
|
||||||
"zod": "^4.6.7"
|
"zod": "^4.6.7"
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@trek/root",
|
"name": "@trek/root",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "5.1.3",
|
"version": "5.1.4",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@trek/server",
|
"name": "@trek/server",
|
||||||
"version": "5.1.3",
|
"version": "5.1.4",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* How TREK 5.1.3 registers with the TREK services.
|
* How TREK 5.1.4 registers with the TREK services.
|
||||||
*
|
*
|
||||||
* The issuer writes this file for each release and the release pipeline
|
* The issuer writes this file for each release and the release pipeline
|
||||||
* commits it with the version bump. It is not edited by hand, and its layout
|
* commits it with the version bump. It is not edited by hand, and its layout
|
||||||
@@ -8,86 +8,101 @@
|
|||||||
*/
|
*/
|
||||||
import type { DialectSelfCheck, RegistrationDialect } from './registration-dialect.types';
|
import type { DialectSelfCheck, RegistrationDialect } from './registration-dialect.types';
|
||||||
|
|
||||||
import { createHash, pbkdf2Sync } from 'node:crypto';
|
import { createHash, createHmac, hkdfSync } from 'node:crypto';
|
||||||
|
|
||||||
const DIALECT = 'qDlOapZrw47x';
|
const DIALECT = '8546tJZpbjQb';
|
||||||
const POND_HILL = Buffer.from('53ee01dfb2711f0b4b7cb1b5d7cd644801289dabda654bf6f53ba0f4727d5d26', 'hex');
|
const ARROW_BARLEY = Buffer.from('95fd08ded3ef3e32af04cf42290d5a6d1ad24d88827f9c745ba46b810962cd8e', 'hex');
|
||||||
|
|
||||||
function saddle(alg: string, data: Buffer): Buffer {
|
function mill(a: Buffer, b: Buffer): Buffer {
|
||||||
return createHash(alg).update(data).digest();
|
const parts: Buffer[] = [];
|
||||||
|
for (let i = 0; i < Math.max(a.length, b.length); i++) parts.push(a.subarray(i, i + 1), b.subarray(i, i + 1));
|
||||||
|
return Buffer.concat(parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
function frost(value: number): Buffer {
|
function moss(input: Buffer, size: number): Buffer {
|
||||||
const out = Buffer.alloc(8);
|
|
||||||
out.writeBigUInt64BE(BigInt(value));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hollow(input: Buffer, size: number): Buffer {
|
|
||||||
const out = Buffer.alloc(size);
|
const out = Buffer.alloc(size);
|
||||||
input.forEach((byte, i) => out.writeUInt8(out.readUInt8(i % size) ^ byte, i % size));
|
input.forEach((byte, i) => out.writeUInt8(out.readUInt8(i % size) ^ byte, i % size));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function zephyr(value: number): Buffer {
|
function meadow(alg: string, key: Buffer, data: Buffer): Buffer {
|
||||||
const out: number[] = [];
|
return createHmac(alg, key).update(data).digest();
|
||||||
let rest = value;
|
|
||||||
do {
|
|
||||||
const low = rest % 128;
|
|
||||||
rest = Math.floor(rest / 128);
|
|
||||||
out.push(low | (Math.sign(rest) << 7));
|
|
||||||
} while (rest > 0);
|
|
||||||
return Buffer.from(out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function barrel(input: Buffer, width: number): Buffer {
|
function rapids(part: Buffer): Buffer {
|
||||||
const out = Buffer.from(input);
|
const head = Buffer.alloc(4);
|
||||||
for (let i = 0; i + width <= out.length; i += width) out.subarray(i, i + width).reverse();
|
head.writeUInt32LE(part.length);
|
||||||
|
return Buffer.concat([head, part]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hollow(value: number): Buffer {
|
||||||
|
const out = Buffer.alloc(8);
|
||||||
|
out.writeBigUInt64BE(BigInt(value));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apple(part: Buffer): Buffer {
|
||||||
|
const head = Buffer.alloc(2);
|
||||||
|
head.writeUInt16LE(part.length);
|
||||||
|
return Buffer.concat([head, part]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blossom(value: number): Buffer {
|
||||||
|
const out = Buffer.alloc(4);
|
||||||
|
out.writeUInt32BE(value >>> 0);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const registrationDialect: RegistrationDialect = {
|
export const registrationDialect: RegistrationDialect = {
|
||||||
id: DIALECT,
|
id: DIALECT,
|
||||||
releasedFor: '5.1.3',
|
releasedFor: '5.1.4',
|
||||||
createdAt: '2026-11-10',
|
createdAt: '2026-11-12',
|
||||||
challengePath: `/v1/register/${DIALECT}/challenge`,
|
challengePath: `/v1/register/${DIALECT}/challenge`,
|
||||||
registerPath: `/v1/register/${DIALECT}`,
|
registerPath: `/v1/register/${DIALECT}`,
|
||||||
begin({ challenge, version, random }) {
|
begin({ challenge, version, random, nowSeconds }) {
|
||||||
const garnet = Buffer.from(challenge.c, 'utf8');
|
const chapel = Buffer.from(challenge.c, 'utf8');
|
||||||
const glade = Buffer.from(version, 'utf8');
|
const fjord = Buffer.from(version, 'utf8');
|
||||||
const banner = createHash('sha256').update(garnet).digest();
|
const cliff = createHash('sha256').update(chapel).digest();
|
||||||
const kettle = Buffer.from([0x5a]);
|
const horizon = Buffer.concat([cliff].map(apple));
|
||||||
const river = Buffer.concat([random, kettle, POND_HILL, kettle, banner, kettle, glade]);
|
const pasture = Buffer.concat([cliff.subarray(0x10, 20), Buffer.from([0x8c]), horizon]);
|
||||||
const robin = createHash('shake128', { outputLength: 31 }).update(river).digest();
|
const sorrel = Buffer.from(pasture.toString('hex'));
|
||||||
const larch = barrel(robin, 8);
|
const bell = meadow('sha256', sorrel, chapel);
|
||||||
const ocean = createHash('sha3-512').update(larch).digest();
|
|
||||||
return {
|
return {
|
||||||
work(nonce) {
|
work(nonce) {
|
||||||
const wren = Buffer.concat([banner.subarray(0xa, 16), garnet, frost(nonce), glade, random]);
|
const cinder = Buffer.concat([
|
||||||
const crystal = pbkdf2Sync(wren, banner, 10, 0x32, 'sha256');
|
rapids(random), rapids(fjord), rapids(hollow(nonce)), rapids(blossom(nowSeconds)), rapids(chapel),
|
||||||
const seal = hollow(crystal, 20);
|
]);
|
||||||
const mist = Buffer.from([...seal.subarray(0x1b % seal.length), ...seal.subarray(0, 0x1b % seal.length)]);
|
const tide = Buffer.from([
|
||||||
const coast = barrel(mist, 4);
|
0, 0xa, 9, 0xb, 1, 15, 3, 16, 13, 6, 8, 4, 0xc, 14, 0x11, 2, 5, 7,
|
||||||
const dawn = saddle('sha512', coast);
|
].map((from) => cinder.readUInt8(from)));
|
||||||
const yarrow = barrel(dawn, 8);
|
const cottage = Buffer.concat([tide, cinder.subarray(18)]);
|
||||||
return { password: yarrow, salt: ocean };
|
const sapling = Buffer.from(hkdfSync(
|
||||||
|
'sha256', cottage, chapel.subarray(8, 0xc), Buffer.from('9b25443bf88e4cbf', 'hex'), 43,
|
||||||
|
));
|
||||||
|
const rowan = Buffer.from(Array.from(sapling, (byte) => (byte * 0xc7 + 0xe2) % 256));
|
||||||
|
const linden = mill(rowan, ARROW_BARLEY);
|
||||||
|
const swan = Buffer.from(linden.subarray(2, 41));
|
||||||
|
return { password: swan, salt: bell };
|
||||||
},
|
},
|
||||||
request(nonce) {
|
request(nonce) {
|
||||||
const pilgrim = Buffer.concat([
|
const falcon = Buffer.concat([
|
||||||
garnet, Buffer.from([7]), glade, Buffer.from([7]), banner, Buffer.from([7]), zephyr(nonce), Buffer.from([7]),
|
Buffer.from(nonce.toString(16), 'utf8'), Buffer.from([0x4b]), cliff.subarray(24, 28), Buffer.from([0x4b]),
|
||||||
random,
|
chapel, Buffer.from([0x4b]), ARROW_BARLEY, Buffer.from([0x4b]), random.subarray(3, 9), Buffer.from([0x4b]),
|
||||||
|
fjord, Buffer.from([0x4b]), blossom(nowSeconds),
|
||||||
]);
|
]);
|
||||||
const pepper = Buffer.from(Array.from(pilgrim, (byte) => (byte * 203 + 60) % 256));
|
const pelican = moss(falcon, 0x14);
|
||||||
const mesa = createHash('blake2b512').update(pepper).digest();
|
const kelp = meadow('sha512', pelican, fjord);
|
||||||
return {
|
return {
|
||||||
body: {
|
body: {
|
||||||
brookCactus: mesa.toString('base64'),
|
willow_jetty: {
|
||||||
tundra: random.toString('hex'),
|
echo: nonce,
|
||||||
ivoryCape: version,
|
banner: version,
|
||||||
mill: nonce,
|
lava_breeze: random.toString('hex'),
|
||||||
jadeBadge: challenge.c,
|
flint_lava: challenge.c,
|
||||||
|
sage_frost: String(nowSeconds),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
headers: {},
|
headers: { 'X-TREK-Fossil-Vessel': kelp.toString('base64url') },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -96,13 +111,13 @@ export const registrationDialect: RegistrationDialect = {
|
|||||||
|
|
||||||
/** Fixed inputs and what this module must make of them; TREK's tests and release job run it. */
|
/** Fixed inputs and what this module must make of them; TREK's tests and release job run it. */
|
||||||
export const SELF_CHECK: DialectSelfCheck = {
|
export const SELF_CHECK: DialectSelfCheck = {
|
||||||
challenge: { c: 'v1.selfcheck.qDlOapZrw47x', alg: 'scrypt', N: 16, r: 1, p: 1, bits: 0, expires_at: 1 },
|
challenge: { c: 'v1.selfcheck.8546tJZpbjQb', alg: 'scrypt', N: 16, r: 1, p: 1, bits: 0, expires_at: 1 },
|
||||||
version: '5.1.3',
|
version: '5.1.4',
|
||||||
random: '000102030405060708090a0b0c0d0e0f',
|
random: '000102030405060708090a0b0c0d0e0f',
|
||||||
nowSeconds: 1800000000,
|
nowSeconds: 1800000000,
|
||||||
nonce: 41,
|
nonce: 41,
|
||||||
inputSha256: 'd0459dd032ca32758d4960be87026844ca0e5f2ccc0cd087838c7cc9f1f054d6',
|
inputSha256: '35bde8e3256e05441ac19c8b6b22f9d4e4adb59b5564f101078aa597e70ece99',
|
||||||
saltSha256: 'e6beeb65b892a5f7e68977ceb33e0193bcf284904db6b55884823b5b2a948787',
|
saltSha256: '8730a0377647a045a6fceade4a2c8c7dd3854e56cc6a87cd20ef94e87107493b',
|
||||||
scrypt: 'cb5f7842c312c4442f9d750305c31d2ea2df36556dbf80e17b56df859d2bc1e9',
|
scrypt: '884c7cf0406e20ccbba3372999d07d80b3cfc13eb9a464510b68942868840189',
|
||||||
requestSha256: '53bc1ae4f560b17631848e100dd2469040bbadc8ae61c881263a489bd14842ed',
|
requestSha256: '49848ec61f040261e65627b276eb996a83ed3d096b5a0216e8ba00a75a90cf70',
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@trek/shared",
|
"name": "@trek/shared",
|
||||||
"version": "5.1.3",
|
"version": "5.1.4",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user