@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.
- package/LICENSE +29 -0
- package/README.md +299 -172
- 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 +47 -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
|
@@ -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;
|