@quantaroute/checkout 1.1.0 → 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.
- package/LICENSE +29 -0
- package/README.md +302 -175
- package/babel.config.js +16 -0
- package/dist/lib/index.d.ts +9 -7
- package/dist/lib/quantaroute-checkout.es.js +9 -3
- package/dist/lib/quantaroute-checkout.umd.js +9 -3
- package/expo-plugin.js +109 -0
- package/package.json +50 -10
- package/src/components/AddressForm.native.tsx +540 -0
- package/src/components/AddressForm.tsx +477 -0
- package/src/components/CheckoutWidget.native.tsx +218 -0
- package/src/components/CheckoutWidget.tsx +196 -0
- package/src/components/MapPinSelector.native.tsx +254 -0
- package/src/components/MapPinSelector.tsx +405 -0
- package/src/core/api.ts +150 -0
- package/src/core/digipin.ts +169 -0
- package/src/core/types.ts +150 -0
- package/src/hooks/useDigiPin.ts +20 -0
- package/src/hooks/useGeolocation.native.ts +55 -0
- package/src/hooks/useGeolocation.ts +48 -0
- package/src/index.ts +59 -0
- package/src/styles/checkout.css +1082 -0
- package/src/styles/checkout.native.ts +839 -0
- /package/dist/{lib/style.css → style.css} +0 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import React, { useEffect, useReducer, useCallback } from 'react';
|
|
2
|
+
import { lookupLocation, reverseGeocode, type AddressComponents } from '../core/api';
|
|
3
|
+
import type {
|
|
4
|
+
AddressFormProps,
|
|
5
|
+
AdministrativeInfo,
|
|
6
|
+
CompleteAddress,
|
|
7
|
+
LocationAlternative,
|
|
8
|
+
} from '../core/types';
|
|
9
|
+
|
|
10
|
+
// ─── State ────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
interface ManualFields {
|
|
13
|
+
flatNumber: string;
|
|
14
|
+
floorNumber: string;
|
|
15
|
+
buildingName: string;
|
|
16
|
+
streetName: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type FormState =
|
|
20
|
+
| { status: 'loading' }
|
|
21
|
+
| { status: 'error'; message: string }
|
|
22
|
+
| {
|
|
23
|
+
status: 'ready';
|
|
24
|
+
adminInfo: AdministrativeInfo;
|
|
25
|
+
alternatives: LocationAlternative[];
|
|
26
|
+
selectedLocality: string; // Selected locality name (from adminInfo or alternatives)
|
|
27
|
+
fields: ManualFields;
|
|
28
|
+
submitting: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type FormAction =
|
|
32
|
+
| { type: 'LOAD_START' }
|
|
33
|
+
| {
|
|
34
|
+
type: 'LOAD_SUCCESS';
|
|
35
|
+
adminInfo: AdministrativeInfo;
|
|
36
|
+
alternatives: LocationAlternative[];
|
|
37
|
+
addressComponents: AddressComponents;
|
|
38
|
+
}
|
|
39
|
+
| { type: 'LOAD_ERROR'; message: string }
|
|
40
|
+
| { type: 'SET_FIELD'; key: keyof ManualFields; value: string }
|
|
41
|
+
| { type: 'SET_LOCALITY'; locality: string }
|
|
42
|
+
| { type: 'SUBMIT_START' }
|
|
43
|
+
| { type: 'SUBMIT_END' };
|
|
44
|
+
|
|
45
|
+
const INITIAL_FIELDS: ManualFields = {
|
|
46
|
+
flatNumber: '',
|
|
47
|
+
floorNumber: '',
|
|
48
|
+
buildingName: '',
|
|
49
|
+
streetName: '',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse addressComponents from Nominatim/OpenStreetMap.
|
|
54
|
+
* - name → Building/Society/POI name
|
|
55
|
+
* - road + suburb → Street/Area
|
|
56
|
+
*/
|
|
57
|
+
function parseAddressComponents(components: AddressComponents): Partial<ManualFields> {
|
|
58
|
+
const fields: Partial<ManualFields> = {};
|
|
59
|
+
|
|
60
|
+
// Building/Society/POI name: prefer 'name' field (most relevant)
|
|
61
|
+
if (components.name) {
|
|
62
|
+
fields.buildingName = components.name;
|
|
63
|
+
} else if (components.building_name) {
|
|
64
|
+
fields.buildingName = components.building_name;
|
|
65
|
+
} else if (components.addr_housename) {
|
|
66
|
+
fields.buildingName = components.addr_housename;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Street/Area: combine road + suburb (if available)
|
|
70
|
+
const streetParts: string[] = [];
|
|
71
|
+
if (components.road) {
|
|
72
|
+
streetParts.push(components.road);
|
|
73
|
+
}
|
|
74
|
+
if (components.suburb) {
|
|
75
|
+
streetParts.push(components.suburb);
|
|
76
|
+
}
|
|
77
|
+
if (streetParts.length > 0) {
|
|
78
|
+
fields.streetName = streetParts.join(', ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return fields;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function reducer(state: FormState, action: FormAction): FormState {
|
|
85
|
+
switch (action.type) {
|
|
86
|
+
case 'LOAD_START':
|
|
87
|
+
return { status: 'loading' };
|
|
88
|
+
case 'LOAD_SUCCESS': {
|
|
89
|
+
// Pre-fill fields from addressComponents
|
|
90
|
+
const preFilled = parseAddressComponents(action.addressComponents);
|
|
91
|
+
// Default to primary locality from adminInfo
|
|
92
|
+
return {
|
|
93
|
+
status: 'ready',
|
|
94
|
+
adminInfo: action.adminInfo,
|
|
95
|
+
alternatives: action.alternatives || [],
|
|
96
|
+
selectedLocality: action.adminInfo.locality,
|
|
97
|
+
fields: { ...INITIAL_FIELDS, ...preFilled },
|
|
98
|
+
submitting: false,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
case 'LOAD_ERROR':
|
|
102
|
+
return { status: 'error', message: action.message };
|
|
103
|
+
case 'SET_FIELD':
|
|
104
|
+
if (state.status !== 'ready') return state;
|
|
105
|
+
return { ...state, fields: { ...state.fields, [action.key]: action.value } };
|
|
106
|
+
case 'SET_LOCALITY':
|
|
107
|
+
if (state.status !== 'ready') return state;
|
|
108
|
+
return { ...state, selectedLocality: action.locality };
|
|
109
|
+
case 'SUBMIT_START':
|
|
110
|
+
if (state.status !== 'ready') return state;
|
|
111
|
+
return { ...state, submitting: true };
|
|
112
|
+
case 'SUBMIT_END':
|
|
113
|
+
if (state.status !== 'ready') return state;
|
|
114
|
+
return { ...state, submitting: false };
|
|
115
|
+
default:
|
|
116
|
+
return state;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const AddressForm: React.FC<AddressFormProps> = ({
|
|
123
|
+
digipin,
|
|
124
|
+
lat,
|
|
125
|
+
lng,
|
|
126
|
+
apiKey,
|
|
127
|
+
apiBaseUrl,
|
|
128
|
+
onAddressComplete,
|
|
129
|
+
onBack,
|
|
130
|
+
onError,
|
|
131
|
+
}) => {
|
|
132
|
+
const [state, dispatch] = useReducer(reducer, { status: 'loading' });
|
|
133
|
+
|
|
134
|
+
// ── Fetch administrative data + address components on mount ────────────────
|
|
135
|
+
const fetchAdminInfo = useCallback(async () => {
|
|
136
|
+
dispatch({ type: 'LOAD_START' });
|
|
137
|
+
try {
|
|
138
|
+
// Call both APIs in parallel:
|
|
139
|
+
// 1. /v1/location/lookup → administrative_info (state, district, pincode) + alternatives
|
|
140
|
+
// 2. /v1/digipin/reverse → addressComponents (name, road, suburb from Nominatim)
|
|
141
|
+
const [adminRes, reverseRes] = await Promise.all([
|
|
142
|
+
lookupLocation(lat, lng, apiKey, apiBaseUrl),
|
|
143
|
+
reverseGeocode(digipin, apiKey, apiBaseUrl),
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
dispatch({
|
|
147
|
+
type: 'LOAD_SUCCESS',
|
|
148
|
+
adminInfo: adminRes.data.administrative_info,
|
|
149
|
+
alternatives: adminRes.data.alternatives || [],
|
|
150
|
+
addressComponents: reverseRes.data.addressComponents,
|
|
151
|
+
});
|
|
152
|
+
} catch (err) {
|
|
153
|
+
const msg = err instanceof Error ? err.message : 'Failed to fetch address data.';
|
|
154
|
+
dispatch({ type: 'LOAD_ERROR', message: msg });
|
|
155
|
+
onError?.(err instanceof Error ? err : new Error(msg));
|
|
156
|
+
}
|
|
157
|
+
}, [digipin, lat, lng, apiKey, apiBaseUrl, onError]);
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
void fetchAdminInfo();
|
|
161
|
+
}, [fetchAdminInfo]);
|
|
162
|
+
|
|
163
|
+
// ── Submit ────────────────────────────────────────────────────────────────
|
|
164
|
+
const handleSubmit = useCallback(
|
|
165
|
+
(e: React.FormEvent<HTMLFormElement>) => {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
if (state.status !== 'ready') return;
|
|
168
|
+
|
|
169
|
+
const { adminInfo, selectedLocality, fields } = state;
|
|
170
|
+
|
|
171
|
+
const parts: string[] = [
|
|
172
|
+
fields.flatNumber,
|
|
173
|
+
fields.floorNumber ? `Floor ${fields.floorNumber}` : '',
|
|
174
|
+
fields.buildingName,
|
|
175
|
+
fields.streetName,
|
|
176
|
+
selectedLocality, // Use selected locality (may be from alternatives)
|
|
177
|
+
adminInfo.district,
|
|
178
|
+
adminInfo.state,
|
|
179
|
+
adminInfo.pincode,
|
|
180
|
+
].filter(Boolean);
|
|
181
|
+
|
|
182
|
+
const complete: CompleteAddress = {
|
|
183
|
+
digipin,
|
|
184
|
+
lat,
|
|
185
|
+
lng,
|
|
186
|
+
state: adminInfo.state,
|
|
187
|
+
district: adminInfo.district,
|
|
188
|
+
division: adminInfo.division,
|
|
189
|
+
locality: selectedLocality, // Use selected locality
|
|
190
|
+
pincode: adminInfo.pincode,
|
|
191
|
+
delivery: adminInfo.delivery,
|
|
192
|
+
country: adminInfo.country ?? 'India',
|
|
193
|
+
...fields,
|
|
194
|
+
formattedAddress: parts.join(', '),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
dispatch({ type: 'SUBMIT_START' });
|
|
198
|
+
try {
|
|
199
|
+
onAddressComplete(complete);
|
|
200
|
+
} finally {
|
|
201
|
+
dispatch({ type: 'SUBMIT_END' });
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
[state, digipin, lat, lng, onAddressComplete]
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<div className="qr-form-wrapper">
|
|
209
|
+
{/* ── Step header ── */}
|
|
210
|
+
<div className="qr-step-header">
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
className="qr-back-btn"
|
|
214
|
+
onClick={onBack}
|
|
215
|
+
aria-label="Back to map"
|
|
216
|
+
>
|
|
217
|
+
<svg
|
|
218
|
+
aria-hidden="true"
|
|
219
|
+
viewBox="0 0 24 24"
|
|
220
|
+
fill="none"
|
|
221
|
+
stroke="currentColor"
|
|
222
|
+
strokeWidth="2.5"
|
|
223
|
+
strokeLinecap="round"
|
|
224
|
+
strokeLinejoin="round"
|
|
225
|
+
>
|
|
226
|
+
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
227
|
+
</svg>
|
|
228
|
+
</button>
|
|
229
|
+
<div className="qr-step-badge">2</div>
|
|
230
|
+
<div className="qr-step-text">
|
|
231
|
+
<span className="qr-step-title">Add Address Details</span>
|
|
232
|
+
<span className="qr-step-sub">Flat number and building info</span>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
{/* ── DigiPin reference strip ── */}
|
|
237
|
+
<div className="qr-form-digipin-strip">
|
|
238
|
+
<svg
|
|
239
|
+
aria-hidden="true"
|
|
240
|
+
viewBox="0 0 24 24"
|
|
241
|
+
fill="none"
|
|
242
|
+
stroke="currentColor"
|
|
243
|
+
strokeWidth="2"
|
|
244
|
+
strokeLinecap="round"
|
|
245
|
+
strokeLinejoin="round"
|
|
246
|
+
>
|
|
247
|
+
<path d="M21 10c0 7-9 13-9 13S3 17 3 10a9 9 0 0118 0z" />
|
|
248
|
+
<circle cx="12" cy="10" r="3" />
|
|
249
|
+
</svg>
|
|
250
|
+
<span className="qr-form-digipin-strip__label">DigiPin</span>
|
|
251
|
+
<span className="qr-form-digipin-strip__code">{digipin}</span>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
{/* ── Loading skeleton ── */}
|
|
255
|
+
{state.status === 'loading' && (
|
|
256
|
+
<div className="qr-loading-state" aria-busy="true" aria-label="Fetching address details">
|
|
257
|
+
<div className="qr-spinner qr-spinner--lg" aria-hidden="true" />
|
|
258
|
+
<p className="qr-loading-state__text">Fetching address details…</p>
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
|
|
262
|
+
{/* ── Error state ── */}
|
|
263
|
+
{state.status === 'error' && (
|
|
264
|
+
<div className="qr-error-state" role="alert">
|
|
265
|
+
<div className="qr-error-state__icon" aria-hidden="true">⚠</div>
|
|
266
|
+
<p className="qr-error-state__msg">{state.message}</p>
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
className="qr-btn qr-btn--secondary qr-btn--sm"
|
|
270
|
+
onClick={() => void fetchAdminInfo()}
|
|
271
|
+
>
|
|
272
|
+
Retry
|
|
273
|
+
</button>
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
|
|
277
|
+
{/* ── Main form ── */}
|
|
278
|
+
{state.status === 'ready' && (
|
|
279
|
+
<form onSubmit={handleSubmit} className="qr-form" noValidate>
|
|
280
|
+
{/* Auto-filled section */}
|
|
281
|
+
<fieldset className="qr-fieldset">
|
|
282
|
+
<legend className="qr-fieldset__legend">
|
|
283
|
+
<span className="qr-fieldset__icon" aria-hidden="true">📍</span>
|
|
284
|
+
Auto-detected from your pin
|
|
285
|
+
</legend>
|
|
286
|
+
|
|
287
|
+
<div className="qr-auto-grid">
|
|
288
|
+
{/* State */}
|
|
289
|
+
<div className="qr-auto-row">
|
|
290
|
+
<span className="qr-auto-row__label">State</span>
|
|
291
|
+
<span className="qr-auto-row__value">{state.adminInfo.state}</span>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* District */}
|
|
295
|
+
<div className="qr-auto-row">
|
|
296
|
+
<span className="qr-auto-row__label">District</span>
|
|
297
|
+
<span className="qr-auto-row__value">{state.adminInfo.district}</span>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* Locality - show dropdown if alternatives exist */}
|
|
301
|
+
<div className="qr-auto-row qr-auto-row--full">
|
|
302
|
+
<span className="qr-auto-row__label">Locality</span>
|
|
303
|
+
{state.alternatives.length > 0 ? (
|
|
304
|
+
<select
|
|
305
|
+
className="qr-auto-row__select"
|
|
306
|
+
value={state.selectedLocality}
|
|
307
|
+
onChange={(e) => dispatch({ type: 'SET_LOCALITY', locality: e.target.value })}
|
|
308
|
+
aria-label="Select locality"
|
|
309
|
+
>
|
|
310
|
+
{/* Primary option (from adminInfo) */}
|
|
311
|
+
<option value={state.adminInfo.locality}>
|
|
312
|
+
{state.adminInfo.locality}
|
|
313
|
+
</option>
|
|
314
|
+
{/* Alternatives */}
|
|
315
|
+
{state.alternatives.map((alt, idx) => (
|
|
316
|
+
<option key={idx} value={alt.name}>
|
|
317
|
+
{alt.name}
|
|
318
|
+
</option>
|
|
319
|
+
))}
|
|
320
|
+
</select>
|
|
321
|
+
) : (
|
|
322
|
+
<span className="qr-auto-row__value">{state.selectedLocality}</span>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
{/* Pincode */}
|
|
327
|
+
<div className="qr-auto-row">
|
|
328
|
+
<span className="qr-auto-row__label">Pincode</span>
|
|
329
|
+
<span className="qr-auto-row__value qr-auto-row__value--pin">
|
|
330
|
+
{state.adminInfo.pincode}
|
|
331
|
+
</span>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
</fieldset>
|
|
335
|
+
|
|
336
|
+
{/* Manual entry section */}
|
|
337
|
+
<fieldset className="qr-fieldset">
|
|
338
|
+
<legend className="qr-fieldset__legend">
|
|
339
|
+
<span className="qr-fieldset__icon" aria-hidden="true">🏠</span>
|
|
340
|
+
Your details
|
|
341
|
+
</legend>
|
|
342
|
+
|
|
343
|
+
<div className="qr-fields-grid">
|
|
344
|
+
{/* Flat / House – required */}
|
|
345
|
+
<div className="qr-field qr-field--full">
|
|
346
|
+
<label className="qr-field__label" htmlFor="qr-flatNumber">
|
|
347
|
+
Flat / House Number
|
|
348
|
+
<span className="qr-required" aria-hidden="true">*</span>
|
|
349
|
+
</label>
|
|
350
|
+
<input
|
|
351
|
+
id="qr-flatNumber"
|
|
352
|
+
type="text"
|
|
353
|
+
className="qr-field__input"
|
|
354
|
+
placeholder="e.g. 4B, Flat 201, House No. 12"
|
|
355
|
+
value={state.fields.flatNumber}
|
|
356
|
+
onChange={(e) =>
|
|
357
|
+
dispatch({ type: 'SET_FIELD', key: 'flatNumber', value: e.target.value })
|
|
358
|
+
}
|
|
359
|
+
autoComplete="address-line1"
|
|
360
|
+
required
|
|
361
|
+
aria-required="true"
|
|
362
|
+
/>
|
|
363
|
+
</div>
|
|
364
|
+
|
|
365
|
+
{/* Floor */}
|
|
366
|
+
<div className="qr-field">
|
|
367
|
+
<label className="qr-field__label" htmlFor="qr-floorNumber">
|
|
368
|
+
Floor
|
|
369
|
+
<span className="qr-optional">optional</span>
|
|
370
|
+
</label>
|
|
371
|
+
<input
|
|
372
|
+
id="qr-floorNumber"
|
|
373
|
+
type="text"
|
|
374
|
+
className="qr-field__input"
|
|
375
|
+
placeholder="e.g. 3rd, Ground"
|
|
376
|
+
value={state.fields.floorNumber}
|
|
377
|
+
onChange={(e) =>
|
|
378
|
+
dispatch({ type: 'SET_FIELD', key: 'floorNumber', value: e.target.value })
|
|
379
|
+
}
|
|
380
|
+
/>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
{/* Building / Society */}
|
|
384
|
+
<div className="qr-field">
|
|
385
|
+
<label className="qr-field__label" htmlFor="qr-buildingName">
|
|
386
|
+
Building / Society
|
|
387
|
+
<span className="qr-optional">optional</span>
|
|
388
|
+
</label>
|
|
389
|
+
<input
|
|
390
|
+
id="qr-buildingName"
|
|
391
|
+
type="text"
|
|
392
|
+
className="qr-field__input"
|
|
393
|
+
placeholder="e.g. Sunshine Apts, DDA Colony"
|
|
394
|
+
value={state.fields.buildingName}
|
|
395
|
+
onChange={(e) =>
|
|
396
|
+
dispatch({ type: 'SET_FIELD', key: 'buildingName', value: e.target.value })
|
|
397
|
+
}
|
|
398
|
+
autoComplete="address-line2"
|
|
399
|
+
/>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
{/* Street / Area */}
|
|
403
|
+
<div className="qr-field qr-field--full">
|
|
404
|
+
<label className="qr-field__label" htmlFor="qr-streetName">
|
|
405
|
+
Street / Area
|
|
406
|
+
<span className="qr-optional">optional</span>
|
|
407
|
+
</label>
|
|
408
|
+
<input
|
|
409
|
+
id="qr-streetName"
|
|
410
|
+
type="text"
|
|
411
|
+
className="qr-field__input"
|
|
412
|
+
placeholder="e.g. MG Road, Sector 12"
|
|
413
|
+
value={state.fields.streetName}
|
|
414
|
+
onChange={(e) =>
|
|
415
|
+
dispatch({ type: 'SET_FIELD', key: 'streetName', value: e.target.value })
|
|
416
|
+
}
|
|
417
|
+
autoComplete="address-level3"
|
|
418
|
+
/>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
</fieldset>
|
|
422
|
+
|
|
423
|
+
{/* Actions */}
|
|
424
|
+
<div className="qr-form-actions">
|
|
425
|
+
<button
|
|
426
|
+
type="button"
|
|
427
|
+
className="qr-btn qr-btn--ghost"
|
|
428
|
+
onClick={onBack}
|
|
429
|
+
>
|
|
430
|
+
<svg
|
|
431
|
+
aria-hidden="true"
|
|
432
|
+
viewBox="0 0 24 24"
|
|
433
|
+
fill="none"
|
|
434
|
+
stroke="currentColor"
|
|
435
|
+
strokeWidth="2"
|
|
436
|
+
strokeLinecap="round"
|
|
437
|
+
strokeLinejoin="round"
|
|
438
|
+
>
|
|
439
|
+
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
440
|
+
</svg>
|
|
441
|
+
Adjust Pin
|
|
442
|
+
</button>
|
|
443
|
+
<button
|
|
444
|
+
type="submit"
|
|
445
|
+
className="qr-btn qr-btn--primary qr-btn--grow"
|
|
446
|
+
disabled={state.submitting || !state.fields.flatNumber.trim()}
|
|
447
|
+
>
|
|
448
|
+
{state.submitting ? (
|
|
449
|
+
<>
|
|
450
|
+
<span className="qr-spinner qr-spinner--sm" aria-hidden="true" />
|
|
451
|
+
Saving…
|
|
452
|
+
</>
|
|
453
|
+
) : (
|
|
454
|
+
<>
|
|
455
|
+
Save Address
|
|
456
|
+
<svg
|
|
457
|
+
aria-hidden="true"
|
|
458
|
+
viewBox="0 0 24 24"
|
|
459
|
+
fill="none"
|
|
460
|
+
stroke="currentColor"
|
|
461
|
+
strokeWidth="2.5"
|
|
462
|
+
strokeLinecap="round"
|
|
463
|
+
strokeLinejoin="round"
|
|
464
|
+
>
|
|
465
|
+
<path d="M20 6L9 17l-5-5" />
|
|
466
|
+
</svg>
|
|
467
|
+
</>
|
|
468
|
+
)}
|
|
469
|
+
</button>
|
|
470
|
+
</div>
|
|
471
|
+
</form>
|
|
472
|
+
)}
|
|
473
|
+
</div>
|
|
474
|
+
);
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
export default AddressForm;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
TouchableOpacity,
|
|
6
|
+
SafeAreaView,
|
|
7
|
+
Linking,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
// Explicit .native imports so TypeScript (tsconfig.native.json) resolves the
|
|
10
|
+
// correct platform files — Metro handles implicit resolution at bundle time.
|
|
11
|
+
import MapPinSelector from './MapPinSelector.native';
|
|
12
|
+
import AddressForm from './AddressForm.native';
|
|
13
|
+
import type { CheckoutWidgetProps, CompleteAddress } from '../core/types';
|
|
14
|
+
import { styles, COLORS } from '../styles/checkout.native';
|
|
15
|
+
|
|
16
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
type Step = 'map' | 'form' | 'done';
|
|
19
|
+
|
|
20
|
+
interface ConfirmedLocation {
|
|
21
|
+
lat: number;
|
|
22
|
+
lng: number;
|
|
23
|
+
digipin: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── Success screen ───────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
interface SuccessScreenProps {
|
|
29
|
+
address: CompleteAddress;
|
|
30
|
+
onEditAddress: () => void;
|
|
31
|
+
isDark: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SuccessScreen: React.FC<SuccessScreenProps> = ({ address, onEditAddress, isDark }) => (
|
|
35
|
+
<View style={styles.successScreen}>
|
|
36
|
+
<Text style={styles.successIcon}>✅</Text>
|
|
37
|
+
|
|
38
|
+
<Text style={[styles.successTitle, isDark && styles.successTitleDark]}>
|
|
39
|
+
Address Saved!
|
|
40
|
+
</Text>
|
|
41
|
+
|
|
42
|
+
<Text style={[styles.successAddress, isDark && styles.successAddressDark]}>
|
|
43
|
+
{address.formattedAddress}
|
|
44
|
+
</Text>
|
|
45
|
+
|
|
46
|
+
{/* DigiPin badge */}
|
|
47
|
+
<View style={[styles.successDigipinBadge, isDark && styles.successDigipinBadgeDark]}>
|
|
48
|
+
<Text style={styles.digipinLabel}>DigiPin</Text>
|
|
49
|
+
<Text style={styles.digipinCode}>{address.digipin}</Text>
|
|
50
|
+
</View>
|
|
51
|
+
|
|
52
|
+
<TouchableOpacity
|
|
53
|
+
style={[styles.btnGhost, { marginTop: 4 }]}
|
|
54
|
+
onPress={onEditAddress}
|
|
55
|
+
activeOpacity={0.8}
|
|
56
|
+
accessibilityRole="button"
|
|
57
|
+
>
|
|
58
|
+
<Text style={[styles.btnGhostText, isDark && styles.btnGhostTextDark]}>
|
|
59
|
+
Change Address
|
|
60
|
+
</Text>
|
|
61
|
+
</TouchableOpacity>
|
|
62
|
+
</View>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// ─── Main Widget ──────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* QuantaRoute Checkout Widget — native (iOS/Android) version.
|
|
69
|
+
*
|
|
70
|
+
* Two-step address collection:
|
|
71
|
+
* Step 1 – Map: drag pin to exact location (DigiPin computed offline, real-time)
|
|
72
|
+
* Step 2 – Form: auto-filled address + manual flat/building details
|
|
73
|
+
*
|
|
74
|
+
* Metro resolves this file on native; CheckoutWidget.tsx is used on web.
|
|
75
|
+
*
|
|
76
|
+
* Usage in Expo:
|
|
77
|
+
* import { CheckoutWidget } from '@quantaroute/checkout';
|
|
78
|
+
* <CheckoutWidget apiKey="..." onComplete={(addr) => console.log(addr)} />
|
|
79
|
+
*/
|
|
80
|
+
const CheckoutWidget: React.FC<CheckoutWidgetProps> = ({
|
|
81
|
+
apiKey,
|
|
82
|
+
apiBaseUrl = 'https://api.quantaroute.com',
|
|
83
|
+
onComplete,
|
|
84
|
+
onError,
|
|
85
|
+
defaultLat,
|
|
86
|
+
defaultLng,
|
|
87
|
+
theme = 'light',
|
|
88
|
+
style,
|
|
89
|
+
mapHeight = 380,
|
|
90
|
+
title = 'Add Delivery Address',
|
|
91
|
+
}) => {
|
|
92
|
+
const [step, setStep] = useState<Step>('map');
|
|
93
|
+
const [confirmedLocation, setConfirmedLocation] = useState<ConfirmedLocation | null>(null);
|
|
94
|
+
const [completedAddress, setCompletedAddress] = useState<CompleteAddress | null>(null);
|
|
95
|
+
|
|
96
|
+
const isDark = theme === 'dark';
|
|
97
|
+
|
|
98
|
+
// ── Handlers ──────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
const handleLocationConfirm = (lat: number, lng: number, digipin: string) => {
|
|
101
|
+
setConfirmedLocation({ lat, lng, digipin });
|
|
102
|
+
setStep('form');
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleAddressComplete = (address: CompleteAddress) => {
|
|
106
|
+
setCompletedAddress(address);
|
|
107
|
+
setStep('done');
|
|
108
|
+
onComplete(address);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleBack = () => setStep('map');
|
|
112
|
+
|
|
113
|
+
const handleEditAddress = () => {
|
|
114
|
+
setStep('map');
|
|
115
|
+
setConfirmedLocation(null);
|
|
116
|
+
setCompletedAddress(null);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ── Step indicator ─────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
const renderStepDots = () => (
|
|
122
|
+
<View style={styles.progress}>
|
|
123
|
+
{/* Dot 1 – always active */}
|
|
124
|
+
<View style={[styles.progressDot, styles.progressDotActive]} />
|
|
125
|
+
{/* Line */}
|
|
126
|
+
<View
|
|
127
|
+
style={[
|
|
128
|
+
styles.progressLine,
|
|
129
|
+
(step === 'form' || step === 'done') && styles.progressLineActive,
|
|
130
|
+
]}
|
|
131
|
+
/>
|
|
132
|
+
{/* Dot 2 */}
|
|
133
|
+
<View
|
|
134
|
+
style={[
|
|
135
|
+
styles.progressDot,
|
|
136
|
+
(step === 'form' || step === 'done') && styles.progressDotActive,
|
|
137
|
+
]}
|
|
138
|
+
/>
|
|
139
|
+
</View>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// ── Render ─────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<SafeAreaView
|
|
146
|
+
style={[
|
|
147
|
+
styles.container,
|
|
148
|
+
isDark && styles.containerDark,
|
|
149
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
150
|
+
style as any,
|
|
151
|
+
]}
|
|
152
|
+
accessibilityLabel="QuantaRoute checkout widget"
|
|
153
|
+
>
|
|
154
|
+
{/* Widget header (hidden on success screen) */}
|
|
155
|
+
{step !== 'done' ? (
|
|
156
|
+
<View style={[styles.header, isDark && styles.headerDark]}>
|
|
157
|
+
<View style={styles.headerBrand}>
|
|
158
|
+
<Text style={[styles.headerIcon, { color: COLORS.primary }]}>📍</Text>
|
|
159
|
+
<Text style={[styles.headerTitle, isDark && styles.headerTitleDark]}>
|
|
160
|
+
{title}
|
|
161
|
+
</Text>
|
|
162
|
+
</View>
|
|
163
|
+
{renderStepDots()}
|
|
164
|
+
</View>
|
|
165
|
+
) : null}
|
|
166
|
+
|
|
167
|
+
{/* ── Step 1: Map ── */}
|
|
168
|
+
{step === 'map' ? (
|
|
169
|
+
<MapPinSelector
|
|
170
|
+
onLocationConfirm={handleLocationConfirm}
|
|
171
|
+
defaultLat={defaultLat}
|
|
172
|
+
defaultLng={defaultLng}
|
|
173
|
+
mapHeight={mapHeight}
|
|
174
|
+
theme={theme}
|
|
175
|
+
/>
|
|
176
|
+
) : null}
|
|
177
|
+
|
|
178
|
+
{/* ── Step 2: Form ── */}
|
|
179
|
+
{step === 'form' && confirmedLocation ? (
|
|
180
|
+
<AddressForm
|
|
181
|
+
digipin={confirmedLocation.digipin}
|
|
182
|
+
lat={confirmedLocation.lat}
|
|
183
|
+
lng={confirmedLocation.lng}
|
|
184
|
+
apiKey={apiKey}
|
|
185
|
+
apiBaseUrl={apiBaseUrl}
|
|
186
|
+
onAddressComplete={handleAddressComplete}
|
|
187
|
+
onBack={handleBack}
|
|
188
|
+
onError={onError}
|
|
189
|
+
theme={theme}
|
|
190
|
+
/>
|
|
191
|
+
) : null}
|
|
192
|
+
|
|
193
|
+
{/* ── Step 3: Done ── */}
|
|
194
|
+
{step === 'done' && completedAddress ? (
|
|
195
|
+
<SuccessScreen
|
|
196
|
+
address={completedAddress}
|
|
197
|
+
onEditAddress={handleEditAddress}
|
|
198
|
+
isDark={isDark}
|
|
199
|
+
/>
|
|
200
|
+
) : null}
|
|
201
|
+
|
|
202
|
+
{/* Powered-by footer */}
|
|
203
|
+
<View style={[styles.footer, isDark && styles.footerDark]}>
|
|
204
|
+
<Text style={[styles.footerText, isDark && styles.footerTextDark]}>Powered by</Text>
|
|
205
|
+
<TouchableOpacity
|
|
206
|
+
onPress={() => void Linking.openURL('https://quantaroute.com')}
|
|
207
|
+
accessibilityRole="link"
|
|
208
|
+
accessibilityLabel="QuantaRoute website"
|
|
209
|
+
>
|
|
210
|
+
<Text style={styles.footerLink}>QuantaRoute</Text>
|
|
211
|
+
</TouchableOpacity>
|
|
212
|
+
<Text style={[styles.footerText, isDark && styles.footerTextDark]}> 🇮🇳</Text>
|
|
213
|
+
</View>
|
|
214
|
+
</SafeAreaView>
|
|
215
|
+
);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export default CheckoutWidget;
|