Skip to content

Commit 86ca313

Browse files
committed
feat(map): POI overlay + right-click destination + a11y fixes
- T-09: /api/pois route handler wrapping Geoapify adapter, usePois SWR hook, PoiLayer with turf-based polygon clipping, CategoryToggles in header. - UX: right-click drops destination (was: any click); plain click dismisses. - A11y: enlarged origin pin hit area with cursor-grab/grabbing. - DestinationCard reused by right-click and POI marker click (one affordance). - PRD FR-11/FR-12 updated to reflect right-click semantics.
1 parent 3213f81 commit 86ca313

9 files changed

Lines changed: 498 additions & 26 deletions

File tree

apps/web/src/app/api/pois/route.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* GET /api/pois?bbox=w,s,e,n&cats=park,cafe&limit=100
3+
*
4+
* Server-side proxy for the Geoapify Places API. Hides the API key from
5+
* the browser, validates inputs with Zod, and adds short-lived caching.
6+
*
7+
* The route returns POIs in the bbox; client filters to inside the
8+
* isochrone polygon (see usePois + PoiLayer).
9+
*
10+
* Error contract:
11+
* 200 OK — { pois, metadata }
12+
* 400 invalid_request — params failed Zod validation
13+
* 401 missing_api_key — GEOAPIFY_API_KEY missing
14+
* 502 upstream_failed — Geoapify returned a non-2xx
15+
* 500 internal_error — anything else
16+
*/
17+
import { NextResponse } from 'next/server';
18+
import { z } from 'zod';
19+
import {
20+
GeoapifyPoiProvider,
21+
PoiCategorySchema,
22+
PoiQuerySchema,
23+
} from '@ilsochrone/providers';
24+
25+
export const runtime = 'nodejs';
26+
export const revalidate = 120;
27+
28+
const QuerySchema = z.object({
29+
bbox: z
30+
.string()
31+
.transform((s) => s.split(',').map(Number))
32+
.refine(
33+
(a): a is [number, number, number, number] =>
34+
a.length === 4 && a.every((n) => Number.isFinite(n)),
35+
{ message: 'bbox must be 4 comma-separated numbers: w,s,e,n' },
36+
),
37+
cats: z
38+
.string()
39+
.transform((s) => s.split(',').filter(Boolean))
40+
.pipe(z.array(PoiCategorySchema).min(1)),
41+
limit: z.coerce.number().int().min(1).max(500).default(100),
42+
});
43+
44+
let provider: GeoapifyPoiProvider | null = null;
45+
function getProvider(): GeoapifyPoiProvider {
46+
if (provider) return provider;
47+
const apiKey = process.env.GEOAPIFY_API_KEY;
48+
if (!apiKey) throw new MissingApiKeyError();
49+
provider = new GeoapifyPoiProvider({ apiKey });
50+
return provider;
51+
}
52+
53+
class MissingApiKeyError extends Error {
54+
constructor() {
55+
super('GEOAPIFY_API_KEY is not set on the server.');
56+
this.name = 'MissingApiKeyError';
57+
}
58+
}
59+
60+
const isDev = process.env.NODE_ENV !== 'production';
61+
62+
export async function GET(request: Request) {
63+
const url = new URL(request.url);
64+
const parsed = QuerySchema.safeParse({
65+
bbox: url.searchParams.get('bbox'),
66+
cats: url.searchParams.get('cats'),
67+
limit: url.searchParams.get('limit') ?? undefined,
68+
});
69+
if (!parsed.success) {
70+
return NextResponse.json(
71+
{ error: 'invalid_request', issues: parsed.error.issues },
72+
{ status: 400 },
73+
);
74+
}
75+
76+
const query = PoiQuerySchema.parse({
77+
bbox: parsed.data.bbox,
78+
categories: parsed.data.cats,
79+
limit: parsed.data.limit,
80+
});
81+
82+
try {
83+
const result = await getProvider().searchInBbox(query);
84+
return NextResponse.json(result, {
85+
headers: {
86+
'Cache-Control': 's-maxage=120, stale-while-revalidate=600',
87+
},
88+
});
89+
} catch (err) {
90+
console.error('[/api/pois] failed', summarize(err));
91+
if (err instanceof MissingApiKeyError) {
92+
return NextResponse.json(withDebug({ error: 'missing_api_key' }, err), {
93+
status: 401,
94+
});
95+
}
96+
return NextResponse.json(withDebug({ error: 'upstream_failed' }, err), {
97+
status: 502,
98+
});
99+
}
100+
}
101+
102+
function withDebug<T extends Record<string, unknown>>(payload: T, err: unknown): T & {
103+
debug?: { name: string; message: string };
104+
} {
105+
if (!isDev || !(err instanceof Error)) return payload;
106+
return { ...payload, debug: { name: err.name, message: err.message } };
107+
}
108+
109+
function summarize(err: unknown): Record<string, unknown> {
110+
if (err instanceof Error) return { name: err.name, message: err.message };
111+
return { value: err };
112+
}

apps/web/src/app/page.tsx

Lines changed: 104 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,20 @@
1212
*/
1313
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1414
import dynamic from 'next/dynamic';
15-
import { StadiaTileProvider, type TimeBandMin } from '@ilsochrone/providers';
15+
import {
16+
StadiaTileProvider,
17+
type Poi,
18+
type PoiCategory,
19+
type TimeBandMin,
20+
} from '@ilsochrone/providers';
1621
import { TimeSelector } from '@/components/controls/TimeSelector';
1722
import { ModeSelector } from '@/components/controls/ModeSelector';
23+
import { CategoryToggles } from '@/components/controls/CategoryToggles';
1824
import { DestinationCard } from '@/components/destination/DestinationCard';
1925
import { tryGeolocate } from '@/lib/geolocation';
2026
import { useIsochrone } from '@/lib/hooks/useIsochrone';
27+
import { usePois } from '@/lib/hooks/usePois';
28+
import { bboxOf } from '@/lib/polygon';
2129
import { PUBLIC_CONFIG } from '@/lib/config';
2230
import {
2331
DEFAULT_CATEGORIES,
@@ -26,11 +34,15 @@ import {
2634
type AppUrlState,
2735
} from '@/lib/url-state';
2836

29-
// Client-only import: MapLibre touches `window` at module load.
37+
// Client-only imports: MapLibre touches `window` at module load.
3038
const IlsochroneMap = dynamic(
3139
() => import('@/components/map/IlsochroneMap.client').then((m) => m.IlsochroneMap),
3240
{ ssr: false, loading: () => <div className="h-full w-full bg-muted" aria-hidden /> },
3341
);
42+
const PoiLayer = dynamic(
43+
() => import('@/components/map/PoiLayer').then((m) => m.PoiLayer),
44+
{ ssr: false },
45+
);
3446

3547
export default function HomePage() {
3648
const tileProvider = useMemo(
@@ -59,6 +71,7 @@ export default function HomePage() {
5971

6072
const [state, setState] = useState<AppUrlState>(initialState);
6173
const [destination, setDestination] = useState<{ lng: number; lat: number } | null>(null);
74+
const [selectedPoi, setSelectedPoi] = useState<Poi | null>(null);
6275

6376
// On mount: if no `lng`/`lat` in URL, ask for geolocation and use it.
6477
const askedGeoRef = useRef(false);
@@ -97,11 +110,30 @@ export default function HomePage() {
97110
setState((s) => ({ ...s, mode }));
98111
}, []);
99112

100-
const onMapClick = useCallback((lngLat: { lng: number; lat: number }) => {
113+
const onPickDestination = useCallback((lngLat: { lng: number; lat: number }) => {
101114
setDestination(lngLat);
115+
setSelectedPoi(null);
116+
}, []);
117+
118+
const onMapBackgroundClick = useCallback(() => {
119+
// Plain click dismisses any open destination card. No pin is dropped.
120+
setDestination(null);
121+
setSelectedPoi(null);
122+
}, []);
123+
124+
const onDismissDestination = useCallback(() => {
125+
setDestination(null);
126+
setSelectedPoi(null);
102127
}, []);
103128

104-
const onDismissDestination = useCallback(() => setDestination(null), []);
129+
const onCategoriesChange = useCallback((categories: PoiCategory[]) => {
130+
setState((s) => ({ ...s, categories }));
131+
}, []);
132+
133+
const onPoiSelect = useCallback((poi: Poi) => {
134+
setSelectedPoi(poi);
135+
setDestination(null);
136+
}, []);
105137

106138
const { data, error, isLoading } = useIsochrone({
107139
lng: state.origin.lng,
@@ -110,6 +142,42 @@ export default function HomePage() {
110142
mode: state.mode,
111143
});
112144

145+
// POIs are scoped to the polygon's bbox; we filter to inside the polygon
146+
// client-side in PoiLayer. Skip fetch entirely if no polygon or no categories.
147+
const polygonBbox = useMemo(
148+
() => (data?.polygon ? bboxOf(data.polygon) : null),
149+
[data?.polygon],
150+
);
151+
const { data: poiData } = usePois({
152+
bbox: polygonBbox,
153+
categories: state.categories,
154+
});
155+
const pois = poiData?.pois ?? [];
156+
157+
// Determine which destination (if any) gets the popup. POI selection wins
158+
// over a right-click drop point.
159+
const activeDestination = useMemo(() => {
160+
if (selectedPoi) {
161+
return {
162+
lng: selectedPoi.lngLat[0],
163+
lat: selectedPoi.lngLat[1],
164+
title: selectedPoi.name,
165+
subtitle: selectedPoi.category,
166+
sourceUrl: selectedPoi.sourceUrl,
167+
};
168+
}
169+
if (destination) {
170+
return {
171+
lng: destination.lng,
172+
lat: destination.lat,
173+
title: 'Drop point',
174+
subtitle: `${destination.lat.toFixed(5)}, ${destination.lng.toFixed(5)}`,
175+
sourceUrl: undefined,
176+
};
177+
}
178+
return null;
179+
}, [selectedPoi, destination]);
180+
113181
return (
114182
<main className="relative h-screen w-screen overflow-hidden">
115183
<div className="absolute inset-0">
@@ -130,17 +198,22 @@ export default function HomePage() {
130198
origin={state.origin}
131199
onOriginDragEnd={onOriginDragEnd}
132200
polygon={data?.polygon}
133-
onMapClick={onMapClick}
201+
onPickDestination={onPickDestination}
202+
onMapBackgroundClick={onMapBackgroundClick}
134203
destination={
135-
destination
204+
activeDestination
136205
? {
137-
lng: destination.lng,
138-
lat: destination.lat,
206+
lng: activeDestination.lng,
207+
lat: activeDestination.lat,
139208
popup: (
140209
<DestinationCard
141-
title="Drop point"
142-
subtitle={`${destination.lat.toFixed(5)}, ${destination.lng.toFixed(5)}`}
143-
destination={destination}
210+
title={activeDestination.title}
211+
subtitle={activeDestination.subtitle}
212+
destination={{
213+
lng: activeDestination.lng,
214+
lat: activeDestination.lat,
215+
name: selectedPoi?.name,
216+
}}
144217
origin={state.origin}
145218
mode={state.mode}
146219
onClose={onDismissDestination}
@@ -149,17 +222,33 @@ export default function HomePage() {
149222
}
150223
: undefined
151224
}
225+
poiMarkers={
226+
<PoiLayer
227+
pois={pois}
228+
polygon={data?.polygon}
229+
onSelect={onPoiSelect}
230+
selectedId={selectedPoi?.id}
231+
/>
232+
}
152233
/>
153234
</div>
154235

155236
<header className="pointer-events-none absolute left-0 right-0 top-0 flex flex-wrap items-start justify-between gap-3 p-4">
156237
<div className="pointer-events-auto rounded-lg bg-background/95 px-4 py-2 shadow-md ring-1 ring-border backdrop-blur">
157238
<h1 className="text-base font-semibold">Ilsochrone</h1>
158-
<p className="text-xs text-muted-foreground">Where can you get in {state.minutes} min?</p>
239+
<p className="text-xs text-muted-foreground">
240+
Where can you get in {state.minutes} min?
241+
</p>
242+
<p className="mt-0.5 text-[10px] text-muted-foreground/70">
243+
Drag the pin to move origin · right-click the map to drop a destination
244+
</p>
159245
</div>
160-
<div className="pointer-events-auto flex flex-wrap gap-2">
161-
<ModeSelector value={state.mode} onChange={onModeChange} />
162-
<TimeSelector value={state.minutes} onChange={onMinutesChange} />
246+
<div className="pointer-events-auto flex flex-col items-end gap-2">
247+
<div className="flex flex-wrap gap-2">
248+
<ModeSelector value={state.mode} onChange={onModeChange} />
249+
<TimeSelector value={state.minutes} onChange={onMinutesChange} />
250+
</div>
251+
<CategoryToggles value={state.categories} onChange={onCategoriesChange} />
163252
</div>
164253
</header>
165254

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use client';
2+
3+
import type { PoiCategory } from '@ilsochrone/providers';
4+
import { cn } from '@/lib/utils';
5+
import {
6+
Trees,
7+
Coffee,
8+
Utensils,
9+
Landmark,
10+
Eye,
11+
Waves,
12+
type LucideIcon,
13+
} from 'lucide-react';
14+
15+
interface Props {
16+
value: PoiCategory[];
17+
onChange: (next: PoiCategory[]) => void;
18+
className?: string;
19+
}
20+
21+
const CATEGORIES: { id: PoiCategory; label: string; Icon: LucideIcon }[] = [
22+
{ id: 'park', label: 'Parks', Icon: Trees },
23+
{ id: 'cafe', label: 'Cafés', Icon: Coffee },
24+
{ id: 'restaurant', label: 'Restaurants', Icon: Utensils },
25+
{ id: 'museum', label: 'Museums', Icon: Landmark },
26+
{ id: 'viewpoint', label: 'Viewpoints', Icon: Eye },
27+
{ id: 'beach', label: 'Beaches', Icon: Waves },
28+
];
29+
30+
export function CategoryToggles({ value, onChange, className }: Props) {
31+
const enabled = new Set(value);
32+
const toggle = (id: PoiCategory) => {
33+
const next = new Set(enabled);
34+
if (next.has(id)) next.delete(id);
35+
else next.add(id);
36+
onChange(CATEGORIES.filter((c) => next.has(c.id)).map((c) => c.id));
37+
};
38+
39+
return (
40+
<div
41+
role="group"
42+
aria-label="POI categories"
43+
className={cn(
44+
'flex flex-wrap gap-1 rounded-md border border-border bg-background p-1 shadow-sm',
45+
className,
46+
)}
47+
>
48+
{CATEGORIES.map(({ id, label, Icon }) => {
49+
const on = enabled.has(id);
50+
return (
51+
<button
52+
key={id}
53+
type="button"
54+
role="switch"
55+
aria-checked={on}
56+
onClick={() => toggle(id)}
57+
title={label}
58+
className={cn(
59+
'flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium transition-colors',
60+
on
61+
? 'bg-blue-600 text-white'
62+
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
63+
)}
64+
>
65+
<Icon className="h-3.5 w-3.5" aria-hidden />
66+
<span>{label}</span>
67+
</button>
68+
);
69+
})}
70+
</div>
71+
);
72+
}

0 commit comments

Comments
 (0)