@tagadapay/plugin-sdk 1.0.29 → 2.0.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.
- package/README.md +150 -18
- package/dist/data/iso3166.d.ts +30 -0
- package/dist/data/iso3166.js +102 -0
- package/dist/react/hooks/useAddressV2.d.ts +53 -0
- package/dist/react/hooks/useAddressV2.js +379 -0
- package/dist/react/hooks/useGoogleAutocomplete.d.ts +69 -0
- package/dist/react/hooks/useGoogleAutocomplete.js +219 -0
- package/dist/react/hooks/useISOData.d.ts +41 -0
- package/dist/react/hooks/useISOData.js +127 -0
- package/dist/react/hooks/useLogin.d.ts +20 -0
- package/dist/react/hooks/useLogin.js +75 -0
- package/dist/react/hooks/usePayment.d.ts +43 -0
- package/dist/react/hooks/usePayment.js +16 -2
- package/dist/react/hooks/usePluginConfig.d.ts +53 -0
- package/dist/react/hooks/usePluginConfig.js +190 -0
- package/dist/react/index.d.ts +7 -0
- package/dist/react/index.js +7 -0
- package/dist/react/providers/TagadaProvider.d.ts +5 -1
- package/dist/react/providers/TagadaProvider.js +48 -2
- package/package.json +2 -1
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
import { usePlacesWidget } from 'react-google-autocomplete';
|
|
3
|
+
// Import the standardized ISO3166 data
|
|
4
|
+
import { getCountries, getAllStates, getStatesForCountry, isValidCountryCode, isValidStateCode, } from '../../data/iso3166';
|
|
5
|
+
// Default field structure
|
|
6
|
+
const createDefaultField = (value = '') => ({
|
|
7
|
+
value,
|
|
8
|
+
isValid: true,
|
|
9
|
+
error: undefined,
|
|
10
|
+
touched: false,
|
|
11
|
+
});
|
|
12
|
+
// Default form data
|
|
13
|
+
const createDefaultFormData = (initialValues = {}) => ({
|
|
14
|
+
firstName: createDefaultField(initialValues.firstName),
|
|
15
|
+
lastName: createDefaultField(initialValues.lastName),
|
|
16
|
+
email: createDefaultField(initialValues.email),
|
|
17
|
+
phone: createDefaultField(initialValues.phone),
|
|
18
|
+
country: createDefaultField(initialValues.country),
|
|
19
|
+
address1: createDefaultField(initialValues.address1),
|
|
20
|
+
address2: createDefaultField(initialValues.address2),
|
|
21
|
+
city: createDefaultField(initialValues.city),
|
|
22
|
+
state: createDefaultField(initialValues.state),
|
|
23
|
+
postal: createDefaultField(initialValues.postal),
|
|
24
|
+
});
|
|
25
|
+
// Validation functions
|
|
26
|
+
const isValidEmail = (email) => {
|
|
27
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
28
|
+
return emailRegex.test(email);
|
|
29
|
+
};
|
|
30
|
+
const isValidPhone = (phone) => {
|
|
31
|
+
const phoneRegex = /^[+]?[\d\s\-()]+$/;
|
|
32
|
+
return phone.length >= 10 && phoneRegex.test(phone);
|
|
33
|
+
};
|
|
34
|
+
const isValidPostal = (postal, country) => {
|
|
35
|
+
if (!postal)
|
|
36
|
+
return false;
|
|
37
|
+
const patterns = {
|
|
38
|
+
US: /^\d{5}(-\d{4})?$/,
|
|
39
|
+
CA: /^[A-Za-z]\d[A-Za-z] \d[A-Za-z]\d$/,
|
|
40
|
+
GB: /^[A-Z]{1,2}\d[A-Z\d]? \d[A-Z]{2}$/,
|
|
41
|
+
DE: /^\d{5}$/,
|
|
42
|
+
FR: /^\d{5}$/,
|
|
43
|
+
};
|
|
44
|
+
const pattern = patterns[country];
|
|
45
|
+
return pattern ? pattern.test(postal) : postal.length >= 3;
|
|
46
|
+
};
|
|
47
|
+
export const useAddressV2 = (config = {}) => {
|
|
48
|
+
const { autoValidate = false, enableGooglePlaces = false, googlePlacesApiKey, countryRestrictions = [], onFieldsChange, debounceConfig = { autoSaveDelay: 1000, enabled: true }, initialValues = {}, } = config;
|
|
49
|
+
// State management
|
|
50
|
+
const [fields, setFields] = useState(() => createDefaultFormData(initialValues));
|
|
51
|
+
// Google Places integration
|
|
52
|
+
const [addressInputValue, setAddressInputValue] = useState(initialValues.address1 || '');
|
|
53
|
+
const addressRef = useRef(null);
|
|
54
|
+
// Debounce timeout ref
|
|
55
|
+
const debounceTimeoutRef = useRef(null);
|
|
56
|
+
// Memoized countries with restrictions applied (using ISO3166 data)
|
|
57
|
+
const countries = useMemo(() => {
|
|
58
|
+
const allCountries = getCountries(); // Already includes uniqueKey and is deduplicated
|
|
59
|
+
return countryRestrictions.length > 0
|
|
60
|
+
? allCountries.filter((country) => countryRestrictions.includes(country.code))
|
|
61
|
+
: allCountries;
|
|
62
|
+
}, [countryRestrictions]);
|
|
63
|
+
// Memoized states for all countries (using ISO3166 data)
|
|
64
|
+
const allStates = useMemo(() => {
|
|
65
|
+
return getAllStates(); // Already includes uniqueKey and is deduplicated
|
|
66
|
+
}, []);
|
|
67
|
+
// Get states for current selected country
|
|
68
|
+
const states = useMemo(() => {
|
|
69
|
+
const selectedCountry = fields.country.value;
|
|
70
|
+
if (!selectedCountry)
|
|
71
|
+
return [];
|
|
72
|
+
return allStates.filter((state) => state.countryCode === selectedCountry);
|
|
73
|
+
}, [allStates, fields.country.value]);
|
|
74
|
+
// Get states for any country (utility function) - use ISO3166 function
|
|
75
|
+
const getStatesForCountryFn = useCallback((countryCode) => {
|
|
76
|
+
return getStatesForCountry(countryCode); // Use the ISO3166 function directly
|
|
77
|
+
}, []);
|
|
78
|
+
// Validation function for a single field
|
|
79
|
+
const validateField = useCallback((field) => {
|
|
80
|
+
const value = fields[field].value;
|
|
81
|
+
switch (field) {
|
|
82
|
+
case 'email':
|
|
83
|
+
return value ? isValidEmail(value) : true;
|
|
84
|
+
case 'phone':
|
|
85
|
+
return value ? isValidPhone(value) : true;
|
|
86
|
+
case 'postal':
|
|
87
|
+
return value ? isValidPostal(value, fields.country.value) : true;
|
|
88
|
+
case 'firstName':
|
|
89
|
+
case 'lastName':
|
|
90
|
+
case 'address1':
|
|
91
|
+
case 'city':
|
|
92
|
+
return value.trim().length > 0;
|
|
93
|
+
case 'country': {
|
|
94
|
+
return isValidCountryCode(value);
|
|
95
|
+
}
|
|
96
|
+
case 'state': {
|
|
97
|
+
if (!fields.country.value)
|
|
98
|
+
return true;
|
|
99
|
+
return isValidStateCode(fields.country.value, value);
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}, [fields]);
|
|
105
|
+
// Validate all fields
|
|
106
|
+
const validateAll = useCallback(() => {
|
|
107
|
+
const fieldsToValidate = [
|
|
108
|
+
'firstName',
|
|
109
|
+
'lastName',
|
|
110
|
+
'email',
|
|
111
|
+
'phone',
|
|
112
|
+
'country',
|
|
113
|
+
'address1',
|
|
114
|
+
'city',
|
|
115
|
+
'state',
|
|
116
|
+
'postal',
|
|
117
|
+
];
|
|
118
|
+
return fieldsToValidate.every((field) => validateField(field));
|
|
119
|
+
}, [validateField]);
|
|
120
|
+
// Check if form is currently valid
|
|
121
|
+
const isValid = useMemo(() => validateAll(), [validateAll]);
|
|
122
|
+
// Get validation error message
|
|
123
|
+
const getFieldError = useCallback((field, value) => {
|
|
124
|
+
if (!value &&
|
|
125
|
+
['firstName', 'lastName', 'email', 'phone', 'country', 'address1', 'city'].includes(field)) {
|
|
126
|
+
return `${field.charAt(0).toUpperCase() + field.slice(1)} is required`;
|
|
127
|
+
}
|
|
128
|
+
switch (field) {
|
|
129
|
+
case 'email':
|
|
130
|
+
return value && !isValidEmail(value) ? 'Please enter a valid email address' : undefined;
|
|
131
|
+
case 'phone':
|
|
132
|
+
return value && !isValidPhone(value) ? 'Please enter a valid phone number' : undefined;
|
|
133
|
+
case 'postal':
|
|
134
|
+
return value && !isValidPostal(value, fields.country.value)
|
|
135
|
+
? 'Please enter a valid postal code'
|
|
136
|
+
: undefined;
|
|
137
|
+
case 'country':
|
|
138
|
+
return value && !isValidCountryCode(value) ? 'Please select a valid country' : undefined;
|
|
139
|
+
case 'state': {
|
|
140
|
+
if (!fields.country.value)
|
|
141
|
+
return undefined;
|
|
142
|
+
const countryStates = getStatesForCountry(fields.country.value);
|
|
143
|
+
if (countryStates.length > 0 && !isValidStateCode(fields.country.value, value)) {
|
|
144
|
+
return 'Please select a valid state/province';
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
default:
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}, [fields.country.value]);
|
|
152
|
+
// Refs for stable references to avoid recreating callbacks
|
|
153
|
+
const onFieldsChangeRef = useRef(onFieldsChange);
|
|
154
|
+
const debounceConfigRef = useRef(debounceConfig);
|
|
155
|
+
// Update refs when values change
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
onFieldsChangeRef.current = onFieldsChange;
|
|
158
|
+
}, [onFieldsChange]);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
debounceConfigRef.current = debounceConfig;
|
|
161
|
+
}, [debounceConfig]);
|
|
162
|
+
// Store fields ref for stable access
|
|
163
|
+
const fieldsRef = useRef(fields);
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
fieldsRef.current = fields;
|
|
166
|
+
}, [fields]);
|
|
167
|
+
// Debounced onFieldsChange callback (stable reference - NO DEPENDENCIES!)
|
|
168
|
+
const triggerFieldsChange = useCallback(() => {
|
|
169
|
+
const currentConfig = debounceConfigRef.current;
|
|
170
|
+
const currentCallback = onFieldsChangeRef.current;
|
|
171
|
+
if (!currentConfig.enabled || !currentCallback)
|
|
172
|
+
return;
|
|
173
|
+
if (debounceTimeoutRef.current) {
|
|
174
|
+
clearTimeout(debounceTimeoutRef.current);
|
|
175
|
+
}
|
|
176
|
+
debounceTimeoutRef.current = setTimeout(() => {
|
|
177
|
+
// Get current field values at execution time, not creation time
|
|
178
|
+
const currentFields = fieldsRef.current;
|
|
179
|
+
const currentValues = {
|
|
180
|
+
firstName: currentFields.firstName.value,
|
|
181
|
+
lastName: currentFields.lastName.value,
|
|
182
|
+
email: currentFields.email.value,
|
|
183
|
+
phone: currentFields.phone.value,
|
|
184
|
+
country: currentFields.country.value,
|
|
185
|
+
address1: currentFields.address1.value,
|
|
186
|
+
address2: currentFields.address2.value,
|
|
187
|
+
city: currentFields.city.value,
|
|
188
|
+
state: currentFields.state.value,
|
|
189
|
+
postal: currentFields.postal.value,
|
|
190
|
+
};
|
|
191
|
+
currentCallback(currentValues);
|
|
192
|
+
}, currentConfig.autoSaveDelay);
|
|
193
|
+
}, []); // NO DEPENDENCIES - truly stable!
|
|
194
|
+
// Set single field value with validation
|
|
195
|
+
const setValue = useCallback((field, value, triggerSave = false) => {
|
|
196
|
+
setFields((prev) => {
|
|
197
|
+
const error = autoValidate ? getFieldError(field, value) : undefined;
|
|
198
|
+
const newFields = {
|
|
199
|
+
...prev,
|
|
200
|
+
[field]: {
|
|
201
|
+
value,
|
|
202
|
+
isValid: !error,
|
|
203
|
+
error,
|
|
204
|
+
touched: true,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
// Reset state when country changes
|
|
208
|
+
if (field === 'country') {
|
|
209
|
+
newFields.state = createDefaultField('');
|
|
210
|
+
}
|
|
211
|
+
return newFields;
|
|
212
|
+
});
|
|
213
|
+
// Handle address input value for Google Places
|
|
214
|
+
if (field === 'address1') {
|
|
215
|
+
setAddressInputValue(value);
|
|
216
|
+
}
|
|
217
|
+
// Only trigger auto-save when explicitly requested (on blur, not on every keystroke)
|
|
218
|
+
if (triggerSave) {
|
|
219
|
+
triggerFieldsChange();
|
|
220
|
+
}
|
|
221
|
+
}, [autoValidate, getFieldError, triggerFieldsChange]);
|
|
222
|
+
// Set multiple field values
|
|
223
|
+
const setValues = useCallback((values) => {
|
|
224
|
+
setFields((prev) => {
|
|
225
|
+
const newFields = { ...prev };
|
|
226
|
+
Object.entries(values).forEach(([key, value]) => {
|
|
227
|
+
const field = key;
|
|
228
|
+
const error = autoValidate ? getFieldError(field, value || '') : undefined;
|
|
229
|
+
newFields[field] = {
|
|
230
|
+
value: value || '',
|
|
231
|
+
isValid: !error,
|
|
232
|
+
error,
|
|
233
|
+
touched: true,
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
// Reset state if country changed
|
|
237
|
+
if (values.country !== undefined) {
|
|
238
|
+
newFields.state = createDefaultField(values.state || '');
|
|
239
|
+
}
|
|
240
|
+
return newFields;
|
|
241
|
+
});
|
|
242
|
+
// Update address input value if address1 was changed
|
|
243
|
+
if (values.address1 !== undefined) {
|
|
244
|
+
setAddressInputValue(values.address1);
|
|
245
|
+
}
|
|
246
|
+
// Trigger debounced callback
|
|
247
|
+
triggerFieldsChange();
|
|
248
|
+
}, [autoValidate, getFieldError, triggerFieldsChange]);
|
|
249
|
+
// Get single field value
|
|
250
|
+
const getValue = useCallback((field) => {
|
|
251
|
+
return fields[field].value;
|
|
252
|
+
}, [fields]);
|
|
253
|
+
// Get all field values
|
|
254
|
+
const getValues = useCallback(() => {
|
|
255
|
+
return {
|
|
256
|
+
firstName: fields.firstName.value,
|
|
257
|
+
lastName: fields.lastName.value,
|
|
258
|
+
email: fields.email.value,
|
|
259
|
+
phone: fields.phone.value,
|
|
260
|
+
country: fields.country.value,
|
|
261
|
+
address1: fields.address1.value,
|
|
262
|
+
address2: fields.address2.value,
|
|
263
|
+
city: fields.city.value,
|
|
264
|
+
state: fields.state.value,
|
|
265
|
+
postal: fields.postal.value,
|
|
266
|
+
};
|
|
267
|
+
}, [fields]);
|
|
268
|
+
// Reset form
|
|
269
|
+
const reset = useCallback(() => {
|
|
270
|
+
setFields(createDefaultFormData());
|
|
271
|
+
setAddressInputValue('');
|
|
272
|
+
}, []);
|
|
273
|
+
// Optimized field change handler factory
|
|
274
|
+
const handleFieldChange = useCallback((field) => {
|
|
275
|
+
return (value) => setValue(field, value);
|
|
276
|
+
}, [setValue]);
|
|
277
|
+
// Optimized field blur handler factory
|
|
278
|
+
const handleFieldBlur = useCallback((field) => {
|
|
279
|
+
return () => {
|
|
280
|
+
if (!autoValidate) {
|
|
281
|
+
setFields((prev) => {
|
|
282
|
+
const value = prev[field].value;
|
|
283
|
+
const error = getFieldError(field, value);
|
|
284
|
+
return {
|
|
285
|
+
...prev,
|
|
286
|
+
[field]: {
|
|
287
|
+
...prev[field],
|
|
288
|
+
isValid: !error,
|
|
289
|
+
error,
|
|
290
|
+
touched: true,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}, [autoValidate, getFieldError]);
|
|
297
|
+
// Google Places autocomplete setup - we'll return the ref for manual connection
|
|
298
|
+
const placesConfig = useMemo(() => ({
|
|
299
|
+
apiKey: googlePlacesApiKey,
|
|
300
|
+
onPlaceSelected: (place) => {
|
|
301
|
+
if (!place.geometry || !enableGooglePlaces)
|
|
302
|
+
return;
|
|
303
|
+
const addressComponents = place.address_components || [];
|
|
304
|
+
const newValues = {};
|
|
305
|
+
addressComponents.forEach((component) => {
|
|
306
|
+
const types = component.types;
|
|
307
|
+
if (types.includes('street_number')) {
|
|
308
|
+
newValues.address1 = `${component.long_name} ${newValues.address1 || ''}`.trim();
|
|
309
|
+
}
|
|
310
|
+
else if (types.includes('route')) {
|
|
311
|
+
newValues.address1 = `${newValues.address1 || ''} ${component.long_name}`.trim();
|
|
312
|
+
}
|
|
313
|
+
else if (types.includes('locality')) {
|
|
314
|
+
newValues.city = component.long_name;
|
|
315
|
+
}
|
|
316
|
+
else if (types.includes('administrative_area_level_1')) {
|
|
317
|
+
newValues.state = component.short_name;
|
|
318
|
+
}
|
|
319
|
+
else if (types.includes('country')) {
|
|
320
|
+
newValues.country = component.short_name;
|
|
321
|
+
}
|
|
322
|
+
else if (types.includes('postal_code')) {
|
|
323
|
+
newValues.postal = component.long_name;
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
// Only update if country is in restrictions (if any)
|
|
327
|
+
if (newValues.country && countryRestrictions.length > 0) {
|
|
328
|
+
if (!countryRestrictions.includes(newValues.country)) {
|
|
329
|
+
console.warn(`Google Places returned country ${newValues.country} which is not in restrictions`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
setValues(newValues);
|
|
334
|
+
},
|
|
335
|
+
options: {
|
|
336
|
+
types: ['address'],
|
|
337
|
+
...(countryRestrictions.length > 0 && {
|
|
338
|
+
componentRestrictions: { country: countryRestrictions },
|
|
339
|
+
}),
|
|
340
|
+
},
|
|
341
|
+
}), [googlePlacesApiKey, enableGooglePlaces, countryRestrictions, setValues]);
|
|
342
|
+
// Use the Google Places hook - always call the hook to avoid conditional hook calls
|
|
343
|
+
const googlePlacesHook = usePlacesWidget(enableGooglePlaces
|
|
344
|
+
? placesConfig
|
|
345
|
+
: {
|
|
346
|
+
apiKey: '',
|
|
347
|
+
onPlaceSelected: () => {
|
|
348
|
+
// No-op when Google Places is disabled
|
|
349
|
+
},
|
|
350
|
+
options: {},
|
|
351
|
+
});
|
|
352
|
+
// Cleanup debounce timeout on unmount
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
return () => {
|
|
355
|
+
if (debounceTimeoutRef.current) {
|
|
356
|
+
clearTimeout(debounceTimeoutRef.current);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}, []);
|
|
360
|
+
return {
|
|
361
|
+
fields,
|
|
362
|
+
setValue,
|
|
363
|
+
setValues,
|
|
364
|
+
getValue,
|
|
365
|
+
getValues,
|
|
366
|
+
validateField,
|
|
367
|
+
validateAll,
|
|
368
|
+
isValid,
|
|
369
|
+
reset,
|
|
370
|
+
countries,
|
|
371
|
+
states,
|
|
372
|
+
getStatesForCountry: getStatesForCountryFn,
|
|
373
|
+
addressRef: enableGooglePlaces ? (googlePlacesHook.ref ?? addressRef) : addressRef,
|
|
374
|
+
addressInputValue,
|
|
375
|
+
setAddressInputValue,
|
|
376
|
+
handleFieldChange,
|
|
377
|
+
handleFieldBlur,
|
|
378
|
+
};
|
|
379
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
declare global {
|
|
2
|
+
interface Window {
|
|
3
|
+
google?: {
|
|
4
|
+
maps?: {
|
|
5
|
+
places?: {
|
|
6
|
+
AutocompleteService: new () => any;
|
|
7
|
+
PlacesService: new (map: any) => any;
|
|
8
|
+
PlacesServiceStatus: {
|
|
9
|
+
OK: string;
|
|
10
|
+
ZERO_RESULTS: string;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
Map: new (element: HTMLElement) => any;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export interface GooglePrediction {
|
|
19
|
+
place_id: string;
|
|
20
|
+
description: string;
|
|
21
|
+
structured_formatting?: {
|
|
22
|
+
main_text: string;
|
|
23
|
+
secondary_text: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export interface GoogleAddressComponent {
|
|
27
|
+
long_name: string;
|
|
28
|
+
short_name: string;
|
|
29
|
+
types: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface GooglePlaceDetails {
|
|
32
|
+
address_components: GoogleAddressComponent[];
|
|
33
|
+
formatted_address: string;
|
|
34
|
+
}
|
|
35
|
+
export interface ExtractedAddress {
|
|
36
|
+
streetNumber: string;
|
|
37
|
+
route: string;
|
|
38
|
+
locality: string;
|
|
39
|
+
administrativeAreaLevel1: string;
|
|
40
|
+
administrativeAreaLevel1Long: string;
|
|
41
|
+
country: string;
|
|
42
|
+
postalCode: string;
|
|
43
|
+
}
|
|
44
|
+
export interface UseGoogleAutocompleteOptions {
|
|
45
|
+
apiKey: string;
|
|
46
|
+
libraries?: string[];
|
|
47
|
+
version?: string;
|
|
48
|
+
language?: string;
|
|
49
|
+
region?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface UseGoogleAutocompleteResult {
|
|
52
|
+
predictions: GooglePrediction[];
|
|
53
|
+
isLoading: boolean;
|
|
54
|
+
isScriptLoaded: boolean;
|
|
55
|
+
searchPlaces: (input: string, countryRestriction?: string) => void;
|
|
56
|
+
getPlaceDetails: (placeId: string) => Promise<GooglePlaceDetails | null>;
|
|
57
|
+
extractAddressComponents: (place: GooglePlaceDetails) => ExtractedAddress;
|
|
58
|
+
clearPredictions: () => void;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* React hook for Google Places Autocomplete with automatic script injection
|
|
62
|
+
* Automatically loads the Google Maps JavaScript API with Places library
|
|
63
|
+
*/
|
|
64
|
+
export declare function useGoogleAutocomplete(options: UseGoogleAutocompleteOptions): UseGoogleAutocompleteResult;
|
|
65
|
+
/**
|
|
66
|
+
* Hook to check if Google Maps API is loaded
|
|
67
|
+
* @deprecated Use useGoogleAutocomplete with isScriptLoaded instead
|
|
68
|
+
*/
|
|
69
|
+
export declare function useGoogleMapsLoaded(): boolean;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* React hook for Google Places Autocomplete with automatic script injection
|
|
4
|
+
* Automatically loads the Google Maps JavaScript API with Places library
|
|
5
|
+
*/
|
|
6
|
+
export function useGoogleAutocomplete(options) {
|
|
7
|
+
const { apiKey, libraries = ['places'], version = 'weekly', language, region } = options;
|
|
8
|
+
const [predictions, setPredictions] = useState([]);
|
|
9
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
10
|
+
const [isScriptLoaded, setIsScriptLoaded] = useState(false);
|
|
11
|
+
const autocompleteServiceRef = useRef(null);
|
|
12
|
+
const placesServiceRef = useRef(null);
|
|
13
|
+
const scriptLoadedRef = useRef(false);
|
|
14
|
+
// Inject Google Maps script
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!apiKey) {
|
|
17
|
+
console.error('Google Maps API key is required');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (scriptLoadedRef.current || window.google?.maps) {
|
|
21
|
+
setIsScriptLoaded(true);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// Check if script is already being loaded
|
|
25
|
+
const existingScript = document.querySelector('script[src*="maps.googleapis.com"]');
|
|
26
|
+
if (existingScript) {
|
|
27
|
+
// Wait for existing script to load
|
|
28
|
+
const checkLoaded = () => {
|
|
29
|
+
if (window.google?.maps?.places?.AutocompleteService) {
|
|
30
|
+
setIsScriptLoaded(true);
|
|
31
|
+
scriptLoadedRef.current = true;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
setTimeout(checkLoaded, 100);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
checkLoaded();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// Create and inject the script
|
|
41
|
+
const script = document.createElement('script');
|
|
42
|
+
const params = new URLSearchParams({
|
|
43
|
+
key: apiKey,
|
|
44
|
+
libraries: libraries.join(','),
|
|
45
|
+
v: version,
|
|
46
|
+
});
|
|
47
|
+
if (language)
|
|
48
|
+
params.append('language', language);
|
|
49
|
+
if (region)
|
|
50
|
+
params.append('region', region);
|
|
51
|
+
script.src = `https://maps.googleapis.com/maps/api/js?${params.toString()}`;
|
|
52
|
+
script.async = true;
|
|
53
|
+
script.defer = true;
|
|
54
|
+
script.onload = () => {
|
|
55
|
+
console.log('🗺️ Google Maps API loaded successfully');
|
|
56
|
+
setIsScriptLoaded(true);
|
|
57
|
+
scriptLoadedRef.current = true;
|
|
58
|
+
};
|
|
59
|
+
script.onerror = () => {
|
|
60
|
+
console.error('Failed to load Google Maps API');
|
|
61
|
+
};
|
|
62
|
+
document.head.appendChild(script);
|
|
63
|
+
// Cleanup function
|
|
64
|
+
return () => {
|
|
65
|
+
// Note: We don't remove the script as it might be used by other components
|
|
66
|
+
// The script will remain in the DOM for the session
|
|
67
|
+
};
|
|
68
|
+
}, [apiKey, libraries, version, language, region]);
|
|
69
|
+
// Initialize Google Places services
|
|
70
|
+
const initializeServices = useCallback(() => {
|
|
71
|
+
if (typeof window === 'undefined')
|
|
72
|
+
return false;
|
|
73
|
+
if (!window.google?.maps?.places?.AutocompleteService) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (!autocompleteServiceRef.current) {
|
|
77
|
+
autocompleteServiceRef.current = new window.google.maps.places.AutocompleteService();
|
|
78
|
+
}
|
|
79
|
+
if (!placesServiceRef.current) {
|
|
80
|
+
// Create a dummy map for PlacesService (required by Google API)
|
|
81
|
+
const map = new window.google.maps.Map(document.createElement('div'));
|
|
82
|
+
placesServiceRef.current = new window.google.maps.places.PlacesService(map);
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}, []);
|
|
86
|
+
// Search for place predictions
|
|
87
|
+
const searchPlaces = useCallback((input, countryRestriction) => {
|
|
88
|
+
if (!isScriptLoaded) {
|
|
89
|
+
console.warn('Google Maps API not yet loaded');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!initializeServices()) {
|
|
93
|
+
console.error('Google Places services not available');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (input.length < 3) {
|
|
97
|
+
setPredictions([]);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
setIsLoading(true);
|
|
101
|
+
const request = {
|
|
102
|
+
input,
|
|
103
|
+
...(countryRestriction && {
|
|
104
|
+
componentRestrictions: { country: countryRestriction.toLowerCase() },
|
|
105
|
+
}),
|
|
106
|
+
};
|
|
107
|
+
autocompleteServiceRef.current.getPlacePredictions(request, (results, status) => {
|
|
108
|
+
setIsLoading(false);
|
|
109
|
+
if (status === window.google.maps.places.PlacesServiceStatus.OK && results) {
|
|
110
|
+
setPredictions(results);
|
|
111
|
+
console.log(`🔍 Found ${results.length} predictions for "${input}"`);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
setPredictions([]);
|
|
115
|
+
if (status !== window.google.maps.places.PlacesServiceStatus.ZERO_RESULTS) {
|
|
116
|
+
console.warn('Google Places prediction failed:', status);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}, [initializeServices, isScriptLoaded]);
|
|
121
|
+
// Get detailed place information
|
|
122
|
+
const getPlaceDetails = useCallback((placeId) => {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
if (!isScriptLoaded) {
|
|
125
|
+
console.warn('Google Maps API not yet loaded');
|
|
126
|
+
resolve(null);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!initializeServices()) {
|
|
130
|
+
console.error('Google Places services not available');
|
|
131
|
+
resolve(null);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const request = {
|
|
135
|
+
placeId,
|
|
136
|
+
fields: ['address_components', 'formatted_address'],
|
|
137
|
+
};
|
|
138
|
+
placesServiceRef.current.getDetails(request, (place, status) => {
|
|
139
|
+
if (status === window.google.maps.places.PlacesServiceStatus.OK && place) {
|
|
140
|
+
console.log('📍 Got place details:', place);
|
|
141
|
+
resolve(place);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
console.error('Failed to get place details:', status);
|
|
145
|
+
resolve(null);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}, [initializeServices, isScriptLoaded]);
|
|
150
|
+
// Extract structured address components from Google place
|
|
151
|
+
const extractAddressComponents = useCallback((place) => {
|
|
152
|
+
const extracted = {
|
|
153
|
+
streetNumber: '',
|
|
154
|
+
route: '',
|
|
155
|
+
locality: '',
|
|
156
|
+
administrativeAreaLevel1: '',
|
|
157
|
+
administrativeAreaLevel1Long: '',
|
|
158
|
+
country: '',
|
|
159
|
+
postalCode: '',
|
|
160
|
+
};
|
|
161
|
+
console.log('🏗️ Extracted address components:', place.address_components);
|
|
162
|
+
place.address_components?.forEach((component) => {
|
|
163
|
+
const types = component.types;
|
|
164
|
+
if (types.includes('street_number')) {
|
|
165
|
+
extracted.streetNumber = component.long_name;
|
|
166
|
+
}
|
|
167
|
+
if (types.includes('route')) {
|
|
168
|
+
extracted.route = component.long_name;
|
|
169
|
+
}
|
|
170
|
+
if (types.includes('locality') || types.includes('administrative_area_level_2')) {
|
|
171
|
+
extracted.locality = component.long_name;
|
|
172
|
+
}
|
|
173
|
+
if (types.includes('administrative_area_level_1')) {
|
|
174
|
+
extracted.administrativeAreaLevel1 = component.short_name;
|
|
175
|
+
extracted.administrativeAreaLevel1Long = component.long_name;
|
|
176
|
+
}
|
|
177
|
+
if (types.includes('country')) {
|
|
178
|
+
extracted.country = component.short_name;
|
|
179
|
+
}
|
|
180
|
+
if (types.includes('postal_code')) {
|
|
181
|
+
extracted.postalCode = component.long_name;
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
console.log('🏗️ Extracted address components:', extracted);
|
|
185
|
+
return extracted;
|
|
186
|
+
}, []);
|
|
187
|
+
// Clear predictions
|
|
188
|
+
const clearPredictions = useCallback(() => {
|
|
189
|
+
setPredictions([]);
|
|
190
|
+
}, []);
|
|
191
|
+
return {
|
|
192
|
+
predictions,
|
|
193
|
+
isLoading,
|
|
194
|
+
isScriptLoaded,
|
|
195
|
+
searchPlaces,
|
|
196
|
+
getPlaceDetails,
|
|
197
|
+
extractAddressComponents,
|
|
198
|
+
clearPredictions,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Hook to check if Google Maps API is loaded
|
|
203
|
+
* @deprecated Use useGoogleAutocomplete with isScriptLoaded instead
|
|
204
|
+
*/
|
|
205
|
+
export function useGoogleMapsLoaded() {
|
|
206
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
const checkLoaded = () => {
|
|
209
|
+
const loaded = !!window.google?.maps?.places?.AutocompleteService;
|
|
210
|
+
setIsLoaded(loaded);
|
|
211
|
+
if (!loaded) {
|
|
212
|
+
// Check again in 100ms
|
|
213
|
+
setTimeout(checkLoaded, 100);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
checkLoaded();
|
|
217
|
+
}, []);
|
|
218
|
+
return isLoaded;
|
|
219
|
+
}
|