@quantaroute/checkout 1.1.1 → 1.2.1

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,196 @@
1
+ import React, { useState } from 'react';
2
+ import MapPinSelector from './MapPinSelector';
3
+ import AddressForm from './AddressForm';
4
+ import type { CheckoutWidgetProps, CompleteAddress } from '../core/types';
5
+ import '../styles/checkout.css';
6
+
7
+ // ─── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ type Step = 'map' | 'form' | 'done';
10
+
11
+ interface ConfirmedLocation {
12
+ lat: number;
13
+ lng: number;
14
+ digipin: string;
15
+ }
16
+
17
+ // ─── Success screen ───────────────────────────────────────────────────────────
18
+
19
+ interface SuccessScreenProps {
20
+ address: CompleteAddress;
21
+ onEditAddress: () => void;
22
+ }
23
+
24
+ const SuccessScreen: React.FC<SuccessScreenProps> = ({ address, onEditAddress }) => (
25
+ <div className="qr-success">
26
+ <div className="qr-success__icon" aria-hidden="true">
27
+ <svg viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg">
28
+ <circle cx="26" cy="26" r="26" fill="var(--qr-success, #10b981)" opacity="0.12" />
29
+ <circle cx="26" cy="26" r="20" fill="var(--qr-success, #10b981)" />
30
+ <path
31
+ d="M15 26l8 8 14-16"
32
+ stroke="white"
33
+ strokeWidth="3"
34
+ strokeLinecap="round"
35
+ strokeLinejoin="round"
36
+ />
37
+ </svg>
38
+ </div>
39
+ <h3 className="qr-success__title">Address Saved!</h3>
40
+ <p className="qr-success__address">{address.formattedAddress}</p>
41
+ <div className="qr-success__meta">
42
+ <span className="qr-digipin-badge qr-digipin-badge--inline">
43
+ <span className="qr-digipin-badge__label">DigiPin</span>
44
+ <span className="qr-digipin-badge__code">{address.digipin}</span>
45
+ </span>
46
+ </div>
47
+ <button type="button" className="qr-btn qr-btn--ghost qr-btn--sm" onClick={onEditAddress}>
48
+ Change Address
49
+ </button>
50
+ </div>
51
+ );
52
+
53
+ // ─── Main Widget ──────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * QuantaRoute Checkout Widget
57
+ *
58
+ * A two-step, mobile-first address collection component:
59
+ * Step 1 – Map: user pins their exact location (DigiPin shown offline, real-time)
60
+ * Step 2 – Form: auto-filled address + manual flat/building details
61
+ *
62
+ * Works in React, Next.js, Nuxt (via nuxt-module), Vite, etc.
63
+ * Embed as a script tag (vanilla JS) via the UMD build.
64
+ */
65
+ const CheckoutWidget: React.FC<CheckoutWidgetProps> = ({
66
+ apiKey,
67
+ apiBaseUrl = 'https://api.quantaroute.com',
68
+ onComplete,
69
+ onError,
70
+ defaultLat,
71
+ defaultLng,
72
+ theme = 'light',
73
+ className = '',
74
+ style,
75
+ mapHeight = '380px',
76
+ title = 'Add Delivery Address',
77
+ indiaBoundaryUrl,
78
+ }) => {
79
+ const [step, setStep] = useState<Step>('map');
80
+ const [confirmedLocation, setConfirmedLocation] = useState<ConfirmedLocation | null>(null);
81
+ const [completedAddress, setCompletedAddress] = useState<CompleteAddress | null>(null);
82
+
83
+ // ── Handlers ──────────────────────────────────────────────────────────────
84
+
85
+ const handleLocationConfirm = (lat: number, lng: number, digipin: string) => {
86
+ setConfirmedLocation({ lat, lng, digipin });
87
+ setStep('form');
88
+ };
89
+
90
+ const handleAddressComplete = (address: CompleteAddress) => {
91
+ setCompletedAddress(address);
92
+ setStep('done');
93
+ onComplete(address);
94
+ };
95
+
96
+ const handleBack = () => {
97
+ setStep('map');
98
+ };
99
+
100
+ const handleEditAddress = () => {
101
+ setStep('map');
102
+ setConfirmedLocation(null);
103
+ setCompletedAddress(null);
104
+ };
105
+
106
+ // ── Step indicator ─────────────────────────────────────────────────────────
107
+
108
+ const renderStepDots = () => (
109
+ <div className="qr-progress" aria-label="Progress: step ${ step === 'map' ? 1 : 2 } of 2" role="progressbar">
110
+ <div className={`qr-progress__dot ${step === 'map' || step === 'form' || step === 'done' ? 'qr-progress__dot--active' : ''}`} />
111
+ <div className={`qr-progress__line ${step === 'form' || step === 'done' ? 'qr-progress__line--active' : ''}`} />
112
+ <div className={`qr-progress__dot ${step === 'form' || step === 'done' ? 'qr-progress__dot--active' : ''}`} />
113
+ </div>
114
+ );
115
+
116
+ // ── Render ─────────────────────────────────────────────────────────────────
117
+
118
+ return (
119
+ <div
120
+ className={`qr-checkout qr-checkout--${theme} ${className}`}
121
+ style={style}
122
+ data-testid="quantaroute-checkout"
123
+ >
124
+ {/* Widget header */}
125
+ {step !== 'done' && (
126
+ <div className="qr-header">
127
+ <div className="qr-header__brand">
128
+ <svg
129
+ className="qr-header__logo"
130
+ aria-hidden="true"
131
+ viewBox="0 0 24 24"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ strokeWidth="2"
135
+ strokeLinecap="round"
136
+ strokeLinejoin="round"
137
+ >
138
+ <path d="M21 10c0 7-9 13-9 13S3 17 3 10a9 9 0 0118 0z" />
139
+ <circle cx="12" cy="10" r="3" />
140
+ </svg>
141
+ <span className="qr-header__title">{title}</span>
142
+ </div>
143
+ {renderStepDots()}
144
+ </div>
145
+ )}
146
+
147
+ {/* ── Step 1: Map ── */}
148
+ {step === 'map' && (
149
+ <MapPinSelector
150
+ onLocationConfirm={handleLocationConfirm}
151
+ defaultLat={defaultLat}
152
+ defaultLng={defaultLng}
153
+ mapHeight={mapHeight}
154
+ theme={theme}
155
+ indiaBoundaryUrl={indiaBoundaryUrl}
156
+ />
157
+ )}
158
+
159
+ {/* ── Step 2: Form ── */}
160
+ {step === 'form' && confirmedLocation && (
161
+ <AddressForm
162
+ digipin={confirmedLocation.digipin}
163
+ lat={confirmedLocation.lat}
164
+ lng={confirmedLocation.lng}
165
+ apiKey={apiKey}
166
+ apiBaseUrl={apiBaseUrl}
167
+ onAddressComplete={handleAddressComplete}
168
+ onBack={handleBack}
169
+ onError={onError}
170
+ theme={theme}
171
+ />
172
+ )}
173
+
174
+ {/* ── Step 3: Done ── */}
175
+ {step === 'done' && completedAddress && (
176
+ <SuccessScreen address={completedAddress} onEditAddress={handleEditAddress} />
177
+ )}
178
+
179
+ {/* Powered-by footer */}
180
+ <div className="qr-footer">
181
+ <span>Powered by</span>
182
+ <a
183
+ href="https://quantaroute.com"
184
+ target="_blank"
185
+ rel="noopener noreferrer"
186
+ className="qr-footer__link"
187
+ >
188
+ QuantaRoute
189
+ </a>
190
+ <span className="qr-footer__flag" aria-label="Made in India">🇮🇳</span>
191
+ </div>
192
+ </div>
193
+ );
194
+ };
195
+
196
+ export default CheckoutWidget;
@@ -0,0 +1,251 @@
1
+ import React, { useRef, useState, useCallback } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ } from 'react-native';
7
+ import { OSMView, LocationButton } from 'expo-osm-sdk';
8
+ import type { OSMViewRef, MarkerConfig } from 'expo-osm-sdk';
9
+ import * as Location from 'expo-location';
10
+ import { getDigiPin, isWithinIndia } from '../core/digipin';
11
+ import type { MapPinSelectorProps } from '../core/types';
12
+ import { styles, COLORS } from '../styles/checkout.native';
13
+
14
+ // ─── Constants ────────────────────────────────────────────────────────────────
15
+
16
+ const INDIA_CENTER = { latitude: 20.5937, longitude: 78.9629 };
17
+ const OVERVIEW_ZOOM = 5;
18
+ const STREET_ZOOM = 16;
19
+
20
+ /**
21
+ * Carto Positron vector basemap — same source as the web version.
22
+ * Free, no API key required. Attribution required (shown by OSMView natively).
23
+ */
24
+ const CARTO_STYLE_URL =
25
+ 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
26
+
27
+ /**
28
+ * Custom pin SVG as a data URI — matches the web pin colour (#0ea5e9).
29
+ * Teardrop shape with white inner circle and blue dot.
30
+ */
31
+ const PIN_ICON_URI =
32
+ "data:image/svg+xml;utf8," +
33
+ encodeURIComponent(
34
+ '<svg width="40" height="52" viewBox="0 0 40 52" fill="none" xmlns="http://www.w3.org/2000/svg">' +
35
+ '<path 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" fill="#0ea5e9"/>' +
36
+ '<circle cx="20" cy="20" r="9" fill="white"/>' +
37
+ '<circle cx="20" cy="20" r="5" fill="#0ea5e9"/>' +
38
+ '</svg>'
39
+ );
40
+
41
+ // ─── Helper ───────────────────────────────────────────────────────────────────
42
+
43
+ function computeDigiPinSafe(lat: number, lng: number): string | null {
44
+ if (!isWithinIndia(lat, lng)) return null;
45
+ try {
46
+ return getDigiPin(lat, lng);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ // ─── Component ────────────────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Native (iOS/Android) map pin selector.
56
+ * Uses expo-osm-sdk's OSMView with a draggable marker over Carto Positron tiles.
57
+ * Metro resolves this file on native; MapPinSelector.tsx is used on web.
58
+ */
59
+ const MapPinSelector: React.FC<MapPinSelectorProps> = ({
60
+ onLocationConfirm,
61
+ defaultLat,
62
+ defaultLng,
63
+ mapHeight = 380,
64
+ theme = 'light',
65
+ }) => {
66
+ const mapRef = useRef<OSMViewRef>(null);
67
+
68
+ const initLat = defaultLat ?? INDIA_CENTER.latitude;
69
+ const initLng = defaultLng ?? INDIA_CENTER.longitude;
70
+ const hasDefault = defaultLat !== undefined && defaultLng !== undefined;
71
+
72
+ const [coord, setCoord] = useState({ latitude: initLat, longitude: initLng });
73
+ const [digipin, setDigipin] = useState<string | null>(() =>
74
+ computeDigiPinSafe(initLat, initLng)
75
+ );
76
+ const [mapReady, setMapReady] = useState(false);
77
+ const [locateError, setLocateError] = useState<string | null>(null);
78
+
79
+ const isDark = theme === 'dark';
80
+
81
+ // Resolve numeric height: web passes '380px', native should pass 380 or a number
82
+ const mapHeightNum =
83
+ typeof mapHeight === 'string' ? parseInt(mapHeight, 10) || 380 : mapHeight;
84
+
85
+ // ── Coordinate update (pin drag or tap) ─────────────────────────────────────
86
+ const handleCoordChange = useCallback(
87
+ (latitude: number, longitude: number) => {
88
+ setCoord({ latitude, longitude });
89
+ setDigipin(computeDigiPinSafe(latitude, longitude));
90
+ },
91
+ []
92
+ );
93
+
94
+ // ── Marker config (draggable checkout pin) ──────────────────────────────────
95
+ const pinMarkers: MarkerConfig[] = [
96
+ {
97
+ id: 'checkout-pin',
98
+ coordinate: coord,
99
+ draggable: true,
100
+ icon: {
101
+ uri: PIN_ICON_URI,
102
+ size: 40,
103
+ anchor: { x: 0.5, y: 1.0 }, // tip of the teardrop
104
+ },
105
+ },
106
+ ];
107
+
108
+ // ── Locate-me: delegate to expo-osm-sdk's LocationButton via getCurrentLocation ──
109
+ const getCurrentLocation = useCallback(async () => {
110
+ setLocateError(null);
111
+ const { status } = await Location.requestForegroundPermissionsAsync();
112
+ if (status !== Location.PermissionStatus.GRANTED) {
113
+ throw new Error('Location permission denied');
114
+ }
115
+ const loc = await Location.getCurrentPositionAsync({
116
+ accuracy: Location.Accuracy.High,
117
+ });
118
+ return { latitude: loc.coords.latitude, longitude: loc.coords.longitude };
119
+ }, []);
120
+
121
+ // ── Confirm handler ──────────────────────────────────────────────────────────
122
+ const handleConfirm = useCallback(() => {
123
+ if (digipin) {
124
+ onLocationConfirm(coord.latitude, coord.longitude, digipin);
125
+ }
126
+ }, [coord, digipin, onLocationConfirm]);
127
+
128
+ const isInIndia = digipin !== null;
129
+ const canConfirm = isInIndia && mapReady;
130
+
131
+ return (
132
+ <View style={[styles.mapWrapper, isDark && { backgroundColor: COLORS.bgDark }]}>
133
+
134
+ {/* ── Step header ── */}
135
+ <View style={[styles.stepHeader, isDark && styles.stepHeaderDark]}>
136
+ <View style={styles.stepBadge}>
137
+ <Text style={styles.stepBadgeText}>1</Text>
138
+ </View>
139
+ <View style={styles.stepText}>
140
+ <Text style={[styles.stepTitle, isDark && styles.textLight]}>
141
+ Pin Your Location
142
+ </Text>
143
+ <Text style={[styles.stepSub, isDark && styles.textMutedStyle]}>
144
+ Tap the map or drag the pin to your exact location
145
+ </Text>
146
+ </View>
147
+ </View>
148
+
149
+ {/* ── Map area ── */}
150
+ <View style={{ height: mapHeightNum, position: 'relative' }}>
151
+ <OSMView
152
+ ref={mapRef}
153
+ style={{ flex: 1 }}
154
+ initialCenter={{ latitude: initLat, longitude: initLng }}
155
+ initialZoom={hasDefault ? STREET_ZOOM : OVERVIEW_ZOOM}
156
+ styleUrl={CARTO_STYLE_URL}
157
+ markers={pinMarkers}
158
+ showUserLocation={false}
159
+ scrollEnabled
160
+ zoomEnabled
161
+ rotateEnabled
162
+ onMapReady={() => setMapReady(true)}
163
+ onPress={(coordinate) => {
164
+ handleCoordChange(coordinate.latitude, coordinate.longitude);
165
+ }}
166
+ onMarkerDragEnd={(_markerId, coordinate) => {
167
+ handleCoordChange(coordinate.latitude, coordinate.longitude);
168
+ }}
169
+ />
170
+
171
+ {/* DigiPin badge overlay */}
172
+ {digipin ? (
173
+ <View
174
+ style={[styles.digipinBadge, isDark && styles.digipinBadgeDark]}
175
+ pointerEvents="none"
176
+ >
177
+ <Text style={[styles.digipinLabel, isDark && styles.digipinLabelDark]}>
178
+ DigiPin
179
+ </Text>
180
+ <Text style={styles.digipinCode}>{digipin}</Text>
181
+ </View>
182
+ ) : null}
183
+
184
+ {/* Out-of-India notice */}
185
+ {!isInIndia && mapReady ? (
186
+ <View style={styles.mapNotice} pointerEvents="none">
187
+ <Text style={styles.mapNoticeText}>Move map to India to get a DigiPin</Text>
188
+ </View>
189
+ ) : null}
190
+
191
+ {/* Locate-me button — expo-osm-sdk's built-in LocationButton */}
192
+ <LocationButton
193
+ style={styles.locateBtn}
194
+ color={COLORS.primary}
195
+ size={44}
196
+ getCurrentLocation={getCurrentLocation}
197
+ onLocationFound={({ latitude, longitude }) => {
198
+ handleCoordChange(latitude, longitude);
199
+ void mapRef.current?.animateToLocation(latitude, longitude, STREET_ZOOM);
200
+ }}
201
+ onLocationError={(err) => setLocateError(err)}
202
+ />
203
+
204
+ {/* Geo error toast */}
205
+ {locateError ? (
206
+ <View style={styles.geoError}>
207
+ <Text style={styles.geoErrorText}>{locateError}</Text>
208
+ <TouchableOpacity
209
+ style={styles.geoErrorDismiss}
210
+ onPress={() => setLocateError(null)}
211
+ accessibilityLabel="Dismiss error"
212
+ >
213
+ <Text style={styles.geoErrorDismissText}>×</Text>
214
+ </TouchableOpacity>
215
+ </View>
216
+ ) : null}
217
+ </View>
218
+
219
+ {/* ── Coordinates strip ── */}
220
+ {mapReady ? (
221
+ <View style={[styles.coordsStrip, isDark && styles.coordsStripDark]}>
222
+ <Text style={[styles.coordsText, isDark && styles.coordsTextDark]}>
223
+ {coord.latitude.toFixed(5)}° N · {coord.longitude.toFixed(5)}° E
224
+ </Text>
225
+ </View>
226
+ ) : null}
227
+
228
+ {/* ── Confirm action bar ── */}
229
+ <View style={[styles.mapActions, isDark && styles.mapActionsDark]}>
230
+ <TouchableOpacity
231
+ style={[styles.btnPrimary, !canConfirm && styles.btnDisabled]}
232
+ onPress={handleConfirm}
233
+ disabled={!canConfirm}
234
+ activeOpacity={0.8}
235
+ accessibilityRole="button"
236
+ accessibilityLabel="Confirm location"
237
+ >
238
+ <Text style={styles.btnPrimaryText}>Confirm Location →</Text>
239
+ </TouchableOpacity>
240
+
241
+ {!isInIndia && mapReady ? (
242
+ <Text style={[styles.mapHint, isDark && styles.mapHintDark]}>
243
+ Move the pin to a location in India to continue
244
+ </Text>
245
+ ) : null}
246
+ </View>
247
+ </View>
248
+ );
249
+ };
250
+
251
+ export default MapPinSelector;