diff --git a/packages/scripts/dist/app.js b/packages/scripts/dist/app.js index 3c687fb9..3d94605c 100644 --- a/packages/scripts/dist/app.js +++ b/packages/scripts/dist/app.js @@ -1,5 +1,5 @@ import { DonationAmount, DonationFrequency, EnForm, ProcessingFees, Country, } from "./events"; -import { AmountLabel, Loader, ProgressBar, UpsellLightbox, ENGrid, OptionsDefaults, setRecurrFreq, PageBackground, MediaAttribution, ApplePay, A11y, CapitalizeFields, Ecard, ClickToExpand, Advocacy, DataAttributes, LiveVariables, iFrame, InputPlaceholders, InputHasValueAndFocus, ShowHideRadioCheckboxes, AutoCountrySelect, SkipToMainContentLink, SrcDefer, NeverBounce, AutoYear, Autocomplete, RememberMe, TranslateFields, ShowIfAmount, EngridLogger, OtherAmount, MinMaxAmount, Ticker, DataReplace, DataHide, AddNameToMessage, ExpandRegionName, AppVersion, UrlToForm, RequiredIfVisible, TidyContact, DataLayer, LiveCurrency, Autosubmit, EventTickets, SwapAmounts, DebugPanel, DebugHiddenFields, FreshAddress, BrandingHtml, CountryDisable, PremiumGift, DigitalWallets, MobileCTA, LiveFrequency, UniversalOptIn, StripeFinancialConnections, GiveBySelect, UrlParamsToBodyAttrs, ExitIntentLightbox, SupporterHub, FastFormFill, SetAttr, ShowIfPresent, ENValidators, CustomCurrency, VGS, PostalCodeValidator, CountryRedirect, WelcomeBack, EcardToTarget, UsOnlyForm, ThankYouPageConditionalContent, EmbeddedEcard, CheckboxLabel, UpsellCheckbox, PostDonationEmbed, FrequencyUpsell, CustomPremium, PreferredPaymentMethod, } from "."; +import { AmountLabel, Loader, ProgressBar, UpsellLightbox, ENGrid, OptionsDefaults, setRecurrFreq, PageBackground, MediaAttribution, ApplePay, A11y, CapitalizeFields, Ecard, ClickToExpand, Advocacy, DataAttributes, LiveVariables, iFrame, InputPlaceholders, InputHasValueAndFocus, ShowHideRadioCheckboxes, AutoCountrySelect, SkipToMainContentLink, SrcDefer, NeverBounce, AutoYear, Autocomplete, RememberMe, TranslateFields, ShowIfAmount, EngridLogger, OtherAmount, MinMaxAmount, Ticker, DataReplace, DataHide, AddNameToMessage, ExpandRegionName, AppVersion, UrlToForm, RequiredIfVisible, TidyContact, DataLayer, LiveCurrency, Autosubmit, EventTickets, SwapAmounts, DebugPanel, DebugHiddenFields, FreshAddress, BrandingHtml, CountryDisable, PremiumGift, DigitalWallets, MobileCTA, LiveFrequency, UniversalOptIn, StripeFinancialConnections, GiveBySelect, UrlParamsToBodyAttrs, ExitIntentLightbox, SupporterHub, FastFormFill, SetAttr, ShowIfPresent, ENValidators, CustomCurrency, VGS, PostalCodeValidator, CountryRedirect, WelcomeBack, EcardToTarget, UsOnlyForm, ThankYouPageConditionalContent, EmbeddedEcard, CheckboxLabel, UpsellCheckbox, PostDonationEmbed, FrequencyUpsell, CustomPremium, StickyNSG, StickyPrepopulation, PreferredPaymentMethod, } from "."; export class App extends ENGrid { constructor(options) { super(); @@ -160,6 +160,8 @@ export class App extends ENGrid { new Autosubmit(); // Adjust display of event tickets. new EventTickets(); + // StickyNSG - Must load before SwapAmounts + new StickyNSG(); // Swap Amounts new SwapAmounts(); // On the end of the script, after all subscribers defined, let's load the current frequency @@ -279,6 +281,7 @@ export class App extends ENGrid { new CheckboxLabel(); new PostDonationEmbed(); new FrequencyUpsell(); + new StickyPrepopulation(); //Debug panel let showDebugPanel = this.options.Debug; try { diff --git a/packages/scripts/dist/index.d.ts b/packages/scripts/dist/index.d.ts index 4a995d38..b2aa9d39 100644 --- a/packages/scripts/dist/index.d.ts +++ b/packages/scripts/dist/index.d.ts @@ -86,6 +86,8 @@ export * from "./optin-ladder"; export * from "./post-donation-embed"; export * from "./frequency-upsell-modal"; export * from "./frequency-upsell"; +export * from "./sticky-nsg"; +export * from "./sticky-prepopulation"; export * from "./preferred-payment-method"; export * from "./events"; export * from "./version"; diff --git a/packages/scripts/dist/index.js b/packages/scripts/dist/index.js index ba2612ba..8b4740a2 100644 --- a/packages/scripts/dist/index.js +++ b/packages/scripts/dist/index.js @@ -86,6 +86,8 @@ export * from "./optin-ladder"; export * from "./post-donation-embed"; export * from "./frequency-upsell-modal"; export * from "./frequency-upsell"; +export * from "./sticky-nsg"; +export * from "./sticky-prepopulation"; export * from "./preferred-payment-method"; // Events export * from "./events"; diff --git a/packages/scripts/dist/interfaces/options.d.ts b/packages/scripts/dist/interfaces/options.d.ts index a6c51e82..0f40b2c7 100644 --- a/packages/scripts/dist/interfaces/options.d.ts +++ b/packages/scripts/dist/interfaces/options.d.ts @@ -50,6 +50,10 @@ export interface Options { fieldClearSelectorTargetLocation?: string; checked?: boolean; }; + StickyNSG?: boolean; + StickyPrepopulation?: false | { + fields: string[]; + }; TidyContact?: false | { cid?: string; page_types?: ("DONATION" | "ECARD" | "SURVEY" | "EMAILTOTARGET" | "ADVOCACY" | "SUBSCRIBEFORM" | "EVENT" | "SUPPORTERHUB" | "UNSUBSCRIBE" | "TWEETPAGE" | "UNKNOWN")[]; diff --git a/packages/scripts/dist/interfaces/options.js b/packages/scripts/dist/interfaces/options.js index a9672ba2..3ed10c9c 100644 --- a/packages/scripts/dist/interfaces/options.js +++ b/packages/scripts/dist/interfaces/options.js @@ -41,6 +41,8 @@ export const OptionsDefaults = { CountryRedirect: false, WelcomeBack: false, OptInLadder: false, + StickyNSG: false, + StickyPrepopulation: false, PreferredPaymentMethod: false, PageLayouts: [ "leftleft1col", diff --git a/packages/scripts/dist/sticky-nsg.d.ts b/packages/scripts/dist/sticky-nsg.d.ts new file mode 100644 index 00000000..3c787c90 --- /dev/null +++ b/packages/scripts/dist/sticky-nsg.d.ts @@ -0,0 +1,10 @@ +export declare class StickyNSG { + private logger; + private cookieName; + constructor(); + private shouldRun; + private nsgActiveOnPage; + private deleteCookieIfGiftProcessComplete; + private createStickyNSGCookie; + private applyStickyNSGCookie; +} diff --git a/packages/scripts/dist/sticky-nsg.js b/packages/scripts/dist/sticky-nsg.js new file mode 100644 index 00000000..7fe0e9b7 --- /dev/null +++ b/packages/scripts/dist/sticky-nsg.js @@ -0,0 +1,109 @@ +import { EngridLogger } from "./logger"; +import { ENGrid } from "./engrid"; +import * as cookie from "./cookie"; +export class StickyNSG { + constructor() { + this.logger = new EngridLogger("StickyNSG", "teal", "white", "📌"); + this.cookieName = "engrid-sticky-nsg"; + if (!this.shouldRun()) + return; + this.logger.log("Sticky NSG is enabled"); + this.deleteCookieIfGiftProcessComplete(); + this.createStickyNSGCookie(); + this.applyStickyNSGCookie(); + } + shouldRun() { + return ENGrid.getOption("StickyNSG") === true; + } + /* + * Determine if NSG provided by EN is active on the page + */ + nsgActiveOnPage() { + return (window.EngagingNetworks && + window.EngagingNetworks.suggestedGift && + typeof window.EngagingNetworks.suggestedGift === "object" && + Object.keys(window.EngagingNetworks.suggestedGift).length > 0); + } + /* + * Delete the cookie if the gift process is complete + */ + deleteCookieIfGiftProcessComplete() { + if (ENGrid.getGiftProcess()) { + this.logger.log("Gift process complete, removing sticky NSG cookie if it exists"); + cookie.remove(this.cookieName); + } + } + /* + * Create the sticky NSG cookie if NSG is active on the page + */ + createStickyNSGCookie() { + var _a, _b, _c, _d, _e, _f; + if (!this.nsgActiveOnPage()) { + this.logger.log("No NSG active on page, not creating sticky NSG cookie"); + return; + } + const url = new URL(window.location.href); + if (url.searchParams.get("skipstickynsg") === "true") { + this.logger.log("'skipstickynsg' param present, not creating sticky NSG cookie"); + return; + } + // We do some reformating to match the EngridAmounts format + // We also add "Other" to the amounts list + const nsg = window.EngagingNetworks.suggestedGift; + this.logger.log("Creating sticky NSG cookie", nsg); + const oneTimeNsg = (_a = nsg.single) === null || _a === void 0 ? void 0 : _a.reduce((acc, curr) => { + acc[curr.value] = curr.value; + return acc; + }, {}); + const oneTimeDefault = (_c = (_b = nsg.single) === null || _b === void 0 ? void 0 : _b.find((gift) => gift.nextSuggestedGift)) === null || _c === void 0 ? void 0 : _c.value; + const recurringNsg = (_d = nsg.recurring) === null || _d === void 0 ? void 0 : _d.reduce((acc, curr) => { + acc[curr.value] = curr.value; + return acc; + }, {}); + const recurringDefault = (_f = (_e = nsg.recurring) === null || _e === void 0 ? void 0 : _e.find((gift) => gift.nextSuggestedGift)) === null || _f === void 0 ? void 0 : _f.value; + const nsgCookieData = {}; + if (oneTimeNsg && oneTimeDefault) { + nsgCookieData.onetime = { + amounts: oneTimeNsg, + default: oneTimeDefault, + stickyDefault: false, + }; + } + if (recurringNsg && recurringDefault) { + nsgCookieData.monthly = { + amounts: recurringNsg, + default: recurringDefault, + stickyDefault: false, + }; + } + if (Object.keys(nsgCookieData).length === 0) { + this.logger.log("No valid NSG data found to create sticky NSG cookie"); + return; + } + const cookieValue = JSON.stringify(nsgCookieData); + cookie.set(this.cookieName, cookieValue, { path: "/", expires: 30 }); + this.logger.log("Sticky NSG cookie created", cookieValue); + } + /* + * Apply the sticky NSG cookie values to window.EngridAmounts if NSG is not active on the page + */ + applyStickyNSGCookie() { + if (this.nsgActiveOnPage()) { + this.logger.log("NSG active on page, not applying sticky NSG cookie, leaving the EN NSG values."); + return; + } + const cookieValue = cookie.get(this.cookieName); + if (!cookieValue) { + this.logger.log("No sticky NSG cookie found, nothing to apply"); + return; + } + try { + const nsg = JSON.parse(cookieValue); + this.logger.log("Applying sticky NSG cookie values", nsg); + window.EngridAmounts = nsg; + } + catch (e) { + this.logger.error("Error parsing sticky NSG cookie, not applying", e); + } + } +} diff --git a/packages/scripts/dist/sticky-prepopulation.d.ts b/packages/scripts/dist/sticky-prepopulation.d.ts new file mode 100644 index 00000000..25aa9a66 --- /dev/null +++ b/packages/scripts/dist/sticky-prepopulation.d.ts @@ -0,0 +1,17 @@ +export declare class StickyPrepopulation { + private logger; + private options; + private cookieName; + constructor(); + private shouldRun; + private deleteCookie; + private createCookie; + private applyPrepopulation; + private getSupporterDetailsFromFields; + private encryptSupporterDetails; + private decryptSupporterDetails; + private createEncryptionKey; + private arrayBufferToBase64; + private base64ToArrayBuffer; + private getSeed; +} diff --git a/packages/scripts/dist/sticky-prepopulation.js b/packages/scripts/dist/sticky-prepopulation.js new file mode 100644 index 00000000..8489a195 --- /dev/null +++ b/packages/scripts/dist/sticky-prepopulation.js @@ -0,0 +1,214 @@ +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 { ENGrid } from "./engrid"; +import { EngridLogger } from "./logger"; +import * as cookie from "./cookie"; +export class StickyPrepopulation { + constructor() { + this.logger = new EngridLogger("StickyPrepopulation", "teal", "white", "📌"); + this.options = { fields: [] }; + this.cookieName = "engrid-sticky-prepop"; + if (!this.shouldRun()) { + return; + } + this.logger.log("StickyPrepopulation initialized"); + if (ENGrid.getGiftProcess()) { + this.deleteCookie(); + return; + } + if (ENGrid.getPageNumber() !== 1 || !ENGrid.getField("supporter.emailAddress")) { + this.logger.log("Not on page 1 or email field not present, not creating cookie or applying pre-population."); + return; + } + this.createCookie(); + this.applyPrepopulation(); + } + /* + * Determine if we should run the script + * Do not run if RememberMe is active + * Do not run if on a chain link + * Only run if StickyPrepopulation option is set with fields + */ + shouldRun() { + if (ENGrid.getOption("RememberMe")) { + return false; + } + const url = new URL(window.location.href); + const options = ENGrid.getOption("StickyPrepopulation"); + if (options && (options === null || options === void 0 ? void 0 : options.fields.length) > 0 && !url.searchParams.has("chain")) { + this.options = options; + return true; + } + else { + return false; + } + } + /* + * Delete the cookie if the gift process is complete + */ + deleteCookie() { + this.logger.log("Gift process complete, removing sticky prepopulation cookie if it exists"); + cookie.remove(this.cookieName); + } + /* + * Create the cookie if we're coming from a campaign link and supporterId is present + */ + createCookie() { + var _a; + return __awaiter(this, void 0, void 0, function* () { + // If we're not coming from a campaign link, don't create the cookie + if (!((_a = window.pageJson) === null || _a === void 0 ? void 0 : _a.supporterId)) { + this.logger.log("No supporterId present, not creating sticky prepopulation cookie"); + return; + } + try { + const encryptedSupporterDetails = yield this.encryptSupporterDetails(this.getSupporterDetailsFromFields()); + cookie.set(this.cookieName, window.btoa(JSON.stringify({ + encryptedData: encryptedSupporterDetails.encryptedData, + iv: encryptedSupporterDetails.iv, + pageId: ENGrid.getPageID() + })), { path: "/", expires: 7 }); + } + catch (e) { + this.logger.log("Error creating sticky prepopulation cookie"); + return; + } + this.logger.log("Sticky prepopulation cookie created"); + }); + } + /* + * If the cookie is present and supporterId is not (it's not a campaign link prefilled by EN), + * then apply the prepopulation + */ + applyPrepopulation() { + var _a; + return __awaiter(this, void 0, void 0, function* () { + const cookieData = cookie.get(this.cookieName); + if (!cookieData) { + this.logger.log("No sticky prepopulation cookie found, not prepopulating fields"); + return; + } + if ((_a = window.pageJson) === null || _a === void 0 ? void 0 : _a.supporterId) { + this.logger.log("SupporterId present, not applying sticky prepopulation"); + return; + } + let supporterDetails = {}; + try { + const encryptedSupporterDetails = JSON.parse(window.atob(cookieData)); + if (!encryptedSupporterDetails || (encryptedSupporterDetails === null || encryptedSupporterDetails === void 0 ? void 0 : encryptedSupporterDetails.pageId) !== ENGrid.getPageID()) { + this.logger.log("No encrypted supporter details found in cookie, or page ID does not match"); + return; + } + supporterDetails = JSON.parse(yield this.decryptSupporterDetails(this.base64ToArrayBuffer(encryptedSupporterDetails.encryptedData), new Uint8Array(this.base64ToArrayBuffer(encryptedSupporterDetails.iv)))); + } + catch (e) { + this.logger.log("Error decrypting supporter details from cookie"); + return; + } + this.options.fields.forEach((fieldName) => { + if (!supporterDetails[fieldName]) + return; + ENGrid.setFieldValue(fieldName, decodeURIComponent(supporterDetails[fieldName])); + this.logger.log(`Setting "${fieldName}" to "${decodeURIComponent(supporterDetails[fieldName])}"`); + }); + }); + } + /* + * Get the supporter details from the form fields + */ + getSupporterDetailsFromFields() { + const supporterDetails = {}; + this.options.fields.forEach((fieldName) => { + let field = document.querySelector(`[name="${fieldName}"]`); + // If it is a radio or checkbox, get the checked value + if (field) { + if (field.type === "radio" || field.type === "checkbox") { + field = document.querySelector(`[name="${fieldName}"]:checked`); + } + supporterDetails[fieldName] = encodeURIComponent(field.value); + } + }); + return supporterDetails; + } + /* + * Encrypt the supporter details + */ + encryptSupporterDetails(supporterDetails) { + return __awaiter(this, void 0, void 0, function* () { + const encryptionKey = yield this.createEncryptionKey(this.getSeed()); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const supporterDetailsString = JSON.stringify(supporterDetails); + const encryptedData = yield window.crypto.subtle.encrypt({ + name: "AES-GCM", + iv: iv, + }, encryptionKey, new TextEncoder().encode(supporterDetailsString)); + return { + encryptedData: this.arrayBufferToBase64(encryptedData), + iv: this.arrayBufferToBase64(iv), + }; + }); + } + /* + * Decrypt the supporter details + */ + decryptSupporterDetails(encryptedSupporterDetails, iv) { + return __awaiter(this, void 0, void 0, function* () { + const encryptionKey = yield this.createEncryptionKey(this.getSeed()); + const decryptedData = yield window.crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, encryptionKey, encryptedSupporterDetails); + return new TextDecoder().decode(decryptedData); + }); + } + /* + * Create the encryption key + */ + createEncryptionKey(seed) { + return __awaiter(this, void 0, void 0, function* () { + const encoder = new TextEncoder(); + const keyMaterial = yield window.crypto.subtle.importKey("raw", encoder.encode(seed), { name: "PBKDF2" }, false, ["deriveKey"]); + return yield window.crypto.subtle.deriveKey({ + name: "PBKDF2", + salt: encoder.encode(seed), + iterations: 100000, + hash: "SHA-256", + }, keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + }); + } + /* + * Convert an ArrayBuffer to a base64 string + */ + arrayBufferToBase64(buffer) { + let binary = ""; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + /* + * Create an Array Buffer from a base64 string + */ + base64ToArrayBuffer(base64) { + const binary_string = window.atob(base64); + const len = binary_string.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes.buffer; + } + /* + * Derive a seed from the page URL + */ + getSeed() { + const url = new URL(window.location.href); + return url.origin + url.pathname + (url.searchParams.get("ea.tracking.id") ? `?ea.tracking.id=${url.searchParams.get("ea.tracking.id")}` : ''); + } +} diff --git a/packages/scripts/dist/swap-amounts.js b/packages/scripts/dist/swap-amounts.js index 64ca674d..fcc1a516 100644 --- a/packages/scripts/dist/swap-amounts.js +++ b/packages/scripts/dist/swap-amounts.js @@ -117,7 +117,12 @@ export class SwapAmounts { })); } shouldRun() { - return !!window.EngridAmounts; + const hasNSG = window.EngagingNetworks.suggestedGift !== undefined && + Object.keys(window.EngagingNetworks.suggestedGift).length > 0; + if (!!window.EngridAmounts && hasNSG) { + this.logger.log("Not swapping amounts because NSG is active on page"); + } + return !!window.EngridAmounts && !hasNSG; } ignoreCurrentValue() { const urlParam = ENGrid.getUrlParameter("transaction.donationAmt"); diff --git a/packages/scripts/src/app.ts b/packages/scripts/src/app.ts index 5ad7b283..547b9044 100644 --- a/packages/scripts/src/app.ts +++ b/packages/scripts/src/app.ts @@ -87,6 +87,8 @@ import { PostDonationEmbed, FrequencyUpsell, CustomPremium, + StickyNSG, + StickyPrepopulation, PreferredPaymentMethod, } from "."; @@ -309,6 +311,8 @@ export class App extends ENGrid { // Adjust display of event tickets. new EventTickets(); + // StickyNSG - Must load before SwapAmounts + new StickyNSG(); // Swap Amounts new SwapAmounts(); @@ -465,6 +469,8 @@ export class App extends ENGrid { new FrequencyUpsell(); + new StickyPrepopulation(); + //Debug panel let showDebugPanel = this.options.Debug; try { diff --git a/packages/scripts/src/index.ts b/packages/scripts/src/index.ts index 6d95fd9b..51880058 100644 --- a/packages/scripts/src/index.ts +++ b/packages/scripts/src/index.ts @@ -102,6 +102,8 @@ export * from "./optin-ladder"; export * from "./post-donation-embed"; export * from "./frequency-upsell-modal"; export * from "./frequency-upsell"; +export * from "./sticky-nsg"; +export * from "./sticky-prepopulation"; export * from "./preferred-payment-method"; // Events diff --git a/packages/scripts/src/interfaces/options.ts b/packages/scripts/src/interfaces/options.ts index 412195f7..129f1cf7 100644 --- a/packages/scripts/src/interfaces/options.ts +++ b/packages/scripts/src/interfaces/options.ts @@ -54,6 +54,8 @@ export interface Options { fieldClearSelectorTargetLocation?: string; checked?: boolean; }; + StickyNSG?: boolean; + StickyPrepopulation?: false | { fields: string[] }; TidyContact?: | false | { @@ -233,6 +235,8 @@ export const OptionsDefaults: Options = { CountryRedirect: false, WelcomeBack: false, OptInLadder: false, + StickyNSG: false, + StickyPrepopulation: false, PreferredPaymentMethod: false, PageLayouts: [ "leftleft1col", diff --git a/packages/scripts/src/sticky-nsg.ts b/packages/scripts/src/sticky-nsg.ts new file mode 100644 index 00000000..ad634923 --- /dev/null +++ b/packages/scripts/src/sticky-nsg.ts @@ -0,0 +1,136 @@ +import { EngridLogger } from "./logger"; +import { ENGrid } from "./engrid"; +import * as cookie from "./cookie"; + +export class StickyNSG { + private logger: EngridLogger = new EngridLogger( + "StickyNSG", + "teal", + "white", + "📌" + ); + private cookieName: string = "engrid-sticky-nsg"; + + constructor() { + if (!this.shouldRun()) return; + this.logger.log("Sticky NSG is enabled"); + this.deleteCookieIfGiftProcessComplete(); + this.createStickyNSGCookie(); + this.applyStickyNSGCookie(); + } + + private shouldRun(): boolean { + return ENGrid.getOption("StickyNSG") === true; + } + + /* + * Determine if NSG provided by EN is active on the page + */ + private nsgActiveOnPage(): boolean { + return ( + window.EngagingNetworks && + window.EngagingNetworks.suggestedGift && + typeof window.EngagingNetworks.suggestedGift === "object" && + Object.keys(window.EngagingNetworks.suggestedGift).length > 0 + ); + } + + /* + * Delete the cookie if the gift process is complete + */ + private deleteCookieIfGiftProcessComplete() { + if (ENGrid.getGiftProcess()) { + this.logger.log( + "Gift process complete, removing sticky NSG cookie if it exists" + ); + cookie.remove(this.cookieName); + } + } + + /* + * Create the sticky NSG cookie if NSG is active on the page + */ + private createStickyNSGCookie() { + if (!this.nsgActiveOnPage()) { + this.logger.log("No NSG active on page, not creating sticky NSG cookie"); + return; + } + + const url = new URL(window.location.href); + if (url.searchParams.get("skipstickynsg") === "true") { + this.logger.log( + "'skipstickynsg' param present, not creating sticky NSG cookie" + ); + return; + } + + // We do some reformating to match the EngridAmounts format + // We also add "Other" to the amounts list + const nsg = window.EngagingNetworks.suggestedGift; + this.logger.log("Creating sticky NSG cookie", nsg); + + const oneTimeNsg = nsg.single?.reduce((acc: any, curr: any) => { + acc[curr.value] = curr.value; + return acc; + }, {}); + const oneTimeDefault = nsg.single?.find((gift: any) => gift.nextSuggestedGift)?.value; + const recurringNsg = nsg.recurring?.reduce((acc: any, curr: any) => { + acc[curr.value] = curr.value; + return acc; + }, {}); + const recurringDefault = nsg.recurring?.find((gift: any) => gift.nextSuggestedGift)?.value; + + const nsgCookieData: any = {}; + + if (oneTimeNsg && oneTimeDefault) { + nsgCookieData.onetime = { + amounts: oneTimeNsg, + default: oneTimeDefault, + stickyDefault: false, + }; + } + + if (recurringNsg && recurringDefault) { + nsgCookieData.monthly = { + amounts: recurringNsg, + default: recurringDefault, + stickyDefault: false, + } + } + + if (Object.keys(nsgCookieData).length === 0) { + this.logger.log("No valid NSG data found to create sticky NSG cookie"); + return; + } + + const cookieValue = JSON.stringify(nsgCookieData); + cookie.set(this.cookieName, cookieValue, { path: "/", expires: 30 }); + this.logger.log("Sticky NSG cookie created", cookieValue); + } + + /* + * Apply the sticky NSG cookie values to window.EngridAmounts if NSG is not active on the page + */ + private applyStickyNSGCookie() { + if (this.nsgActiveOnPage()) { + this.logger.log( + "NSG active on page, not applying sticky NSG cookie, leaving the EN NSG values." + ); + return; + } + + const cookieValue = cookie.get(this.cookieName); + if (!cookieValue) { + this.logger.log("No sticky NSG cookie found, nothing to apply"); + return; + } + + try { + const nsg = JSON.parse(cookieValue); + this.logger.log("Applying sticky NSG cookie values", nsg); + window.EngridAmounts = nsg; + } catch (e) { + this.logger.error("Error parsing sticky NSG cookie, not applying", e); + } + } +} diff --git a/packages/scripts/src/sticky-prepopulation.ts b/packages/scripts/src/sticky-prepopulation.ts new file mode 100644 index 00000000..c105f743 --- /dev/null +++ b/packages/scripts/src/sticky-prepopulation.ts @@ -0,0 +1,266 @@ +import { ENGrid } from "./engrid"; +import { EngridLogger } from "./logger"; +import * as cookie from "./cookie"; + +export class StickyPrepopulation { + private logger: EngridLogger = new EngridLogger( + "StickyPrepopulation", + "teal", + "white", + "📌" + ); + private options: { fields: string[] } = { fields: [] }; + private cookieName: string = "engrid-sticky-prepop"; + + constructor() { + if (!this.shouldRun()) { + return; + } + this.logger.log("StickyPrepopulation initialized"); + if (ENGrid.getGiftProcess()) { + this.deleteCookie(); + return; + } + if (ENGrid.getPageNumber() !== 1 || !ENGrid.getField("supporter.emailAddress")) { + this.logger.log("Not on page 1 or email field not present, not creating cookie or applying pre-population."); + return; + } + this.createCookie(); + this.applyPrepopulation(); + } + + /* + * Determine if we should run the script + * Do not run if RememberMe is active + * Do not run if on a chain link + * Only run if StickyPrepopulation option is set with fields + */ + private shouldRun(): boolean { + if (ENGrid.getOption("RememberMe")) { + return false; + } + const url = new URL(window.location.href); + const options = ENGrid.getOption("StickyPrepopulation"); + if (options && options?.fields.length > 0 && !url.searchParams.has("chain")) { + this.options = options; + return true; + } else { + return false; + } + } + + /* + * Delete the cookie if the gift process is complete + */ + private deleteCookie() { + this.logger.log( + "Gift process complete, removing sticky prepopulation cookie if it exists" + ); + cookie.remove(this.cookieName); + } + + /* + * Create the cookie if we're coming from a campaign link and supporterId is present + */ + private async createCookie() { + // If we're not coming from a campaign link, don't create the cookie + if (! window.pageJson?.supporterId) { + this.logger.log("No supporterId present, not creating sticky prepopulation cookie"); + return; + } + + try { + const encryptedSupporterDetails = await this.encryptSupporterDetails( + this.getSupporterDetailsFromFields() + ); + + cookie.set(this.cookieName, window.btoa(JSON.stringify({ + encryptedData: encryptedSupporterDetails.encryptedData, + iv: encryptedSupporterDetails.iv, + pageId: ENGrid.getPageID() + })), { path: "/", expires: 7 }); + } catch (e) { + this.logger.log("Error creating sticky prepopulation cookie"); + return; + } + + this.logger.log("Sticky prepopulation cookie created"); + } + + /* + * If the cookie is present and supporterId is not (it's not a campaign link prefilled by EN), + * then apply the prepopulation + */ + private async applyPrepopulation() { + const cookieData = cookie.get(this.cookieName); + + if (!cookieData) { + this.logger.log("No sticky prepopulation cookie found, not prepopulating fields"); + return; + } + + if (window.pageJson?.supporterId) { + this.logger.log("SupporterId present, not applying sticky prepopulation"); + return; + } + + let supporterDetails: { [key: string]: string } = {}; + + try { + const encryptedSupporterDetails = JSON.parse( + window.atob(cookieData) + ); + + if (!encryptedSupporterDetails || encryptedSupporterDetails?.pageId !== ENGrid.getPageID()) { + this.logger.log("No encrypted supporter details found in cookie, or page ID does not match"); + return; + } + + supporterDetails = JSON.parse( + await this.decryptSupporterDetails( + this.base64ToArrayBuffer(encryptedSupporterDetails.encryptedData), + new Uint8Array(this.base64ToArrayBuffer(encryptedSupporterDetails.iv)) + ) + ); + } catch (e) { + this.logger.log("Error decrypting supporter details from cookie"); + return; + } + + this.options.fields.forEach((fieldName) => { + if (!supporterDetails[fieldName]) return; + ENGrid.setFieldValue( + fieldName, + decodeURIComponent(supporterDetails[fieldName]) + ); + this.logger.log(`Setting "${fieldName}" to "${decodeURIComponent(supporterDetails[fieldName])}"`); + }); + } + + /* + * Get the supporter details from the form fields + */ + private getSupporterDetailsFromFields(): { [key: string]: string } { + const supporterDetails: { [key: string]: string } = {}; + + this.options.fields.forEach((fieldName) => { + let field = document.querySelector( + `[name="${fieldName}"]` + ) as HTMLInputElement; + // If it is a radio or checkbox, get the checked value + if (field) { + if (field.type === "radio" || field.type === "checkbox") { + field = document.querySelector( + `[name="${fieldName}"]:checked` + ) as HTMLInputElement; + } + supporterDetails[fieldName] = encodeURIComponent(field.value); + } + }); + + return supporterDetails; + } + + /* + * Encrypt the supporter details + */ + private async encryptSupporterDetails(supporterDetails: { + [key: string]: string; + }): Promise<{ encryptedData: string; iv: string }> { + const encryptionKey = await this.createEncryptionKey(this.getSeed()); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + const supporterDetailsString = JSON.stringify(supporterDetails); + const encryptedData = await window.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv, + }, + encryptionKey, + new TextEncoder().encode(supporterDetailsString) + ); + + return { + encryptedData: this.arrayBufferToBase64(encryptedData), + iv: this.arrayBufferToBase64(iv), + }; + } + + /* + * Decrypt the supporter details + */ + private async decryptSupporterDetails( + encryptedSupporterDetails: ArrayBuffer, + iv: ArrayBuffer + ): Promise { + const encryptionKey = await this.createEncryptionKey(this.getSeed()); + + const decryptedData = await window.crypto.subtle.decrypt( + { name: "AES-GCM", iv: iv }, + encryptionKey, + encryptedSupporterDetails + ); + + return new TextDecoder().decode(decryptedData); + } + + /* + * Create the encryption key + */ + private async createEncryptionKey(seed: string): Promise { + const encoder = new TextEncoder(); + const keyMaterial = await window.crypto.subtle.importKey( + "raw", + encoder.encode(seed), + { name: "PBKDF2" }, + false, + ["deriveKey"] + ); + return await window.crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: encoder.encode(seed), + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); + } + + /* + * Convert an ArrayBuffer to a base64 string + */ + private arrayBufferToBase64(buffer: ArrayBuffer): string { + let binary = ""; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + + /* + * Create an Array Buffer from a base64 string + */ + private base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary_string = window.atob(base64); + const len = binary_string.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes.buffer; + } + + /* + * Derive a seed from the page URL + */ + private getSeed() { + const url = new URL(window.location.href); + return url.origin + url.pathname + (url.searchParams.get("ea.tracking.id") ? `?ea.tracking.id=${url.searchParams.get("ea.tracking.id")}` : ''); + } +} diff --git a/packages/scripts/src/swap-amounts.ts b/packages/scripts/src/swap-amounts.ts index eeb12989..cca328b0 100644 --- a/packages/scripts/src/swap-amounts.ts +++ b/packages/scripts/src/swap-amounts.ts @@ -142,7 +142,13 @@ export class SwapAmounts { })); } shouldRun() { - return !!window.EngridAmounts; + const hasNSG = + window.EngagingNetworks.suggestedGift !== undefined && + Object.keys(window.EngagingNetworks.suggestedGift).length > 0; + if (!!window.EngridAmounts && hasNSG) { + this.logger.log("Not swapping amounts because NSG is active on page"); + } + return !!window.EngridAmounts && !hasNSG; } ignoreCurrentValue() { const urlParam = ENGrid.getUrlParameter("transaction.donationAmt");