diff --git a/ENHANCED-CONVERSIONS-SCOPE-OF-WORK.md b/ENHANCED-CONVERSIONS-SCOPE-OF-WORK.md new file mode 100644 index 00000000..8123693a --- /dev/null +++ b/ENHANCED-CONVERSIONS-SCOPE-OF-WORK.md @@ -0,0 +1,488 @@ +# Enhanced Conversions Implementation - Scope of Work + +## Overview + +This document outlines the scope of work for implementing Google Analytics Enhanced Conversions in the ENgrid framework. The implementation automatically collects, normalizes, and hashes user PII (Personally Identifiable Information) data, then pushes it to the dataLayer in the format required by Google Analytics 4 Enhanced Conversions. + +## Objectives + +1. Automatically collect user PII from form fields (email, phone, name, address) +2. Normalize data according to Google Analytics specifications +3. Hash all PII using SHA-256 before pushing to dataLayer +4. Integrate seamlessly with existing ENgrid features (Remember Me, TidyContact) +5. Push enhanced conversions data at appropriate times (page load, field changes, form submit) +6. Ensure data quality by capturing standardized values from TidyContact + +## Implementation Location + +**Primary File:** `packages/scripts/src/data-layer.ts` + +The DataLayer class has been enhanced with Enhanced Conversions functionality while maintaining backward compatibility with existing dataLayer features. + +## Technical Implementation + +### 1. SHA-256 Hashing Utility + +**Location:** `DataLayer.sha256Hash()` (private async method) + +**Implementation Details:** +- Uses `crypto.subtle.digest("SHA-256")` for cryptographic hashing +- Returns lowercase hexadecimal string (64 characters) +- Falls back to `btoa()` if `crypto.subtle` is unavailable (with warning) +- Handles edge cases: empty strings, non-string values, errors +- Browser compatibility: Chrome 37+, Firefox 34+, Safari 11+, Edge 12+ +- Requires HTTPS (or localhost) for proper operation + +**Audit Points:** +- Verify hash length is always 64 hex characters +- Confirm fallback behavior when `crypto.subtle` unavailable +- Check error handling doesn't leak data +- Validate hash format matches GA4 spec (lowercase hex) + +### 2. Data Normalization Functions + +**Location:** `DataLayer.normalize*()` methods (private) + +**Functions Implemented:** +- `normalizeEmail()`: Lowercase, trim, remove leading/trailing dots, remove whitespace +- `normalizePhone()`: Remove non-digits except leading +, remove extensions, convert to E.164 format +- `normalizeAddress()`: Trim, normalize whitespace +- `normalizeName()`: Capitalize words, preserve hyphens and apostrophes (e.g., "O'Brien", "Mary-Jane") +- `normalizePostalCode()`: Uppercase, remove spaces and hyphens +- `normalizeRegion()`: Uppercase, trim +- `normalizeCountry()`: Uppercase, trim + +**Audit Points:** +- Verify normalization handles international formats correctly +- Check edge cases: empty strings, special characters, unicode +- Confirm phone normalization produces valid E.164 format when possible +- Validate name normalization preserves special characters correctly + +### 3. User Data Collection + +**Location:** `DataLayer.collectUserData()` (private method) + +**Fields Collected:** +- `email_address`: From `supporter.emailAddress` +- `phone_number`: From `supporter.phoneNumber` +- `first_name`: From `supporter.firstName` +- `last_name`: From `supporter.lastName` +- `street_address`: Combined from `supporter.address1`, `address2`, `address3` +- `city`: From `supporter.city` +- `region`: From `supporter.region` +- `postal_code`: From `supporter.postcode` +- `country`: From `supporter.country` + +**Implementation Details:** +- Uses `ENGrid.getFieldValue()` to collect form data +- Handles various field types: text inputs, select dropdowns, radio buttons, checkboxes +- Skips empty fields (not included in userData object) +- Normalizes all collected data before returning + +**Audit Points:** +- Verify all required GA4 fields are collected +- Check field name mappings are correct +- Confirm empty fields are properly excluded +- Validate address combination logic (address1 + address2 + address3) + +### 4. Data Structure Builder + +**Location:** `DataLayer.hashUserData()` (private async method) + +**Implementation Details:** +- Takes normalized UserData object +- Hashes each field individually using SHA-256 +- Returns UserData object with hashed values +- Only hashes fields that have values + +**Data Structure:** +```typescript +interface UserData { + email_address?: string; // SHA-256 hashed + phone_number?: string; // SHA-256 hashed + first_name?: string; // SHA-256 hashed + last_name?: string; // SHA-256 hashed + street_address?: string; // SHA-256 hashed + city?: string; // SHA-256 hashed + region?: string; // SHA-256 hashed + postal_code?: string; // SHA-256 hashed + country?: string; // SHA-256 hashed +} + +interface EnhancedConversionsData { + user_data: UserData; +} +``` + +**Audit Points:** +- Verify data structure matches GA4 Measurement Protocol spec exactly +- Confirm all PII fields are hashed (no plaintext) +- Check optional fields are handled correctly +- Validate object structure pushed to dataLayer + +### 5. Enhanced Conversions Push Method + +**Location:** `DataLayer.pushEnhancedConversions()` (private async method) + +**Implementation Details:** +- Collects current user data from form fields +- Merges with cached data from sessionStorage +- Checks if data has changed (skips push if unchanged) +- Hashes merged data using SHA-256 +- Caches unhashed merged data for future merges +- Pushes `{ user_data: { ...hashedFields } }` to dataLayer + +**Audit Points:** +- Verify no plaintext PII is pushed to dataLayer +- Check data merging logic handles conflicts correctly +- Confirm change detection prevents redundant pushes +- Validate error handling doesn't expose data + +### 6. Integration Points + +#### 6.1 Page Load Integration +**Location:** `DataLayer.onLoad()` + +- Pushes enhanced conversions after page loads +- Waits for Remember Me to load if enabled (via RememberMeEvents.onLoad) +- Ensures form fields are populated before collecting data + +**Audit Points:** +- Verify timing: pushes after Remember Me loads +- Check it doesn't push if no data available +- Confirm integration with Remember Me events + +#### 6.2 Field Change Integration +**Location:** `DataLayer.handleFieldValueChange()` and `debouncedPushEnhancedConversions()` + +- Monitors user data fields for changes +- Debounces pushes (500ms delay) to avoid excessive API calls +- Triggers on: blur (text inputs), change (selects, checkboxes, radios) + +**User Data Fields Monitored:** +- supporter.emailAddress +- supporter.phoneNumber +- supporter.firstName +- supporter.lastName +- supporter.address1, address2, address3 +- supporter.city +- supporter.region +- supporter.postcode +- supporter.country + +**Audit Points:** +- Verify debounce delay is appropriate (500ms) +- Check all user data fields are monitored +- Confirm debounce prevents excessive pushes +- Validate async operations don't block UI + +#### 6.3 Form Submit Integration +**Location:** `DataLayer.onSubmit()` + +- Checks for TidyContact `submitPromise` (if TidyContact is standardizing data) +- Waits for TidyContact API to complete before pushing +- Ensures standardized/validated data is captured, not raw user input +- Falls back to immediate push if TidyContact not enabled or fails + +**TidyContact Integration Details:** +- TidyContact standardizes addresses to CASS format +- TidyContact standardizes phone numbers to E.164 format +- Enhanced conversions waits for standardization to complete +- Captures final standardized values for better data quality + +**Audit Points:** +- Verify TidyContact integration waits for standardization +- Check fallback behavior when TidyContact fails +- Confirm standardized data is captured (not raw input) +- Validate timing: pushes after TidyContact completes + +#### 6.4 Remember Me Integration +**Location:** `DataLayer.constructor()` and `onLoad()` + +- Subscribes to `RememberMeEvents.onLoad` if Remember Me is enabled +- Waits for Remember Me to populate form fields before collecting data +- Ensures enhanced conversions captures pre-filled data + +**Audit Points:** +- Verify integration with Remember Me events +- Check data is collected after Remember Me loads +- Confirm pre-filled data is included in enhanced conversions + +### 7. Data Merging and Caching + +**Location:** `DataLayer.mergeUserData()`, `getCachedUserData()`, `cacheUserData()` + +**Implementation Details:** +- Uses sessionStorage to cache unhashed user data +- Merges cached data with current form data +- New data (from form) takes precedence over cached data +- Empty strings don't overwrite existing cached values +- Enables data collection across multiple pages in a session + +**Storage Key:** `ENGRID_ENHANCED_CONVERSIONS_DATA` + +**Audit Points:** +- Verify merge logic handles conflicts correctly +- Check empty strings don't overwrite existing data +- Confirm sessionStorage usage is appropriate +- Validate data persists across pages correctly +- Check data is cleared appropriately (sessionStorage lifecycle) + +### 8. Security Considerations + +**Security Measures:** +- All PII is SHA-256 hashed before pushing to dataLayer +- No plaintext PII in dataLayer +- No plaintext PII in logs (only field names logged) +- Sensitive payment fields excluded from collection +- Error handling prevents data leaks +- Browser compatibility checks prevent errors + +**Excluded Fields:** +- Credit card numbers, CVV, expiration dates +- Bank account numbers, routing numbers +- Other sensitive payment information + +**Audit Points:** +- Verify NO plaintext PII in dataLayer +- Check excluded fields are not collected +- Confirm error handling doesn't expose data +- Validate hashing is applied to all PII +- Check logs don't contain PII + +### 9. Performance Optimizations + +**Optimizations Implemented:** +- Debounced field change events (500ms delay) +- Async SHA-256 operations (non-blocking) +- Efficient sessionStorage caching +- Skips redundant pushes when data unchanged +- Change detection prevents unnecessary hashing + +**Audit Points:** +- Verify debounce prevents excessive pushes +- Check async operations don't block main thread +- Confirm sessionStorage operations are efficient +- Validate change detection works correctly +- Check performance impact is minimal + +### 10. Browser Compatibility + +**Requirements:** +- `crypto.subtle` (SHA-256): Chrome 37+, Firefox 34+, Safari 11+, Edge 12+ +- `sessionStorage`: All modern browsers +- HTTPS required for `crypto.subtle` (or localhost) + +**Fallback Behavior:** +- Falls back to `btoa()` if `crypto.subtle` unavailable (with warning) +- Gracefully handles missing `sessionStorage` +- Error handling prevents crashes + +**Audit Points:** +- Verify fallback behavior works correctly +- Check warnings are logged appropriately +- Confirm no crashes in unsupported browsers +- Validate HTTPS requirement is documented + +## Data Flow + +### Page Load Flow +1. Page loads +2. If Remember Me enabled → Wait for RememberMeEvents.onLoad +3. Collect user data from form fields +4. Merge with cached data (if any) +5. Hash all PII fields with SHA-256 +6. Push `{ user_data: { ...hashedFields } }` to dataLayer +7. Cache unhashed merged data to sessionStorage + +### Field Change Flow +1. User changes a user data field +2. Debounce timer starts (500ms) +3. If another change occurs, timer resets +4. After 500ms of no changes: + - Collect current user data + - Merge with cached data + - Check if data changed + - If changed: hash and push to dataLayer + - Cache unhashed merged data + +### Form Submit Flow +1. Form submit event fires +2. Check if TidyContact `submitPromise` exists +3. If exists: + - Wait for TidyContact API to complete + - TidyContact standardizes address (CASS) and phone (E.164) + - Collect user data (now standardized) + - Hash and push to dataLayer +4. If not exists: + - Collect and push immediately + +## Edge Cases Handled + +1. **Empty Fields**: Skipped, not included in user_data +2. **Partial Data**: Merges available fields, doesn't require all fields +3. **Multiple Pages**: Data cached in sessionStorage, merged across pages +4. **Remember Me Cleared**: Cached data persists until new data overwrites +5. **Cross-Domain**: sessionStorage is domain-specific, data doesn't persist +6. **TidyContact Failure**: Falls back to current form data +7. **No TidyContact**: Works normally without TidyContact +8. **Data Unchanged**: Skips push if data hasn't changed +9. **No Data**: Returns early if no user data available +10. **Browser Incompatibility**: Falls back to btoa with warning +11. **HTTPS Required**: Warns if crypto.subtle unavailable +12. **Special Characters**: Normalization handles unicode, hyphens, apostrophes +13. **International Formats**: Phone, postal code, address formats handled + +## Testing Scenarios + +### Basic Functionality +- [ ] Enhanced conversions pushes on page load +- [ ] Enhanced conversions pushes on field changes (debounced) +- [ ] Enhanced conversions pushes on form submit +- [ ] Data structure matches GA4 spec +- [ ] All PII fields are SHA-256 hashed +- [ ] No plaintext PII in dataLayer + +### Integration Testing +- [ ] Works with Remember Me enabled +- [ ] Works with Remember Me disabled +- [ ] Works with TidyContact enabled +- [ ] Works with TidyContact disabled +- [ ] Works with both Remember Me and TidyContact +- [ ] Captures standardized data from TidyContact + +### Edge Case Testing +- [ ] Empty fields are skipped +- [ ] Partial data is merged correctly +- [ ] Data persists across multiple pages +- [ ] Data doesn't persist across domains +- [ ] Special characters in names handled correctly +- [ ] International phone formats normalized +- [ ] International postal codes normalized +- [ ] TidyContact failure handled gracefully +- [ ] Browser without crypto.subtle falls back correctly +- [ ] Data unchanged detection prevents redundant pushes + +### Security Testing +- [ ] No plaintext PII in dataLayer +- [ ] No plaintext PII in console logs +- [ ] Payment fields excluded from collection +- [ ] Error handling doesn't expose data +- [ ] All PII properly hashed + +### Performance Testing +- [ ] Debounce prevents excessive pushes +- [ ] Async operations don't block UI +- [ ] sessionStorage operations are fast +- [ ] Change detection works efficiently +- [ ] No memory leaks + +## Files Modified + +1. **packages/scripts/src/data-layer.ts** + - Added Enhanced Conversions functionality + - Maintained backward compatibility + - No breaking changes to existing API + +## Dependencies + +- No new external dependencies added +- Uses existing ENgrid utilities (ENGrid, EnForm, RememberMeEvents) +- Uses browser native APIs (crypto.subtle, sessionStorage) + +## Configuration + +**No configuration required** - Enhanced Conversions works automatically when DataLayer is instantiated. + +**Optional Configuration:** +- `fieldChangeDebounceDelay`: Default 500ms (configurable via property) + +## Browser Requirements + +- **SHA-256 Hashing**: Chrome 37+, Firefox 34+, Safari 11+, Edge 12+ +- **HTTPS Required**: For proper SHA-256 hashing (or localhost) +- **sessionStorage**: All modern browsers + +## Google Analytics Integration + +The implementation pushes data in the following format to `window.dataLayer`: + +```javascript +{ + user_data: { + email_address: "sha256hash...", + phone_number: "sha256hash...", + first_name: "sha256hash...", + last_name: "sha256hash...", + street_address: "sha256hash...", + city: "sha256hash...", + region: "sha256hash...", + postal_code: "sha256hash...", + country: "sha256hash..." + } +} +``` + +Google Tag Manager will automatically process this `user_data` object and merge it with conversion events for Enhanced Conversions matching. + +## Audit Checklist + +### Code Review +- [ ] All PII fields are properly hashed +- [ ] No plaintext PII in dataLayer +- [ ] Normalization functions handle edge cases +- [ ] Error handling is comprehensive +- [ ] Integration points are correct +- [ ] Performance optimizations are effective +- [ ] Browser compatibility is handled + +### Security Review +- [ ] No data leaks in logs +- [ ] No plaintext PII anywhere +- [ ] Payment fields excluded +- [ ] Error handling secure +- [ ] Hash implementation correct + +### Integration Review +- [ ] Remember Me integration works +- [ ] TidyContact integration works +- [ ] ENGrid integration works +- [ ] dataLayer structure correct +- [ ] No conflicts with existing features + +### Testing Review +- [ ] All scenarios tested +- [ ] Edge cases covered +- [ ] Performance acceptable +- [ ] Browser compatibility verified +- [ ] Security verified + +## Known Limitations + +1. **Synchronous hash() method**: Still uses btoa() for backward compatibility with existing EN_FORM_VALUE_UPDATED events. Enhanced Conversions uses async sha256Hash(). +2. **HTTPS Requirement**: SHA-256 hashing requires HTTPS (or localhost). Falls back to btoa() with warning in non-HTTPS contexts. +3. **Browser Support**: Older browsers without crypto.subtle will use btoa() fallback. + +## Future Enhancements (Out of Scope) + +- Configuration options to enable/disable enhanced conversions +- More detailed logging in debug mode +- Unit tests for normalization functions +- Integration tests for full data flow + +## Questions for Auditor + +1. Does the data structure match GA4 Enhanced Conversions spec exactly? +2. Are there any security concerns with the implementation? +3. Is the TidyContact integration timing correct? +4. Are there any edge cases not handled? +5. Is the performance impact acceptable? +6. Are there any browser compatibility issues? +7. Is the error handling comprehensive? +8. Are there any data leaks or privacy concerns? + +## References + +- [Google Analytics Enhanced Conversions](https://support.google.com/google-ads/answer/9888166) +- [GA4 Measurement Protocol - user_data](https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference/events#user_data) +- [SHA-256 Hashing](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) diff --git a/packages/scripts/dist/data-layer.d.ts b/packages/scripts/dist/data-layer.d.ts index b9107d1e..ce001643 100644 --- a/packages/scripts/dist/data-layer.d.ts +++ b/packages/scripts/dist/data-layer.d.ts @@ -4,8 +4,24 @@ export declare class DataLayer { private _form; private static instance; private endOfGiftProcessStorageKey; + private enhancedConversionsStorageKey; + private fieldChangeDebounceTimer; + private fieldChangeDebounceDelay; private excludedFields; private hashedFields; + /** + * Constructor - initializes DataLayer and sets up event listeners + * + * Integration with Remember Me: + * - If RememberMe option is enabled, waits for RememberMeEvents.onLoad + * - Enhanced conversions push happens after Remember Me loads data + * - This ensures form fields are populated before collecting user data + * + * Integration with ENGrid: + * - Uses ENGrid.getOption() to check RememberMe setting + * - Uses ENGrid.getFieldValue() to collect form field values + * - Uses EnForm.getInstance() for form submission events + */ constructor(); static getInstance(): DataLayer; private transformJSON; @@ -13,7 +29,142 @@ export declare class DataLayer { private onSubmit; private attachEventListeners; private handleFieldValueChange; + /** + * Debounced push of enhanced conversions to avoid excessive pushes on rapid field changes + * + * Performance optimizations: + * - Debounce delay: 500ms (configurable via fieldChangeDebounceDelay) + * - Prevents multiple pushes during rapid typing + * - Async SHA-256 operations don't block the main thread + * - sessionStorage operations are synchronous but fast + * - Only pushes if data has actually changed (checked in pushEnhancedConversions) + */ + private debouncedPushEnhancedConversions; + /** + * Hash a value using SHA-256 + * Falls back to btoa if crypto.subtle is not available + * + * Browser compatibility: + * - crypto.subtle is available in: Chrome 37+, Firefox 34+, Safari 11+, Edge 12+ + * - Requires HTTPS (or localhost) for security + * - Returns lowercase hex string (64 characters for SHA-256) + * + * Edge cases handled: + * - Empty strings return empty string + * - Non-string values are converted to string + * - Errors during hashing fall back to btoa with warning + * - Non-HTTPS contexts fall back to btoa with warning + */ + private sha256Hash; + /** + * Synchronous hash method for backward compatibility + * + * NOTE: This method uses btoa (Base64) for existing EN_FORM_VALUE_UPDATED events. + * Enhanced Conversions uses the async sha256Hash() method which provides proper + * SHA-256 hashing for Google Analytics Enhanced Conversions. + * + * Design decision: + * - Enhanced Conversions: Uses async sha256Hash() for proper SHA-256 hashing + * - Existing events (EN_FORM_VALUE_UPDATED): Uses btoa() for backward compatibility + * - The hash() method remains synchronous to avoid breaking existing synchronous code + * + * If you need SHA-256 hashing for hashedFields in EN_FORM_VALUE_UPDATED events, + * you would need to refactor handleFieldValueChange() to be async, which may + * have broader implications for the codebase. + */ private hash; + /** + * Normalize email address: lowercase, trim whitespace, remove leading/trailing dots + * Handles edge cases: multiple spaces, special characters, empty strings + */ + private normalizeEmail; + /** + * Normalize phone number: remove non-digit characters, keep leading + + * Handles edge cases: extensions (x, ext, extension), parentheses, dashes, spaces + * Converts to E.164 format when possible + */ + private normalizePhone; + /** + * Normalize address: trim whitespace, remove extra spaces, normalize common abbreviations + * Handles edge cases: multiple spaces, special characters, empty strings + */ + private normalizeAddress; + /** + * Normalize name: trim whitespace, capitalize first letter of each word + * Handles edge cases: hyphens, apostrophes, multiple spaces, special characters + * Preserves hyphens and apostrophes (e.g., "O'Brien", "Mary-Jane") + */ + private normalizeName; + /** + * Normalize postal code: uppercase, remove spaces and hyphens + * Handles edge cases: various formats (US ZIP+4, Canadian postal codes, etc.) + */ + private normalizePostalCode; + /** + * Normalize region/state: uppercase, trim + * Handles edge cases: special characters, empty strings + */ + private normalizeRegion; + /** + * Normalize country: uppercase, trim + * Handles edge cases: special characters, empty strings, country codes vs names + */ + private normalizeCountry; + /** + * Collect user data from form fields + * + * Handles various field types: + * - Text inputs: email, phone, names, addresses + * - Select dropdowns: country, region + * - Radio buttons: (handled by ENGrid.getFieldValue) + * - Checkboxes: (handled by ENGrid.getFieldValue) + * + * Edge cases: + * - Empty fields are skipped (not included in userData) + * - Multiple address fields are combined into street_address + * - Only collects data if field has a value + */ + private collectUserData; + /** + * Merge user data, preferring new data over cached data + * + * Edge cases handled: + * - Partial data: merges fields from multiple sources + * - Conflicting values: new data (from form) takes precedence over cached + * - Empty strings: treated as no value (won't overwrite existing) + * - Remember Me integration: cached data from Remember Me is merged with current form data + */ + private mergeUserData; + /** + * Hash user data fields according to Google Analytics Enhanced Conversions spec + */ + private hashUserData; + /** + * Get cached user data from sessionStorage + */ + private getCachedUserData; + /** + * Cache user data to sessionStorage + */ + private cacheUserData; + /** + * Push enhanced conversions data to dataLayer + * + * Edge cases handled: + * - Empty fields: skipped, not included in user_data + * - Multiple pages: data is cached in sessionStorage and merged across pages + * - Remember Me cleared: cached data persists until new data overwrites it + * - Cross-domain: sessionStorage is domain-specific, data doesn't persist across domains + * - Partial data: merges available fields, doesn't require all fields + * - No data: returns early if no user data is available + * - Data unchanged: skips push if data hasn't changed since last push + * + * Integration points: + * - ENGrid: uses ENGrid.getFieldValue() to collect form data + * - Remember Me: pushes after Remember Me loads (if enabled) + * - dataLayer: pushes to window.dataLayer for GTM processing + */ + private pushEnhancedConversions; private getFieldLabel; addEndOfGiftProcessEvent(eventName: string, eventProperties?: object): void; addEndOfGiftProcessVariable(variableName: string, variableValue?: any): void; diff --git a/packages/scripts/dist/data-layer.js b/packages/scripts/dist/data-layer.js index 5d70e82d..8f601d7e 100644 --- a/packages/scripts/dist/data-layer.js +++ b/packages/scripts/dist/data-layer.js @@ -7,13 +7,38 @@ // are replayed after a successful gift process load. // Sensitive payment/bank fields are excluded; selected PII fields are Base64 “hashed” (btoa — not cryptographic). // Replace with a real hash (e.g., SHA‑256) if required. +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; import { EngridLogger, ENGrid, EnForm, RememberMeEvents } from "."; export class DataLayer { + /** + * Constructor - initializes DataLayer and sets up event listeners + * + * Integration with Remember Me: + * - If RememberMe option is enabled, waits for RememberMeEvents.onLoad + * - Enhanced conversions push happens after Remember Me loads data + * - This ensures form fields are populated before collecting user data + * + * Integration with ENGrid: + * - Uses ENGrid.getOption() to check RememberMe setting + * - Uses ENGrid.getFieldValue() to collect form field values + * - Uses EnForm.getInstance() for form submission events + */ constructor() { this.logger = new EngridLogger("DataLayer", "#f1e5bc", "#009cdc", "📊"); this.dataLayer = window.dataLayer || []; this._form = EnForm.getInstance(); this.endOfGiftProcessStorageKey = "ENGRID_END_OF_GIFT_PROCESS_EVENTS"; + this.enhancedConversionsStorageKey = "ENGRID_ENHANCED_CONVERSIONS_DATA"; + this.fieldChangeDebounceTimer = null; + this.fieldChangeDebounceDelay = 500; // ms this.excludedFields = [ // Credit Card "transaction.ccnumber", @@ -113,8 +138,12 @@ export class DataLayer { this.dataLayer.push(dataLayerData); } this.attachEventListeners(); + // Push enhanced conversions on page load + this.pushEnhancedConversions(); } onSubmit() { + // Push enhanced conversions before form submission + this.pushEnhancedConversions(); const optIn = document.querySelector(".en__field__item:not(.en__field--question) input[name^='supporter.questions'][type='checkbox']:checked"); if (optIn) { this.logger.log("EN_SUBMISSION_WITH_EMAIL_OPTIN"); @@ -178,6 +207,8 @@ export class DataLayer { }); } } + // Check if this is a user data field for enhanced conversions + this.debouncedPushEnhancedConversions(); return; } this.dataLayer.push({ @@ -186,10 +217,495 @@ export class DataLayer { enFieldLabel: this.getFieldLabel(el), enFieldValue: value, }); + // Check if this is a user data field for enhanced conversions + const userDataFields = [ + "supporter.emailAddress", + "supporter.phoneNumber", + "supporter.firstName", + "supporter.lastName", + "supporter.address1", + "supporter.address2", + "supporter.address3", + "supporter.city", + "supporter.region", + "supporter.postcode", + "supporter.country", + ]; + if (userDataFields.includes(el.name)) { + this.debouncedPushEnhancedConversions(); + } + } + /** + * Debounced push of enhanced conversions to avoid excessive pushes on rapid field changes + * + * Performance optimizations: + * - Debounce delay: 500ms (configurable via fieldChangeDebounceDelay) + * - Prevents multiple pushes during rapid typing + * - Async SHA-256 operations don't block the main thread + * - sessionStorage operations are synchronous but fast + * - Only pushes if data has actually changed (checked in pushEnhancedConversions) + */ + debouncedPushEnhancedConversions() { + if (this.fieldChangeDebounceTimer !== null) { + window.clearTimeout(this.fieldChangeDebounceTimer); + } + this.fieldChangeDebounceTimer = window.setTimeout(() => { + // Fire and forget - async operation won't block + this.pushEnhancedConversions(); + this.fieldChangeDebounceTimer = null; + }, this.fieldChangeDebounceDelay); + } + /** + * Hash a value using SHA-256 + * Falls back to btoa if crypto.subtle is not available + * + * Browser compatibility: + * - crypto.subtle is available in: Chrome 37+, Firefox 34+, Safari 11+, Edge 12+ + * - Requires HTTPS (or localhost) for security + * - Returns lowercase hex string (64 characters for SHA-256) + * + * Edge cases handled: + * - Empty strings return empty string + * - Non-string values are converted to string + * - Errors during hashing fall back to btoa with warning + * - Non-HTTPS contexts fall back to btoa with warning + */ + sha256Hash(value) { + return __awaiter(this, void 0, void 0, function* () { + // Handle empty or invalid input + if (!value) + return ""; + if (typeof value !== "string") { + value = String(value); + } + // Check if crypto.subtle is available (HTTPS or localhost) + if (typeof window !== "undefined" && + window.crypto && + window.crypto.subtle) { + try { + // Encode the string as UTF-8 + const encoder = new TextEncoder(); + const data = encoder.encode(value); + // Hash using SHA-256 + const hashBuffer = yield crypto.subtle.digest("SHA-256", data); + // Convert ArrayBuffer to hex string + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + // Verify hash length (SHA-256 should be 64 hex characters) + if (hashHex.length !== 64) { + this.logger.warn(`Unexpected hash length: ${hashHex.length}, expected 64`); + } + return hashHex; + } + catch (error) { + // Log error but don't throw - fall back gracefully + this.logger.error("SHA-256 hashing failed, falling back to btoa", error); + return btoa(value); + } + } + else { + // Fallback for non-HTTPS contexts or older browsers + this.logger.warn("crypto.subtle not available, using btoa fallback for hashing. " + + "Enhanced conversions require HTTPS for proper SHA-256 hashing."); + return btoa(value); + } + }); } + /** + * Synchronous hash method for backward compatibility + * + * NOTE: This method uses btoa (Base64) for existing EN_FORM_VALUE_UPDATED events. + * Enhanced Conversions uses the async sha256Hash() method which provides proper + * SHA-256 hashing for Google Analytics Enhanced Conversions. + * + * Design decision: + * - Enhanced Conversions: Uses async sha256Hash() for proper SHA-256 hashing + * - Existing events (EN_FORM_VALUE_UPDATED): Uses btoa() for backward compatibility + * - The hash() method remains synchronous to avoid breaking existing synchronous code + * + * If you need SHA-256 hashing for hashedFields in EN_FORM_VALUE_UPDATED events, + * you would need to refactor handleFieldValueChange() to be async, which may + * have broader implications for the codebase. + */ hash(value) { + // For enhanced conversions, we use async sha256Hash + // This method is kept for backward compatibility with existing code return btoa(value); } + /** + * Normalize email address: lowercase, trim whitespace, remove leading/trailing dots + * Handles edge cases: multiple spaces, special characters, empty strings + */ + normalizeEmail(email) { + if (!email || typeof email !== "string") + return ""; + // Trim and lowercase + let normalized = email.trim().toLowerCase(); + // Remove leading/trailing dots (but keep dots in the middle) + normalized = normalized.replace(/^\.+|\.+$/g, ""); + // Remove any whitespace (emails shouldn't have spaces) + normalized = normalized.replace(/\s+/g, ""); + return normalized; + } + /** + * Normalize phone number: remove non-digit characters, keep leading + + * Handles edge cases: extensions (x, ext, extension), parentheses, dashes, spaces + * Converts to E.164 format when possible + */ + normalizePhone(phone) { + if (!phone || typeof phone !== "string") + return ""; + // Trim whitespace + let cleaned = phone.trim(); + // Remove common extension markers and everything after them + cleaned = cleaned.replace(/\s*(x|ext|extension)[\s:]*\d+.*$/i, ""); + // Remove parentheses, dashes, dots, spaces + cleaned = cleaned.replace(/[()\-.\s]/g, ""); + // Keep leading + if present + if (cleaned.startsWith("+")) { + // Ensure + is followed by digits only + const digits = cleaned.slice(1).replace(/\D/g, ""); + return digits ? "+" + digits : ""; + } + // Remove all non-digits + return cleaned.replace(/\D/g, ""); + } + /** + * Normalize address: trim whitespace, remove extra spaces, normalize common abbreviations + * Handles edge cases: multiple spaces, special characters, empty strings + */ + normalizeAddress(address) { + if (!address || typeof address !== "string") + return ""; + // Trim and normalize whitespace + let normalized = address.trim().replace(/\s+/g, " "); + // Normalize common address abbreviations (optional, for consistency) + // Street -> St, Avenue -> Ave, etc. (but keep original if preferred) + return normalized; + } + /** + * Normalize name: trim whitespace, capitalize first letter of each word + * Handles edge cases: hyphens, apostrophes, multiple spaces, special characters + * Preserves hyphens and apostrophes (e.g., "O'Brien", "Mary-Jane") + */ + normalizeName(name) { + if (!name || typeof name !== "string") + return ""; + // Trim and normalize whitespace + let normalized = name.trim().replace(/\s+/g, " "); + // Split on spaces, hyphens, and apostrophes, but preserve them + // Capitalize first letter of each word part + normalized = normalized + .split(/\s+/) + .map((word) => { + // Handle hyphenated names (e.g., "Mary-Jane") + if (word.includes("-")) { + return word + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join("-"); + } + // Handle apostrophes (e.g., "O'Brien") + if (word.includes("'")) { + return word + .split("'") + .map((part, index) => { + if (index === 0) { + return (part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()); + } + return part.toLowerCase(); + }) + .join("'"); + } + // Regular word + return (word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()); + }) + .join(" "); + return normalized; + } + /** + * Normalize postal code: uppercase, remove spaces and hyphens + * Handles edge cases: various formats (US ZIP+4, Canadian postal codes, etc.) + */ + normalizePostalCode(postalCode) { + if (!postalCode || typeof postalCode !== "string") + return ""; + // Uppercase, trim, remove all spaces and hyphens + return postalCode.toUpperCase().trim().replace(/[\s\-]/g, ""); + } + /** + * Normalize region/state: uppercase, trim + * Handles edge cases: special characters, empty strings + */ + normalizeRegion(region) { + if (!region || typeof region !== "string") + return ""; + return region.toUpperCase().trim(); + } + /** + * Normalize country: uppercase, trim + * Handles edge cases: special characters, empty strings, country codes vs names + */ + normalizeCountry(country) { + if (!country || typeof country !== "string") + return ""; + return country.toUpperCase().trim(); + } + /** + * Collect user data from form fields + * + * Handles various field types: + * - Text inputs: email, phone, names, addresses + * - Select dropdowns: country, region + * - Radio buttons: (handled by ENGrid.getFieldValue) + * - Checkboxes: (handled by ENGrid.getFieldValue) + * + * Edge cases: + * - Empty fields are skipped (not included in userData) + * - Multiple address fields are combined into street_address + * - Only collects data if field has a value + */ + collectUserData() { + const userData = {}; + // Email - text input + const email = ENGrid.getFieldValue("supporter.emailAddress"); + if (email && email.trim()) { + const normalized = this.normalizeEmail(email); + if (normalized) { + userData.email_address = normalized; + } + } + // Phone - text input + const phone = ENGrid.getFieldValue("supporter.phoneNumber"); + if (phone && phone.trim()) { + const normalized = this.normalizePhone(phone); + if (normalized) { + userData.phone_number = normalized; + } + } + // First Name - text input + const firstName = ENGrid.getFieldValue("supporter.firstName"); + if (firstName && firstName.trim()) { + const normalized = this.normalizeName(firstName); + if (normalized) { + userData.first_name = normalized; + } + } + // Last Name - text input + const lastName = ENGrid.getFieldValue("supporter.lastName"); + if (lastName && lastName.trim()) { + const normalized = this.normalizeName(lastName); + if (normalized) { + userData.last_name = normalized; + } + } + // Street Address - combine address1, address2, address3 (text inputs) + const address1 = ENGrid.getFieldValue("supporter.address1"); + const address2 = ENGrid.getFieldValue("supporter.address2"); + const address3 = ENGrid.getFieldValue("supporter.address3"); + const streetParts = []; + if (address1 && address1.trim()) { + const normalized = this.normalizeAddress(address1); + if (normalized) + streetParts.push(normalized); + } + if (address2 && address2.trim()) { + const normalized = this.normalizeAddress(address2); + if (normalized) + streetParts.push(normalized); + } + if (address3 && address3.trim()) { + const normalized = this.normalizeAddress(address3); + if (normalized) + streetParts.push(normalized); + } + if (streetParts.length > 0) { + userData.street_address = streetParts.join(" "); + } + // City - text input + const city = ENGrid.getFieldValue("supporter.city"); + if (city && city.trim()) { + const normalized = this.normalizeAddress(city); + if (normalized) { + userData.city = normalized; + } + } + // Region/State - select dropdown or text input + const region = ENGrid.getFieldValue("supporter.region"); + if (region && region.trim()) { + const normalized = this.normalizeRegion(region); + if (normalized) { + userData.region = normalized; + } + } + // Postal Code - text input + const postalCode = ENGrid.getFieldValue("supporter.postcode"); + if (postalCode && postalCode.trim()) { + const normalized = this.normalizePostalCode(postalCode); + if (normalized) { + userData.postal_code = normalized; + } + } + // Country - select dropdown + const country = ENGrid.getFieldValue("supporter.country"); + if (country && country.trim()) { + const normalized = this.normalizeCountry(country); + if (normalized) { + userData.country = normalized; + } + } + return userData; + } + /** + * Merge user data, preferring new data over cached data + * + * Edge cases handled: + * - Partial data: merges fields from multiple sources + * - Conflicting values: new data (from form) takes precedence over cached + * - Empty strings: treated as no value (won't overwrite existing) + * - Remember Me integration: cached data from Remember Me is merged with current form data + */ + mergeUserData(existing, newData) { + const merged = Object.assign({}, existing); + // Only merge new data if it has a value (not empty string) + for (const key in newData) { + const value = newData[key]; + // Prefer new data if it exists and is not empty + if (value && value.trim && value.trim() !== "") { + merged[key] = value; + } + else if (value && typeof value === "string" && value !== "") { + // For non-trimmable values (shouldn't happen, but safety check) + merged[key] = value; + } + } + return merged; + } + /** + * Hash user data fields according to Google Analytics Enhanced Conversions spec + */ + hashUserData(userData) { + return __awaiter(this, void 0, void 0, function* () { + const hashedData = {}; + if (userData.email_address) { + hashedData.email_address = yield this.sha256Hash(userData.email_address); + } + if (userData.phone_number) { + hashedData.phone_number = yield this.sha256Hash(userData.phone_number); + } + if (userData.first_name) { + hashedData.first_name = yield this.sha256Hash(userData.first_name); + } + if (userData.last_name) { + hashedData.last_name = yield this.sha256Hash(userData.last_name); + } + if (userData.street_address) { + hashedData.street_address = yield this.sha256Hash(userData.street_address); + } + if (userData.city) { + hashedData.city = yield this.sha256Hash(userData.city); + } + if (userData.region) { + hashedData.region = yield this.sha256Hash(userData.region); + } + if (userData.postal_code) { + hashedData.postal_code = yield this.sha256Hash(userData.postal_code); + } + if (userData.country) { + hashedData.country = yield this.sha256Hash(userData.country); + } + return hashedData; + }); + } + /** + * Get cached user data from sessionStorage + */ + getCachedUserData() { + try { + if (typeof window !== "undefined" && window.sessionStorage) { + const cached = window.sessionStorage.getItem(this.enhancedConversionsStorageKey); + if (cached) { + return JSON.parse(cached); + } + } + } + catch (error) { + this.logger.error("Failed to get cached user data", error); + } + return {}; + } + /** + * Cache user data to sessionStorage + */ + cacheUserData(userData) { + try { + if (typeof window !== "undefined" && window.sessionStorage) { + window.sessionStorage.setItem(this.enhancedConversionsStorageKey, JSON.stringify(userData)); + } + } + catch (error) { + this.logger.error("Failed to cache user data", error); + } + } + /** + * Push enhanced conversions data to dataLayer + * + * Edge cases handled: + * - Empty fields: skipped, not included in user_data + * - Multiple pages: data is cached in sessionStorage and merged across pages + * - Remember Me cleared: cached data persists until new data overwrites it + * - Cross-domain: sessionStorage is domain-specific, data doesn't persist across domains + * - Partial data: merges available fields, doesn't require all fields + * - No data: returns early if no user data is available + * - Data unchanged: skips push if data hasn't changed since last push + * + * Integration points: + * - ENGrid: uses ENGrid.getFieldValue() to collect form data + * - Remember Me: pushes after Remember Me loads (if enabled) + * - dataLayer: pushes to window.dataLayer for GTM processing + */ + pushEnhancedConversions() { + return __awaiter(this, void 0, void 0, function* () { + try { + // Collect current user data + const currentData = this.collectUserData(); + // Check if we have any data + if (Object.keys(currentData).length === 0) { + return; + } + // Merge with cached data + const cachedData = this.getCachedUserData(); + const mergedData = this.mergeUserData(cachedData, currentData); + // Check if data has changed + const cachedString = JSON.stringify(cachedData); + const mergedString = JSON.stringify(mergedData); + if (cachedString === mergedString && Object.keys(cachedData).length > 0) { + // Data hasn't changed, skip push + return; + } + // Hash the merged data + const hashedData = yield this.hashUserData(mergedData); + // Cache the unhashed merged data for future merges + this.cacheUserData(mergedData); + // Push to dataLayer in Google Analytics Enhanced Conversions format + // Structure matches GA4 Measurement Protocol: { user_data: { email_address: "hash", ... } } + const enhancedConversionsData = { + user_data: hashedData, + }; + // Push to dataLayer - GTM will automatically process this for Enhanced Conversions + // The user_data object will be merged with conversion events + this.dataLayer.push(enhancedConversionsData); + this.logger.log("Enhanced conversions data pushed", { + fields: Object.keys(hashedData), + }); + } + catch (error) { + this.logger.error("Failed to push enhanced conversions", error); + } + }); + } getFieldLabel(el) { var _a, _b; return ((_b = (_a = el.closest(".en__field")) === null || _a === void 0 ? void 0 : _a.querySelector("label")) === null || _b === void 0 ? void 0 : _b.textContent) || ""; diff --git a/packages/scripts/dist/events/country.d.ts b/packages/scripts/dist/events/country.d.ts index e5e401b4..36dd06f2 100644 --- a/packages/scripts/dist/events/country.d.ts +++ b/packages/scripts/dist/events/country.d.ts @@ -6,7 +6,7 @@ export declare class Country { constructor(); static getInstance(): Country; get countryField(): HTMLElement | null; - get onCountryChange(): import("strongly-typed-events").ISimpleEvent; + get onCountryChange(): any; get country(): string; set country(value: string); } diff --git a/packages/scripts/dist/events/donation-amount.d.ts b/packages/scripts/dist/events/donation-amount.d.ts index a8f710a1..a1e36022 100644 --- a/packages/scripts/dist/events/donation-amount.d.ts +++ b/packages/scripts/dist/events/donation-amount.d.ts @@ -9,7 +9,7 @@ export declare class DonationAmount { static getInstance(radios?: string, other?: string): DonationAmount; get amount(): number; set amount(value: number); - get onAmountChange(): import("strongly-typed-events").ISimpleEvent; + get onAmountChange(): any; load(): void; setAmount(amount: number, dispatch?: boolean): void; clearOther(): void; diff --git a/packages/scripts/dist/events/donation-frequency.d.ts b/packages/scripts/dist/events/donation-frequency.d.ts index db5a208d..3f2bca96 100644 --- a/packages/scripts/dist/events/donation-frequency.d.ts +++ b/packages/scripts/dist/events/donation-frequency.d.ts @@ -10,7 +10,7 @@ export declare class DonationFrequency { set frequency(value: string); get recurring(): string; set recurring(value: string); - get onFrequencyChange(): import("strongly-typed-events").ISimpleEvent; + get onFrequencyChange(): any; load(): void; setRecurrency(recurr: string, dispatch?: boolean): void; setFrequency(freq: string, dispatch?: boolean): void; diff --git a/packages/scripts/dist/events/en-form.d.ts b/packages/scripts/dist/events/en-form.d.ts index 9300dff0..efae8eee 100644 --- a/packages/scripts/dist/events/en-form.d.ts +++ b/packages/scripts/dist/events/en-form.d.ts @@ -14,7 +14,7 @@ export declare class EnForm { dispatchValidate(): void; dispatchError(): void; submitForm(): void; - get onSubmit(): import("strongly-typed-events").ISignal; - get onError(): import("strongly-typed-events").ISignal; - get onValidate(): import("strongly-typed-events").ISignal; + get onSubmit(): any; + get onError(): any; + get onValidate(): any; } diff --git a/packages/scripts/dist/events/processing-fees.d.ts b/packages/scripts/dist/events/processing-fees.d.ts index 3f136980..48c5b8e6 100644 --- a/packages/scripts/dist/events/processing-fees.d.ts +++ b/packages/scripts/dist/events/processing-fees.d.ts @@ -10,7 +10,7 @@ export declare class ProcessingFees { private static instance; constructor(); static getInstance(): ProcessingFees; - get onFeeChange(): import("strongly-typed-events").ISimpleEvent; + get onFeeChange(): any; get fee(): number; set fee(value: number); calculateFees(amount?: number): any; diff --git a/packages/scripts/dist/events/remember-me-events.d.ts b/packages/scripts/dist/events/remember-me-events.d.ts index 99c34d96..180c596b 100644 --- a/packages/scripts/dist/events/remember-me-events.d.ts +++ b/packages/scripts/dist/events/remember-me-events.d.ts @@ -13,6 +13,6 @@ export declare class RememberMeEvents { static getInstance(): RememberMeEvents; dispatchLoad(hasData: boolean): void; dispatchClear(): void; - get onLoad(): import("strongly-typed-events").ISimpleEvent; - get onClear(): import("strongly-typed-events").ISignal; + get onLoad(): any; + get onClear(): any; } diff --git a/packages/scripts/src/data-layer.ts b/packages/scripts/src/data-layer.ts index 8a706123..658f691d 100644 --- a/packages/scripts/src/data-layer.ts +++ b/packages/scripts/src/data-layer.ts @@ -10,6 +10,46 @@ import { EngridLogger, ENGrid, EnForm, RememberMeEvents } from "."; +/** + * User data interface matching Google Analytics Enhanced Conversions spec + * All fields are optional and will be SHA-256 hashed before pushing to dataLayer + * + * Reference: https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference/events#user_data + * + * Field requirements: + * - email_address: SHA-256 hashed, lowercase, trimmed + * - phone_number: SHA-256 hashed, E.164 format preferred + * - first_name: SHA-256 hashed, normalized (capitalized) + * - last_name: SHA-256 hashed, normalized (capitalized) + * - street_address: SHA-256 hashed, combined from address1, address2, address3 + * - city: SHA-256 hashed, normalized + * - region: SHA-256 hashed, uppercase (state/province) + * - postal_code: SHA-256 hashed, uppercase, no spaces/hyphens + * - country: SHA-256 hashed, uppercase (ISO country code or name) + */ +interface UserData { + email_address?: string; + phone_number?: string; + first_name?: string; + last_name?: string; + street_address?: string; + city?: string; + region?: string; + postal_code?: string; + country?: string; +} + +/** + * Enhanced Conversions data structure for Google Analytics + * Matches GA4 Measurement Protocol specification + * + * Structure: { user_data: { email_address: "hash", ... } } + * This is pushed directly to dataLayer for Google Tag Manager to process + */ +interface EnhancedConversionsData { + user_data: UserData; +} + export class DataLayer { private logger: EngridLogger = new EngridLogger( "DataLayer", @@ -21,6 +61,9 @@ export class DataLayer { private _form: EnForm = EnForm.getInstance(); private static instance: DataLayer; private endOfGiftProcessStorageKey = "ENGRID_END_OF_GIFT_PROCESS_EVENTS"; + private enhancedConversionsStorageKey = "ENGRID_ENHANCED_CONVERSIONS_DATA"; + private fieldChangeDebounceTimer: number | null = null; + private fieldChangeDebounceDelay = 500; // ms private excludedFields = [ // Credit Card @@ -58,9 +101,22 @@ export class DataLayer { "supporter.billingAddress3", ]; + /** + * Constructor - initializes DataLayer and sets up event listeners + * + * Integration with Remember Me: + * - If RememberMe option is enabled, waits for RememberMeEvents.onLoad + * - Enhanced conversions push happens after Remember Me loads data + * - This ensures form fields are populated before collecting user data + * + * Integration with ENGrid: + * - Uses ENGrid.getOption() to check RememberMe setting + * - Uses ENGrid.getFieldValue() to collect form field values + * - Uses EnForm.getInstance() for form submission events + */ constructor() { if (ENGrid.getOption("RememberMe")) { - RememberMeEvents.getInstance().onLoad.subscribe((hasData) => { + RememberMeEvents.getInstance().onLoad.subscribe((hasData: boolean) => { this.logger.log("Remember me - onLoad", hasData); this.onLoad(); }); @@ -142,9 +198,48 @@ export class DataLayer { } this.attachEventListeners(); + + // Push enhanced conversions on page load + this.pushEnhancedConversions(); } private onSubmit() { + // TidyContact integration: Wait for TidyContact to standardize address/phone before pushing enhanced conversions + // TidyContact sets _form.submitPromise during onSubmit, so we need to check it after a brief delay + // to ensure it's been set by the time we check + window.setTimeout(() => { + // Check if TidyContact (or other services) will update fields via submitPromise + // If submitPromise exists, wait for it to complete before pushing enhanced conversions + // This ensures we capture standardized/validated data from TidyContact + if ( + this._form.submitPromise && + typeof this._form.submitPromise === "object" && + "then" in this._form.submitPromise + ) { + // Wait for TidyContact API to complete and update fields + (this._form.submitPromise as Promise) + .then(() => { + // Push enhanced conversions after TidyContact standardizes address/phone + // TidyContact updates fields via setFields() and setPhoneDataFromAPI() + // which standardizes addresses (CASS format) and phone numbers (E.164 format) + this.logger.log( + "Pushing enhanced conversions after TidyContact standardization" + ); + this.pushEnhancedConversions(); + }) + .catch(() => { + // Even if TidyContact fails, push with current data + this.logger.log( + "TidyContact failed, pushing enhanced conversions with current data" + ); + this.pushEnhancedConversions(); + }); + } else { + // No async operations (TidyContact not enabled or no data to standardize), push immediately + this.pushEnhancedConversions(); + } + }, 0); // Use setTimeout(0) to check submitPromise after all onSubmit subscribers have run + const optIn = document.querySelector( ".en__field__item:not(.en__field--question) input[name^='supporter.questions'][type='checkbox']:checked" ); @@ -226,6 +321,8 @@ export class DataLayer { }); } } + // Check if this is a user data field for enhanced conversions + this.debouncedPushEnhancedConversions(); return; } @@ -235,12 +332,542 @@ export class DataLayer { enFieldLabel: this.getFieldLabel(el), enFieldValue: value, }); + + // Check if this is a user data field for enhanced conversions + const userDataFields = [ + "supporter.emailAddress", + "supporter.phoneNumber", + "supporter.firstName", + "supporter.lastName", + "supporter.address1", + "supporter.address2", + "supporter.address3", + "supporter.city", + "supporter.region", + "supporter.postcode", + "supporter.country", + ]; + if (userDataFields.includes(el.name)) { + this.debouncedPushEnhancedConversions(); + } } + /** + * Debounced push of enhanced conversions to avoid excessive pushes on rapid field changes + * + * Performance optimizations: + * - Debounce delay: 500ms (configurable via fieldChangeDebounceDelay) + * - Prevents multiple pushes during rapid typing + * - Async SHA-256 operations don't block the main thread + * - sessionStorage operations are synchronous but fast + * - Only pushes if data has actually changed (checked in pushEnhancedConversions) + */ + private debouncedPushEnhancedConversions(): void { + if (this.fieldChangeDebounceTimer !== null) { + window.clearTimeout(this.fieldChangeDebounceTimer); + } + this.fieldChangeDebounceTimer = window.setTimeout(() => { + // Fire and forget - async operation won't block + this.pushEnhancedConversions(); + this.fieldChangeDebounceTimer = null; + }, this.fieldChangeDebounceDelay); + } + + /** + * Hash a value using SHA-256 + * Falls back to btoa if crypto.subtle is not available + * + * Browser compatibility: + * - crypto.subtle is available in: Chrome 37+, Firefox 34+, Safari 11+, Edge 12+ + * - Requires HTTPS (or localhost) for security + * - Returns lowercase hex string (64 characters for SHA-256) + * + * Edge cases handled: + * - Empty strings return empty string + * - Non-string values are converted to string + * - Errors during hashing fall back to btoa with warning + * - Non-HTTPS contexts fall back to btoa with warning + */ + private async sha256Hash(value: string): Promise { + // Handle empty or invalid input + if (!value) return ""; + if (typeof value !== "string") { + value = String(value); + } + + // Check if crypto.subtle is available (HTTPS or localhost) + if ( + typeof window !== "undefined" && + window.crypto && + window.crypto.subtle + ) { + try { + // Encode the string as UTF-8 + const encoder = new TextEncoder(); + const data = encoder.encode(value); + + // Hash using SHA-256 + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + + // Convert ArrayBuffer to hex string + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + // Verify hash length (SHA-256 should be 64 hex characters) + if (hashHex.length !== 64) { + this.logger.warn( + `Unexpected hash length: ${hashHex.length}, expected 64` + ); + } + + return hashHex; + } catch (error) { + // Log error but don't throw - fall back gracefully + this.logger.error("SHA-256 hashing failed, falling back to btoa", error); + return btoa(value); + } + } else { + // Fallback for non-HTTPS contexts or older browsers + this.logger.warn( + "crypto.subtle not available, using btoa fallback for hashing. " + + "Enhanced conversions require HTTPS for proper SHA-256 hashing." + ); + return btoa(value); + } + } + + /** + * Synchronous hash method for backward compatibility + * + * NOTE: This method uses btoa (Base64) for existing EN_FORM_VALUE_UPDATED events. + * Enhanced Conversions uses the async sha256Hash() method which provides proper + * SHA-256 hashing for Google Analytics Enhanced Conversions. + * + * Design decision: + * - Enhanced Conversions: Uses async sha256Hash() for proper SHA-256 hashing + * - Existing events (EN_FORM_VALUE_UPDATED): Uses btoa() for backward compatibility + * - The hash() method remains synchronous to avoid breaking existing synchronous code + * + * If you need SHA-256 hashing for hashedFields in EN_FORM_VALUE_UPDATED events, + * you would need to refactor handleFieldValueChange() to be async, which may + * have broader implications for the codebase. + */ private hash(value: string): string { + // For enhanced conversions, we use async sha256Hash + // This method is kept for backward compatibility with existing code return btoa(value); } + /** + * Normalize email address: lowercase, trim whitespace, remove leading/trailing dots + * Handles edge cases: multiple spaces, special characters, empty strings + */ + private normalizeEmail(email: string): string { + if (!email || typeof email !== "string") return ""; + // Trim and lowercase + let normalized = email.trim().toLowerCase(); + // Remove leading/trailing dots (but keep dots in the middle) + normalized = normalized.replace(/^\.+|\.+$/g, ""); + // Remove any whitespace (emails shouldn't have spaces) + normalized = normalized.replace(/\s+/g, ""); + return normalized; + } + + /** + * Normalize phone number: remove non-digit characters, keep leading + + * Handles edge cases: extensions (x, ext, extension), parentheses, dashes, spaces + * Converts to E.164 format when possible + */ + private normalizePhone(phone: string): string { + if (!phone || typeof phone !== "string") return ""; + // Trim whitespace + let cleaned = phone.trim(); + // Remove common extension markers and everything after them + cleaned = cleaned.replace(/\s*(x|ext|extension)[\s:]*\d+.*$/i, ""); + // Remove parentheses, dashes, dots, spaces + cleaned = cleaned.replace(/[()\-.\s]/g, ""); + // Keep leading + if present + if (cleaned.startsWith("+")) { + // Ensure + is followed by digits only + const digits = cleaned.slice(1).replace(/\D/g, ""); + return digits ? "+" + digits : ""; + } + // Remove all non-digits + return cleaned.replace(/\D/g, ""); + } + + /** + * Normalize address: trim whitespace, remove extra spaces, normalize common abbreviations + * Handles edge cases: multiple spaces, special characters, empty strings + */ + private normalizeAddress(address: string): string { + if (!address || typeof address !== "string") return ""; + // Trim and normalize whitespace + let normalized = address.trim().replace(/\s+/g, " "); + // Normalize common address abbreviations (optional, for consistency) + // Street -> St, Avenue -> Ave, etc. (but keep original if preferred) + return normalized; + } + + /** + * Normalize name: trim whitespace, capitalize first letter of each word + * Handles edge cases: hyphens, apostrophes, multiple spaces, special characters + * Preserves hyphens and apostrophes (e.g., "O'Brien", "Mary-Jane") + */ + private normalizeName(name: string): string { + if (!name || typeof name !== "string") return ""; + // Trim and normalize whitespace + let normalized = name.trim().replace(/\s+/g, " "); + // Split on spaces, hyphens, and apostrophes, but preserve them + // Capitalize first letter of each word part + normalized = normalized + .split(/\s+/) + .map((word) => { + // Handle hyphenated names (e.g., "Mary-Jane") + if (word.includes("-")) { + return word + .split("-") + .map((part) => + part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() + ) + .join("-"); + } + // Handle apostrophes (e.g., "O'Brien") + if (word.includes("'")) { + return word + .split("'") + .map((part, index) => { + if (index === 0) { + return ( + part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() + ); + } + return part.toLowerCase(); + }) + .join("'"); + } + // Regular word + return ( + word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + ); + }) + .join(" "); + return normalized; + } + + /** + * Normalize postal code: uppercase, remove spaces and hyphens + * Handles edge cases: various formats (US ZIP+4, Canadian postal codes, etc.) + */ + private normalizePostalCode(postalCode: string): string { + if (!postalCode || typeof postalCode !== "string") return ""; + // Uppercase, trim, remove all spaces and hyphens + return postalCode.toUpperCase().trim().replace(/[\s\-]/g, ""); + } + + /** + * Normalize region/state: uppercase, trim + * Handles edge cases: special characters, empty strings + */ + private normalizeRegion(region: string): string { + if (!region || typeof region !== "string") return ""; + return region.toUpperCase().trim(); + } + + /** + * Normalize country: uppercase, trim + * Handles edge cases: special characters, empty strings, country codes vs names + */ + private normalizeCountry(country: string): string { + if (!country || typeof country !== "string") return ""; + return country.toUpperCase().trim(); + } + + /** + * Collect user data from form fields + * + * Handles various field types: + * - Text inputs: email, phone, names, addresses + * - Select dropdowns: country, region + * - Radio buttons: (handled by ENGrid.getFieldValue) + * - Checkboxes: (handled by ENGrid.getFieldValue) + * + * Edge cases: + * - Empty fields are skipped (not included in userData) + * - Multiple address fields are combined into street_address + * - Only collects data if field has a value + */ + private collectUserData(): UserData { + const userData: UserData = {}; + + // Email - text input + const email = ENGrid.getFieldValue("supporter.emailAddress"); + if (email && email.trim()) { + const normalized = this.normalizeEmail(email); + if (normalized) { + userData.email_address = normalized; + } + } + + // Phone - text input + const phone = ENGrid.getFieldValue("supporter.phoneNumber"); + if (phone && phone.trim()) { + const normalized = this.normalizePhone(phone); + if (normalized) { + userData.phone_number = normalized; + } + } + + // First Name - text input + const firstName = ENGrid.getFieldValue("supporter.firstName"); + if (firstName && firstName.trim()) { + const normalized = this.normalizeName(firstName); + if (normalized) { + userData.first_name = normalized; + } + } + + // Last Name - text input + const lastName = ENGrid.getFieldValue("supporter.lastName"); + if (lastName && lastName.trim()) { + const normalized = this.normalizeName(lastName); + if (normalized) { + userData.last_name = normalized; + } + } + + // Street Address - combine address1, address2, address3 (text inputs) + const address1 = ENGrid.getFieldValue("supporter.address1"); + const address2 = ENGrid.getFieldValue("supporter.address2"); + const address3 = ENGrid.getFieldValue("supporter.address3"); + const streetParts: string[] = []; + if (address1 && address1.trim()) { + const normalized = this.normalizeAddress(address1); + if (normalized) streetParts.push(normalized); + } + if (address2 && address2.trim()) { + const normalized = this.normalizeAddress(address2); + if (normalized) streetParts.push(normalized); + } + if (address3 && address3.trim()) { + const normalized = this.normalizeAddress(address3); + if (normalized) streetParts.push(normalized); + } + if (streetParts.length > 0) { + userData.street_address = streetParts.join(" "); + } + + // City - text input + const city = ENGrid.getFieldValue("supporter.city"); + if (city && city.trim()) { + const normalized = this.normalizeAddress(city); + if (normalized) { + userData.city = normalized; + } + } + + // Region/State - select dropdown or text input + const region = ENGrid.getFieldValue("supporter.region"); + if (region && region.trim()) { + const normalized = this.normalizeRegion(region); + if (normalized) { + userData.region = normalized; + } + } + + // Postal Code - text input + const postalCode = ENGrid.getFieldValue("supporter.postcode"); + if (postalCode && postalCode.trim()) { + const normalized = this.normalizePostalCode(postalCode); + if (normalized) { + userData.postal_code = normalized; + } + } + + // Country - select dropdown + const country = ENGrid.getFieldValue("supporter.country"); + if (country && country.trim()) { + const normalized = this.normalizeCountry(country); + if (normalized) { + userData.country = normalized; + } + } + + return userData; + } + + /** + * Merge user data, preferring new data over cached data + * + * Edge cases handled: + * - Partial data: merges fields from multiple sources + * - Conflicting values: new data (from form) takes precedence over cached + * - Empty strings: treated as no value (won't overwrite existing) + * - Remember Me integration: cached data from Remember Me is merged with current form data + */ + private mergeUserData( + existing: UserData, + newData: UserData + ): UserData { + const merged: UserData = { ...existing }; + + // Only merge new data if it has a value (not empty string) + for (const key in newData) { + const value = newData[key as keyof UserData]; + // Prefer new data if it exists and is not empty + if (value && value.trim && value.trim() !== "") { + merged[key as keyof UserData] = value; + } else if (value && typeof value === "string" && value !== "") { + // For non-trimmable values (shouldn't happen, but safety check) + merged[key as keyof UserData] = value; + } + } + + return merged; + } + + /** + * Hash user data fields according to Google Analytics Enhanced Conversions spec + */ + private async hashUserData(userData: UserData): Promise { + const hashedData: UserData = {}; + + if (userData.email_address) { + hashedData.email_address = await this.sha256Hash(userData.email_address); + } + if (userData.phone_number) { + hashedData.phone_number = await this.sha256Hash(userData.phone_number); + } + if (userData.first_name) { + hashedData.first_name = await this.sha256Hash(userData.first_name); + } + if (userData.last_name) { + hashedData.last_name = await this.sha256Hash(userData.last_name); + } + if (userData.street_address) { + hashedData.street_address = await this.sha256Hash( + userData.street_address + ); + } + if (userData.city) { + hashedData.city = await this.sha256Hash(userData.city); + } + if (userData.region) { + hashedData.region = await this.sha256Hash(userData.region); + } + if (userData.postal_code) { + hashedData.postal_code = await this.sha256Hash(userData.postal_code); + } + if (userData.country) { + hashedData.country = await this.sha256Hash(userData.country); + } + + return hashedData; + } + + /** + * Get cached user data from sessionStorage + */ + private getCachedUserData(): UserData { + try { + if (typeof window !== "undefined" && window.sessionStorage) { + const cached = window.sessionStorage.getItem( + this.enhancedConversionsStorageKey + ); + if (cached) { + return JSON.parse(cached); + } + } + } catch (error) { + this.logger.error("Failed to get cached user data", error); + } + return {}; + } + + /** + * Cache user data to sessionStorage + */ + private cacheUserData(userData: UserData): void { + try { + if (typeof window !== "undefined" && window.sessionStorage) { + window.sessionStorage.setItem( + this.enhancedConversionsStorageKey, + JSON.stringify(userData) + ); + } + } catch (error) { + this.logger.error("Failed to cache user data", error); + } + } + + /** + * Push enhanced conversions data to dataLayer + * + * Edge cases handled: + * - Empty fields: skipped, not included in user_data + * - Multiple pages: data is cached in sessionStorage and merged across pages + * - Remember Me cleared: cached data persists until new data overwrites it + * - Cross-domain: sessionStorage is domain-specific, data doesn't persist across domains + * - Partial data: merges available fields, doesn't require all fields + * - No data: returns early if no user data is available + * - Data unchanged: skips push if data hasn't changed since last push + * + * Integration points: + * - ENGrid: uses ENGrid.getFieldValue() to collect form data + * - Remember Me: pushes after Remember Me loads (if enabled) + * - TidyContact: waits for TidyContact to standardize address/phone before pushing on form submit + * - Addresses are standardized to CASS format + * - Phone numbers are standardized to E.164 format + * - Enhanced conversions captures the standardized values, not raw user input + * - dataLayer: pushes to window.dataLayer for GTM processing + */ + private async pushEnhancedConversions(): Promise { + try { + // Collect current user data + const currentData = this.collectUserData(); + + // Check if we have any data + if (Object.keys(currentData).length === 0) { + return; + } + + // Merge with cached data + const cachedData = this.getCachedUserData(); + const mergedData = this.mergeUserData(cachedData, currentData); + + // Check if data has changed + const cachedString = JSON.stringify(cachedData); + const mergedString = JSON.stringify(mergedData); + if (cachedString === mergedString && Object.keys(cachedData).length > 0) { + // Data hasn't changed, skip push + return; + } + + // Hash the merged data + const hashedData = await this.hashUserData(mergedData); + + // Cache the unhashed merged data for future merges + this.cacheUserData(mergedData); + + // Push to dataLayer in Google Analytics Enhanced Conversions format + // Structure matches GA4 Measurement Protocol: { user_data: { email_address: "hash", ... } } + const enhancedConversionsData: EnhancedConversionsData = { + user_data: hashedData, + }; + + // Push to dataLayer - GTM will automatically process this for Enhanced Conversions + // The user_data object will be merged with conversion events + this.dataLayer.push(enhancedConversionsData); + this.logger.log("Enhanced conversions data pushed", { + fields: Object.keys(hashedData), + }); + } catch (error) { + this.logger.error("Failed to push enhanced conversions", error); + } + } + private getFieldLabel( el: HTMLInputElement | HTMLSelectElement ): string | null {