@4site/engrid-scripts 0.25.11 → 0.25.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.js CHANGED
@@ -180,6 +180,8 @@ export class App extends ENGrid {
180
180
  new CustomCurrency();
181
181
  // Auto Country Select
182
182
  new AutoCountrySelect();
183
+ // Page Background
184
+ new PageBackground(this.options.UseBodyBannerImageAsBackground);
183
185
  // Add Image Attribution
184
186
  if (this.options.MediaAttribution)
185
187
  new MediaAttribution();
@@ -230,8 +232,6 @@ export class App extends ENGrid {
230
232
  new A11y();
231
233
  new AddNameToMessage();
232
234
  new ExpandRegionName();
233
- // Page Background
234
- new PageBackground();
235
235
  // Url Params to Form Fields
236
236
  new UrlToForm();
237
237
  // Required if Visible Fields
package/dist/engrid.js CHANGED
@@ -171,7 +171,7 @@ export class ENGrid {
171
171
  return null;
172
172
  }
173
173
  static isThankYouPage() {
174
- return this.getPageNumber() === this.getPageCount();
174
+ return (this.getPageNumber() === this.getPageCount() && this.getPageCount() > 1);
175
175
  }
176
176
  // Return the current page ID
177
177
  static getPageID() {
@@ -1,5 +1,6 @@
1
1
  export interface Options {
2
2
  backgroundImage?: string | string[];
3
+ UseBodyBannerImageAsBackground?: boolean;
3
4
  MediaAttribution?: boolean;
4
5
  applePay?: boolean;
5
6
  CapitalizeFields?: boolean;
@@ -50,7 +51,10 @@ export interface Options {
50
51
  fieldOptInSelectorTargetLocation?: string;
51
52
  fieldClearSelectorTarget?: string;
52
53
  fieldClearSelectorTargetLocation?: string;
54
+ fieldClearLabel?: string;
53
55
  checked?: boolean;
56
+ encryptData?: boolean;
57
+ hide?: boolean;
54
58
  };
55
59
  StickyNSG?: boolean;
56
60
  StickyPrepopulation?: false | {
@@ -54,4 +54,5 @@ export const OptionsDefaults = {
54
54
  "rightright1col",
55
55
  "none",
56
56
  ],
57
+ UseBodyBannerImageAsBackground: false,
57
58
  };
@@ -1,12 +1,18 @@
1
1
  export declare class PageBackground {
2
2
  private pageBackground;
3
+ private bodyBanner;
4
+ private bodyBannerImage;
3
5
  private mutationObserver;
4
6
  private logger;
5
- constructor();
7
+ constructor(useBodyBannerImage?: boolean);
8
+ private findBodyBannerImage;
6
9
  /**
7
10
  * Initialize background image by finding and setting CSS custom property
8
11
  */
9
12
  private initializeBackgroundImage;
13
+ private getBackgroundImage;
14
+ private useBodyBannerAsBackground;
15
+ private getImageSource;
10
16
  /**
11
17
  * Set the background image URL as a CSS custom property
12
18
  */
@@ -1,11 +1,16 @@
1
1
  import { ENGrid, EngridLogger } from ".";
2
2
  export class PageBackground {
3
- constructor() {
3
+ constructor(useBodyBannerImage = false) {
4
4
  // @TODO: Change page-backgroundImage to page-background
5
5
  this.pageBackground = document.querySelector(".page-backgroundImage");
6
+ this.bodyBanner = document.querySelector(".body-banner");
7
+ this.bodyBannerImage = null;
6
8
  this.mutationObserver = null;
7
9
  this.logger = new EngridLogger("PageBackground", "lightblue", "darkblue", "🖼️");
8
- if (!this.pageBackground) {
10
+ if (useBodyBannerImage) {
11
+ this.bodyBannerImage = this.findBodyBannerImage();
12
+ }
13
+ if (!this.pageBackground && !this.bodyBannerImage) {
9
14
  this.logger.log("A background image set in the page was not found, any default image set in the theme on --engrid__page-backgroundImage_url will be used");
10
15
  return;
11
16
  }
@@ -14,28 +19,66 @@ export class PageBackground {
14
19
  this.processAttributionPositioning();
15
20
  this.setupMutationObserver();
16
21
  }
22
+ findBodyBannerImage() {
23
+ var _a;
24
+ if (!this.bodyBanner) {
25
+ return null;
26
+ }
27
+ return ((_a = this.bodyBanner.querySelector("img.preferred-image")) !== null && _a !== void 0 ? _a : this.bodyBanner.querySelector("img"));
28
+ }
17
29
  /**
18
30
  * Initialize background image by finding and setting CSS custom property
19
31
  */
20
32
  initializeBackgroundImage() {
21
- if (!this.pageBackground)
33
+ const backgroundImg = this.getBackgroundImage();
34
+ if (!backgroundImg) {
35
+ this.logger.log("No image found in page background and no body banner image found (or pageBackground is already occupied), any default image set in the theme on --engrid__page-backgroundImage_url will be used");
22
36
  return;
23
- const pageBackgroundImg = this.pageBackground.querySelector("img");
24
- if (!pageBackgroundImg) {
25
- this.logger.log("A background image set in the page was not found, any default image set in the theme on --engrid__page-backgroundImage_url will be used");
37
+ }
38
+ const imageSource = this.getImageSource(backgroundImg);
39
+ if (!imageSource) {
40
+ this.logger.log("A background image set in the page was found but without a data-src or src value, no action taken", backgroundImg);
26
41
  return;
27
42
  }
28
- const dataSrc = pageBackgroundImg.getAttribute("data-src");
29
- const src = pageBackgroundImg.src;
30
- if (dataSrc) {
31
- this.setBackgroundImageUrl(dataSrc, "data-src");
43
+ this.setBackgroundImageUrl(imageSource.url, imageSource.sourceType);
44
+ }
45
+ getBackgroundImage() {
46
+ if (!this.pageBackground) {
47
+ return null;
32
48
  }
33
- else if (src) {
34
- this.setBackgroundImageUrl(src, "src");
49
+ const existingImage = this.pageBackground.querySelector("img");
50
+ if (existingImage) {
51
+ return existingImage;
35
52
  }
36
- else {
37
- this.logger.log("A background image set in the page was found but without a data-src or src value, no action taken", pageBackgroundImg);
53
+ if (this.bodyBannerImage && this.pageBackground.children.length === 0) {
54
+ return this.useBodyBannerAsBackground();
55
+ }
56
+ return null;
57
+ }
58
+ useBodyBannerAsBackground() {
59
+ var _a;
60
+ if (!this.pageBackground || !this.bodyBanner) {
61
+ return null;
62
+ }
63
+ this.logger.log("No image found in page background, using body banner image as background image instead");
64
+ const clonedBodyBanner = this.bodyBanner.cloneNode(true);
65
+ while (clonedBodyBanner.firstChild) {
66
+ this.pageBackground.appendChild(clonedBodyBanner.firstChild);
67
+ }
68
+ document.body.removeAttribute("data-engrid-no-page-backgroundImage");
69
+ ENGrid.setBodyData("use-body-banner-background", "");
70
+ return ((_a = this.pageBackground.querySelector("img.preferred-image")) !== null && _a !== void 0 ? _a : this.pageBackground.querySelector("img"));
71
+ }
72
+ getImageSource(backgroundImg) {
73
+ const dataSrc = backgroundImg.getAttribute("data-src");
74
+ if (dataSrc) {
75
+ return { sourceType: "data-src", url: dataSrc };
76
+ }
77
+ const src = backgroundImg.src;
78
+ if (src) {
79
+ return { sourceType: "src", url: src };
38
80
  }
81
+ return null;
39
82
  }
40
83
  /**
41
84
  * Set the background image URL as a CSS custom property
@@ -2,6 +2,7 @@ import { EnForm, RememberMeEvents } from "./events";
2
2
  export declare class RememberMe {
3
3
  _form: EnForm;
4
4
  _events: RememberMeEvents;
5
+ private _frequency;
5
6
  private remoteUrl;
6
7
  private cookieName;
7
8
  private fieldNames;
@@ -9,14 +10,18 @@ export declare class RememberMe {
9
10
  private cookieExpirationDays;
10
11
  private iframe;
11
12
  private rememberMeOptIn;
13
+ private encryptData;
14
+ private hide;
12
15
  private fieldDonationAmountRadioName;
13
16
  private fieldDonationAmountOtherName;
14
17
  private fieldDonationRecurrPayRadioName;
18
+ private fieldDonationRecurrFreqRadioName;
15
19
  private fieldDonationAmountOtherCheckboxID;
16
20
  private fieldOptInSelectorTarget;
17
21
  private fieldOptInSelectorTargetLocation;
18
22
  private fieldClearSelectorTarget;
19
23
  private fieldClearSelectorTargetLocation;
24
+ private fieldClearLabel;
20
25
  constructor(options: {
21
26
  remoteUrl?: string;
22
27
  cookieName?: string;
@@ -25,12 +30,16 @@ export declare class RememberMe {
25
30
  fieldDonationAmountRadioName?: string;
26
31
  fieldDonationAmountOtherName?: string;
27
32
  fieldDonationRecurrPayRadioName?: string;
33
+ fieldDonationRecurrFreqRadioName?: string;
28
34
  fieldDonationAmountOtherCheckboxID?: string;
29
35
  fieldOptInSelectorTarget?: string;
30
36
  fieldOptInSelectorTargetLocation?: string;
31
37
  fieldClearSelectorTarget?: string;
32
38
  fieldClearSelectorTargetLocation?: string;
39
+ fieldClearLabel?: string;
33
40
  checked?: boolean;
41
+ encryptData?: boolean;
42
+ hide?: boolean;
34
43
  });
35
44
  private updateFieldData;
36
45
  private insertClearRememberMeLink;
@@ -43,6 +52,42 @@ export declare class RememberMe {
43
52
  private saveCookieToRemote;
44
53
  private readCookie;
45
54
  private saveCookie;
55
+ /**
56
+ * Reads and decrypts the local (non-remote) Remember Me cookie using
57
+ * browser-native AES-GCM (Web Crypto), with the key held in localStorage
58
+ * on this device. If the key is absent (different device or cleared
59
+ * storage) or decryption otherwise fails, the field data is left empty
60
+ * and the component falls back to the normal, no-autofill experience.
61
+ */
62
+ private readCookieEncrypted;
63
+ /**
64
+ * Encrypts the current fieldData with AES-GCM (Web Crypto) and stores the
65
+ * base64-encoded result in the local cookie. If encryption isn't possible
66
+ * (e.g. Web Crypto unavailable), nothing is written.
67
+ */
68
+ private saveCookieEncrypted;
69
+ private clearCookieEncrypted;
70
+ /**
71
+ * Retrieves the per-device AES-GCM encryption key. A random secret
72
+ * generated once per device and held in localStorage — never written
73
+ * to the cookie, so it never travels with the transported value.
74
+ */
75
+ private getEncryptionKey;
76
+ /**
77
+ * Encrypts a plaintext string with AES-GCM and returns the base64-encoded
78
+ * IV + ciphertext, ready for storage. Returns null if a key isn't
79
+ * available (e.g. Web Crypto unsupported).
80
+ */
81
+ private encryptPayload;
82
+ /**
83
+ * Decrypts a base64-encoded IV + ciphertext payload previously produced by
84
+ * encryptPayload. Returns null (rather than throwing) if the key is
85
+ * missing or decryption otherwise fails, so callers can gracefully fall
86
+ * back to the standard, no-autofill experience.
87
+ */
88
+ private decryptPayload;
89
+ private arrayBufferToBase64;
90
+ private base64ToArrayBuffer;
46
91
  private readFields;
47
92
  private setFieldValue;
48
93
  private clearFields;
@@ -57,5 +102,25 @@ export declare class RememberMe {
57
102
  * @param overwrite - A boolean indicating whether to overwrite the existing value of the fields. Defaults to false.
58
103
  */
59
104
  private writeFields;
105
+ /**
106
+ * SwapAmounts replaces the donationAmt radio DOM nodes ~1 second after page
107
+ * load (triggered by DonationFrequency.load() setTimeout). When that happens
108
+ * the selection the RememberMe just wrote gets wiped out.
109
+ *
110
+ * This method subscribes to the first onFrequencyChange event and, after a
111
+ * short delay to let SwapAmounts finish its DOM update, re-applies only the
112
+ * donation amount. It unsubscribes immediately so it only fires once.
113
+ *
114
+ * To avoid overwriting a manual donor interaction, the handler checks
115
+ * whether the current amount selection is empty/wiped (as SwapAmounts does)
116
+ * OR still matches what writeFields originally set. If the donor already
117
+ * picked a different amount, we skip re-application.
118
+ */
119
+ private reapplyDonationAmtAfterSwap;
120
+ /**
121
+ * Returns the currently selected donation amount value, or null if nothing
122
+ * is selected. Checks both predefined radio buttons and the "Other" text input.
123
+ */
124
+ private getCurrentSelectedAmount;
60
125
  private isJson;
61
126
  }
@@ -1,11 +1,26 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
1
10
  import * as cookie from "./cookie";
2
- import { EnForm, RememberMeEvents } from "./events";
11
+ import { EnForm, RememberMeEvents, DonationFrequency } from "./events";
3
12
  const tippy = require("tippy.js").default;
13
+ // localStorage key used to cache the per-device AES-GCM encryption key.
14
+ // A random secret generated once per device and held in localStorage.
15
+ const RM_ENCRYPTION_KEY_STORAGE_NAME = "engrid-remember-me-key";
4
16
  export class RememberMe {
5
17
  constructor(options) {
6
18
  this._form = EnForm.getInstance();
7
19
  this._events = RememberMeEvents.getInstance();
20
+ this._frequency = DonationFrequency.getInstance();
8
21
  this.iframe = null;
22
+ this.encryptData = options.encryptData ? options.encryptData : false;
23
+ this.hide = options.hide ? options.hide : false;
9
24
  this.remoteUrl = options.remoteUrl ? options.remoteUrl : null;
10
25
  this.cookieName = options.cookieName
11
26
  ? options.cookieName
@@ -25,6 +40,10 @@ export class RememberMe {
25
40
  options.fieldDonationRecurrPayRadioName
26
41
  ? options.fieldDonationRecurrPayRadioName
27
42
  : "transaction.recurrpay";
43
+ this.fieldDonationRecurrFreqRadioName =
44
+ options.fieldDonationRecurrFreqRadioName
45
+ ? options.fieldDonationRecurrFreqRadioName
46
+ : "transaction.recurrfreq";
28
47
  this.fieldDonationAmountOtherCheckboxID =
29
48
  options.fieldDonationAmountOtherCheckboxID
30
49
  ? options.fieldDonationAmountOtherCheckboxID
@@ -43,11 +62,18 @@ export class RememberMe {
43
62
  options.fieldClearSelectorTargetLocation
44
63
  ? options.fieldClearSelectorTargetLocation
45
64
  : "before";
65
+ this.fieldClearLabel = options.fieldClearLabel
66
+ ? options.fieldClearLabel
67
+ : "(clear autofill)";
46
68
  this.fieldData = {};
47
69
  if (this.useRemote()) {
48
70
  this.createIframe(() => {
49
71
  if (this.iframe && this.iframe.contentWindow) {
50
- this.iframe.contentWindow.postMessage(JSON.stringify({ key: this.cookieName, operation: "read" }), "*");
72
+ this.iframe.contentWindow.postMessage(JSON.stringify({
73
+ key: this.cookieName,
74
+ operation: "read",
75
+ encryptData: this.encryptData,
76
+ }), "*");
51
77
  this._form.onSubmit.subscribe(() => {
52
78
  if (this.rememberMeOptIn) {
53
79
  this.readFields();
@@ -66,7 +92,9 @@ export class RememberMe {
66
92
  data.key &&
67
93
  data.value !== undefined &&
68
94
  data.key === this.cookieName) {
69
- this.updateFieldData(data.value);
95
+ if (data.value !== null) {
96
+ this.updateFieldData(data.value);
97
+ }
70
98
  this.writeFields();
71
99
  let hasFieldData = Object.keys(this.fieldData).length > 0;
72
100
  if (!hasFieldData) {
@@ -74,10 +102,37 @@ export class RememberMe {
74
102
  }
75
103
  else {
76
104
  this.insertClearRememberMeLink();
105
+ this.reapplyDonationAmtAfterSwap();
77
106
  }
78
107
  }
79
108
  });
80
109
  }
110
+ else if (this.encryptData) {
111
+ // Same flow as the unencrypted branch below, but the cookie payload is
112
+ // AES-GCM encrypted/decrypted (browser-native Web Crypto), so reading
113
+ // the cookie is asynchronous. A failed decrypt (foreign device or
114
+ // cleared localStorage) leaves fieldData empty and silently falls back
115
+ // to the standard, no-autofill experience.
116
+ this.readCookieEncrypted().then(() => {
117
+ let hasFieldData = Object.keys(this.fieldData).length > 0;
118
+ if (!hasFieldData) {
119
+ this.insertRememberMeOptin();
120
+ }
121
+ else {
122
+ this.insertClearRememberMeLink();
123
+ }
124
+ this.writeFields();
125
+ if (hasFieldData) {
126
+ this.reapplyDonationAmtAfterSwap();
127
+ }
128
+ this._form.onSubmit.subscribe(() => {
129
+ if (this.rememberMeOptIn) {
130
+ this.readFields();
131
+ this.saveCookieEncrypted();
132
+ }
133
+ });
134
+ });
135
+ }
81
136
  else {
82
137
  this.readCookie();
83
138
  let hasFieldData = Object.keys(this.fieldData).length > 0;
@@ -88,6 +143,9 @@ export class RememberMe {
88
143
  this.insertClearRememberMeLink();
89
144
  }
90
145
  this.writeFields();
146
+ if (hasFieldData) {
147
+ this.reapplyDonationAmtAfterSwap();
148
+ }
91
149
  this._form.onSubmit.subscribe(() => {
92
150
  if (this.rememberMeOptIn) {
93
151
  this.readFields();
@@ -97,24 +155,31 @@ export class RememberMe {
97
155
  }
98
156
  }
99
157
  updateFieldData(jsonData) {
100
- if (jsonData) {
101
- let data = JSON.parse(jsonData);
102
- for (let i = 0; i < this.fieldNames.length; i++) {
103
- if (data[this.fieldNames[i]] !== undefined) {
104
- this.fieldData[this.fieldNames[i]] = decodeURIComponent(data[this.fieldNames[i]]);
105
- }
158
+ if (!jsonData)
159
+ return;
160
+ let data;
161
+ try {
162
+ data = JSON.parse(jsonData);
163
+ }
164
+ catch (e) {
165
+ // Payload is not valid JSON (e.g. corrupted or unexpected ciphertext).
166
+ // Fall back silently to the no-autofill experience.
167
+ return;
168
+ }
169
+ for (let i = 0; i < this.fieldNames.length; i++) {
170
+ if (data[this.fieldNames[i]] !== undefined) {
171
+ this.fieldData[this.fieldNames[i]] = decodeURIComponent(data[this.fieldNames[i]]);
106
172
  }
107
173
  }
108
174
  }
109
175
  insertClearRememberMeLink() {
110
176
  let clearRememberMeField = document.getElementById("clear-autofill-data");
111
177
  if (!clearRememberMeField) {
112
- const clearAutofillLabel = "clear autofill";
113
178
  clearRememberMeField = document.createElement("a");
114
179
  clearRememberMeField.setAttribute("id", "clear-autofill-data");
115
180
  clearRememberMeField.classList.add("label-tooltip");
116
181
  clearRememberMeField.setAttribute("style", "cursor: pointer;");
117
- clearRememberMeField.innerHTML = `(${clearAutofillLabel})`;
182
+ clearRememberMeField.innerHTML = this.fieldClearLabel;
118
183
  const targetField = this.getElementByFirstSelector(this.fieldClearSelectorTarget);
119
184
  if (targetField) {
120
185
  if (this.fieldClearSelectorTargetLocation === "after") {
@@ -131,6 +196,9 @@ export class RememberMe {
131
196
  if (this.useRemote()) {
132
197
  this.clearCookieOnRemote();
133
198
  }
199
+ else if (this.encryptData) {
200
+ this.clearCookieEncrypted();
201
+ }
134
202
  else {
135
203
  this.clearCookie();
136
204
  }
@@ -202,6 +270,9 @@ export class RememberMe {
202
270
  }
203
271
  });
204
272
  }
273
+ if (this.hide) {
274
+ rememberMeOptInField.classList.add("hide");
275
+ }
205
276
  tippy("#rememberme-learn-more-toggle", { content: rememberMeInfo });
206
277
  }
207
278
  }
@@ -251,6 +322,7 @@ export class RememberMe {
251
322
  value: this.fieldData,
252
323
  operation: "write",
253
324
  expires: this.cookieExpirationDays,
325
+ encryptData: this.encryptData,
254
326
  }), "*");
255
327
  }
256
328
  }
@@ -262,6 +334,142 @@ export class RememberMe {
262
334
  expires: this.cookieExpirationDays,
263
335
  });
264
336
  }
337
+ /**
338
+ * Reads and decrypts the local (non-remote) Remember Me cookie using
339
+ * browser-native AES-GCM (Web Crypto), with the key held in localStorage
340
+ * on this device. If the key is absent (different device or cleared
341
+ * storage) or decryption otherwise fails, the field data is left empty
342
+ * and the component falls back to the normal, no-autofill experience.
343
+ */
344
+ readCookieEncrypted() {
345
+ return __awaiter(this, void 0, void 0, function* () {
346
+ const raw = cookie.get(this.cookieName);
347
+ if (!raw) {
348
+ return;
349
+ }
350
+ const decrypted = yield this.decryptPayload(raw);
351
+ if (decrypted) {
352
+ this.updateFieldData(decrypted);
353
+ }
354
+ });
355
+ }
356
+ /**
357
+ * Encrypts the current fieldData with AES-GCM (Web Crypto) and stores the
358
+ * base64-encoded result in the local cookie. If encryption isn't possible
359
+ * (e.g. Web Crypto unavailable), nothing is written.
360
+ */
361
+ saveCookieEncrypted() {
362
+ return __awaiter(this, void 0, void 0, function* () {
363
+ const encrypted = yield this.encryptPayload(JSON.stringify(this.fieldData));
364
+ if (encrypted) {
365
+ cookie.set(this.cookieName, encrypted, {
366
+ expires: this.cookieExpirationDays,
367
+ });
368
+ }
369
+ });
370
+ }
371
+ clearCookieEncrypted() {
372
+ this.fieldData = {};
373
+ this.saveCookieEncrypted();
374
+ }
375
+ /**
376
+ * Retrieves the per-device AES-GCM encryption key. A random secret
377
+ * generated once per device and held in localStorage — never written
378
+ * to the cookie, so it never travels with the transported value.
379
+ */
380
+ getEncryptionKey() {
381
+ return __awaiter(this, void 0, void 0, function* () {
382
+ if (!window.crypto || !window.crypto.subtle) {
383
+ return null;
384
+ }
385
+ const storedKey = window.localStorage.getItem(RM_ENCRYPTION_KEY_STORAGE_NAME);
386
+ if (storedKey) {
387
+ try {
388
+ return yield window.crypto.subtle.importKey("raw", this.base64ToArrayBuffer(storedKey), { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
389
+ }
390
+ catch (e) {
391
+ return null;
392
+ }
393
+ }
394
+ try {
395
+ const key = yield window.crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
396
+ const exported = yield window.crypto.subtle.exportKey("raw", key);
397
+ window.localStorage.setItem(RM_ENCRYPTION_KEY_STORAGE_NAME, this.arrayBufferToBase64(exported));
398
+ return key;
399
+ }
400
+ catch (e) {
401
+ return null;
402
+ }
403
+ });
404
+ }
405
+ /**
406
+ * Encrypts a plaintext string with AES-GCM and returns the base64-encoded
407
+ * IV + ciphertext, ready for storage. Returns null if a key isn't
408
+ * available (e.g. Web Crypto unsupported).
409
+ */
410
+ encryptPayload(plaintext) {
411
+ return __awaiter(this, void 0, void 0, function* () {
412
+ const key = yield this.getEncryptionKey();
413
+ if (!key) {
414
+ return null;
415
+ }
416
+ const iv = window.crypto.getRandomValues(new Uint8Array(12));
417
+ const ciphertext = yield window.crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(plaintext));
418
+ const combined = new Uint8Array(iv.length + ciphertext.byteLength);
419
+ combined.set(iv);
420
+ combined.set(new Uint8Array(ciphertext), iv.length);
421
+ return this.arrayBufferToBase64(combined);
422
+ });
423
+ }
424
+ /**
425
+ * Decrypts a base64-encoded IV + ciphertext payload previously produced by
426
+ * encryptPayload. Returns null (rather than throwing) if the key is
427
+ * missing or decryption otherwise fails, so callers can gracefully fall
428
+ * back to the standard, no-autofill experience.
429
+ */
430
+ decryptPayload(encryptedBase64) {
431
+ return __awaiter(this, void 0, void 0, function* () {
432
+ const key = yield this.getEncryptionKey();
433
+ if (!key) {
434
+ return null;
435
+ }
436
+ let combined;
437
+ try {
438
+ combined = new Uint8Array(this.base64ToArrayBuffer(encryptedBase64));
439
+ }
440
+ catch (e) {
441
+ return null;
442
+ }
443
+ if (combined.length < 13) {
444
+ return null;
445
+ }
446
+ const iv = combined.slice(0, 12);
447
+ const ciphertext = combined.slice(12);
448
+ try {
449
+ const decrypted = yield window.crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
450
+ return new TextDecoder().decode(decrypted);
451
+ }
452
+ catch (e) {
453
+ return null;
454
+ }
455
+ });
456
+ }
457
+ arrayBufferToBase64(buffer) {
458
+ let binary = "";
459
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
460
+ for (let i = 0; i < bytes.byteLength; i++) {
461
+ binary += String.fromCharCode(bytes[i]);
462
+ }
463
+ return window.btoa(binary);
464
+ }
465
+ base64ToArrayBuffer(base64) {
466
+ const binary = window.atob(base64);
467
+ const bytes = new Uint8Array(binary.length);
468
+ for (let i = 0; i < binary.length; i++) {
469
+ bytes[i] = binary.charCodeAt(i);
470
+ }
471
+ return bytes.buffer;
472
+ }
265
473
  readFields() {
266
474
  for (let i = 0; i < this.fieldNames.length; i++) {
267
475
  let fieldSelector = "[name='" + this.fieldNames[i] + "']";
@@ -272,6 +480,17 @@ export class RememberMe {
272
480
  if (type === "radio" || type === "checkbox") {
273
481
  field = document.querySelector(fieldSelector + ":checked");
274
482
  }
483
+ // When the donation amount radio is set to "Other", save the actual
484
+ // custom value from the .other text input instead of "Other".
485
+ if (this.fieldNames[i] === this.fieldDonationAmountRadioName &&
486
+ field &&
487
+ field.value.toLowerCase() === "other") {
488
+ const otherField = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']");
489
+ if (otherField && otherField.value) {
490
+ this.fieldData[this.fieldNames[i]] = encodeURIComponent(otherField.value);
491
+ continue;
492
+ }
493
+ }
275
494
  this.fieldData[this.fieldNames[i]] = encodeURIComponent(field.value);
276
495
  }
277
496
  else if (field.tagName === "SELECT") {
@@ -355,17 +574,36 @@ export class RememberMe {
355
574
  field.click();
356
575
  }
357
576
  }
577
+ else if (this.fieldNames[i] === this.fieldDonationRecurrFreqRadioName) {
578
+ // recurrfreq is a radio group — find the specific radio with the saved value and click it
579
+ const savedValue = this.fieldData[this.fieldNames[i]];
580
+ if (savedValue) {
581
+ const freqRadio = document.querySelector(fieldSelector + "[value='" + CSS.escape(savedValue) + "']");
582
+ if (freqRadio) {
583
+ freqRadio.click();
584
+ }
585
+ }
586
+ }
358
587
  else if (this.fieldDonationAmountRadioName === this.fieldNames[i]) {
359
- field = document.querySelector(fieldSelector +
360
- "[value='" +
361
- this.fieldData[this.fieldNames[i]] +
362
- "']");
588
+ const savedAmt = this.fieldData[this.fieldNames[i]];
589
+ const escapedAmt = CSS.escape(savedAmt);
590
+ field = document.querySelector(fieldSelector + "[value='" + escapedAmt + "']");
363
591
  if (field) {
592
+ // Saved value matches a predefined radio option — just click it
364
593
  field.click();
365
594
  }
366
595
  else {
367
- field = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']");
368
- this.setFieldValue(field, this.fieldData[this.fieldNames[i]], true);
596
+ // No matching radio: the value is a custom amount.
597
+ // Click the "Other" radio first so the text input becomes active,
598
+ // then fill in the numeric value.
599
+ const otherRadio = document.querySelector(fieldSelector + "[value='Other'], " +
600
+ fieldSelector + "[value='other'], " +
601
+ fieldSelector + "[value='OTHER']");
602
+ if (otherRadio) {
603
+ otherRadio.click();
604
+ }
605
+ const otherField = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']");
606
+ this.setFieldValue(otherField, savedAmt, true);
369
607
  }
370
608
  }
371
609
  else {
@@ -378,6 +616,74 @@ export class RememberMe {
378
616
  }
379
617
  }
380
618
  }
619
+ /**
620
+ * SwapAmounts replaces the donationAmt radio DOM nodes ~1 second after page
621
+ * load (triggered by DonationFrequency.load() setTimeout). When that happens
622
+ * the selection the RememberMe just wrote gets wiped out.
623
+ *
624
+ * This method subscribes to the first onFrequencyChange event and, after a
625
+ * short delay to let SwapAmounts finish its DOM update, re-applies only the
626
+ * donation amount. It unsubscribes immediately so it only fires once.
627
+ *
628
+ * To avoid overwriting a manual donor interaction, the handler checks
629
+ * whether the current amount selection is empty/wiped (as SwapAmounts does)
630
+ * OR still matches what writeFields originally set. If the donor already
631
+ * picked a different amount, we skip re-application.
632
+ */
633
+ reapplyDonationAmtAfterSwap() {
634
+ const savedAmt = this.fieldData[this.fieldDonationAmountRadioName];
635
+ if (!savedAmt)
636
+ return;
637
+ // Capture the amount that writeFields just set so we can detect manual changes
638
+ const amountAtRegistration = this.getCurrentSelectedAmount();
639
+ const handler = () => {
640
+ // SwapAmounts calls _amount.load() after swapList — give it a tick to settle
641
+ window.setTimeout(() => {
642
+ const currentAmt = this.getCurrentSelectedAmount();
643
+ // Only re-apply if the selection is now empty (DOM was swapped out)
644
+ // or still matches what we originally wrote. If the donor manually
645
+ // selected a different amount, respect their choice.
646
+ const selectionWiped = currentAmt === null || currentAmt === "";
647
+ const selectionUnchanged = currentAmt === amountAtRegistration;
648
+ if (!selectionWiped && !selectionUnchanged) {
649
+ return;
650
+ }
651
+ const fieldSelector = "[name='" + this.fieldDonationAmountRadioName + "']";
652
+ const escapedAmt = CSS.escape(savedAmt);
653
+ let radio = document.querySelector(fieldSelector + "[value='" + escapedAmt + "']");
654
+ if (radio) {
655
+ radio.click();
656
+ }
657
+ else {
658
+ // Custom amount: click "Other" radio then fill the text input
659
+ const otherRadio = document.querySelector(fieldSelector + "[value='Other'], " +
660
+ fieldSelector + "[value='other'], " +
661
+ fieldSelector + "[value='OTHER']");
662
+ if (otherRadio)
663
+ otherRadio.click();
664
+ const otherField = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']");
665
+ this.setFieldValue(otherField, savedAmt, true);
666
+ }
667
+ }, 200);
668
+ };
669
+ // Subscribe once: fires on the first frequency change then auto-unsubscribes
670
+ this._frequency.onFrequencyChange.one(handler);
671
+ }
672
+ /**
673
+ * Returns the currently selected donation amount value, or null if nothing
674
+ * is selected. Checks both predefined radio buttons and the "Other" text input.
675
+ */
676
+ getCurrentSelectedAmount() {
677
+ const fieldSelector = "[name='" + this.fieldDonationAmountRadioName + "']";
678
+ const checkedRadio = document.querySelector(fieldSelector + ":checked");
679
+ if (!checkedRadio)
680
+ return null;
681
+ if (checkedRadio.value.toLowerCase() === "other") {
682
+ const otherField = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']");
683
+ return otherField ? otherField.value : null;
684
+ }
685
+ return checkedRadio.value;
686
+ }
381
687
  isJson(str) {
382
688
  try {
383
689
  JSON.parse(str);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const AppVersion = "0.25.11";
1
+ export declare const AppVersion = "0.25.13";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const AppVersion = "0.25.11";
1
+ export const AppVersion = "0.25.13";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4site/engrid-scripts",
3
- "version": "0.25.11",
3
+ "version": "0.25.13",
4
4
  "description": "Scripts for the ENGRID Project",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -25,7 +25,7 @@
25
25
  "strongly-typed-events": "^2.0.9"
26
26
  },
27
27
  "homepage": "https://github.com/4site-interactive-studios/engrid#readme",
28
- "gitHead": "ab9e0c5cfefa3568c81101134ae40110939ae874",
28
+ "gitHead": "6e3e2a64fd59921011967c02bd5c68fa2aaa3a65",
29
29
  "devDependencies": {
30
30
  "nodemon": "^2.0.20"
31
31
  }