feat(maps): show where an Amap hit came from, and keep its id

An Amap row now carries the same source mark the TREK index, OpenStreetMap
and Google rows get, named per locale because 高德地图 is the name it goes
by where it matters. The "search is on OpenStreetMap" notices and the
key hint in the details column are gone with the keys dev had already
removed, and the client-side provider mirror with them.

amap_poi_id travels with a picked result into the form, the details
column and the shadow log, so a saved place keeps the id it was found
by. A pasted Amap link goes to the resolver like a Google one.
This commit is contained in:
Maurice
2026-09-12 13:56:48 +02:00
parent 43a6c30325
commit 84c277993c
35 changed files with 148 additions and 459 deletions
+2 -4
View File
@@ -284,7 +284,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -301,15 +301,13 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
if (config?.version) setAppVersion(config.version)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
if (config?.places_provider) setPlacesProvider(config.places_provider)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
@@ -474,49 +474,12 @@ describe('PlaceDetailsColumn — hours and rating', () => {
* same thing in the shape every other empty state in TREK uses, and whose key is
* missing is not this panel's business.
*/
describe('PlaceDetailsColumn — no key behind place search', () => {
const empty = () => placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
describe('PlaceDetailsColumn — nothing found', () => {
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
renderColumn()
it('FE-PDC-027: suggests a key when nothing was found and none is set', async () => {
empty()
renderColumn({ keySuggestion: 'google' })
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
expect(screen.getByText('places.details.noKeyHint')).toBeInTheDocument()
})
it('FE-PDC-027b: suggests the Amap key when that is the one the admin chose', async () => {
// An install on Amap is not "missing a Google key" — the hint has to name
// the key that would actually move search off OpenStreetMap.
empty()
renderColumn({ keySuggestion: 'amap' })
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
expect(screen.getByText('places.details.noKeyHintAmap')).toBeInTheDocument()
})
it('FE-PDC-027c: names both keys when the choice is automatic and neither is set', async () => {
empty()
renderColumn({ keySuggestion: 'any' })
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
expect(screen.getByText('places.details.noKeyHintAny')).toBeInTheDocument()
})
it('FE-PDC-027d: stays quiet when OpenStreetMap was chosen on purpose', async () => {
// The null suggestion: an explicit admin decision, not a gap to fill.
empty()
renderColumn({ keySuggestion: null })
await screen.findByText('places.details.nothing')
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
})
it('FE-PDC-028: stays quiet when a key is already configured', async () => {
empty()
renderColumn({ keySuggestion: null })
await screen.findByText('places.details.nothing')
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
expect(screen.queryByText('places.details.noKeyHint')).not.toBeInTheDocument()
})
@@ -527,7 +490,7 @@ describe('PlaceDetailsColumn — no key behind place search', () => {
facts: [],
description: { text: 'Ein Museum.', source: 'wikipedia', sourceUrl: null, license: 'CC BY-SA 4.0' },
})
renderColumn({ keySuggestion: 'google' })
renderColumn()
await screen.findByText('Ein Museum.')
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
@@ -15,7 +15,6 @@ import {
Loader2,
ScrollText,
ShoppingBag,
Sparkles,
Sprout,
Star,
Sun,
@@ -27,19 +26,8 @@ import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './place
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
import { safeHttpUrl } from '../../utils/safeUrl'
import EmptyState from '../shared/EmptyState'
import type { KeySuggestion } from '../../utils/placesProvider'
import type { TranslationFn } from '../../types'
/**
* The "add a key" hint line, per which key would move search off OpenStreetMap.
* The Google variant reuses the long-standing key every locale already carries.
*/
const NO_KEY_HINT_KEY: Record<Exclude<KeySuggestion, never>, string> = {
google: 'places.details.noKeyHint',
amap: 'places.details.noKeyHintAmap',
any: 'places.details.noKeyHintAny',
}
/** The place the column is describing. Null while nothing is selected. */
export interface PlaceDetailsSelection {
placeId?: string
@@ -63,12 +51,7 @@ interface PlaceDetailsColumnProps {
timeFormat?: string
/** For grouping the rating count's digits. */
locale?: string
/**
* Which API key the "more detail available" hint should suggest, or null when
* there is nothing to suggest — a configured provider answers, or the admin
* chose OpenStreetMap on purpose and the hint would be a nag.
*/
keySuggestion?: KeySuggestion | null
/** False on an instance with no Google key, which is most of them. */
t: TranslationFn
}
@@ -134,7 +117,6 @@ export default function PlaceDetailsColumn({
language,
timeFormat = '24h',
locale = 'en-US',
keySuggestion = null,
t,
}: PlaceDetailsColumnProps): React.ReactElement {
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
@@ -254,20 +236,14 @@ export default function PlaceDetailsColumn({
plainly; whose key is missing is not this panel's business, and on a
place the free sources describe fine it read as an advert. */}
{isEmpty && (
<div className="space-y-2">
<EmptyState
scene="search"
mood="sad"
title={t('places.details.nothing')}
size={92}
fill
surface="var(--bg-secondary)"
/>
{/* Only when both are true. With a key behind the search there is
nothing to suggest, and on a place the free sources DID describe
the suggestion would be an advert. */}
{keySuggestion && <NoKeyHint t={t} suggestion={keySuggestion} />}
</div>
<EmptyState
scene="search"
mood="sad"
title={t('places.details.nothing')}
size={92}
fill
surface="var(--bg-secondary)"
/>
)}
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
@@ -496,19 +472,6 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem
* It also names who to ask: the key is an instance-wide setting, so on most
* installs the person reading this cannot act on it themselves.
*/
function NoKeyHint({ t, suggestion }: { t: TranslationFn; suggestion: KeySuggestion }): React.ReactElement {
return (
<div className="rounded-xl border border-accent/25 bg-accent-subtle p-3">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text">
<Sparkles className="h-3 w-3" />
</span>
<p className="text-caption font-semibold text-content">{t('places.details.noKeyTitle')}</p>
</div>
<p className="mt-2 text-caption leading-relaxed text-content-secondary">{t(NO_KEY_HINT_KEY[suggestion])}</p>
</div>
)
}
/**
* The star rating, as stars.
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { isGoogleMapsUrl } from './PlaceFormModal.helpers'
import { isAmapUrl, isGoogleMapsUrl, isMapUrl } from './PlaceFormModal.helpers'
describe('isGoogleMapsUrl', () => {
it('accepts the short share hosts', () => {
@@ -39,3 +39,25 @@ describe('isGoogleMapsUrl', () => {
expect(isGoogleMapsUrl(' https://maps.app.goo.gl/abc123 ')).toBe(true)
})
})
describe('isAmapUrl', () => {
it('accepts the share, web and short-link hosts', () => {
expect(isAmapUrl('https://uri.amap.com/marker?position=116.397,39.908&name=天安门')).toBe(true)
expect(isAmapUrl('https://www.amap.com/place/B000A7BD6C')).toBe(true)
expect(isAmapUrl('https://surl.amap.com/abc')).toBe(true)
})
it('is an exact host list, so a lookalike stays a search query', () => {
expect(isAmapUrl('https://amap.com.evil.example/place/x')).toBe(false)
expect(isAmapUrl('https://restapi.amap.com/v3/place/text')).toBe(false)
expect(isAmapUrl('天安门')).toBe(false)
})
})
describe('isMapUrl', () => {
it('sends either provider\'s link to the resolver and nothing else', () => {
expect(isMapUrl('https://maps.app.goo.gl/abc123')).toBe(true)
expect(isMapUrl('https://uri.amap.com/marker?position=116.397,39.908')).toBe(true)
expect(isMapUrl('https://example.com/maps')).toBe(false)
})
})
@@ -17,6 +17,7 @@ export interface PlaceFormData {
google_place_id?: string
google_ftid?: string
osm_id?: string
amap_poi_id?: string
// Hero image picked from the detail column. Optional and absent from
// DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets
// it, and places.service already writes image_url through on create/update.
@@ -55,6 +56,26 @@ export function isGoogleMapsUrl(input: string): boolean {
}
}
/**
* Amap's own hosts, the same set the server resolves (amap.provider.ts). An
* exact list rather than a shape: Amap has no ccTLD family, so a pattern would
* only widen what the search box hands to the resolver.
*/
const AMAP_HOSTS = new Set(['amap.com', 'www.amap.com', 'uri.amap.com', 'wb.amap.com', 'surl.amap.com', 'gaode.com', 'www.gaode.com'])
export function isAmapUrl(input: string): boolean {
try {
return AMAP_HOSTS.has(new URL(input.trim()).hostname.toLowerCase())
} catch {
return false
}
}
/** A pasted link the server can turn into a place, from either provider. */
export function isMapUrl(input: string): boolean {
return isGoogleMapsUrl(input) || isAmapUrl(input)
}
export const DEFAULT_FORM: PlaceFormData = {
name: '',
description: '',
@@ -81,6 +102,7 @@ export const RESULT_FIELDS = [
'google_place_id',
'google_ftid',
'osm_id',
'amap_poi_id',
'website',
'phone',
] as const
@@ -462,33 +462,34 @@ describe('PlaceFormModal', () => {
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
});
// The notice says what is actually true per provider choice, not "add a
// Google key" on an install whose admin picked Amap. See utils/placesProvider.
describe('FE-PLANNER-PLACEFORM-022b: the OSM notice follows the provider choice', () => {
it('suggests the Amap key when the admin chose Amap and none is set', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false, hasAmapKey: false, placesProvider: 'amap' });
render(<PlaceFormModal {...defaultProps} />);
expect(screen.getByText(/高德|Amap/i)).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-022d: an Amap list marks its rows, and a pick keeps the Amap id', async () => {
// Amap answers from the slot Google otherwise holds, so its rows get the
// same mark the other sources get. The id travels with the place: it is
// what lets the saved place keep opening against Amap after the admin
// switches providers.
const user = userEvent.setup();
const onSave = vi.fn();
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [
{ name: '天安门', address: '北京市东城区', lat: '39.9087', lng: '116.3975', amap_poi_id: 'amap:B000A7BD6C', source: 'amap' },
],
source: 'amap',
}),
),
);
it('stays quiet when the chosen provider has its key', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false, hasAmapKey: true, placesProvider: 'amap' });
render(<PlaceFormModal {...defaultProps} />);
expect(screen.queryByText(/OpenStreetMap/i)).not.toBeInTheDocument();
});
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Tiananmen');
await user.keyboard('{Enter}');
it('stays quiet when the admin explicitly chose OpenStreetMap', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false, hasAmapKey: false, placesProvider: 'openstreetmap' });
render(<PlaceFormModal {...defaultProps} />);
// A deliberate choice is not a gap to fill — no upsell line at all.
expect(screen.queryByText(/OpenStreetMap/i)).not.toBeInTheDocument();
});
expect(await screen.findByText('Amap')).toBeInTheDocument();
await user.click(screen.getByText('天安门'));
await user.click(screen.getByRole('button', { name: /^Add$/i }));
it('stays quiet under auto when a Google key is configured', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true, hasAmapKey: true, placesProvider: 'auto' });
render(<PlaceFormModal {...defaultProps} />);
expect(screen.queryByText(/OpenStreetMap/i)).not.toBeInTheDocument();
});
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].amap_poi_id).toBe('amap:B000A7BD6C');
});
// ── Category ─────────────────────────────────────────────────────────────────
@@ -7,18 +7,6 @@ import { mapsApi } from '../../api/client'
import { recordPlacePick } from '../../api/placeShadow'
import { useAuthStore } from '../../store/authStore'
import { useAddonStore } from '../../store/addonStore'
import { effectivePlacesProvider, osmKeySuggestion, type KeySuggestion } from '../../utils/placesProvider'
/**
* The "search is on OpenStreetMap" line, per which key would actually move it
* off. The Google variant reuses the long-standing key, which every locale
* already carries; the other two are new with Amap.
*/
const OSM_ACTIVE_KEY: Record<KeySuggestion, string> = {
google: 'places.osmActive',
amap: 'places.osmActiveAmap',
any: 'places.osmActiveAny',
}
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -28,7 +16,7 @@ import { useToast } from '../shared/Toast'
import { Search, Paperclip, X, AlertTriangle, Loader2, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
import { DEFAULT_FORM, isMapUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../utils/apiError'
import { sourceLabelFor } from '../../utils/placeSource'
import { useLocationBias } from '../../hooks/useLocationBias'
@@ -170,11 +158,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const placesSessionRef = useRef(new PlacesSession())
const toast = useToast()
const { t, language, locale } = useTranslation()
const { hasMapsKey, hasAmapKey, placesProvider, placesEnrichEnabled } = useAuthStore()
// What the search notices say depends on what will actually answer: an
// install whose admin picked Amap is not "missing a Google key".
const searchProvider = effectivePlacesProvider({ placesProvider, hasMapsKey, hasAmapKey })
const keySuggestion = osmKeySuggestion(placesProvider)
const { hasMapsKey, placesEnrichEnabled } = useAuthStore()
const can = useCanDo()
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
const tripObj = useTripStore((s) => s.trip)
@@ -251,7 +235,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// because only handleSelectMapsResult ever set it.
if (place && place.lat != null && place.lng != null) {
setDetailsSelection({
placeId: place.google_place_id || place.osm_id || undefined,
placeId: place.google_place_id || place.amap_poi_id || place.osm_id || undefined,
lat: Number(place.lat),
lng: Number(place.lng),
name: place.name || '',
@@ -290,7 +274,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// Autocomplete fetch — aborts any in-flight request before starting a new one
const fetchSuggestions = useCallback(async (query: string) => {
if (query.length < 2 || isGoogleMapsUrl(query)) {
if (query.length < 2 || isMapUrl(query)) {
setAcSuggestions([])
setAcHighlight(-1)
return
@@ -317,7 +301,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
const trimmed = mapsSearch.trim()
if (trimmed.length < 2 || isGoogleMapsUrl(trimmed)) {
if (trimmed.length < 2 || isMapUrl(trimmed)) {
setAcSuggestions([])
setAcHighlight(-1)
placesSessionRef.current.end()
@@ -341,9 +325,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
if (!mapsSearch.trim()) return
setIsSearchingMaps(true)
try {
// Detect Google Maps URLs and resolve them directly
// A pasted Google Maps or Amap link resolves server-side into a place
const trimmed = mapsSearch.trim()
if (isGoogleMapsUrl(trimmed)) {
if (isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) {
setForm(prev => ({
@@ -385,7 +369,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const lng = Number(result.lng)
if (Number.isFinite(lat) && Number.isFinite(lng)) {
setDetailsSelection({
placeId: result.google_place_id || result.osm_id || undefined,
placeId: result.google_place_id || result.amap_poi_id || result.osm_id || undefined,
lat,
lng,
name: result.name || '',
@@ -411,7 +395,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
pickedName: result.name || '',
pickedLat: lat,
pickedLng: lng,
pickedPlaceId: result.google_place_id || result.osm_id || null,
pickedPlaceId: result.google_place_id || result.amap_poi_id || result.osm_id || null,
})
}
}
@@ -663,8 +647,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
language,
locale,
timeFormat,
searchProvider,
keySuggestion,
hasMapsKey,
placesEnrichEnabled,
can,
tripObj,
@@ -736,8 +719,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
toast,
t,
language,
searchProvider,
keySuggestion,
hasMapsKey,
placesEnrichEnabled,
can,
tripObj,
@@ -811,21 +793,12 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
language={language}
timeFormat={S.timeFormat}
locale={S.locale}
// Null when search genuinely has no key behind it — the column then
// explains which one would help, or stays quiet when OpenStreetMap
// was chosen on purpose.
keySuggestion={searchProvider === 'openstreetmap' ? keySuggestion : null}
t={t}
/>
)}
<form onSubmit={handleSubmit} className={twoColumn || showDetails ? 'flex-1 min-w-0 space-y-3' : 'space-y-3'} onPaste={handlePaste}>
{/* Place Search */}
<div className="bg-surface-secondary rounded-xl p-3 border border-edge">
{searchProvider === 'openstreetmap' && keySuggestion && (
<p className="mb-2 text-xs text-content-faint">
{t(OSM_ACTIVE_KEY[keySuggestion])}
</p>
)}
<div className="relative">
<div className="flex gap-2">
<input
@@ -873,7 +846,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
<div className="text-xs text-content-muted truncate">{s.secondaryText}</div>
)}
</div>
<SourceBadge label={sourceLabelFor(s, acSource)} />
<SourceBadge label={sourceLabelFor(s, acSource, t)} />
</div>
</button>
))}
@@ -896,7 +869,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
<div className="font-medium text-sm truncate">{result.name}</div>
<div className="text-xs text-content-muted truncate">{result.address}</div>
</div>
<SourceBadge label={sourceLabelFor(result, searchSource)} />
<SourceBadge label={sourceLabelFor(result, searchSource, t)} />
</div>
</button>
))}
@@ -4,7 +4,7 @@ import { mapsApi } from '../../../../api/client'
import { sourceLabelFor } from '../../../../utils/placeSource'
import { recordPlacePick } from '../../../../api/placeShadow'
import { PlacesSession } from '../../../../utils/placesSession'
import { isGoogleMapsUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
import { isMapUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../../../utils/apiError'
import { pointFromBox } from '../../../../hooks/useLocationBias'
import { FIELD_CLS } from './PlSheetChrome'
@@ -19,6 +19,7 @@ export interface PlSearchPick {
google_place_id?: string
google_ftid?: string
osm_id?: string
amap_poi_id?: string
website?: string
phone?: string
}
@@ -67,6 +68,7 @@ function placeToPick(place: MapsPlace): PlSearchPick {
google_place_id: s(place.google_place_id),
google_ftid: s(place.google_ftid),
osm_id: s(place.osm_id),
amap_poi_id: s(place.amap_poi_id),
website: s(place.website),
phone: s(place.phone),
}
@@ -125,7 +127,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
const trimmed = query.trim()
if (trimmed.length < 2 || isGoogleMapsUrl(trimmed) || COORD_RE.test(trimmed)) {
if (trimmed.length < 2 || isMapUrl(trimmed) || COORD_RE.test(trimmed)) {
setSuggestions([])
return
}
@@ -157,7 +159,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
pickedName: String(place.name ?? ''),
pickedLat: lat,
pickedLng: lng,
pickedPlaceId: (place.google_place_id as string) || (place.osm_id as string) || null,
pickedPlaceId: (place.google_place_id as string) || (place.amap_poi_id as string) || (place.osm_id as string) || null,
})
}
}
@@ -181,7 +183,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
setResolving(true)
try {
if (isGoogleMapsUrl(trimmed)) {
if (isMapUrl(trimmed)) {
const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) {
onPick({
@@ -308,7 +310,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
<div className="truncate font-geist text-[0.65625rem] text-m-muted">{s.secondaryText}</div>
)}
</div>
<SourceMark label={sourceLabelFor(s, acSource)} />
<SourceMark label={sourceLabelFor(s, acSource, t)} />
</div>
</button>
))}
-17
View File
@@ -46,15 +46,7 @@ interface AuthState {
devMode: boolean
isPrerelease: boolean
appVersion: string
/** Google Maps API key. Please note that secondary map api provider is available,
* check `hasAmapKey`. */
hasMapsKey: boolean
/** The same question for the Amap key, and the admin's provider choice. Not
* boolean "hasSearchKey": a notice that says "add a Google key" on an install
* whose admin picked Amap is wrong twice, and the two facts together are what
* the place-search notices need to say something true. */
hasAmapKey: boolean
placesProvider: string
serverTimezone: string
/** Server policy: all users must enable MFA */
appRequireMfa: boolean
@@ -83,8 +75,6 @@ interface AuthState {
setIsPrerelease: (val: boolean) => void
setAppVersion: (val: string) => void
setHasMapsKey: (val: boolean) => void
setHasAmapKey: (val: boolean) => void
setPlacesProvider: (val: string) => void
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
setTripRemindersEnabled: (val: boolean) => void
@@ -135,8 +125,6 @@ export const useAuthStore = create<AuthState>()(
isPrerelease: false,
appVersion: '',
hasMapsKey: false,
hasAmapKey: false,
placesProvider: 'auto',
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
@@ -355,9 +343,6 @@ export const useAuthStore = create<AuthState>()(
if ('maps_api_key' in keys) {
set({ hasMapsKey: !!keys.maps_api_key })
}
if ('amap_api_key' in keys) {
set({ hasAmapKey: !!keys.amap_api_key })
}
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error saving API keys'))
}
@@ -401,8 +386,6 @@ export const useAuthStore = create<AuthState>()(
setIsPrerelease: (val: boolean) => set({ isPrerelease: val }),
setAppVersion: (val: string) => set({ appVersion: val }),
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setHasAmapKey: (val: boolean) => set({ hasAmapKey: val }),
setPlacesProvider: (val: string) => set({ placesProvider: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
+18 -7
View File
@@ -11,10 +11,11 @@
* nothing on the other is worse than either alone.
*/
import type { TranslationFn } from '../types'
/**
* The three names are proper nouns, so they are not translated, and that is
* also why there is no fourth: a source without a name people already know
* would need a string in 23 languages to say less than nothing.
* Proper nouns, so they are not translated. A source without a name people
* already know would need a string in 23 languages to say less than nothing.
*/
export const SOURCE_LABELS: Record<string, string> = {
'trek-places': 'TREK',
@@ -23,6 +24,14 @@ export const SOURCE_LABELS: Record<string, string> = {
google: 'Google',
}
/**
* The one name that changes with the reader: 高德地图 to the people it exists
* for, Amap to everyone else. Hence a locale string rather than a fourth noun.
*/
const SOURCE_KEYS: Record<string, string> = {
amap: 'places.source.amap',
}
/**
* The label for one row.
*
@@ -31,9 +40,11 @@ export const SOURCE_LABELS: Record<string, string> = {
* answered the call: Google never marks its places, and a merged list marks only
* the index side, so an unmarked row in one is OpenStreetMap by elimination.
*/
export function sourceLabelFor(place: unknown, listSource: string): string | null {
export function sourceLabelFor(place: unknown, listSource: string, t: TranslationFn): string | null {
const own = (place as { source?: unknown } | null)?.source
if (typeof own === 'string' && own) return SOURCE_LABELS[own] ?? null
if (listSource.includes('openstreetmap')) return SOURCE_LABELS.openstreetmap
return SOURCE_LABELS[listSource] ?? null
const source = typeof own === 'string' && own
? own
: listSource.includes('openstreetmap') ? 'openstreetmap' : listSource
if (SOURCE_KEYS[source]) return t(SOURCE_KEYS[source])
return SOURCE_LABELS[source] ?? null
}
-63
View File
@@ -1,63 +0,0 @@
import { describe, expect, it } from 'vitest'
import { effectivePlacesProvider, osmKeySuggestion } from './placesProvider'
/**
* These mirror MapsService.resolvePlacesProvider() on the server, case for case.
* The point of the mirror is honest notices, so every case where the notices
* would lie — "add a Google key" on an install whose admin picked Amap — is
* pinned here.
*/
describe('effectivePlacesProvider', () => {
it('auto keeps Google when a Google key is configured, Amap key or not', () => {
// The incumbent wins: an existing install must not move providers because
// a new one became available.
expect(effectivePlacesProvider({ placesProvider: 'auto', hasMapsKey: true, hasAmapKey: true })).toBe('google')
expect(effectivePlacesProvider({ placesProvider: 'auto', hasMapsKey: true, hasAmapKey: false })).toBe('google')
})
it('auto falls to Amap only when there is no Google key', () => {
expect(effectivePlacesProvider({ placesProvider: 'auto', hasMapsKey: false, hasAmapKey: true })).toBe('amap')
})
it('auto with neither key means OpenStreetMap', () => {
expect(effectivePlacesProvider({ placesProvider: 'auto', hasMapsKey: false, hasAmapKey: false })).toBe('openstreetmap')
})
it('an explicit amap choice wins even with a Google key configured', () => {
expect(effectivePlacesProvider({ placesProvider: 'amap', hasMapsKey: true, hasAmapKey: true })).toBe('amap')
})
it('an explicit google choice never silently uses Amap instead', () => {
// Misconfigured means "answer with OSM", matching the server — not "bill
// somebody else's provider".
expect(effectivePlacesProvider({ placesProvider: 'google', hasMapsKey: false, hasAmapKey: true })).toBe('openstreetmap')
})
it('openstreetmap ignores both keys', () => {
expect(effectivePlacesProvider({ placesProvider: 'openstreetmap', hasMapsKey: true, hasAmapKey: true })).toBe('openstreetmap')
})
it('a nonsense stored value degrades to auto rather than throwing', () => {
expect(effectivePlacesProvider({ placesProvider: 'not-a-provider', hasMapsKey: false, hasAmapKey: true })).toBe('amap')
expect(effectivePlacesProvider({ placesProvider: undefined, hasMapsKey: true, hasAmapKey: false })).toBe('google')
})
})
describe('osmKeySuggestion', () => {
it('a chosen provider names its own key', () => {
expect(osmKeySuggestion('google')).toBe('google')
expect(osmKeySuggestion('amap')).toBe('amap')
})
it('auto names both, because landing on OSM means neither key is set', () => {
expect(osmKeySuggestion('auto')).toBe('any')
})
it('an explicit OpenStreetMap choice gets no upsell — it is a decision, not a gap', () => {
expect(osmKeySuggestion('openstreetmap')).toBeNull()
// An unrecognised value behaves like auto: the reader degrades it to auto,
// so the notice must not claim the admin chose OSM when they chose nothing.
expect(osmKeySuggestion('garbage')).toBe('any')
expect(osmKeySuggestion(undefined)).toBe('any')
})
})
-69
View File
@@ -1,69 +0,0 @@
/**
* Which provider place search will actually use, as far as the client can tell.
*
* This mirrors the server's MapsService.resolvePlacesProvider(): the instance's
* places_provider choice, the keys the server reported for this caller, and the
* same precedence. It exists so a notice can say the truth — "search is running
* on OpenStreetMap", not "you lack a Google key" on an install whose admin
* deliberately picked Amap.
*
* A mirror and not the source of truth on purpose: the server resolves keys per
* request against the database, and only it knows for sure. If the two ever
* disagree, the client shows a slightly wrong hint under a notice and nothing
* else — search itself keeps asking the server.
*/
export type PlacesProviderChoice = 'auto' | 'google' | 'amap' | 'openstreetmap'
export type EffectiveSearchProvider = 'google' | 'amap' | 'openstreetmap'
export interface PlacesProviderState {
/** The admin's places_provider choice; anything unrecognised means 'auto'. */
placesProvider?: string | null
/** Whether a Google key resolves for this caller (operator env, instance, own row). */
hasMapsKey: boolean
hasAmapKey: boolean
}
export function effectivePlacesProvider(state: PlacesProviderState): EffectiveSearchProvider {
const choice: PlacesProviderChoice =
state.placesProvider === 'google' || state.placesProvider === 'amap' || state.placesProvider === 'openstreetmap'
? state.placesProvider
: 'auto'
if (choice === 'openstreetmap') return 'openstreetmap'
if (choice === 'google') return state.hasMapsKey ? 'google' : 'openstreetmap'
if (choice === 'amap') return state.hasAmapKey ? 'amap' : 'openstreetmap'
// auto prefers Google, the incumbent: an existing install must not move to a
// different provider, with a different bill, just because Amap appeared.
if (state.hasMapsKey) return 'google'
if (state.hasAmapKey) return 'amap'
return 'openstreetmap'
}
/**
* Which key the "still on OpenStreetMap" upsell should suggest.
*
* A chosen provider names the key that belongs to it — telling an admin who
* picked Amap to add a Google key is advice they already declined. `auto` with
* no key at all is the only case where both are worth naming.
*
* Null when the admin explicitly chose OpenStreetMap: that is a decision, not a
* gap to fill, and the notices stay quiet rather than nag.
*/
export type KeySuggestion = 'google' | 'amap' | 'any'
export function osmKeySuggestion(placesProvider?: string | null): KeySuggestion | null {
switch (placesProvider) {
case 'google':
return 'google'
case 'amap':
return 'amap'
case 'openstreetmap':
return null
// auto resolving to OSM means neither key is configured (a Google key would
// have won), so both are honest suggestions.
default:
return 'any'
}
}