@tagadapay/plugin-sdk 1.0.30 → 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 +546 -414
- package/dist/data/iso3166.d.ts +30 -0
- package/dist/data/iso3166.js +102 -0
- package/dist/react/hooks/useAddress.js +19 -19
- 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/usePluginConfig.d.ts +53 -0
- package/dist/react/hooks/usePluginConfig.js +190 -0
- package/dist/react/index.d.ts +6 -0
- package/dist/react/index.js +6 -0
- package/dist/react/providers/TagadaProvider.d.ts +5 -1
- package/dist/react/providers/TagadaProvider.js +53 -7
- package/package.json +67 -66
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface Country {
|
|
2
|
+
code: string;
|
|
3
|
+
name: string;
|
|
4
|
+
iso3?: string;
|
|
5
|
+
numeric?: number;
|
|
6
|
+
uniqueKey?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface State {
|
|
9
|
+
code: string;
|
|
10
|
+
name: string;
|
|
11
|
+
countryCode: string;
|
|
12
|
+
uniqueKey?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const getCountries: () => Country[];
|
|
15
|
+
export declare const getAllStates: () => State[];
|
|
16
|
+
export declare const getStatesForCountry: (countryCode: string) => State[];
|
|
17
|
+
export declare const findCountryByName: (countryName: string) => Country | null;
|
|
18
|
+
export declare const findRegionByCode: (regionCode: string) => State | null;
|
|
19
|
+
export declare const isValidCountryCode: (countryCode: string) => boolean;
|
|
20
|
+
export declare const isValidStateCode: (countryCode: string, stateCode: string) => boolean;
|
|
21
|
+
export declare const getCountryWithRegions: (countryCode: string) => {
|
|
22
|
+
country: {
|
|
23
|
+
code: any;
|
|
24
|
+
name: any;
|
|
25
|
+
iso3: any;
|
|
26
|
+
numeric: any;
|
|
27
|
+
uniqueKey: string;
|
|
28
|
+
};
|
|
29
|
+
regions: State[];
|
|
30
|
+
} | null;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Import the ISO3166 library API functions
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
3
|
+
// @ts-ignore - iso3166-2-db doesn't have TypeScript definitions
|
|
4
|
+
import { getDataSet, reduce } from 'iso3166-2-db';
|
|
5
|
+
// Get the full dataset with default English language
|
|
6
|
+
const worldDatabase = reduce(getDataSet(), 'en');
|
|
7
|
+
// Transform the ISO3166 data into our expected format
|
|
8
|
+
export const getCountries = () => {
|
|
9
|
+
const countries = [];
|
|
10
|
+
Object.keys(worldDatabase).forEach((countryCode) => {
|
|
11
|
+
const countryData = worldDatabase[countryCode];
|
|
12
|
+
countries.push({
|
|
13
|
+
code: countryData.iso, // iso3166-1 alpha-2 code
|
|
14
|
+
name: countryData.name,
|
|
15
|
+
iso3: countryData.iso3, // iso3166-1 alpha-3 code
|
|
16
|
+
numeric: countryData.numeric,
|
|
17
|
+
uniqueKey: `country-${countryData.iso}`,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
// Sort countries alphabetically by name
|
|
21
|
+
return countries.sort((a, b) => a.name.localeCompare(b.name));
|
|
22
|
+
};
|
|
23
|
+
// Get all states/regions for all countries
|
|
24
|
+
export const getAllStates = () => {
|
|
25
|
+
const states = [];
|
|
26
|
+
Object.keys(worldDatabase).forEach((countryCode) => {
|
|
27
|
+
const countryData = worldDatabase[countryCode];
|
|
28
|
+
if (countryData.regions && Array.isArray(countryData.regions)) {
|
|
29
|
+
countryData.regions.forEach((region, index) => {
|
|
30
|
+
states.push({
|
|
31
|
+
code: region.iso, // iso3166-2 code (the part after the dash)
|
|
32
|
+
name: region.name,
|
|
33
|
+
countryCode: countryData.iso,
|
|
34
|
+
uniqueKey: `state-${countryData.iso}-${region.iso}-${index}`,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
// Sort states alphabetically by name
|
|
40
|
+
return states.sort((a, b) => a.name.localeCompare(b.name));
|
|
41
|
+
};
|
|
42
|
+
// Get states for a specific country
|
|
43
|
+
export const getStatesForCountry = (countryCode) => {
|
|
44
|
+
const countryData = worldDatabase[countryCode];
|
|
45
|
+
if (!countryData?.regions || !Array.isArray(countryData.regions)) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
return countryData.regions
|
|
49
|
+
.map((region, index) => ({
|
|
50
|
+
code: region.iso,
|
|
51
|
+
name: region.name,
|
|
52
|
+
countryCode: countryData.iso,
|
|
53
|
+
uniqueKey: `state-${countryData.iso}-${region.iso}-${index}`,
|
|
54
|
+
}))
|
|
55
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
56
|
+
};
|
|
57
|
+
// Find country by name (fuzzy search)
|
|
58
|
+
export const findCountryByName = (countryName) => {
|
|
59
|
+
const countries = getCountries();
|
|
60
|
+
const normalizedSearchName = countryName.toLowerCase().trim();
|
|
61
|
+
// Exact match first
|
|
62
|
+
const exactMatch = countries.find((country) => country.name.toLowerCase() === normalizedSearchName);
|
|
63
|
+
if (exactMatch)
|
|
64
|
+
return exactMatch;
|
|
65
|
+
// Partial match
|
|
66
|
+
const partialMatch = countries.find((country) => country.name.toLowerCase().includes(normalizedSearchName) ||
|
|
67
|
+
normalizedSearchName.includes(country.name.toLowerCase()));
|
|
68
|
+
return partialMatch ?? null;
|
|
69
|
+
};
|
|
70
|
+
// Find region by ISO code (e.g., "US-CA" for California)
|
|
71
|
+
export const findRegionByCode = (regionCode) => {
|
|
72
|
+
const [countryCode, stateCode] = regionCode.split('-');
|
|
73
|
+
if (!countryCode || !stateCode)
|
|
74
|
+
return null;
|
|
75
|
+
const states = getStatesForCountry(countryCode);
|
|
76
|
+
return states.find((state) => state.code === stateCode) ?? null;
|
|
77
|
+
};
|
|
78
|
+
// Validate if a country code exists
|
|
79
|
+
export const isValidCountryCode = (countryCode) => {
|
|
80
|
+
return Object.prototype.hasOwnProperty.call(worldDatabase, countryCode);
|
|
81
|
+
};
|
|
82
|
+
// Validate if a state code exists for a given country
|
|
83
|
+
export const isValidStateCode = (countryCode, stateCode) => {
|
|
84
|
+
const states = getStatesForCountry(countryCode);
|
|
85
|
+
return states.some((state) => state.code === stateCode);
|
|
86
|
+
};
|
|
87
|
+
// Get country info including regions
|
|
88
|
+
export const getCountryWithRegions = (countryCode) => {
|
|
89
|
+
const countryData = worldDatabase[countryCode];
|
|
90
|
+
if (!countryData)
|
|
91
|
+
return null;
|
|
92
|
+
return {
|
|
93
|
+
country: {
|
|
94
|
+
code: countryData.iso,
|
|
95
|
+
name: countryData.name,
|
|
96
|
+
iso3: countryData.iso3,
|
|
97
|
+
numeric: countryData.numeric,
|
|
98
|
+
uniqueKey: `country-${countryData.iso}`,
|
|
99
|
+
},
|
|
100
|
+
regions: getStatesForCountry(countryCode),
|
|
101
|
+
};
|
|
102
|
+
};
|
|
@@ -277,25 +277,25 @@ export function useAddress(options = {}) {
|
|
|
277
277
|
// Custom styles for Google Places dropdown
|
|
278
278
|
useEffect(() => {
|
|
279
279
|
if (enableGooglePlaces && !document.getElementById('google-places-custom-styles')) {
|
|
280
|
-
const autocompleteStyles = `
|
|
281
|
-
.pac-container {
|
|
282
|
-
border-radius: 8px;
|
|
283
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
284
|
-
border: 1px solid #e2e8f0;
|
|
285
|
-
margin-top: 4px;
|
|
286
|
-
font-family: inherit;
|
|
287
|
-
z-index: 9999;
|
|
288
|
-
}
|
|
289
|
-
.pac-item {
|
|
290
|
-
padding: 10px 12px;
|
|
291
|
-
font-size: 14px;
|
|
292
|
-
cursor: pointer;
|
|
293
|
-
border-top: 1px solid #f3f4f6;
|
|
294
|
-
}
|
|
295
|
-
.pac-item:first-child { border-top: none; }
|
|
296
|
-
.pac-item:hover { background-color: #f8fafc; }
|
|
297
|
-
.pac-item-selected, .pac-item-selected:hover { background-color: #eef2ff; }
|
|
298
|
-
.pac-matched { font-weight: 600; }
|
|
280
|
+
const autocompleteStyles = `
|
|
281
|
+
.pac-container {
|
|
282
|
+
border-radius: 8px;
|
|
283
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
284
|
+
border: 1px solid #e2e8f0;
|
|
285
|
+
margin-top: 4px;
|
|
286
|
+
font-family: inherit;
|
|
287
|
+
z-index: 9999;
|
|
288
|
+
}
|
|
289
|
+
.pac-item {
|
|
290
|
+
padding: 10px 12px;
|
|
291
|
+
font-size: 14px;
|
|
292
|
+
cursor: pointer;
|
|
293
|
+
border-top: 1px solid #f3f4f6;
|
|
294
|
+
}
|
|
295
|
+
.pac-item:first-child { border-top: none; }
|
|
296
|
+
.pac-item:hover { background-color: #f8fafc; }
|
|
297
|
+
.pac-item-selected, .pac-item-selected:hover { background-color: #eef2ff; }
|
|
298
|
+
.pac-matched { font-weight: 600; }
|
|
299
299
|
`;
|
|
300
300
|
const styleElement = document.createElement('style');
|
|
301
301
|
styleElement.id = 'google-places-custom-styles';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type Country as ISO3166Country, type State as ISO3166State } from '../../data/iso3166';
|
|
2
|
+
export type Country = ISO3166Country;
|
|
3
|
+
export type State = ISO3166State;
|
|
4
|
+
export interface AddressField {
|
|
5
|
+
value: string;
|
|
6
|
+
isValid: boolean;
|
|
7
|
+
error?: string;
|
|
8
|
+
touched?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface AddressData {
|
|
11
|
+
firstName: string;
|
|
12
|
+
lastName: string;
|
|
13
|
+
email: string;
|
|
14
|
+
phone: string;
|
|
15
|
+
country: string;
|
|
16
|
+
address1: string;
|
|
17
|
+
address2: string;
|
|
18
|
+
city: string;
|
|
19
|
+
state: string;
|
|
20
|
+
postal: string;
|
|
21
|
+
}
|
|
22
|
+
export interface UseAddressV2Config {
|
|
23
|
+
autoValidate?: boolean;
|
|
24
|
+
enableGooglePlaces?: boolean;
|
|
25
|
+
googlePlacesApiKey?: string;
|
|
26
|
+
countryRestrictions?: string[];
|
|
27
|
+
onFieldsChange?: (data: AddressData) => void;
|
|
28
|
+
debounceConfig?: {
|
|
29
|
+
autoSaveDelay?: number;
|
|
30
|
+
enabled?: boolean;
|
|
31
|
+
};
|
|
32
|
+
initialValues?: Partial<AddressData>;
|
|
33
|
+
}
|
|
34
|
+
export interface UseAddressV2Return {
|
|
35
|
+
fields: Record<keyof AddressData, AddressField>;
|
|
36
|
+
setValue: (field: keyof AddressData, value: string) => void;
|
|
37
|
+
setValues: (values: Partial<AddressData>) => void;
|
|
38
|
+
getValue: (field: keyof AddressData) => string;
|
|
39
|
+
getValues: () => AddressData;
|
|
40
|
+
validateField: (field: keyof AddressData) => boolean;
|
|
41
|
+
validateAll: () => boolean;
|
|
42
|
+
isValid: boolean;
|
|
43
|
+
reset: () => void;
|
|
44
|
+
countries: Country[];
|
|
45
|
+
states: State[];
|
|
46
|
+
getStatesForCountry: (countryCode: string) => State[];
|
|
47
|
+
addressRef: React.RefObject<HTMLInputElement | null>;
|
|
48
|
+
addressInputValue: string;
|
|
49
|
+
setAddressInputValue: (value: string) => void;
|
|
50
|
+
handleFieldChange: (field: keyof AddressData) => (value: string) => void;
|
|
51
|
+
handleFieldBlur: (field: keyof AddressData) => () => void;
|
|
52
|
+
}
|
|
53
|
+
export declare const useAddressV2: (config?: UseAddressV2Config) => UseAddressV2Return;
|
|
@@ -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;
|