@tagadapay/plugin-sdk 2.1.2 → 2.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.
@@ -1,557 +0,0 @@
1
- import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
2
- import { usePlacesWidget } from 'react-google-autocomplete';
3
- // Import the comprehensive countries and states data
4
- import { countries as COUNTRIES_DATA, states as STATES_DATA } from '../../data/countries';
5
- // Countries without state fields
6
- const COUNTRIES_WITHOUT_STATE_FIELD = [
7
- 'VA',
8
- 'MC',
9
- 'SM',
10
- 'LI',
11
- 'LU',
12
- 'MT',
13
- 'SG',
14
- 'IS',
15
- 'EE',
16
- 'LV',
17
- 'LT',
18
- 'SI',
19
- 'SK',
20
- 'ME',
21
- 'AL',
22
- 'XK',
23
- 'MK',
24
- ];
25
- // Default validation rules
26
- const defaultValidation = {
27
- firstName: (value) => (value.trim() ? undefined : 'First name is required'),
28
- lastName: (value) => (value.trim() ? undefined : 'Last name is required'),
29
- email: (value) => {
30
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
31
- if (!value.trim())
32
- return 'Email is required';
33
- if (!emailRegex.test(value))
34
- return 'Valid email is required';
35
- return undefined;
36
- },
37
- phone: (value) => (value.trim() ? undefined : 'Phone number is required'),
38
- country: (value) => (value.trim() ? undefined : 'Country is required'),
39
- address1: (value) => (value.trim() ? undefined : 'Address is required'),
40
- address2: () => undefined, // Optional field
41
- city: (value) => (value.trim() ? undefined : 'City is required'),
42
- state: (value) => (value.trim() ? undefined : 'State/Province is required'),
43
- postal: (value) => (value.trim() ? undefined : 'Zip/Postal code is required'),
44
- };
45
- // Map Google Places state names to state codes
46
- const mapGoogleStateToStateCode = (countryCode, stateName) => {
47
- const matchingState = STATES_DATA.find((state) => state.country_code === countryCode &&
48
- (state.state_name === stateName || state.state_code === stateName));
49
- return matchingState?.state_code || stateName;
50
- };
51
- export function useAddress(options = {}) {
52
- const { autoValidate = false, enableGooglePlaces = false, googlePlacesApiKey, initialValues = {}, validation = {}, countryRestrictions = [], onFieldsChange, debounceConfig = {}, } = options;
53
- // Extract debounce configuration with defaults
54
- const { manualInputDelay = 1500, googlePlacesDelay = 300, enabled: debounceEnabled = true, } = debounceConfig;
55
- // Combine default and custom validation
56
- const validationRules = { ...defaultValidation, ...validation };
57
- // Initialize form fields
58
- const [fields, setFields] = useState(() => {
59
- const initialFields = {};
60
- const fieldNames = [
61
- 'firstName',
62
- 'lastName',
63
- 'email',
64
- 'phone',
65
- 'country',
66
- 'address1',
67
- 'address2',
68
- 'city',
69
- 'state',
70
- 'postal',
71
- ];
72
- fieldNames.forEach((field) => {
73
- const value = initialValues[field] || '';
74
- // Don't show errors on initial load, even with autoValidate
75
- initialFields[field] = {
76
- value,
77
- isValid: true, // Start as valid to avoid showing errors on first load
78
- error: undefined, // No errors on initial load
79
- touched: false, // Mark as untouched initially
80
- };
81
- });
82
- return initialFields;
83
- });
84
- // Google Places integration
85
- const [isAddressSelected, setIsAddressSelected] = useState(false);
86
- const [addressInputValue, setAddressInputValue] = useState(fields.address1.value);
87
- // Transform countries data to our format
88
- const countries = useMemo(() => {
89
- let countryList = COUNTRIES_DATA.map((country) => ({
90
- code: country['alpha-2'],
91
- name: country.name,
92
- }));
93
- // Apply country restrictions if provided
94
- if (countryRestrictions.length > 0) {
95
- countryList = countryList.filter((country) => countryRestrictions.includes(country.code));
96
- }
97
- return countryList.sort((a, b) => a.name.localeCompare(b.name));
98
- }, [countryRestrictions]);
99
- // Transform states data to our format and filter by selected country
100
- const states = useMemo(() => {
101
- if (!fields.country.value)
102
- return [];
103
- return STATES_DATA.filter((state) => state.country_code === fields.country.value && state.country_code) // Filter out undefined country_code
104
- .map((state) => ({
105
- code: state.state_code || '',
106
- name: state.state_name,
107
- countryCode: state.country_code, // Use non-null assertion since we filtered above
108
- }))
109
- .sort((a, b) => a.name.localeCompare(b.name));
110
- }, [fields.country.value]);
111
- // Auto-save state management
112
- const [autoSaveTimeout, setAutoSaveTimeout] = useState(null);
113
- const [isGooglePlacesUpdating, setIsGooglePlacesUpdating] = useState(false);
114
- // Use ref to always access current fields (avoids stale closure issues)
115
- const fieldsRef = useRef(fields);
116
- useEffect(() => {
117
- fieldsRef.current = fields;
118
- }, [fields]);
119
- // Smart auto-save trigger with configurable debouncing
120
- const triggerAutoSave = useCallback((type = 'manual') => {
121
- if (!onFieldsChange || !debounceEnabled)
122
- return;
123
- // Clear existing timeout
124
- if (autoSaveTimeout) {
125
- clearTimeout(autoSaveTimeout);
126
- }
127
- // Determine delay based on trigger type
128
- const delay = type === 'googlePlaces' ? googlePlacesDelay : manualInputDelay;
129
- console.log(`📝 useAddress: Scheduling auto-save (${type}) in ${delay}ms`);
130
- // Set new timeout with appropriate debounce
131
- const timeoutId = setTimeout(() => {
132
- // Get current address data using ref (always fresh, no stale closures)
133
- const currentFields = fieldsRef.current;
134
- const addressData = {
135
- firstName: currentFields.firstName.value,
136
- lastName: currentFields.lastName.value,
137
- email: currentFields.email.value,
138
- phone: currentFields.phone.value,
139
- country: currentFields.country.value,
140
- address1: currentFields.address1.value,
141
- address2: currentFields.address2.value,
142
- city: currentFields.city.value,
143
- state: currentFields.state.value,
144
- postal: currentFields.postal.value,
145
- };
146
- console.log(`🔄 useAddress: Auto-save triggered (${type}) with data:`, addressData);
147
- // Call the auto-save callback directly (no React state setter involved)
148
- onFieldsChange(addressData);
149
- setAutoSaveTimeout(null);
150
- }, delay);
151
- setAutoSaveTimeout(timeoutId);
152
- }, [onFieldsChange, autoSaveTimeout, debounceEnabled, manualInputDelay, googlePlacesDelay]);
153
- // Handle Google Places selection
154
- const handlePlaceSelected = useCallback((place) => {
155
- // Clear any pending auto-saves and mark Google Places as updating
156
- if (autoSaveTimeout) {
157
- clearTimeout(autoSaveTimeout);
158
- setAutoSaveTimeout(null);
159
- }
160
- setIsGooglePlacesUpdating(true);
161
- let streetNumber = '';
162
- let route = '';
163
- let locality = '';
164
- let postal_town = '';
165
- let newCountry = '';
166
- let stateValue = '';
167
- console.log('🌍 Google Places selected:', place);
168
- for (const component of place.address_components || []) {
169
- const componentType = component.types[0];
170
- switch (componentType) {
171
- case 'street_number':
172
- streetNumber = component.long_name;
173
- break;
174
- case 'route':
175
- route = component.long_name;
176
- break;
177
- case 'locality':
178
- locality = component.long_name;
179
- break;
180
- case 'postal_town':
181
- postal_town = component.long_name;
182
- if (postal_town !== locality) {
183
- // Prefer postal_town over locality for city
184
- locality = postal_town;
185
- }
186
- break;
187
- case 'postal_code':
188
- break; // We'll handle this separately
189
- case 'postal_code_prefix':
190
- break; // We'll handle this separately
191
- case 'administrative_area_level_1':
192
- stateValue = component.short_name;
193
- break;
194
- case 'country':
195
- newCountry = component.short_name;
196
- break;
197
- }
198
- }
199
- // Set address first
200
- const addressValue = `${streetNumber} ${route}`.trim();
201
- setAddressInputValue(addressValue);
202
- // Helper function to update field with validation to clear errors
203
- const updateFieldWithValidation = (field, value) => {
204
- const error = validationRules[field]?.(value);
205
- return {
206
- value,
207
- isValid: !error,
208
- error,
209
- touched: true, // Mark as touched when Google Places fills the field
210
- };
211
- };
212
- console.log('🔄 Updating fields from Google Places:', {
213
- address1: addressValue,
214
- city: locality,
215
- country: newCountry,
216
- stateValue,
217
- });
218
- // Use requestAnimationFrame to ensure updates happen in the next frame
219
- requestAnimationFrame(() => {
220
- // Update fields with validation to clear any existing errors
221
- setFields((prev) => ({
222
- ...prev,
223
- address1: updateFieldWithValidation('address1', String(addressValue)),
224
- city: updateFieldWithValidation('city', String(locality)),
225
- country: updateFieldWithValidation('country', String(newCountry)),
226
- }));
227
- // Handle postal code separately with validation
228
- const postalComponent = place.address_components?.find((comp) => Array.isArray(comp.types) &&
229
- (comp.types.includes('postal_code') ||
230
- comp.types.includes('postal_code_prefix')));
231
- if (postalComponent) {
232
- setFields((prev) => ({
233
- ...prev,
234
- postal: updateFieldWithValidation('postal', String(postalComponent.long_name)),
235
- }));
236
- }
237
- // Handle state after country is set (with small delay to ensure country is processed first)
238
- if (newCountry && stateValue) {
239
- setTimeout(() => {
240
- const mappedStateValue = mapGoogleStateToStateCode(newCountry, stateValue);
241
- console.log('🗺️ Mapped state value:', mappedStateValue);
242
- setFields((prev) => ({
243
- ...prev,
244
- state: updateFieldWithValidation('state', String(mappedStateValue || stateValue)),
245
- }));
246
- console.log('✅ Google Places update completed');
247
- // Mark updating as complete and trigger single auto-save
248
- setIsGooglePlacesUpdating(false);
249
- setTimeout(() => {
250
- console.log('🌍 useAddress: Google Places completed - triggering auto-save');
251
- triggerAutoSave('googlePlaces'); // Use Google Places specific timing
252
- }, 200); // Extra delay to ensure all state updates are complete
253
- }, 50); // Small delay to ensure country state processing
254
- }
255
- else {
256
- // Mark updating as complete and trigger auto-save (for countries without states)
257
- setIsGooglePlacesUpdating(false);
258
- setTimeout(() => {
259
- console.log('🌍 useAddress: Google Places completed (no state) - triggering auto-save');
260
- triggerAutoSave('googlePlaces'); // Use Google Places specific timing
261
- }, 200);
262
- }
263
- setIsAddressSelected(true);
264
- });
265
- }, [validationRules, triggerAutoSave, autoSaveTimeout]);
266
- // Memoize Google Places options to prevent re-initialization
267
- const placesOptions = useMemo(() => ({
268
- types: ['address'],
269
- componentRestrictions: countryRestrictions.length > 0 ? { country: countryRestrictions } : undefined,
270
- }), [countryRestrictions]);
271
- // Setup Google Places autocomplete
272
- const { ref: placesRef } = usePlacesWidget({
273
- apiKey: googlePlacesApiKey || process.env.NEXT_PUBLIC_GOOGLE_AUTOCOMPLETE_API_KEY,
274
- onPlaceSelected: handlePlaceSelected,
275
- options: placesOptions,
276
- });
277
- // Custom styles for Google Places dropdown
278
- useEffect(() => {
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; }
299
- `;
300
- const styleElement = document.createElement('style');
301
- styleElement.id = 'google-places-custom-styles';
302
- styleElement.textContent = autocompleteStyles;
303
- document.head.appendChild(styleElement);
304
- }
305
- }, [enableGooglePlaces]);
306
- // Handle immediate form field updates (no debounce for responsive typing)
307
- const handleAddressChange = useCallback((event) => {
308
- const value = event.target.value;
309
- // Update local state immediately for responsive UI
310
- setAddressInputValue(value);
311
- // Update form field immediately (no debounce) and mark as touched
312
- setFields((prev) => ({
313
- ...prev,
314
- address1: { ...prev.address1, value, touched: true },
315
- }));
316
- // Reset address selection flag when user types manually
317
- if (isAddressSelected) {
318
- setIsAddressSelected(false);
319
- }
320
- }, [isAddressSelected]);
321
- // Sync addressInputValue with external changes
322
- useEffect(() => {
323
- const currentAddress = fields.address1.value;
324
- if (currentAddress !== addressInputValue) {
325
- setAddressInputValue(currentAddress);
326
- }
327
- }, [fields.address1.value, addressInputValue]);
328
- // Auto-clear state when country changes to one without states
329
- useEffect(() => {
330
- const currentCountry = fields.country.value;
331
- if (currentCountry && COUNTRIES_WITHOUT_STATE_FIELD.includes(currentCountry)) {
332
- setFields((prev) => ({
333
- ...prev,
334
- state: { ...prev.state, value: '' },
335
- }));
336
- }
337
- }, [fields.country.value]);
338
- // Form methods
339
- const setValue = useCallback((field, value) => {
340
- setFields((prev) => {
341
- // Only show validation errors for touched fields when autoValidate is enabled
342
- const shouldValidate = autoValidate && prev[field].touched;
343
- const error = shouldValidate ? validationRules[field]?.(value) : undefined;
344
- const newFields = {
345
- ...prev,
346
- [field]: {
347
- value,
348
- isValid: !error,
349
- error,
350
- touched: true, // Mark field as touched when user interacts with it
351
- },
352
- };
353
- return newFields;
354
- });
355
- // Special handling for address field with Google Places
356
- if (field === 'address1') {
357
- setAddressInputValue(value);
358
- }
359
- // Reset state when country changes
360
- if (field === 'country') {
361
- setFields((prev) => ({
362
- ...prev,
363
- state: { ...prev.state, value: '', error: undefined, isValid: true, touched: false },
364
- }));
365
- }
366
- // Trigger auto-save after field update with longer debounce for manual typing
367
- triggerAutoSave('manual'); // Use manual input timing
368
- }, [autoValidate, validationRules, triggerAutoSave]);
369
- const setValues = useCallback((values) => {
370
- setFields((prev) => {
371
- const newFields = { ...prev };
372
- Object.entries(values).forEach(([key, value]) => {
373
- const field = key;
374
- // Only validate if field was previously touched or if we're setting a non-empty value
375
- const hasNonEmptyValue = value && value.trim() !== '';
376
- const shouldValidate = autoValidate && (prev[field].touched || hasNonEmptyValue);
377
- const error = shouldValidate ? validationRules[field]?.(value || '') : undefined;
378
- newFields[field] = {
379
- value: value || '',
380
- isValid: !error,
381
- error,
382
- touched: prev[field].touched || !!hasNonEmptyValue, // Mark as touched if setting a non-empty value
383
- };
384
- });
385
- return newFields;
386
- });
387
- }, [autoValidate, validationRules]);
388
- const resetField = useCallback((field) => {
389
- setFields((prev) => ({
390
- ...prev,
391
- [field]: {
392
- value: '',
393
- isValid: true,
394
- error: undefined,
395
- touched: false,
396
- },
397
- }));
398
- }, []);
399
- const resetForm = useCallback(() => {
400
- const resetFields = {};
401
- const fieldNames = [
402
- 'firstName',
403
- 'lastName',
404
- 'email',
405
- 'phone',
406
- 'country',
407
- 'address1',
408
- 'address2',
409
- 'city',
410
- 'state',
411
- 'postal',
412
- ];
413
- fieldNames.forEach((field) => {
414
- resetFields[field] = {
415
- value: '',
416
- isValid: true,
417
- error: undefined,
418
- touched: false,
419
- };
420
- });
421
- setFields(resetFields);
422
- setAddressInputValue('');
423
- setIsAddressSelected(false);
424
- }, []);
425
- // Validate specific field
426
- const validateField = useCallback((fieldName) => {
427
- const value = fields[fieldName].value;
428
- const rule = validationRules[fieldName];
429
- const error = rule ? rule(value) : undefined;
430
- setFields((prev) => ({
431
- ...prev,
432
- [fieldName]: {
433
- ...prev[fieldName],
434
- isValid: !error,
435
- error,
436
- touched: true, // Mark as touched when explicitly validating
437
- },
438
- }));
439
- return !error;
440
- }, [fields, validationRules]);
441
- // Validate all fields
442
- const validateAll = useCallback(() => {
443
- const fieldNames = [
444
- 'firstName',
445
- 'lastName',
446
- 'email',
447
- 'phone',
448
- 'country',
449
- 'address1',
450
- 'address2',
451
- 'city',
452
- 'state',
453
- 'postal',
454
- ];
455
- let isValid = true;
456
- const newFields = { ...fields };
457
- fieldNames.forEach((key) => {
458
- const field = key;
459
- const value = fields[field].value;
460
- // Skip state validation for countries without states
461
- if (field === 'state' &&
462
- fields.country.value &&
463
- COUNTRIES_WITHOUT_STATE_FIELD.includes(fields.country.value)) {
464
- newFields[field] = {
465
- ...newFields[field],
466
- isValid: true,
467
- error: undefined,
468
- touched: true,
469
- };
470
- return;
471
- }
472
- const error = validationRules[field]?.(value);
473
- newFields[field] = {
474
- ...newFields[field],
475
- isValid: !error,
476
- error,
477
- touched: true, // Mark all fields as touched when validating all
478
- };
479
- if (error)
480
- isValid = false;
481
- });
482
- setFields(newFields);
483
- return isValid;
484
- }, [fields, validationRules]);
485
- // Check if all required fields are valid
486
- const isValid = useCallback(() => {
487
- const { address1, city, state, postal, country } = fields;
488
- return address1.isValid && city.isValid && state.isValid && postal.isValid && country.isValid;
489
- }, [fields]);
490
- // Get form data
491
- const getFormData = useCallback(() => {
492
- return {
493
- firstName: fields.firstName.value,
494
- lastName: fields.lastName.value,
495
- email: fields.email.value,
496
- phone: fields.phone.value,
497
- country: fields.country.value,
498
- address1: fields.address1.value,
499
- address2: fields.address2.value,
500
- city: fields.city.value,
501
- state: fields.state.value,
502
- postal: fields.postal.value,
503
- };
504
- }, [fields]);
505
- const getFormattedAddress = useCallback(() => {
506
- const { address1, city, state, postal, country } = fields;
507
- const parts = [
508
- address1.value,
509
- city.value,
510
- state.value,
511
- postal.value,
512
- countries.find((c) => c.code === country.value)?.name || country.value,
513
- ].filter(Boolean);
514
- return parts.join(', ');
515
- }, [fields, countries]);
516
- const getAddressObject = useCallback(() => {
517
- return {
518
- firstName: fields.firstName.value,
519
- lastName: fields.lastName.value,
520
- email: fields.email.value,
521
- phone: fields.phone.value,
522
- country: fields.country.value,
523
- address1: fields.address1.value,
524
- address2: fields.address2.value,
525
- city: fields.city.value,
526
- state: fields.state.value,
527
- postal: fields.postal.value,
528
- };
529
- }, [fields]);
530
- // Cleanup auto-save timeout on unmount
531
- useEffect(() => {
532
- return () => {
533
- if (autoSaveTimeout) {
534
- clearTimeout(autoSaveTimeout);
535
- }
536
- };
537
- }, [autoSaveTimeout]);
538
- return {
539
- fields,
540
- countries,
541
- states,
542
- setValue,
543
- setValues,
544
- resetField,
545
- resetForm,
546
- validate: validateField,
547
- validateAll,
548
- getFormattedAddress,
549
- getAddressObject,
550
- ...(enableGooglePlaces && {
551
- addressRef: placesRef,
552
- addressInputValue,
553
- handleAddressChange,
554
- isAddressSelected,
555
- }),
556
- };
557
- }
@@ -1,53 +0,0 @@
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;