@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.
@@ -0,0 +1,540 @@
1
+ import React, { useEffect, useReducer, useCallback, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TextInput,
6
+ TouchableOpacity,
7
+ ScrollView,
8
+ Modal,
9
+ FlatList,
10
+ ActivityIndicator,
11
+ KeyboardAvoidingView,
12
+ Platform,
13
+ } from 'react-native';
14
+ import { lookupLocation, reverseGeocode, type AddressComponents } from '../core/api';
15
+ import type {
16
+ AddressFormProps,
17
+ AdministrativeInfo,
18
+ CompleteAddress,
19
+ LocationAlternative,
20
+ } from '../core/types';
21
+ import { styles, COLORS } from '../styles/checkout.native';
22
+
23
+ // ─── State (identical logic to web AddressForm) ───────────────────────────────
24
+
25
+ interface ManualFields {
26
+ flatNumber: string;
27
+ floorNumber: string;
28
+ buildingName: string;
29
+ streetName: string;
30
+ }
31
+
32
+ type FormState =
33
+ | { status: 'loading' }
34
+ | { status: 'error'; message: string }
35
+ | {
36
+ status: 'ready';
37
+ adminInfo: AdministrativeInfo;
38
+ alternatives: LocationAlternative[];
39
+ selectedLocality: string;
40
+ fields: ManualFields;
41
+ submitting: boolean;
42
+ };
43
+
44
+ type FormAction =
45
+ | { type: 'LOAD_START' }
46
+ | {
47
+ type: 'LOAD_SUCCESS';
48
+ adminInfo: AdministrativeInfo;
49
+ alternatives: LocationAlternative[];
50
+ addressComponents: AddressComponents;
51
+ }
52
+ | { type: 'LOAD_ERROR'; message: string }
53
+ | { type: 'SET_FIELD'; key: keyof ManualFields; value: string }
54
+ | { type: 'SET_LOCALITY'; locality: string }
55
+ | { type: 'SUBMIT_START' }
56
+ | { type: 'SUBMIT_END' };
57
+
58
+ const INITIAL_FIELDS: ManualFields = {
59
+ flatNumber: '',
60
+ floorNumber: '',
61
+ buildingName: '',
62
+ streetName: '',
63
+ };
64
+
65
+ function parseAddressComponents(components: AddressComponents): Partial<ManualFields> {
66
+ const fields: Partial<ManualFields> = {};
67
+
68
+ if (components.name) {
69
+ fields.buildingName = components.name;
70
+ } else if (components.building_name) {
71
+ fields.buildingName = components.building_name;
72
+ } else if (components.addr_housename) {
73
+ fields.buildingName = components.addr_housename;
74
+ }
75
+
76
+ const streetParts: string[] = [];
77
+ if (components.road) streetParts.push(components.road);
78
+ if (components.suburb) streetParts.push(components.suburb);
79
+ if (streetParts.length > 0) {
80
+ fields.streetName = streetParts.join(', ');
81
+ }
82
+
83
+ return fields;
84
+ }
85
+
86
+ function reducer(state: FormState, action: FormAction): FormState {
87
+ switch (action.type) {
88
+ case 'LOAD_START':
89
+ return { status: 'loading' };
90
+ case 'LOAD_SUCCESS': {
91
+ const preFilled = parseAddressComponents(action.addressComponents);
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
+ /**
123
+ * Native (iOS/Android) address form — identical business logic to web AddressForm.tsx,
124
+ * rewritten with React Native primitives (TextInput, ScrollView, Modal picker).
125
+ * Metro resolves this file on native; AddressForm.tsx is used on web.
126
+ */
127
+ const AddressForm: React.FC<AddressFormProps> = ({
128
+ digipin,
129
+ lat,
130
+ lng,
131
+ apiKey,
132
+ apiBaseUrl,
133
+ onAddressComplete,
134
+ onBack,
135
+ onError,
136
+ theme = 'light',
137
+ }) => {
138
+ const [state, dispatch] = useReducer(reducer, { status: 'loading' });
139
+ const [showLocalityPicker, setShowLocalityPicker] = useState(false);
140
+ const [focusedField, setFocusedField] = useState<keyof ManualFields | null>(null);
141
+
142
+ const isDark = theme === 'dark';
143
+
144
+ // ── Fetch administrative data ──────────────────────────────────────────────
145
+ const fetchAdminInfo = useCallback(async () => {
146
+ dispatch({ type: 'LOAD_START' });
147
+ try {
148
+ const [adminRes, reverseRes] = await Promise.all([
149
+ lookupLocation(lat, lng, apiKey, apiBaseUrl),
150
+ reverseGeocode(digipin, apiKey, apiBaseUrl),
151
+ ]);
152
+
153
+ dispatch({
154
+ type: 'LOAD_SUCCESS',
155
+ adminInfo: adminRes.data.administrative_info,
156
+ alternatives: adminRes.data.alternatives || [],
157
+ addressComponents: reverseRes.data.addressComponents,
158
+ });
159
+ } catch (err) {
160
+ const msg = err instanceof Error ? err.message : 'Failed to fetch address data.';
161
+ dispatch({ type: 'LOAD_ERROR', message: msg });
162
+ onError?.(err instanceof Error ? err : new Error(msg));
163
+ }
164
+ }, [digipin, lat, lng, apiKey, apiBaseUrl, onError]);
165
+
166
+ useEffect(() => {
167
+ void fetchAdminInfo();
168
+ }, [fetchAdminInfo]);
169
+
170
+ // ── Submit ────────────────────────────────────────────────────────────────
171
+ const handleSubmit = useCallback(() => {
172
+ if (state.status !== 'ready') return;
173
+ if (!state.fields.flatNumber.trim()) return;
174
+
175
+ const { adminInfo, selectedLocality, fields } = state;
176
+
177
+ const parts: string[] = [
178
+ fields.flatNumber,
179
+ fields.floorNumber ? `Floor ${fields.floorNumber}` : '',
180
+ fields.buildingName,
181
+ fields.streetName,
182
+ selectedLocality,
183
+ adminInfo.district,
184
+ adminInfo.state,
185
+ adminInfo.pincode,
186
+ ].filter(Boolean);
187
+
188
+ const complete: CompleteAddress = {
189
+ digipin,
190
+ lat,
191
+ lng,
192
+ state: adminInfo.state,
193
+ district: adminInfo.district,
194
+ division: adminInfo.division,
195
+ locality: selectedLocality,
196
+ pincode: adminInfo.pincode,
197
+ delivery: adminInfo.delivery,
198
+ country: adminInfo.country ?? 'India',
199
+ ...fields,
200
+ formattedAddress: parts.join(', '),
201
+ };
202
+
203
+ dispatch({ type: 'SUBMIT_START' });
204
+ try {
205
+ onAddressComplete(complete);
206
+ } finally {
207
+ dispatch({ type: 'SUBMIT_END' });
208
+ }
209
+ }, [state, digipin, lat, lng, onAddressComplete]);
210
+
211
+ // ─── Field input helper ────────────────────────────────────────────────────
212
+ const renderField = (
213
+ key: keyof ManualFields,
214
+ label: string,
215
+ placeholder: string,
216
+ required = false
217
+ ) => (
218
+ <View style={state.status === 'ready' ? undefined : { opacity: 0 }}>
219
+ <View style={styles.fieldLabel}>
220
+ <Text style={[styles.fieldLabelText, isDark && styles.fieldLabelTextDark]}>
221
+ {label}
222
+ </Text>
223
+ {required ? (
224
+ <Text style={styles.fieldRequired}>*</Text>
225
+ ) : (
226
+ <Text style={[styles.fieldOptional, isDark && styles.fieldOptionalDark]}>
227
+ optional
228
+ </Text>
229
+ )}
230
+ </View>
231
+ <TextInput
232
+ style={[
233
+ styles.fieldInput,
234
+ isDark && styles.fieldInputDark,
235
+ focusedField === key && styles.fieldInputFocused,
236
+ ]}
237
+ placeholder={placeholder}
238
+ placeholderTextColor={isDark ? COLORS.textFaintDark : COLORS.textFaint}
239
+ value={state.status === 'ready' ? state.fields[key] : ''}
240
+ onChangeText={(value) =>
241
+ state.status === 'ready' &&
242
+ dispatch({ type: 'SET_FIELD', key, value })
243
+ }
244
+ onFocus={() => setFocusedField(key)}
245
+ onBlur={() => setFocusedField(null)}
246
+ autoCapitalize="words"
247
+ returnKeyType="next"
248
+ />
249
+ </View>
250
+ );
251
+
252
+ return (
253
+ <KeyboardAvoidingView
254
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
255
+ style={{ flex: 1 }}
256
+ >
257
+ <ScrollView
258
+ style={[{ flex: 1 }, isDark && { backgroundColor: COLORS.bgDark }]}
259
+ keyboardShouldPersistTaps="handled"
260
+ showsVerticalScrollIndicator={false}
261
+ >
262
+ {/* ── Step header ── */}
263
+ <View style={[styles.stepHeader, isDark && styles.stepHeaderDark]}>
264
+ <TouchableOpacity
265
+ style={[styles.backBtn, isDark && styles.backBtnDark]}
266
+ onPress={onBack}
267
+ accessibilityLabel="Back to map"
268
+ accessibilityRole="button"
269
+ >
270
+ <Text style={[styles.backBtnText, isDark && styles.backBtnTextDark]}>←</Text>
271
+ </TouchableOpacity>
272
+ <View style={styles.stepBadge}>
273
+ <Text style={styles.stepBadgeText}>2</Text>
274
+ </View>
275
+ <View style={styles.stepText}>
276
+ <Text style={[styles.stepTitle, isDark && styles.textLight]}>
277
+ Add Address Details
278
+ </Text>
279
+ <Text style={[styles.stepSub, isDark && styles.textMutedStyle]}>
280
+ Flat number and building info
281
+ </Text>
282
+ </View>
283
+ </View>
284
+
285
+ {/* ── DigiPin reference strip ── */}
286
+ <View style={[styles.formDigipinStrip, isDark && styles.formDigipinStripDark]}>
287
+ <Text style={styles.formDigipinStripIcon}>📍</Text>
288
+ <Text style={styles.formDigipinStripLabel}>DigiPin</Text>
289
+ <Text style={styles.formDigipinStripCode}>{digipin}</Text>
290
+ </View>
291
+
292
+ {/* ── Loading state ── */}
293
+ {state.status === 'loading' ? (
294
+ <View style={styles.loadingState} accessibilityLabel="Fetching address details">
295
+ <ActivityIndicator size="large" color={COLORS.primary} />
296
+ <Text style={[styles.loadingText, isDark && styles.loadingTextDark]}>
297
+ Fetching address details…
298
+ </Text>
299
+ </View>
300
+ ) : null}
301
+
302
+ {/* ── Error state ── */}
303
+ {state.status === 'error' ? (
304
+ <View style={styles.errorState} accessibilityRole="alert">
305
+ <Text style={styles.errorIcon}>⚠</Text>
306
+ <Text style={[styles.errorMsg, isDark && styles.errorMsgDark]}>
307
+ {state.message}
308
+ </Text>
309
+ <TouchableOpacity
310
+ style={styles.btnSecondary}
311
+ onPress={() => void fetchAdminInfo()}
312
+ activeOpacity={0.8}
313
+ >
314
+ <Text style={styles.btnSecondaryText}>Retry</Text>
315
+ </TouchableOpacity>
316
+ </View>
317
+ ) : null}
318
+
319
+ {/* ── Main form ── */}
320
+ {state.status === 'ready' ? (
321
+ <View>
322
+ {/* Auto-detected section */}
323
+ <View style={[styles.sectionDivider, isDark && styles.sectionDividerDark]} />
324
+ <View style={styles.sectionHeader}>
325
+ <Text style={styles.sectionHeaderIcon}>📍</Text>
326
+ <Text style={[styles.sectionHeaderText, isDark && styles.sectionHeaderTextDark]}>
327
+ Auto-detected from your pin
328
+ </Text>
329
+ </View>
330
+
331
+ <View style={styles.autoGrid}>
332
+ {/* State & District in a row */}
333
+ <View style={styles.autoRowsRow}>
334
+ <View style={[styles.autoRow, styles.autoRowHalf]}>
335
+ <Text style={[styles.autoRowLabel, isDark && styles.autoRowLabelDark]}>
336
+ State
337
+ </Text>
338
+ <Text style={[styles.autoRowValue, isDark && styles.autoRowValueDark]}>
339
+ {state.adminInfo.state}
340
+ </Text>
341
+ </View>
342
+ <View style={[styles.autoRow, styles.autoRowHalf]}>
343
+ <Text style={[styles.autoRowLabel, isDark && styles.autoRowLabelDark]}>
344
+ District
345
+ </Text>
346
+ <Text style={[styles.autoRowValue, isDark && styles.autoRowValueDark]}>
347
+ {state.adminInfo.district}
348
+ </Text>
349
+ </View>
350
+ </View>
351
+
352
+ {/* Locality */}
353
+ <View style={styles.autoRow}>
354
+ <Text style={[styles.autoRowLabel, isDark && styles.autoRowLabelDark]}>
355
+ Locality
356
+ </Text>
357
+ {state.alternatives.length > 0 ? (
358
+ <TouchableOpacity
359
+ style={[
360
+ styles.localitySelector,
361
+ isDark && styles.localitySelectorDark,
362
+ ]}
363
+ onPress={() => setShowLocalityPicker(true)}
364
+ activeOpacity={0.8}
365
+ accessibilityRole="button"
366
+ accessibilityLabel="Select locality"
367
+ >
368
+ <Text
369
+ style={[
370
+ styles.localitySelectorText,
371
+ isDark && styles.localitySelectorTextDark,
372
+ ]}
373
+ numberOfLines={1}
374
+ >
375
+ {state.selectedLocality}
376
+ </Text>
377
+ <Text style={styles.localitySelectorArrow}>▼</Text>
378
+ </TouchableOpacity>
379
+ ) : (
380
+ <Text style={[styles.autoRowValue, isDark && styles.autoRowValueDark]}>
381
+ {state.selectedLocality}
382
+ </Text>
383
+ )}
384
+ </View>
385
+
386
+ {/* Pincode */}
387
+ <View style={styles.autoRow}>
388
+ <Text style={[styles.autoRowLabel, isDark && styles.autoRowLabelDark]}>
389
+ Pincode
390
+ </Text>
391
+ <Text style={styles.autoRowValuePincode}>{state.adminInfo.pincode}</Text>
392
+ </View>
393
+ </View>
394
+
395
+ {/* Manual entry section */}
396
+ <View style={[styles.sectionDivider, isDark && styles.sectionDividerDark]} />
397
+ <View style={styles.sectionHeader}>
398
+ <Text style={styles.sectionHeaderIcon}>🏠</Text>
399
+ <Text style={[styles.sectionHeaderText, isDark && styles.sectionHeaderTextDark]}>
400
+ Your details
401
+ </Text>
402
+ </View>
403
+
404
+ <View style={styles.fieldsGrid}>
405
+ {/* Flat / House – required */}
406
+ <View style={styles.fieldFull}>
407
+ {renderField(
408
+ 'flatNumber',
409
+ 'Flat / House Number',
410
+ 'e.g. 4B, Flat 201, House No. 12',
411
+ true
412
+ )}
413
+ </View>
414
+
415
+ {/* Floor + Building in a row */}
416
+ <View style={styles.fieldRow}>
417
+ <View style={styles.field}>
418
+ {renderField('floorNumber', 'Floor', 'e.g. 3rd, Ground')}
419
+ </View>
420
+ <View style={styles.field}>
421
+ {renderField('buildingName', 'Building / Society', 'e.g. Sunshine Apts')}
422
+ </View>
423
+ </View>
424
+
425
+ {/* Street – full width */}
426
+ <View style={styles.fieldFull}>
427
+ {renderField(
428
+ 'streetName',
429
+ 'Street / Area',
430
+ 'e.g. MG Road, Sector 12'
431
+ )}
432
+ </View>
433
+ </View>
434
+ </View>
435
+ ) : null}
436
+ </ScrollView>
437
+
438
+ {/* ── Sticky form actions ── */}
439
+ {state.status === 'ready' ? (
440
+ <View style={[styles.formActions, isDark && styles.formActionsDark]}>
441
+ <TouchableOpacity
442
+ style={[styles.btnGhost, isDark && styles.btnGhostDark]}
443
+ onPress={onBack}
444
+ activeOpacity={0.8}
445
+ >
446
+ <Text style={[styles.btnGhostText, isDark && styles.btnGhostTextDark]}>
447
+ ← Adjust Pin
448
+ </Text>
449
+ </TouchableOpacity>
450
+ <TouchableOpacity
451
+ style={[
452
+ styles.btnPrimary,
453
+ { flex: 1 },
454
+ (state.submitting || !state.fields.flatNumber.trim()) && styles.btnDisabled,
455
+ ]}
456
+ onPress={handleSubmit}
457
+ disabled={state.submitting || !state.fields.flatNumber.trim()}
458
+ activeOpacity={0.8}
459
+ accessibilityRole="button"
460
+ accessibilityLabel="Save address"
461
+ >
462
+ {state.submitting ? (
463
+ <ActivityIndicator size="small" color="#fff" />
464
+ ) : (
465
+ <Text style={styles.btnPrimaryText}>Save Address ✓</Text>
466
+ )}
467
+ </TouchableOpacity>
468
+ </View>
469
+ ) : null}
470
+
471
+ {/* ── Locality picker modal ── */}
472
+ {state.status === 'ready' && state.alternatives.length > 0 ? (
473
+ <Modal
474
+ visible={showLocalityPicker}
475
+ transparent
476
+ animationType="slide"
477
+ onRequestClose={() => setShowLocalityPicker(false)}
478
+ accessibilityViewIsModal
479
+ >
480
+ <TouchableOpacity
481
+ style={styles.modalOverlay}
482
+ activeOpacity={1}
483
+ onPress={() => setShowLocalityPicker(false)}
484
+ >
485
+ <TouchableOpacity
486
+ style={[styles.modalSheet, isDark && styles.modalSheetDark]}
487
+ activeOpacity={1}
488
+ >
489
+ <View style={styles.modalHandle} />
490
+ <Text style={[styles.modalTitle, isDark && styles.modalTitleDark]}>
491
+ Select Locality
492
+ </Text>
493
+ <FlatList
494
+ data={[
495
+ { name: state.adminInfo.locality } as LocationAlternative,
496
+ ...state.alternatives,
497
+ ]}
498
+ keyExtractor={(item, idx) => `${item.name}-${idx}`}
499
+ renderItem={({ item }) => {
500
+ const isSelected = item.name === state.selectedLocality;
501
+ return (
502
+ <TouchableOpacity
503
+ style={[
504
+ styles.modalOption,
505
+ isDark && styles.modalOptionDark,
506
+ isSelected && styles.modalOptionSelected,
507
+ ]}
508
+ onPress={() => {
509
+ if (state.status === 'ready') {
510
+ dispatch({ type: 'SET_LOCALITY', locality: item.name });
511
+ }
512
+ setShowLocalityPicker(false);
513
+ }}
514
+ activeOpacity={0.7}
515
+ >
516
+ <Text
517
+ style={[
518
+ styles.modalOptionText,
519
+ isDark && styles.modalOptionTextDark,
520
+ isSelected && styles.modalOptionTextSelected,
521
+ ]}
522
+ >
523
+ {item.name}
524
+ </Text>
525
+ {isSelected ? (
526
+ <Text style={styles.modalOptionCheckmark}>✓</Text>
527
+ ) : null}
528
+ </TouchableOpacity>
529
+ );
530
+ }}
531
+ />
532
+ </TouchableOpacity>
533
+ </TouchableOpacity>
534
+ </Modal>
535
+ ) : null}
536
+ </KeyboardAvoidingView>
537
+ );
538
+ };
539
+
540
+ export default AddressForm;