@quantaroute/checkout 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,405 @@
1
+ import React, { useEffect, useRef, useState, useCallback } from 'react';
2
+ import maplibregl from 'maplibre-gl';
3
+ import 'maplibre-gl/dist/maplibre-gl.css';
4
+ import { getDigiPin, isWithinIndia } from '../core/digipin';
5
+ import { useGeolocation } from '../hooks/useGeolocation';
6
+ import type { MapPinSelectorProps } from '../core/types';
7
+
8
+ // ─── Constants ───────────────────────────────────────────────────────────────
9
+
10
+ /** India's geographic center – shown when no default location is given */
11
+ const INDIA_CENTER = { lat: 13.004270, lng: 77.589291 };
12
+ const MIN_ZOOM = 6; // users cannot zoom out beyond a useful India-level view
13
+ const OVERVIEW_ZOOM = 9;
14
+ const STREET_ZOOM = 18;
15
+
16
+ /**
17
+ * Carto Positron – free vector basemap, no API key required.
18
+ * Attribution: © OpenStreetMap contributors © CARTO
19
+ * Terms: https://carto.com/legal/
20
+ */
21
+ const CARTO_STYLE_URL =
22
+ 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
23
+
24
+ // ─── Pin dimensions ───────────────────────────────────────────────────────────
25
+ // MUST match the SVG width/height exactly.
26
+ // MapLibre reads element.offsetWidth/offsetHeight to compute `anchor:'bottom'`.
27
+ // The pin tip lives at SVG coordinate (20, 52) = bottom-center of the element.
28
+ const PIN_W = 40;
29
+ const PIN_H = 52;
30
+
31
+ // ─── Marker helper ───────────────────────────────────────────────────────────
32
+
33
+ function computeDigiPinSafe(la: number, lo: number): string | null {
34
+ if (!isWithinIndia(la, lo)) return null;
35
+ try {
36
+ return getDigiPin(la, lo);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function createPinElement(): HTMLDivElement {
43
+ const el = document.createElement('div');
44
+ el.className = 'qr-pin';
45
+ el.setAttribute('aria-label', 'Drag to adjust your location');
46
+
47
+ // ─ Set explicit inline dimensions ─────────────────────────────────────────
48
+ // This is critical: without them, the browser may add inline-baseline
49
+ // whitespace below the <svg> tag, making offsetHeight > PIN_H and causing
50
+ // the anchor='bottom' point to land below the actual pin tip.
51
+ el.style.width = `${PIN_W}px`;
52
+ el.style.height = `${PIN_H}px`;
53
+
54
+ el.innerHTML = `
55
+ <svg
56
+ class="qr-pin__svg"
57
+ width="${PIN_W}"
58
+ height="${PIN_H}"
59
+ viewBox="0 0 ${PIN_W} ${PIN_H}"
60
+ overflow="visible"
61
+ fill="none"
62
+ xmlns="http://www.w3.org/2000/svg"
63
+ aria-hidden="true"
64
+ >
65
+ <defs>
66
+ <!--
67
+ The filter region must be generous enough to contain the shadow blur.
68
+ stdDeviation=3 → ~9px blur radius; our region extends 50% on each side.
69
+ overflow="visible" on the <svg> lets it paint outside the viewport.
70
+ -->
71
+ <filter id="qr-pin-shadow" x="-60%" y="-40%" width="220%" height="220%">
72
+ <feDropShadow dx="0" dy="3" stdDeviation="3.5"
73
+ flood-color="#000" flood-opacity="0.28"/>
74
+ </filter>
75
+ </defs>
76
+
77
+ <!-- Teardrop body — tip is at (20, 52) = bottom-center of the SVG -->
78
+ <path
79
+ d="M20 0C9.402 0 0 9.402 0 20C0 34 20 52 20 52S40 34 40 20C40 9.402 30.598 0 20 0Z"
80
+ fill="#0ea5e9"
81
+ filter="url(#qr-pin-shadow)"
82
+ />
83
+ <!-- Dot inside the pin -->
84
+ <circle cx="20" cy="20" r="9" fill="white"/>
85
+ <circle cx="20" cy="20" r="5" fill="#0ea5e9"/>
86
+ </svg>
87
+
88
+ <!--
89
+ Pulse ring — position: absolute inside .qr-pin (position: relative).
90
+ bottom: -(height/2) → bottom: -10px centres the 20×20 circle
91
+ exactly at y = PIN_H = 52px = the pin tip coordinate.
92
+ -->
93
+ <div class="qr-pin__pulse" aria-hidden="true"></div>
94
+ `;
95
+ return el;
96
+ }
97
+
98
+ // ─── Component ───────────────────────────────────────────────────────────────
99
+
100
+ const MapPinSelector: React.FC<MapPinSelectorProps> = ({
101
+ onLocationConfirm,
102
+ defaultLat,
103
+ defaultLng,
104
+ mapHeight = '380px',
105
+ theme: _theme = 'light', // kept for future dark-mode pin tint; unused for now
106
+ indiaBoundaryUrl,
107
+ }) => {
108
+ const containerRef = useRef<HTMLDivElement>(null);
109
+ const mapRef = useRef<maplibregl.Map | null>(null);
110
+ const markerRef = useRef<maplibregl.Marker | null>(null);
111
+
112
+ // Compute once here so both state init and the useEffect use the same values.
113
+ const initLat = defaultLat ?? INDIA_CENTER.lat;
114
+ const initLng = defaultLng ?? INDIA_CENTER.lng;
115
+ const hasDefault = defaultLat !== undefined && defaultLng !== undefined;
116
+
117
+ const [lat, setLat] = useState(initLat);
118
+ const [lng, setLng] = useState(initLng);
119
+ // Compute DigiPin eagerly (offline, ~0.1 ms) so the badge shows immediately.
120
+ const [digipin, setDigipin] = useState<string | null>(() =>
121
+ computeDigiPinSafe(initLat, initLng)
122
+ );
123
+ const [mapLoaded, setMapLoaded] = useState(false);
124
+
125
+ const { loading: geoLoading, error: geoError, locate, clearError } = useGeolocation();
126
+
127
+ // Update DigiPin badge (offline, instant)
128
+ const refreshDigipin = useCallback((la: number, lo: number) => {
129
+ setDigipin(computeDigiPinSafe(la, lo));
130
+ }, []);
131
+
132
+ // ── Map initialization (runs once) ────────────────────────────────────────
133
+ useEffect(() => {
134
+ if (!containerRef.current) return;
135
+
136
+ // ── Fetch India boundary GeoJSON eagerly, in parallel with map style load ──
137
+ // Starting the fetch here (before map fires 'load') means by the time the
138
+ // map is ready the data is likely already parsed → zero perceived delay.
139
+ // Silent failure: the boundary is a legal safeguard, not critical UI.
140
+ const boundaryPromise: Promise<unknown> = indiaBoundaryUrl
141
+ ? fetch(indiaBoundaryUrl)
142
+ .then((r) => (r.ok ? r.json() : null))
143
+ .catch(() => null)
144
+ : Promise.resolve(null);
145
+
146
+ const initZoom = hasDefault ? STREET_ZOOM : OVERVIEW_ZOOM;
147
+
148
+ const map = new maplibregl.Map({
149
+ container: containerRef.current,
150
+ style: CARTO_STYLE_URL,
151
+ center: [initLng, initLat],
152
+ zoom: initZoom,
153
+ minZoom: MIN_ZOOM, // lock: cannot zoom out past level 7
154
+ attributionControl: false,
155
+ // Smooth touch gestures on mobile (tap center for zoom)
156
+ touchZoomRotate: true,
157
+ });
158
+
159
+ // Attribution (required by Carto & OSM terms)
160
+ map.addControl(
161
+ new maplibregl.AttributionControl({
162
+ compact: true,
163
+ customAttribution:
164
+ '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors &nbsp;© <a href="https://carto.com/attributions" target="_blank" rel="noopener">CARTO</a>',
165
+ }),
166
+ 'bottom-right'
167
+ );
168
+
169
+ // Zoom +/− (hidden on small screens via CSS)
170
+ map.addControl(
171
+ new maplibregl.NavigationControl({ showCompass: false }),
172
+ 'top-right'
173
+ );
174
+
175
+ map.on('load', () => {
176
+ setMapLoaded(true);
177
+
178
+ // ── India boundary overlay (legal compliance) ────────────────────────
179
+ // Resolves instantly if the fetch finished before map loaded (typical),
180
+ // or waits the remaining milliseconds if still in-flight.
181
+ boundaryPromise.then((geoJson) => {
182
+ if (!geoJson || !map.isStyleLoaded()) return;
183
+ try {
184
+ map.addSource('india-boundary', {
185
+ type: 'geojson',
186
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
187
+ data: geoJson as any,
188
+ });
189
+ map.addLayer({
190
+ id: 'india-boundary-line',
191
+ type: 'line',
192
+ source: 'india-boundary',
193
+ paint: {
194
+ 'line-color': '#94a3b8', // slate-400 — subtle grey
195
+ 'line-width': 1.5,
196
+ 'line-opacity': 0.65,
197
+ },
198
+ });
199
+ } catch {
200
+ // Silent: duplicate source/layer on hot-reload is not an error
201
+ }
202
+ });
203
+
204
+ // Create draggable pin marker.
205
+ // anchor='bottom' → MapLibre aligns element's bottom-center with the coord.
206
+ // Our SVG tip is at (20, 52) = exactly the bottom-center of the 40×52 element.
207
+ // No offset needed — any offset shifts the tip away from the coordinate.
208
+ const pinEl = createPinElement();
209
+ const marker = new maplibregl.Marker({
210
+ element: pinEl,
211
+ draggable: true,
212
+ anchor: 'bottom',
213
+ })
214
+ .setLngLat([initLng, initLat])
215
+ .addTo(map);
216
+
217
+ // Live DigiPin update while dragging
218
+ marker.on('drag', () => {
219
+ const pos = marker.getLngLat();
220
+ setLat(pos.lat);
221
+ setLng(pos.lng);
222
+ refreshDigipin(pos.lat, pos.lng);
223
+ });
224
+
225
+ // Re-center map after drag ends
226
+ marker.on('dragend', () => {
227
+ const pos = marker.getLngLat();
228
+ map.easeTo({ center: [pos.lng, pos.lat], duration: 250 });
229
+ });
230
+
231
+ markerRef.current = marker;
232
+ // DigiPin is already computed in useState() init; no need to recompute here.
233
+ });
234
+
235
+ // Tap/click map to reposition pin
236
+ map.on('click', (e) => {
237
+ const pos = e.lngLat;
238
+ markerRef.current?.setLngLat([pos.lng, pos.lat]);
239
+ setLat(pos.lat);
240
+ setLng(pos.lng);
241
+ refreshDigipin(pos.lat, pos.lng);
242
+ map.easeTo({ center: [pos.lng, pos.lat], duration: 200 });
243
+ });
244
+
245
+ mapRef.current = map;
246
+
247
+ return () => {
248
+ map.remove();
249
+ mapRef.current = null;
250
+ markerRef.current = null;
251
+ };
252
+ // eslint-disable-next-line react-hooks/exhaustive-deps
253
+ }, []); // intentionally empty – runs once on mount
254
+
255
+ // ── Geolocation handler ───────────────────────────────────────────────────
256
+ const handleLocateMe = useCallback(() => {
257
+ clearError();
258
+ locate((userLat, userLng) => {
259
+ setLat(userLat);
260
+ setLng(userLng);
261
+ refreshDigipin(userLat, userLng);
262
+
263
+ if (mapRef.current) {
264
+ mapRef.current.flyTo({
265
+ center: [userLng, userLat],
266
+ zoom: STREET_ZOOM,
267
+ duration: 1400,
268
+ essential: true,
269
+ });
270
+ }
271
+ markerRef.current?.setLngLat([userLng, userLat]);
272
+ });
273
+ }, [locate, clearError, refreshDigipin]);
274
+
275
+ // ── Confirm handler ───────────────────────────────────────────────────────
276
+ const handleConfirm = useCallback(() => {
277
+ if (digipin) {
278
+ onLocationConfirm(lat, lng, digipin);
279
+ }
280
+ }, [lat, lng, digipin, onLocationConfirm]);
281
+
282
+ const isInIndia = digipin !== null;
283
+ const canConfirm = isInIndia && mapLoaded;
284
+
285
+ return (
286
+ <div className="qr-map-wrapper">
287
+ {/* ── Step header ── */}
288
+ <div className="qr-step-header">
289
+ <div className="qr-step-badge">1</div>
290
+ <div className="qr-step-text">
291
+ <span className="qr-step-title">Pin Your Location</span>
292
+ <span className="qr-step-sub">
293
+ Tap the map or drag the pin to your exact home / office
294
+ </span>
295
+ </div>
296
+ </div>
297
+
298
+ {/* ── Map area ── */}
299
+ <div className="qr-map-outer" style={{ height: mapHeight }}>
300
+ {/* MapLibre GL container */}
301
+ <div ref={containerRef} className="qr-map-canvas" />
302
+
303
+ {/* DigiPin overlay badge – shown once inside India */}
304
+ {digipin && (
305
+ <div className="qr-digipin-badge" aria-live="polite" aria-label={`DigiPin: ${digipin}`}>
306
+ <span className="qr-digipin-badge__label">DigiPin</span>
307
+ <span className="qr-digipin-badge__code">{digipin}</span>
308
+ </div>
309
+ )}
310
+
311
+ {/* Out-of-India notice */}
312
+ {!isInIndia && mapLoaded && (
313
+ <div className="qr-map-notice" role="status">
314
+ Move map to India to get a DigiPin
315
+ </div>
316
+ )}
317
+
318
+ {/* Locate-me button */}
319
+ <button
320
+ className={`qr-locate-btn${geoLoading ? ' qr-locate-btn--loading' : ''}`}
321
+ onClick={handleLocateMe}
322
+ disabled={geoLoading}
323
+ title="Use my current location"
324
+ aria-label="Use my current location"
325
+ type="button"
326
+ >
327
+ {geoLoading ? (
328
+ <span className="qr-spinner" aria-hidden="true" />
329
+ ) : (
330
+ <svg
331
+ aria-hidden="true"
332
+ viewBox="0 0 24 24"
333
+ fill="none"
334
+ stroke="currentColor"
335
+ strokeWidth="2"
336
+ strokeLinecap="round"
337
+ strokeLinejoin="round"
338
+ >
339
+ {/* 1. Inner solid dot */}
340
+ <circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" />
341
+
342
+ {/* 2. Outer ring */}
343
+ <circle cx="12" cy="12" r="7" />
344
+
345
+ {/* 3. Crosshair bumps attached to the ring */}
346
+ <path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
347
+ </svg>
348
+ )}
349
+ </button>
350
+
351
+ {/* Geo error toast */}
352
+ {geoError && (
353
+ <div className="qr-geo-error" role="alert">
354
+ <span>{geoError}</span>
355
+ <button
356
+ type="button"
357
+ className="qr-geo-error__dismiss"
358
+ onClick={clearError}
359
+ aria-label="Dismiss error"
360
+ >
361
+ ×
362
+ </button>
363
+ </div>
364
+ )}
365
+ </div>
366
+
367
+ {/* ── Coordinates strip ── */}
368
+ {mapLoaded && (
369
+ <div className="qr-coords-strip" aria-label="Current pin coordinates">
370
+ <span>{lat.toFixed(5)}° N</span>
371
+ <span className="qr-coords-sep">·</span>
372
+ <span>{lng.toFixed(5)}° E</span>
373
+ </div>
374
+ )}
375
+
376
+ {/* ── Confirm action bar ── */}
377
+ <div className="qr-map-actions">
378
+ <button
379
+ type="button"
380
+ className="qr-btn qr-btn--primary qr-btn--full"
381
+ onClick={handleConfirm}
382
+ disabled={!canConfirm}
383
+ >
384
+ Confirm Location
385
+ <svg
386
+ aria-hidden="true"
387
+ viewBox="0 0 24 24"
388
+ fill="none"
389
+ stroke="currentColor"
390
+ strokeWidth="2.5"
391
+ strokeLinecap="round"
392
+ strokeLinejoin="round"
393
+ >
394
+ <path d="M5 12h14M12 5l7 7-7 7" />
395
+ </svg>
396
+ </button>
397
+ {!isInIndia && mapLoaded && (
398
+ <p className="qr-map-hint">Move the pin to a location in India to continue</p>
399
+ )}
400
+ </div>
401
+ </div>
402
+ );
403
+ };
404
+
405
+ export default MapPinSelector;
@@ -0,0 +1,150 @@
1
+ import type { LocationLookupResponse } from './types';
2
+
3
+ const DEFAULT_BASE_URL = 'https://api.quantaroute.com';
4
+
5
+ /**
6
+ * Address components from Nominatim/OpenStreetMap (via reverse geocoding).
7
+ */
8
+ export interface AddressComponents {
9
+ house_number?: string;
10
+ road?: string;
11
+ neighbourhood?: string;
12
+ suburb?: string;
13
+ city?: string;
14
+ state?: string;
15
+ postcode?: string;
16
+ country?: string;
17
+ country_code?: string;
18
+ name?: string; // Building/Society/POI name
19
+ addr_housename?: string;
20
+ addr_place?: string;
21
+ building?: string;
22
+ building_name?: string;
23
+ [key: string]: string | undefined;
24
+ }
25
+
26
+ /**
27
+ * Response from /v1/digipin/reverse endpoint.
28
+ */
29
+ export interface ReverseGeocodeResponse {
30
+ success: boolean;
31
+ data: {
32
+ digipin: string;
33
+ address: string;
34
+ coordinates: { latitude: number; longitude: number };
35
+ confidence: number;
36
+ displayName: string;
37
+ addressComponents: AddressComponents;
38
+ };
39
+ error?: string;
40
+ message?: string;
41
+ }
42
+
43
+ /**
44
+ * Call QuantaRoute's Location Lookup API.
45
+ * Converts lat/lng → full Indian administrative address + DigiPin.
46
+ */
47
+ export async function lookupLocation(
48
+ lat: number,
49
+ lng: number,
50
+ apiKey: string,
51
+ baseUrl: string = DEFAULT_BASE_URL
52
+ ): Promise<LocationLookupResponse> {
53
+ const url = `${baseUrl.replace(/\/$/, '')}/v1/location/lookup`;
54
+
55
+ let res: Response;
56
+ try {
57
+ res = await fetch(url, {
58
+ method: 'POST',
59
+ headers: {
60
+ 'Content-Type': 'application/json',
61
+ 'x-api-key': apiKey,
62
+ },
63
+ body: JSON.stringify({ latitude: lat, longitude: lng }),
64
+ signal: AbortSignal.timeout(15_000), // 15s timeout
65
+ });
66
+ } catch (err) {
67
+ if (err instanceof Error && err.name === 'TimeoutError') {
68
+ throw new Error('Request timed out. Check your internet connection.');
69
+ }
70
+ throw new Error(`Network error: ${err instanceof Error ? err.message : String(err)}`);
71
+ }
72
+
73
+ if (!res.ok) {
74
+ let body = '';
75
+ try {
76
+ body = await res.text();
77
+ } catch {
78
+ // ignore
79
+ }
80
+ if (res.status === 401 || res.status === 403) {
81
+ throw new Error('Invalid API key. Check your QuantaRoute API key.');
82
+ }
83
+ if (res.status === 429) {
84
+ throw new Error('Rate limit exceeded. Upgrade your plan or try again later.');
85
+ }
86
+ throw new Error(`API error ${res.status}: ${body || res.statusText}`);
87
+ }
88
+
89
+ const data = (await res.json()) as LocationLookupResponse;
90
+
91
+ if (!data.success) {
92
+ throw new Error(data.message ?? 'Location lookup failed');
93
+ }
94
+
95
+ return data;
96
+ }
97
+
98
+ /**
99
+ * Call QuantaRoute's Reverse Geocoding API.
100
+ * Converts DigiPin → address components from Nominatim/OpenStreetMap.
101
+ */
102
+ export async function reverseGeocode(
103
+ digipin: string,
104
+ apiKey: string,
105
+ baseUrl: string = DEFAULT_BASE_URL
106
+ ): Promise<ReverseGeocodeResponse> {
107
+ const url = `${baseUrl.replace(/\/$/, '')}/v1/digipin/reverse`;
108
+
109
+ let res: Response;
110
+ try {
111
+ res = await fetch(url, {
112
+ method: 'POST',
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ 'x-api-key': apiKey,
116
+ },
117
+ body: JSON.stringify({ digipin }),
118
+ signal: AbortSignal.timeout(15_000), // 15s timeout
119
+ });
120
+ } catch (err) {
121
+ if (err instanceof Error && err.name === 'TimeoutError') {
122
+ throw new Error('Request timed out. Check your internet connection.');
123
+ }
124
+ throw new Error(`Network error: ${err instanceof Error ? err.message : String(err)}`);
125
+ }
126
+
127
+ if (!res.ok) {
128
+ let body = '';
129
+ try {
130
+ body = await res.text();
131
+ } catch {
132
+ // ignore
133
+ }
134
+ if (res.status === 401 || res.status === 403) {
135
+ throw new Error('Invalid API key. Check your QuantaRoute API key.');
136
+ }
137
+ if (res.status === 429) {
138
+ throw new Error('Rate limit exceeded. Upgrade your plan or try again later.');
139
+ }
140
+ throw new Error(`API error ${res.status}: ${body || res.statusText}`);
141
+ }
142
+
143
+ const data = (await res.json()) as ReverseGeocodeResponse;
144
+
145
+ if (!data.success) {
146
+ throw new Error(data.message ?? 'Reverse geocoding failed');
147
+ }
148
+
149
+ return data;
150
+ }