@4site/engrid-scripts 0.27.1 → 0.27.3

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/a11y.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  export declare class A11y {
2
2
  private logger;
3
3
  private observer;
4
+ private liveRegionUpdateTimeout;
5
+ private shouldFocusFirstInvalidField;
4
6
  constructor();
5
7
  /**
6
- * Apply the field-level accessibility tagging (error alert live regions,
8
+ * Apply the field-level accessibility tagging (error containers,
7
9
  * aria-required, fallback aria-labels, radio group roles) to every field
8
10
  * within `root`. Defaults to the whole document on initial load, but can be
9
11
  * pointed at a freshly injected fragment (e.g. a Supporter Hub overlay) so
@@ -11,22 +13,23 @@ export declare class A11y {
11
13
  * idempotent, so re-scanning already tagged fields is safe.
12
14
  */
13
15
  static scanFields(root?: ParentNode): void;
14
- private static addErrorAlertArea;
16
+ private static addGlobalErrorLiveRegion;
15
17
  private static addGroupRole;
16
18
  private static addRequired;
17
19
  private static addLabel;
18
20
  private updateFrequencyLabel;
19
21
  private setAutoGeneratedAltTags;
20
22
  /**
21
- * Observe #engrid for .en__field__error additions and removals, mirroring
22
- * text into the per-field .en__field__error__alert live region and toggling
23
- * aria-invalid / aria-describedby on the corresponding input. Runs for the
24
- * lifetime of the page so async validators (NeverBounce, VGS, server
25
- * re-renders) are caught without timing assumptions.
23
+ * Observe #engrid for .en__field__error additions and removals, relocating
24
+ * each error after its field's input so DOM order matches visual order. Also
25
+ * toggles aria-invalid / aria-describedby on the corresponding input (or radio
26
+ * group when the field has role="group") and updates a single, DOM-ordered
27
+ * global live region. Runs for the lifetime of the page so async validators
28
+ * (NeverBounce, VGS, server re-renders) are caught without timing assumptions.
26
29
  */
27
30
  private observeErrorMessages;
28
- private moveErrorMessage;
29
- private clearErrorMessage;
31
+ private tagFieldError;
32
+ private clearFieldError;
30
33
  /**
31
34
  * Make everything on the page inert except the supplied overlay element and
32
35
  * its ancestors. This hides background content from assistive technology and
@@ -40,5 +43,10 @@ export declare class A11y {
40
43
  * `inert` is true; ignored when `inert` is false.
41
44
  */
42
45
  static inertPage(inert: boolean, overlay?: HTMLElement): void;
43
- private manageErrorListAlertRole;
46
+ private scheduleLiveRegionUpdate;
47
+ private updateGlobalErrorLiveRegion;
48
+ private formatErrorMessage;
49
+ private focusFirstInvalidField;
50
+ private getFieldLabel;
51
+ private normalizeLabel;
44
52
  }
package/dist/a11y.js CHANGED
@@ -1,19 +1,32 @@
1
1
  import { EngridLogger } from "./logger";
2
+ import { ENGrid } from "./engrid";
3
+ import { EnForm } from "./events/en-form";
2
4
  // a11y means accessibility
3
5
  // This Component is supposed to be used as a helper for Aria Attributes & Other Accessibility Features
4
6
  export class A11y {
5
7
  constructor() {
8
+ var _a;
6
9
  this.logger = new EngridLogger("A11y", "#FFFFFF", "#811212", "👁️‍🗨️");
7
10
  this.observer = null;
11
+ this.liveRegionUpdateTimeout = null;
12
+ this.shouldFocusFirstInvalidField = false;
8
13
  A11y.scanFields();
9
14
  this.updateFrequencyLabel();
10
15
  const ecardImages = document.querySelectorAll('.en__ecarditems__list img');
11
16
  this.setAutoGeneratedAltTags(ecardImages);
12
- this.manageErrorListAlertRole();
13
17
  this.observeErrorMessages();
18
+ (_a = ENGrid.enForm) === null || _a === void 0 ? void 0 : _a.addEventListener('submit', () => {
19
+ this.shouldFocusFirstInvalidField = true;
20
+ });
21
+ // onSubmit only fires once validation has passed, so disarm the focus
22
+ // flag: a successful submit must not leave it set, or the next unrelated
23
+ // async field error (e.g. NeverBounce on blur) would steal focus.
24
+ EnForm.getInstance().onSubmit.subscribe(() => {
25
+ this.shouldFocusFirstInvalidField = false;
26
+ });
14
27
  }
15
28
  /**
16
- * Apply the field-level accessibility tagging (error alert live regions,
29
+ * Apply the field-level accessibility tagging (error containers,
17
30
  * aria-required, fallback aria-labels, radio group roles) to every field
18
31
  * within `root`. Defaults to the whole document on initial load, but can be
19
32
  * pointed at a freshly injected fragment (e.g. a Supporter Hub overlay) so
@@ -21,24 +34,29 @@ export class A11y {
21
34
  * idempotent, so re-scanning already tagged fields is safe.
22
35
  */
23
36
  static scanFields(root = document) {
24
- A11y.addErrorAlertArea(root);
37
+ A11y.addGlobalErrorLiveRegion(root);
25
38
  A11y.addRequired(root);
26
39
  A11y.addLabel(root);
27
40
  A11y.addGroupRole(root);
28
41
  }
29
- static addErrorAlertArea(root = document) {
30
- const fieldElements = root.querySelectorAll('.en__field .en__field__element');
31
- fieldElements.forEach((fieldElement) => {
32
- const fieldWrapper = fieldElement.closest('.en__field');
33
- if (fieldWrapper === null || fieldWrapper === void 0 ? void 0 : fieldWrapper.querySelector('.en__field__error__alert'))
34
- return;
35
- const errorAlert = document.createElement('div');
36
- errorAlert.setAttribute('aria-live', 'polite');
37
- errorAlert.setAttribute('aria-atomic', 'true');
38
- errorAlert.classList.add('en__field__error__alert');
39
- errorAlert.id = `en__field__error__alert--${Math.random().toString(36).slice(2, 7)}`;
40
- fieldElement.insertAdjacentElement('afterend', errorAlert);
41
- });
42
+ static addGlobalErrorLiveRegion(root = document) {
43
+ var _a;
44
+ const liveRegionId = 'engrid-a11y-error-summary';
45
+ if (document.getElementById(liveRegionId))
46
+ return;
47
+ const form = (_a = ENGrid.enForm) !== null && _a !== void 0 ? _a : root.querySelector('form.en__component');
48
+ if (!form)
49
+ return;
50
+ const region = document.createElement('div');
51
+ region.id = liveRegionId;
52
+ region.className = 'engrid__sr-only';
53
+ region.setAttribute('aria-live', 'polite');
54
+ region.setAttribute('aria-atomic', 'true');
55
+ form.insertBefore(region, form.firstChild);
56
+ const errorList = document.querySelector('ul.en__errorList');
57
+ if (errorList === null || errorList === void 0 ? void 0 : errorList.hasAttribute('role')) {
58
+ errorList.removeAttribute('role');
59
+ }
42
60
  }
43
61
  static addGroupRole(root = document) {
44
62
  // Add role="group" to all EN Radio fields
@@ -52,6 +70,13 @@ export class A11y {
52
70
  const label = field.querySelector("label");
53
71
  if (label) {
54
72
  label.setAttribute("id", `en__field__label--${Math.random().toString(36).slice(2, 7)}`);
73
+ // EN renders the group's question label as a <label>, but a radio group
74
+ // has no single control for `for` to point at. When EN leaves it blank
75
+ // (`for=""`) it's an invalid IDREF, so strip only that empty case
76
+ const forAttr = label.getAttribute("for");
77
+ if (forAttr !== null && forAttr.trim() === "") {
78
+ label.removeAttribute("for");
79
+ }
55
80
  field.setAttribute("aria-labelledby", label.id);
56
81
  }
57
82
  });
@@ -128,11 +153,12 @@ export class A11y {
128
153
  });
129
154
  }
130
155
  /**
131
- * Observe #engrid for .en__field__error additions and removals, mirroring
132
- * text into the per-field .en__field__error__alert live region and toggling
133
- * aria-invalid / aria-describedby on the corresponding input. Runs for the
134
- * lifetime of the page so async validators (NeverBounce, VGS, server
135
- * re-renders) are caught without timing assumptions.
156
+ * Observe #engrid for .en__field__error additions and removals, relocating
157
+ * each error after its field's input so DOM order matches visual order. Also
158
+ * toggles aria-invalid / aria-describedby on the corresponding input (or radio
159
+ * group when the field has role="group") and updates a single, DOM-ordered
160
+ * global live region. Runs for the lifetime of the page so async validators
161
+ * (NeverBounce, VGS, server re-renders) are caught without timing assumptions.
136
162
  */
137
163
  observeErrorMessages() {
138
164
  var _a;
@@ -143,22 +169,22 @@ export class A11y {
143
169
  continue;
144
170
  record.addedNodes.forEach(node => {
145
171
  if (node instanceof HTMLElement && node.classList.contains('en__field__error')) {
146
- this.moveErrorMessage(node);
172
+ this.tagFieldError(node);
147
173
  }
148
174
  });
149
175
  record.removedNodes.forEach(node => {
150
- var _a, _b;
151
176
  if (!(node instanceof HTMLElement))
152
177
  return;
153
178
  if (!node.classList.contains('en__field__error'))
154
179
  return;
180
+ if (node.isConnected)
181
+ return; // relocated, not removed
155
182
  // node.parentElement is null after removal; record.target is the
156
183
  // former parent. Walk up to the enclosing .en__field to be defensive
157
184
  // against deeper nesting.
158
- const fieldWrapper = (_b = (_a = record.target).closest) === null || _b === void 0 ? void 0 : _b.call(_a, '.en__field');
159
- const alert = fieldWrapper === null || fieldWrapper === void 0 ? void 0 : fieldWrapper.querySelector('.en__field__error__alert');
160
- if (alert)
161
- this.clearErrorMessage(alert);
185
+ const fieldWrapper = record.target.closest('.en__field');
186
+ if (fieldWrapper)
187
+ this.clearFieldError(fieldWrapper, node.id);
162
188
  });
163
189
  }
164
190
  });
@@ -167,55 +193,76 @@ export class A11y {
167
193
  // before this observer was attached.
168
194
  document.querySelectorAll('.en__field').forEach(field => {
169
195
  const error = field.querySelector('.en__field__error');
170
- const alert = field.querySelector('.en__field__error__alert');
171
196
  if (error)
172
- this.moveErrorMessage(error);
173
- else if (alert)
174
- this.clearErrorMessage(alert);
197
+ this.tagFieldError(error);
175
198
  });
176
199
  }
177
- moveErrorMessage(field) {
200
+ tagFieldError(error) {
178
201
  var _a;
179
- if (field.closest('.en__field__error__alert'))
180
- return;
181
- const fieldWrapper = field.closest('.en__field');
202
+ const fieldWrapper = error.closest('.en__field');
182
203
  if (!fieldWrapper)
183
204
  return;
184
- const alertContainer = fieldWrapper.querySelector('.en__field__error__alert');
185
- if (!alertContainer)
186
- return;
187
- alertContainer.textContent = field.textContent;
188
- field.classList.add('en__field__error--hidden-by-a11y');
189
- const inputElement = fieldWrapper.querySelector('.en__field__element input, .en__field__element select, .en__field__element textarea');
190
- if (!inputElement)
205
+ const fieldElement = fieldWrapper.querySelector('.en__field__element');
206
+ if (fieldElement && fieldElement.nextElementSibling !== error) {
207
+ fieldElement.insertAdjacentElement('afterend', error);
208
+ }
209
+ if (!error.id) {
210
+ error.id = `en__field__error--a11y-${Math.random().toString(36).slice(2, 7)}`;
211
+ }
212
+ this.scheduleLiveRegionUpdate();
213
+ const target = fieldWrapper.getAttribute('role') === 'group'
214
+ ? fieldWrapper
215
+ : fieldWrapper.querySelector('.en__field__element input, .en__field__element select, .en__field__element textarea');
216
+ if (!target)
191
217
  return;
192
- inputElement.setAttribute('aria-invalid', 'true');
193
- const describedBy = ((_a = inputElement.getAttribute('aria-describedby')) !== null && _a !== void 0 ? _a : '')
218
+ target.setAttribute('aria-invalid', 'true');
219
+ const describedBy = ((_a = target.getAttribute('aria-describedby')) !== null && _a !== void 0 ? _a : '')
194
220
  .split(/\s+/)
195
221
  .filter(Boolean);
196
- if (describedBy.indexOf(alertContainer.id) === -1) {
197
- describedBy.push(alertContainer.id);
222
+ if (describedBy.indexOf(error.id) === -1) {
223
+ describedBy.push(error.id);
224
+ }
225
+ target.setAttribute('aria-describedby', describedBy.join(' '));
226
+ // For radio groups, the group itself is the accessible widget; keep the
227
+ // individual inputs from also being announced as invalid.
228
+ if (target === fieldWrapper) {
229
+ fieldWrapper
230
+ .querySelectorAll('.en__field__element input, .en__field__element select, .en__field__element textarea')
231
+ .forEach(input => {
232
+ var _a;
233
+ input.removeAttribute('aria-invalid');
234
+ const remaining = ((_a = input.getAttribute('aria-describedby')) !== null && _a !== void 0 ? _a : '')
235
+ .split(/\s+/)
236
+ .filter(id => id && id !== error.id);
237
+ if (remaining.length) {
238
+ input.setAttribute('aria-describedby', remaining.join(' '));
239
+ }
240
+ else {
241
+ input.removeAttribute('aria-describedby');
242
+ }
243
+ });
198
244
  }
199
- inputElement.setAttribute('aria-describedby', describedBy.join(' '));
200
245
  }
201
- clearErrorMessage(alert) {
246
+ clearFieldError(fieldWrapper, errorId) {
202
247
  var _a;
203
- alert.textContent = '';
204
- const fieldWrapper = alert.closest('.en__field');
205
- if (!fieldWrapper)
206
- return;
207
- const inputElement = fieldWrapper.querySelector('.en__field__element input, .en__field__element select, .en__field__element textarea');
208
- if (!inputElement)
248
+ this.scheduleLiveRegionUpdate();
249
+ const target = fieldWrapper.getAttribute('role') === 'group'
250
+ ? fieldWrapper
251
+ : fieldWrapper.querySelector('.en__field__element input, .en__field__element select, .en__field__element textarea');
252
+ if (!target)
209
253
  return;
210
- inputElement.removeAttribute('aria-invalid');
211
- const remaining = ((_a = inputElement.getAttribute('aria-describedby')) !== null && _a !== void 0 ? _a : '')
254
+ const hasRemainingError = fieldWrapper.querySelector('.en__field__error') !== null;
255
+ if (!hasRemainingError) {
256
+ target.removeAttribute('aria-invalid');
257
+ }
258
+ const remaining = ((_a = target.getAttribute('aria-describedby')) !== null && _a !== void 0 ? _a : '')
212
259
  .split(/\s+/)
213
- .filter(id => id && id !== alert.id);
260
+ .filter(id => id && id !== errorId);
214
261
  if (remaining.length) {
215
- inputElement.setAttribute('aria-describedby', remaining.join(' '));
262
+ target.setAttribute('aria-describedby', remaining.join(' '));
216
263
  }
217
264
  else {
218
- inputElement.removeAttribute('aria-describedby');
265
+ target.removeAttribute('aria-describedby');
219
266
  }
220
267
  }
221
268
  /**
@@ -259,29 +306,108 @@ export class A11y {
259
306
  });
260
307
  }
261
308
  }
262
- manageErrorListAlertRole() {
309
+ scheduleLiveRegionUpdate() {
310
+ if (this.liveRegionUpdateTimeout !== null) {
311
+ window.clearTimeout(this.liveRegionUpdateTimeout);
312
+ }
313
+ this.liveRegionUpdateTimeout = window.setTimeout(() => {
314
+ this.liveRegionUpdateTimeout = null;
315
+ this.updateGlobalErrorLiveRegion();
316
+ }, 100);
317
+ }
318
+ updateGlobalErrorLiveRegion() {
319
+ var _a;
320
+ const region = document.getElementById('engrid-a11y-error-summary');
321
+ if (!region)
322
+ return;
263
323
  const errorList = document.querySelector('ul.en__errorList');
264
- if (!errorList)
324
+ if (errorList === null || errorList === void 0 ? void 0 : errorList.hasAttribute('role')) {
325
+ errorList.removeAttribute('role');
326
+ }
327
+ const fields = Array.from(document.querySelectorAll('.en__field'));
328
+ const messages = fields
329
+ .map(field => {
330
+ var _a;
331
+ const error = field.querySelector('.en__field__error');
332
+ if (!((_a = error === null || error === void 0 ? void 0 : error.textContent) === null || _a === void 0 ? void 0 : _a.trim()))
333
+ return null;
334
+ const label = this.getFieldLabel(field);
335
+ const message = this.formatErrorMessage(label, error.textContent.trim());
336
+ return message;
337
+ })
338
+ .filter((message) => Boolean(message));
339
+ // Top-of-form server errors (ul.en__errorList) aren't tied to a .en__field,
340
+ // so fold in any that aren't already covered by the per-field messages,
341
+ // otherwise they would be silent for screen readers.
342
+ const normalizeForCompare = (value) => value.replace(/\s+/g, ' ').replace(/[.!?:]+$/, '').trim().toLowerCase();
343
+ const coveredMessages = messages.map(normalizeForCompare).filter(Boolean);
344
+ const serverMessages = Array.from((_a = errorList === null || errorList === void 0 ? void 0 : errorList.querySelectorAll('li')) !== null && _a !== void 0 ? _a : [])
345
+ .map(item => { var _a, _b; return (_b = (_a = item.textContent) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : ''; })
346
+ .filter(text => {
347
+ if (!text)
348
+ return false;
349
+ const normalized = normalizeForCompare(text);
350
+ if (!normalized)
351
+ return false;
352
+ return !coveredMessages.some(covered => covered === normalized ||
353
+ covered.includes(normalized) ||
354
+ normalized.includes(covered));
355
+ });
356
+ const allMessages = [...messages, ...serverMessages];
357
+ region.textContent = '';
358
+ if (!allMessages.length) {
359
+ this.shouldFocusFirstInvalidField = false;
265
360
  return;
266
- const hasErrorItems = () => Boolean(errorList.querySelector('li'));
267
- const enableAlert = () => {
268
- if (!errorList.hasAttribute('role')) {
269
- errorList.setAttribute('role', 'alert');
270
- }
271
- };
272
- const disableAlert = () => {
273
- if (errorList.hasAttribute('role')) {
274
- errorList.removeAttribute('role');
275
- }
276
- };
277
- hasErrorItems() ? enableAlert() : disableAlert();
278
- new MutationObserver(records => {
279
- for (const record of records) {
280
- if (record.type === 'childList') {
281
- hasErrorItems() ? enableAlert() : disableAlert();
282
- break;
283
- }
361
+ }
362
+ if (allMessages.length === 1) {
363
+ region.textContent = allMessages[0];
364
+ }
365
+ else {
366
+ const cleaned = allMessages.map(message => message.replace(/[.!?]+$/, '').trim());
367
+ region.textContent = `There are ${allMessages.length} errors: ${cleaned.join('. ')}.`;
368
+ }
369
+ if (this.shouldFocusFirstInvalidField) {
370
+ this.shouldFocusFirstInvalidField = false;
371
+ this.focusFirstInvalidField();
372
+ }
373
+ }
374
+ formatErrorMessage(label, message) {
375
+ const cleanMessage = message.trim();
376
+ const cleanLabel = this.normalizeLabel(label);
377
+ if (!cleanLabel)
378
+ return cleanMessage;
379
+ // If the message already mentions the field label, no need to prefix it.
380
+ const escapedLabel = cleanLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
381
+ const labelRegex = new RegExp(`\\b${escapedLabel}\\b`, 'i');
382
+ if (labelRegex.test(cleanMessage))
383
+ return cleanMessage;
384
+ return `${cleanLabel}: ${cleanMessage}`;
385
+ }
386
+ focusFirstInvalidField() {
387
+ const fields = Array.from(document.querySelectorAll('.en__field'));
388
+ for (const field of fields) {
389
+ if (!field.querySelector('.en__field__error'))
390
+ continue;
391
+ const target = field.querySelector('.en__field__element input:not([type="hidden"]), .en__field__element select, .en__field__element textarea');
392
+ if (target && target.offsetParent !== null && !target.hasAttribute('disabled')) {
393
+ target.focus();
394
+ return;
284
395
  }
285
- }).observe(errorList, { childList: true });
396
+ }
397
+ }
398
+ getFieldLabel(field) {
399
+ var _a, _b, _c, _d, _e, _f, _g;
400
+ const label = (_c = (_b = (_a = field.querySelector('.en__field__label:not(.en__field__label--item)')) !== null && _a !== void 0 ? _a : field.querySelector('.en__field__label')) !== null && _b !== void 0 ? _b : field.querySelector('label')) !== null && _c !== void 0 ? _c : field.querySelector('legend');
401
+ const labelText = (_e = (_d = label === null || label === void 0 ? void 0 : label.textContent) === null || _d === void 0 ? void 0 : _d.trim()) !== null && _e !== void 0 ? _e : '';
402
+ if (labelText)
403
+ return this.normalizeLabel(labelText);
404
+ const input = field.querySelector('input, select, textarea');
405
+ return (_g = (_f = input === null || input === void 0 ? void 0 : input.getAttribute('aria-label')) === null || _f === void 0 ? void 0 : _f.trim()) !== null && _g !== void 0 ? _g : '';
406
+ }
407
+ normalizeLabel(label) {
408
+ return label
409
+ .replace(/\s+/g, ' ')
410
+ .replace(/^[*:\s]+|[*:\s]+$/g, '')
411
+ .trim();
286
412
  }
287
413
  }
@@ -3,6 +3,8 @@ export declare class DonationFrequency {
3
3
  private _frequency;
4
4
  private _recurring;
5
5
  private _dispatch;
6
+ private _frequencies;
7
+ private logger;
6
8
  private static instance;
7
9
  private constructor();
8
10
  static getInstance(): DonationFrequency;
@@ -11,6 +13,7 @@ export declare class DonationFrequency {
11
13
  get recurring(): string;
12
14
  set recurring(value: string);
13
15
  get onFrequencyChange(): import("strongly-typed-events").ISimpleEvent<string>;
16
+ get frequencies(): string[];
14
17
  load(): void;
15
18
  setRecurrency(recurr: string, dispatch?: boolean): void;
16
19
  setFrequency(freq: string, dispatch?: boolean): void;
@@ -1,11 +1,14 @@
1
1
  import { SimpleEventDispatcher } from "strongly-typed-events";
2
2
  import { ENGrid } from "../engrid";
3
+ import { EngridLogger } from "../logger";
3
4
  export class DonationFrequency {
4
5
  constructor() {
5
6
  this._onFrequencyChange = new SimpleEventDispatcher();
6
7
  this._frequency = "onetime";
7
8
  this._recurring = "n";
8
9
  this._dispatch = true;
10
+ this._frequencies = ["onetime"];
11
+ this.logger = new EngridLogger("DonationFrequency", "white", "black", "💰");
9
12
  // Watch the Radios for Changes
10
13
  document.addEventListener("change", (e) => {
11
14
  const element = e.target;
@@ -57,6 +60,9 @@ export class DonationFrequency {
57
60
  get onFrequencyChange() {
58
61
  return this._onFrequencyChange.asEvent();
59
62
  }
63
+ get frequencies() {
64
+ return this._frequencies;
65
+ }
60
66
  // Set amount var with currently selected amount
61
67
  load() {
62
68
  var _a;
@@ -73,6 +79,11 @@ export class DonationFrequency {
73
79
  ((_a = window.EngagingNetworks.require._defined.enjs
74
80
  .getSupporterData("recurrpay")) === null || _a === void 0 ? void 0 : _a.toLowerCase()) || "n";
75
81
  }
82
+ // List of available frequencies on the form
83
+ this._frequencies = Array.from(document.querySelectorAll('input[name="transaction.recurrfreq"]'))
84
+ .filter((el) => el instanceof HTMLInputElement)
85
+ .map((el) => el.value.toLowerCase());
86
+ this.logger.log(`Loaded with frequency: ${this.frequency} and recurring: ${this.recurring} \nAvailable frequencies: ${this._frequencies.join(", ")}`);
76
87
  // ENGrid.enParseDependencies();
77
88
  }
78
89
  // Force a new recurrency
@@ -109,6 +120,9 @@ export class DonationFrequency {
109
120
  this.setRecurrency("Y", dispatch);
110
121
  }
111
122
  }
123
+ else {
124
+ this.logger.warn(`Attempted to set a frequency of "${freq}" but it was not found on the form.`);
125
+ }
112
126
  // Revert dispatch to default value (true)
113
127
  this._dispatch = true;
114
128
  }
@@ -1,3 +1,4 @@
1
+ // ! WE ARE PHASING OUT THIS COMPONENT IN FAVOR OF UPSELL-LIGHTBOX. PLEASE USE THAT COMPONENT FOR NEW IMPLEMENTATIONS.
1
2
  /*
2
3
  * FrequencyUpsell component which creates a modal to upsell the frequency of the donation
3
4
  * This is typically used to upsell a single donation into an annual donation, but the component
@@ -11,11 +11,14 @@ export interface UpsellOptions {
11
11
  amountRange: Array<{
12
12
  max: number;
13
13
  suggestion: number | string;
14
+ frequency?: "onetime" | "monthly" | "quarterly" | "semi_annual" | "annual";
14
15
  }>;
16
+ upsellToFrequency?: "onetime" | "monthly" | "quarterly" | "semi_annual" | "annual";
15
17
  minAmount: number;
16
18
  canClose: boolean;
17
19
  submitOnClose: boolean;
18
20
  oneTime: boolean;
21
+ monthly: boolean;
19
22
  annual: boolean;
20
23
  disablePaymentMethods: Array<string>;
21
24
  skipUpsell: boolean;
@@ -1,31 +1,37 @@
1
1
  export const UpsellOptionsDefaults = {
2
2
  image: "https://picsum.photos/480/650",
3
3
  imagePosition: "left",
4
- title: "Will you change your gift to just {new-amount} a month to boost your impact?",
5
- paragraph: "Make a monthly pledge today to support us with consistent, reliable resources during emergency moments.",
6
- yesLabel: "Yes! Process My <br> {new-amount} monthly gift",
7
- noLabel: "No, thanks. Continue with my <br> {old-amount} one-time gift",
4
+ title: "Will you change your gift to just {new-amount} {new-frequency} to boost your impact?",
5
+ paragraph: "Make a {new-frequency} pledge today to support us with consistent, reliable resources during emergency moments.",
6
+ yesLabel: "Yes! Process My <br> {new-amount} {new-frequency} gift",
7
+ noLabel: "No, thanks. Continue with my <br> {old-amount} {old-frequency} gift",
8
8
  otherAmount: true,
9
- otherLabel: "Or enter a different monthly amount:",
9
+ otherLabel: "Or enter a different {new-frequency} amount:",
10
10
  upsellOriginalGiftAmountFieldName: "",
11
11
  amountRange: [
12
- { max: 10, suggestion: 5 },
13
- { max: 15, suggestion: 7 },
14
- { max: 20, suggestion: 8 },
15
- { max: 25, suggestion: 9 },
16
- { max: 30, suggestion: 10 },
17
- { max: 35, suggestion: 11 },
18
- { max: 40, suggestion: 12 },
19
- { max: 50, suggestion: 14 },
20
- { max: 100, suggestion: 15 },
21
- { max: 200, suggestion: 19 },
22
- { max: 300, suggestion: 29 },
23
- { max: 500, suggestion: "Math.ceil((amount / 12)/5)*5" },
12
+ { max: 10, suggestion: 5, frequency: "monthly" },
13
+ { max: 15, suggestion: 7, frequency: "monthly" },
14
+ { max: 20, suggestion: 8, frequency: "monthly" },
15
+ { max: 25, suggestion: 9, frequency: "monthly" },
16
+ { max: 30, suggestion: 10, frequency: "monthly" },
17
+ { max: 35, suggestion: 11, frequency: "monthly" },
18
+ { max: 40, suggestion: 12, frequency: "monthly" },
19
+ { max: 50, suggestion: 14, frequency: "monthly" },
20
+ { max: 100, suggestion: 15, frequency: "monthly" },
21
+ { max: 200, suggestion: 19, frequency: "monthly" },
22
+ { max: 300, suggestion: 29, frequency: "monthly" },
23
+ {
24
+ max: 500,
25
+ suggestion: "Math.ceil((amount / 12)/5)*5",
26
+ frequency: "monthly",
27
+ },
24
28
  ],
29
+ upsellToFrequency: "monthly",
25
30
  minAmount: 0,
26
31
  canClose: true,
27
32
  submitOnClose: false,
28
33
  oneTime: true,
34
+ monthly: false,
29
35
  annual: false,
30
36
  disablePaymentMethods: [],
31
37
  skipUpsell: false,
@@ -1,3 +1,8 @@
1
+ declare global {
2
+ interface Window {
3
+ UseBodyBannerImageAsBackground?: boolean;
4
+ }
5
+ }
1
6
  export declare class PageBackground {
2
7
  private pageBackground;
3
8
  private bodyBanner;
@@ -7,6 +7,9 @@ export class PageBackground {
7
7
  this.bodyBannerImage = null;
8
8
  this.mutationObserver = null;
9
9
  this.logger = new EngridLogger("PageBackground", "lightblue", "darkblue", "🖼️");
10
+ if (typeof window.UseBodyBannerImageAsBackground !== "undefined") {
11
+ useBodyBannerImage = !!window.UseBodyBannerImageAsBackground;
12
+ }
10
13
  if (useBodyBannerImage) {
11
14
  this.bodyBannerImage = this.findBodyBannerImage();
12
15
  }
@@ -9,15 +9,17 @@ export declare class UpsellLightbox {
9
9
  private _frequency;
10
10
  private _dataLayer;
11
11
  private _suggestAmount;
12
+ private _upsellFrequency;
12
13
  private logger;
13
14
  constructor();
15
+ private parseMergeTags;
14
16
  private renderLightbox;
15
17
  private shouldRun;
16
18
  private shouldSkip;
17
19
  private popupOtherField;
18
20
  private liveAmounts;
19
21
  private liveFrequency;
20
- private getUpsellAmount;
22
+ private resolveUpsell;
21
23
  private shouldOpen;
22
24
  private freqAllowed;
23
25
  private open;
@@ -9,6 +9,7 @@ export class UpsellLightbox {
9
9
  this._frequency = DonationFrequency.getInstance();
10
10
  this._dataLayer = DataLayer.getInstance();
11
11
  this._suggestAmount = 0;
12
+ this._upsellFrequency = "monthly";
12
13
  this.logger = new EngridLogger("UpsellLightbox", "black", "pink", "🪟");
13
14
  let options = "EngridUpsell" in window ? window.EngridUpsell : {};
14
15
  this.options = Object.assign(Object.assign({}, UpsellOptionsDefaults), options);
@@ -26,23 +27,19 @@ export class UpsellLightbox {
26
27
  this.renderLightbox();
27
28
  this._form.onSubmit.subscribe(() => this.open());
28
29
  }
30
+ parseMergeTags(str) {
31
+ return str
32
+ .replace(/\{new-amount\}/g, "<span class='upsell_suggestion'></span>")
33
+ .replace(/\{new-frequency\}/g, "<span class='upsell_suggestion_frequency'></span>")
34
+ .replace(/\{old-amount\}/g, "<span class='upsell_amount'></span>")
35
+ .replace(/\{old-frequency\}/g, "<span class='upsell_frequency'></span>");
36
+ }
29
37
  renderLightbox() {
30
- const title = this.options.title
31
- .replace("{new-amount}", "<span class='upsell_suggestion'></span>")
32
- .replace("{old-amount}", "<span class='upsell_amount'></span>")
33
- .replace("{old-frequency}", "<span class='upsell_frequency'></span>");
34
- const paragraph = this.options.paragraph
35
- .replace("{new-amount}", "<span class='upsell_suggestion'></span>")
36
- .replace("{old-amount}", "<span class='upsell_amount'></span>")
37
- .replace("{old-frequency}", "<span class='upsell_frequency'></span>");
38
- const yes = this.options.yesLabel
39
- .replace("{new-amount}", "<span class='upsell_suggestion'></span>")
40
- .replace("{old-amount}", "<span class='upsell_amount'></span>")
41
- .replace("{old-frequency}", "<span class='upsell_frequency'></span>");
42
- const no = this.options.noLabel
43
- .replace("{new-amount}", "<span class='upsell_suggestion'></span>")
44
- .replace("{old-amount}", "<span class='upsell_amount'></span>")
45
- .replace("{old-frequency}", "<span class='upsell_frequency'></span>");
38
+ const title = this.parseMergeTags(this.options.title);
39
+ const paragraph = this.parseMergeTags(this.options.paragraph);
40
+ const yes = this.parseMergeTags(this.options.yesLabel);
41
+ const no = this.parseMergeTags(this.options.noLabel);
42
+ const other = this.parseMergeTags(this.options.otherLabel);
46
43
  const markup = `
47
44
  <div class="upsellLightboxContainer" id="goMonthly">
48
45
  <!-- ideal image size is 480x650 pixels -->
@@ -57,7 +54,7 @@ export class UpsellLightbox {
57
54
  <div class="upsellOtherAmount">
58
55
  <div class="upsellOtherAmountLabel">
59
56
  <p>
60
- ${this.options.otherLabel}
57
+ ${other}
61
58
  </p>
62
59
  </div>
63
60
  <div class="upsellOtherAmountInput">
@@ -140,7 +137,7 @@ export class UpsellLightbox {
140
137
  var _a, _b;
141
138
  const value = parseFloat((_b = (_a = this.overlay.querySelector("#secondOtherField")) === null || _a === void 0 ? void 0 : _a.value) !== null && _b !== void 0 ? _b : "");
142
139
  const live_upsell_amount = document.querySelectorAll("#upsellYesButton .upsell_suggestion");
143
- const upsellAmount = this.getUpsellAmount();
140
+ const { amount: upsellAmount } = this.resolveUpsell();
144
141
  if (!isNaN(value) && value > 0) {
145
142
  this.checkOtherAmount(value);
146
143
  }
@@ -152,59 +149,87 @@ export class UpsellLightbox {
152
149
  liveAmounts() {
153
150
  const live_upsell_amount = document.querySelectorAll(".upsell_suggestion");
154
151
  const live_amount = document.querySelectorAll(".upsell_amount");
155
- const upsellAmount = this.getUpsellAmount();
152
+ const { amount: upsellAmount } = this.resolveUpsell();
156
153
  const suggestedAmount = upsellAmount + this._fees.calculateFees(upsellAmount);
157
154
  live_upsell_amount.forEach((elem) => (elem.innerHTML = this.getAmountTxt(suggestedAmount)));
158
155
  live_amount.forEach((elem) => (elem.innerHTML = this.getAmountTxt(this._amount.amount + this._fees.fee)));
159
156
  }
160
157
  liveFrequency() {
161
158
  const live_upsell_frequency = document.querySelectorAll(".upsell_frequency");
159
+ const live_upsell_suggestion_frequency = document.querySelectorAll(".upsell_suggestion_frequency");
162
160
  live_upsell_frequency.forEach((elem) => (elem.innerHTML = this.getFrequencyTxt()));
161
+ live_upsell_suggestion_frequency.forEach((elem) => (elem.innerHTML = this.getFrequencyTxt(this._upsellFrequency)));
163
162
  }
164
- // Return the Suggested Upsell Amount
165
- getUpsellAmount() {
166
- var _a, _b;
163
+ // Resolve the upsell amount and target frequency in a single pass and keep
164
+ // the cached _suggestAmount / _upsellFrequency in sync with the current
165
+ // donation amount and any value entered in the "other amount" field.
166
+ resolveUpsell() {
167
+ var _a, _b, _c, _d, _e;
167
168
  const amount = this._amount.amount;
168
169
  const otherAmount = parseFloat((_b = (_a = this.overlay.querySelector("#secondOtherField")) === null || _a === void 0 ? void 0 : _a.value) !== null && _b !== void 0 ? _b : "");
170
+ const defaultFrequency = (_c = this.options.upsellToFrequency) !== null && _c !== void 0 ? _c : "monthly";
171
+ let upsellAmount;
172
+ let upsellFrequency;
169
173
  if (otherAmount > 0) {
170
- return otherAmount > this.options.minAmount
171
- ? otherAmount
172
- : this.options.minAmount;
174
+ // An "other" amount overrides the amount but keeps the frequency that
175
+ // was already shown when the lightbox opened
176
+ upsellAmount =
177
+ otherAmount > this.options.minAmount
178
+ ? otherAmount
179
+ : this.options.minAmount;
180
+ upsellFrequency = this._upsellFrequency;
173
181
  }
174
- let upsellAmount = 0;
175
- for (let i = 0; i < this.options.amountRange.length; i++) {
176
- let val = this.options.amountRange[i];
177
- if (upsellAmount == 0 && amount <= val.max) {
178
- upsellAmount = val.suggestion;
179
- if (upsellAmount === 0)
180
- return 0;
181
- if (typeof upsellAmount !== "number") {
182
- const suggestionMath = upsellAmount.replace("amount", amount.toFixed(2));
183
- upsellAmount = parseFloat(Function('"use strict";return (' + suggestionMath + ")")());
182
+ else {
183
+ upsellAmount = 0;
184
+ upsellFrequency = defaultFrequency;
185
+ for (let i = 0; i < this.options.amountRange.length; i++) {
186
+ const val = this.options.amountRange[i];
187
+ if (upsellAmount == 0 && amount <= val.max) {
188
+ if (val.suggestion === 0) {
189
+ upsellFrequency = (_d = val.frequency) !== null && _d !== void 0 ? _d : defaultFrequency;
190
+ this._suggestAmount = 0;
191
+ this._upsellFrequency = upsellFrequency;
192
+ return { amount: 0, frequency: upsellFrequency };
193
+ }
194
+ else if (typeof val.suggestion === "number") {
195
+ upsellAmount = val.suggestion;
196
+ }
197
+ else {
198
+ const suggestionMath = val.suggestion.replace("amount", amount.toFixed(2));
199
+ upsellAmount = parseFloat(Function('"use strict";return (' + suggestionMath + ")")());
200
+ }
201
+ upsellFrequency = (_e = val.frequency) !== null && _e !== void 0 ? _e : defaultFrequency;
202
+ break;
184
203
  }
185
- break;
186
204
  }
205
+ upsellAmount =
206
+ upsellAmount > this.options.minAmount
207
+ ? upsellAmount
208
+ : this.options.minAmount;
187
209
  }
188
- return upsellAmount > this.options.minAmount
189
- ? upsellAmount
190
- : this.options.minAmount;
210
+ this._suggestAmount = upsellAmount;
211
+ this._upsellFrequency = upsellFrequency;
212
+ return { amount: upsellAmount, frequency: upsellFrequency };
191
213
  }
192
214
  shouldOpen() {
193
- const upsellAmount = this.getUpsellAmount();
215
+ const { amount: upsellAmount, frequency: upsellFrequency } = this.resolveUpsell();
194
216
  const paymenttype = ENGrid.getFieldValue("transaction.paymenttype") || "";
195
- this._suggestAmount = upsellAmount;
196
- // If frequency is not onetime or
197
- // the modal is already opened or
198
- // there's no suggestion for this donation amount,
217
+ // If frequency is not allowed, or
218
+ // the modal is already opened, or
219
+ // there's no suggestion for this donation amount, or
220
+ // the target upsell frequency is not available on the form,
199
221
  // we should not open
200
222
  if (this.freqAllowed() &&
201
223
  !this.shouldSkip() &&
202
224
  !this.options.disablePaymentMethods.includes(paymenttype.toLowerCase()) &&
203
225
  !this.overlay.classList.contains("is-submitting") &&
204
- upsellAmount > 0) {
226
+ upsellAmount > 0 &&
227
+ this._frequency.frequencies.includes(upsellFrequency) &&
228
+ this._frequency.frequency !== upsellFrequency) {
205
229
  this.logger.log("Upsell Frequency " + this._frequency.frequency);
206
230
  this.logger.log("Upsell Amount " + this._amount.amount);
207
231
  this.logger.log("Upsell Suggested Amount " + upsellAmount);
232
+ this.logger.log("Upsell Suggested Frequency " + upsellFrequency);
208
233
  return true;
209
234
  }
210
235
  return false;
@@ -215,6 +240,8 @@ export class UpsellLightbox {
215
240
  const allowed = [];
216
241
  if (this.options.oneTime)
217
242
  allowed.push("onetime");
243
+ if (this.options.monthly)
244
+ allowed.push("monthly");
218
245
  if (this.options.annual)
219
246
  allowed.push("annual");
220
247
  return allowed.includes(freq);
@@ -274,27 +301,30 @@ export class UpsellLightbox {
274
301
  ((_a = document.querySelector("#upsellYesButton")) === null || _a === void 0 ? void 0 : _a.contains(e.target))) {
275
302
  this.logger.success("Upsold");
276
303
  this.setOriginalAmount(this._amount.amount.toString());
277
- const upsoldAmount = this.getUpsellAmount();
304
+ const { amount: upsoldAmount, frequency: upsellFrequency } = this.resolveUpsell();
278
305
  const originalAmount = this._amount.amount;
279
- this._frequency.setFrequency("monthly");
306
+ const originalFrequency = this._frequency.frequency;
307
+ this._frequency.setFrequency(upsellFrequency);
280
308
  this._amount.setAmount(upsoldAmount);
281
309
  this._dataLayer.addEndOfGiftProcessEvent("ENGRID_UPSELL", {
282
310
  eventValue: true,
311
+ originalFrequency: originalFrequency,
283
312
  originalAmount: originalAmount,
284
313
  upsoldAmount: upsoldAmount,
285
- frequency: "monthly",
314
+ frequency: upsellFrequency,
286
315
  });
287
316
  this._dataLayer.addEndOfGiftProcessVariable("ENGRID_UPSELL", true);
288
317
  this._dataLayer.addEndOfGiftProcessVariable("ENGRID_UPSELL_ORIGINAL_AMOUNT", originalAmount);
289
- this._dataLayer.addEndOfGiftProcessVariable("ENGRID_UPSELL_DONATION_FREQUENCY", "MONTHLY");
290
- this.renderConversionField("upsellSuccess", "onetime", originalAmount, "monthly", this._suggestAmount, "monthly", upsoldAmount);
318
+ this._dataLayer.addEndOfGiftProcessVariable("ENGRID_UPSELL_ORIGINAL_FREQUENCY", this.getFrequencyTxt(originalFrequency).toUpperCase());
319
+ this._dataLayer.addEndOfGiftProcessVariable("ENGRID_UPSELL_DONATION_FREQUENCY", this.getFrequencyTxt(upsellFrequency).toUpperCase());
320
+ this.renderConversionField("upsellSuccess", originalFrequency, originalAmount, upsellFrequency, this._suggestAmount, upsellFrequency, upsoldAmount);
291
321
  }
292
322
  else {
293
323
  this.setOriginalAmount("");
294
324
  window.sessionStorage.removeItem("original");
295
325
  this._dataLayer.addEndOfGiftProcessVariable("ENGRID_UPSELL", false);
296
- this._dataLayer.addEndOfGiftProcessVariable("ENGRID_UPSELL_DONATION_FREQUENCY", "ONE-TIME");
297
- this.renderConversionField("upsellFail", this._frequency.frequency, this._amount.amount, "monthly", this._suggestAmount, this._frequency.frequency, this._amount.amount);
326
+ this._dataLayer.addEndOfGiftProcessVariable("ENGRID_UPSELL_DONATION_FREQUENCY", this.getFrequencyTxt(this._frequency.frequency).toUpperCase());
327
+ this.renderConversionField("upsellFail", this._frequency.frequency, this._amount.amount, this._upsellFrequency, this._suggestAmount, this._frequency.frequency, this._amount.amount);
298
328
  }
299
329
  this._form.submitForm();
300
330
  }
@@ -304,7 +334,7 @@ export class UpsellLightbox {
304
334
  this.overlay.classList.add("is-hidden");
305
335
  ENGrid.setBodyData("has-lightbox", false);
306
336
  if (this.options.submitOnClose) {
307
- this.renderConversionField("upsellFail", this._frequency.frequency, this._amount.amount, "monthly", this._suggestAmount, this._frequency.frequency, this._amount.amount);
337
+ this.renderConversionField("upsellFail", this._frequency.frequency, this._amount.amount, this._upsellFrequency, this._suggestAmount, this._frequency.frequency, this._amount.amount);
308
338
  this._form.submitForm();
309
339
  }
310
340
  else {
@@ -320,14 +350,16 @@ export class UpsellLightbox {
320
350
  const amountTxt = ENGrid.formatNumber(amount, dec_places, dec_separator, thousands_separator);
321
351
  return amount > 0 ? symbol + amountTxt : "";
322
352
  }
323
- getFrequencyTxt() {
353
+ getFrequencyTxt(frequency = this._frequency.frequency) {
324
354
  const freqTxt = {
325
355
  onetime: "one-time",
326
356
  monthly: "monthly",
357
+ quarterly: "quarterly",
358
+ semi_annual: "semi-annual",
327
359
  annual: "annual",
328
360
  };
329
- const frequency = this._frequency.frequency;
330
- return frequency in freqTxt ? freqTxt[frequency] : frequency;
361
+ const freq = frequency;
362
+ return freq in freqTxt ? freqTxt[freq] : frequency;
331
363
  }
332
364
  checkOtherAmount(value) {
333
365
  const otherInput = document.querySelector(".upsellOtherAmountInput");
@@ -343,7 +375,7 @@ export class UpsellLightbox {
343
375
  renderConversionField(event, // The event that triggered the conversion
344
376
  freq, // The frequency of the donation (onetime, monthly, annual)
345
377
  amt, // The original amount of the donation (before the upsell)
346
- sugFreq, // The suggested frequency of the upsell (monthly)
378
+ sugFreq, // The suggested frequency of the upsell
347
379
  sugAmt, // The suggested amount of the upsell
348
380
  subFreq, // The submitted frequency of the upsell (onetime, monthly, annual)
349
381
  subAmt // The submitted amount of the upsell
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const AppVersion = "0.27.1";
1
+ export declare const AppVersion = "0.27.3";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const AppVersion = "0.27.1";
1
+ export const AppVersion = "0.27.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4site/engrid-scripts",
3
- "version": "0.27.1",
3
+ "version": "0.27.3",
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": "b44fa74fbb36e60b9fbfb8be30d041ff9c3b7b0d",
28
+ "gitHead": "b117056008fd80443843cefd5178ad9942a59843",
29
29
  "devDependencies": {
30
30
  "nodemon": "^2.0.20"
31
31
  }