PluginProbe
Mailchimp List Subscribe Form / trunk
Mailchimp List Subscribe Form vtrunk
1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 1.3 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 All 55 releases
mailchimp / assets / js / analytics.js

analytics.js in Mailchimp List Subscribe Form trunk, at assets/js/analytics.js

1,710 lines 43.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Mailchimp Analytics Page JavaScript
3 *
4 * @package Mailchimp
5 */
6
7 /*
8 * External dependencies
9 */
10 import { Datepicker } from 'vanillajs-datepicker';
11 import 'vanillajs-datepicker/css/datepicker.css'; // eslint-disable-line import/no-unresolved
12 import '../css/analytics.scss';
13
14 /**
15 * WordPress dependencies
16 */
17 import { __ } from '@wordpress/i18n';
18
19 (function () {
20 /**
21 * `true` when the user has set the OS-level "Reduce motion" preference.
22 * Used to disable Chart.js animations
23 */
24 const PREFERS_REDUCED_MOTION =
25 typeof window.matchMedia === 'function' &&
26 window.matchMedia('(prefers-reduced-motion: reduce)').matches;
27
28 const dateRangeSelect = document.getElementById('mailchimp-sf-date-range');
29 const dateFrom = document.getElementById('mailchimp-sf-date-from');
30 const dateTo = document.getElementById('mailchimp-sf-date-to');
31 const listFilter = document.getElementById('mailchimp-sf-list-filter');
32 const trigger = document.getElementById('mailchimp-sf-date-picker-trigger');
33 const triggerLabel = document.getElementById('mailchimp-sf-date-picker-label');
34 const popover = document.getElementById('mailchimp-sf-date-picker-popover');
35 const cancelBtn = document.getElementById('mailchimp-sf-date-picker-cancel');
36 const applyBtn = document.getElementById('mailchimp-sf-date-picker-apply');
37 const datePickerWrap = trigger ? trigger.closest('.mailchimp-sf-date-picker') : null;
38 const settings = window.mailchimpSFAnalytics || {};
39 const PHP_DATE_FORMAT = settings.dateFormat || 'Y-m-d';
40 const START_OF_WEEK = Number.isFinite(settings.startOfWeek) ? settings.startOfWeek : 0;
41
42 /**
43 * Translate a WordPress (PHP `date()`) format string
44 *
45 * @param {string} php PHP date format (e.g. `F j, Y` or `d/m/Y`).
46 * @returns {string} Equivalent vanillajs-datepicker format string.
47 */
48 function phpToDatepickerFormat(php) {
49 const map = {
50 Y: 'yyyy',
51 y: 'yyyy',
52 F: 'MM',
53 M: 'M',
54 m: 'mm',
55 n: 'm',
56 d: 'dd',
57 j: 'd',
58 D: 'D',
59 l: 'DD',
60 };
61 let out = '';
62 for (let i = 0; i < php.length; i++) {
63 const c = php.charAt(i);
64 if (c === '\\' && i + 1 < php.length) {
65 // PHP escape: emit the next character literally.
66 out += php.charAt(i + 1);
67 i += 1;
68 } else {
69 out += Object.prototype.hasOwnProperty.call(map, c) ? map[c] : c;
70 }
71 }
72 return out;
73 }
74
75 const DATEPICKER_FORMAT = phpToDatepickerFormat(PHP_DATE_FORMAT);
76
77 // Initialize datepicker only when both inputs are present.
78 let fromDatepicker = null;
79 let toDatepicker = null;
80
81 if (dateFrom && dateTo) {
82 fromDatepicker = new Datepicker(dateFrom, {
83 maxView: 0,
84 format: DATEPICKER_FORMAT,
85 autohide: true,
86 maxDate: new Date(),
87 weekStart: START_OF_WEEK,
88 });
89
90 toDatepicker = new Datepicker(dateTo, {
91 maxView: 0,
92 format: DATEPICKER_FORMAT,
93 autohide: true,
94 maxDate: new Date(),
95 weekStart: START_OF_WEEK,
96 });
97 }
98 const PRESET_VALUES = ['7', '30', '90', '180', '365'];
99
100 let appliedState = {
101 preset: '30',
102 from: '',
103 to: '',
104 };
105
106 /**
107 * Format a Date object to a YYYY-MM-DD string in local time.
108 *
109 * @param {Date} date Date to format.
110 * @returns {string} Date string in YYYY-MM-DD format.
111 */
112 function toLocalDateString(date) {
113 const year = date.getFullYear();
114 const month = String(date.getMonth() + 1).padStart(2, '0');
115 const day = String(date.getDate()).padStart(2, '0');
116 return `${year}-${month}-${day}`;
117 }
118
119 /**
120 * Parse an ISO `YYYY-MM-DD` string into a local-calendar Date object.
121 *
122 * @param {string} iso ISO date string.
123 * @returns {Date}
124 */
125 function isoToLocalDate(iso) {
126 const parts = iso.split('-').map(Number);
127 return new Date(parts[0], parts[1] - 1, parts[2]);
128 }
129
130 /**
131 * Format a date string
132 *
133 * @param {string} dateStr Date string in YYYY-MM-DD format.
134 * @returns {string} Date formatted per the site's `date_format` option.
135 */
136 function formatDisplayDate(dateStr) {
137 const parts = dateStr.split('-').map(Number);
138 const date = new Date(parts[0], parts[1] - 1, parts[2]);
139 return Datepicker.formatDate(date, DATEPICKER_FORMAT);
140 }
141
142 /**
143 * Read a datepicker's selected date as ISO `YYYY-MM-DD`
144 *
145 * @param {Datepicker} datepicker vanillajs-datepicker instance.
146 * @returns {string} ISO date or empty string.
147 */
148 function getDatepickerIso(datepicker) {
149 if (!datepicker || typeof datepicker.getDate !== 'function') {
150 return '';
151 }
152 const d = datepicker.getDate();
153 return d instanceof Date ? toLocalDateString(d) : '';
154 }
155
156 /**
157 * Get the label text for the selected preset.
158 *
159 * @param {string} value The preset value.
160 * @returns {string} The label text.
161 */
162 function getPresetLabel(value) {
163 if (!dateRangeSelect) {
164 return '';
165 }
166 const option = dateRangeSelect.querySelector(`option[value="${value}"]`);
167 return option ? option.textContent.trim() : '';
168 }
169
170 /**
171 * Inclusive last-N-days range ending today (local calendar).
172 *
173 * @param {string} presetValue Numeric preset id (e.g. "7", "30").
174 * @returns {{ from: string, to: string }|null} Range or null if not a numeric preset.
175 */
176 function getRangeForPreset(presetValue) {
177 if (presetValue === 'custom') {
178 return null;
179 }
180 const days = parseInt(presetValue, 10);
181 if (Number.isNaN(days) || days < 1) {
182 return null;
183 }
184 const to = new Date();
185 const from = new Date();
186 from.setDate(from.getDate() - (days - 1));
187 return { from: toLocalDateString(from), to: toLocalDateString(to) };
188 }
189
190 /**
191 * Get the resolved date range based on current applied state.
192 *
193 * @returns {{ from: string, to: string }|null} Date range strings (YYYY-MM-DD) or null.
194 */
195 function getDateRange() {
196 if (appliedState.preset === 'custom') {
197 if (!appliedState.from || !appliedState.to) {
198 return null;
199 }
200 return { from: appliedState.from, to: appliedState.to };
201 }
202
203 return getRangeForPreset(appliedState.preset);
204 }
205
206 /**
207 * Populate start/end inputs from applied state (when opening popover).
208 */
209 function syncDateInputs() {
210 const range = getDateRange();
211 if (range && fromDatepicker && toDatepicker) {
212 fromDatepicker.setDate(isoToLocalDate(range.from));
213 toDatepicker.setDate(isoToLocalDate(range.to));
214 }
215 }
216
217 /**
218 * Fill date inputs from a preset (popover: user picked a non-custom range).
219 *
220 * @param {string} presetVal Preset value.
221 */
222 function applyPresetToInputs(presetVal) {
223 if (!fromDatepicker || !toDatepicker || presetVal === 'custom') {
224 return;
225 }
226 const range = getRangeForPreset(presetVal);
227 if (range) {
228 fromDatepicker.setDate(isoToLocalDate(range.from));
229 toDatepicker.setDate(isoToLocalDate(range.to));
230 }
231 }
232
233 /**
234 * If current inputs match a rolling preset for today, select it; otherwise Custom.
235 */
236 function syncSelectFromDateInputs() {
237 if (!dateRangeSelect || !fromDatepicker || !toDatepicker) {
238 return;
239 }
240
241 if (dateFrom.value) {
242 fromDatepicker.setDate(dateFrom.value);
243 }
244 if (dateTo.value) {
245 toDatepicker.setDate(dateTo.value);
246 }
247
248 const fromIso = getDatepickerIso(fromDatepicker);
249 const toIso = getDatepickerIso(toDatepicker);
250 if (!fromIso || !toIso) {
251 return;
252 }
253
254 for (let i = 0; i < PRESET_VALUES.length; i++) {
255 const preset = PRESET_VALUES[i];
256 const range = getRangeForPreset(preset);
257 if (range && range.from === fromIso && range.to === toIso) {
258 dateRangeSelect.value = preset;
259 return;
260 }
261 }
262 dateRangeSelect.value = 'custom';
263 }
264
265 /**
266 * Update the trigger button label.
267 */
268 function updateTriggerLabel() {
269 if (!triggerLabel) {
270 return;
271 }
272 if (appliedState.preset === 'custom') {
273 if (appliedState.from && appliedState.to) {
274 triggerLabel.textContent = `${formatDisplayDate(appliedState.from)} \u2013 ${formatDisplayDate(appliedState.to)}`;
275 }
276 } else {
277 triggerLabel.textContent = getPresetLabel(appliedState.preset);
278 }
279 }
280
281 /**
282 * Set the popover and date picker wrap open state.
283 *
284 * @param {boolean} open Whether the popover is visible.
285 */
286 function setPopoverOpen(open) {
287 if (popover) {
288 popover.classList.toggle('is-open', open);
289 }
290 if (datePickerWrap) {
291 datePickerWrap.classList.toggle('is-open', open);
292 }
293 if (trigger) {
294 trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
295 }
296 }
297
298 /**
299 * Toggle the popover open/closed.
300 */
301 function togglePopover() {
302 if (!popover) {
303 return;
304 }
305 const isOpen = popover.classList.contains('is-open');
306 if (isOpen) {
307 setPopoverOpen(false);
308 } else {
309 syncDateInputs();
310 if (dateRangeSelect) {
311 dateRangeSelect.value = appliedState.preset;
312 }
313 setPopoverOpen(true);
314 }
315 }
316
317 /**
318 * Close the popover without applying.
319 */
320 function closePopover() {
321 setPopoverOpen(false);
322 }
323
324 /**
325 * Apply the selected date range and close the popover.
326 */
327 function applyDateRange() {
328 if (!dateRangeSelect) {
329 return;
330 }
331
332 const preset = dateRangeSelect.value;
333
334 if (preset === 'custom') {
335 const fromIso = getDatepickerIso(fromDatepicker);
336 const toIso = getDatepickerIso(toDatepicker);
337 if (!fromIso || !toIso) {
338 return;
339 }
340 if (fromIso > toIso) {
341 return;
342 }
343 appliedState = {
344 preset: 'custom',
345 from: fromIso,
346 to: toIso,
347 };
348 } else {
349 appliedState = {
350 preset,
351 from: '',
352 to: '',
353 };
354 }
355
356 updateTriggerLabel();
357 closePopover();
358 // eslint-disable-next-line no-use-before-define
359 refreshAnalytics();
360 }
361
362 /**
363 * Refresh analytics sections when filters change.
364 */
365 function refreshAnalytics() {
366 const range = getDateRange();
367 const listId = listFilter ? listFilter.value : '';
368
369 if (!range) {
370 return;
371 }
372
373 const event = new CustomEvent('mailchimp-analytics-refresh', {
374 detail: {
375 from: range.from,
376 to: range.to,
377 listId,
378 },
379 });
380 document.dispatchEvent(event);
381 }
382
383 // Bind events.
384 if (trigger) {
385 trigger.setAttribute('aria-expanded', 'false');
386 trigger.addEventListener('click', togglePopover);
387 }
388
389 if (cancelBtn) {
390 cancelBtn.addEventListener('click', closePopover);
391 }
392
393 if (applyBtn) {
394 applyBtn.addEventListener('click', applyDateRange);
395 }
396
397 if (dateRangeSelect) {
398 dateRangeSelect.addEventListener('change', function () {
399 if (dateRangeSelect.value === 'custom') {
400 return;
401 }
402 applyPresetToInputs(dateRangeSelect.value);
403 });
404 }
405
406 if (dateFrom) {
407 dateFrom.addEventListener('change', syncSelectFromDateInputs);
408 dateFrom.addEventListener('changeDate', syncSelectFromDateInputs);
409 }
410 if (dateTo) {
411 dateTo.addEventListener('change', syncSelectFromDateInputs);
412 dateTo.addEventListener('changeDate', syncSelectFromDateInputs);
413 }
414
415 if (listFilter) {
416 listFilter.addEventListener('change', refreshAnalytics);
417 }
418
419 // Close popover when clicking outside.
420 document.addEventListener('click', function (e) {
421 if (
422 popover &&
423 popover.classList.contains('is-open') &&
424 !popover.contains(e.target) &&
425 trigger &&
426 !trigger.contains(e.target)
427 ) {
428 closePopover();
429 }
430 });
431
432 // Close popover on Escape and return focus to the trigger button
433 document.addEventListener('keydown', function (e) {
434 if (e.key !== 'Escape') {
435 return;
436 }
437 if (!popover || !popover.classList.contains('is-open')) {
438 return;
439 }
440
441 const openCalendar = document.querySelector('.datepicker.active');
442 if (openCalendar) {
443 return;
444 }
445 closePopover();
446 if (trigger) {
447 trigger.focus();
448 }
449 });
450
451 /**
452 * Forms performance over time
453 */
454 (function formPerformanceModule() {
455 const section = document.querySelector('[data-section="form-performance"]');
456 if (!section) {
457 return;
458 }
459
460 const chartCanvas = document.getElementById('mailchimp-sf-fp-line');
461 const dateRangeEl = document.getElementById('mailchimp-sf-fp-daterange');
462 const overlayEl = document.getElementById('mailchimp-sf-fp-overlay');
463 const errorBannerEl = document.getElementById('mailchimp-sf-fp-error-banner');
464 const errorMessageEl = document.getElementById('mailchimp-sf-fp-error-message');
465 const retryBtnEl = document.getElementById('mailchimp-sf-fp-error-retry');
466 const dataTableEl = document.getElementById('mailchimp-sf-fp-data-table');
467
468 const COLORS = {
469 viewsFill: '#3B82F6',
470 viewsBorder: '#2563EB',
471 submissionsFill: '#0E9384',
472 submissionsBorder: '#0B7A6E',
473 rateBorder: '#A88008',
474 gridLine: 'rgba(0, 0, 0, 0.06)',
475 text: '#6B7280',
476 // Legend chip fills — translucent version of each bar color so the
477 // legend markers match the outlined-chip style from the Figma spec.
478 viewsLegendFill: 'rgba(59, 130, 246, 0.35)',
479 submissionsLegendFill: 'rgba(14, 147, 132, 0.35)',
480 };
481
482 const STRINGS = {
483 loadingSubtitle: __('Loading form performance…', 'mailchimp'),
484 loadingOverlay: __('Loading form performance…', 'mailchimp'),
485 emptySubtitle: __('No submissions recorded for the selected date range', 'mailchimp'),
486 emptyOverlay: __('No data available for this date range', 'mailchimp'),
487 errorDefault: __(
488 'Unable to load data for the selected date range. Please check your connection and try again.',
489 'mailchimp',
490 ),
491 reconnect: __('Reconnect Mailchimp Account', 'mailchimp'),
492 views: __('Form Views', 'mailchimp'),
493 submissions: __('Submissions', 'mailchimp'),
494 conversionRate: __('Conversion Rate', 'mailchimp'),
495 };
496
497 let lastErrorCode = '';
498
499 const STATE_CLASSES = ['is-loading', 'is-ready', 'is-empty', 'is-error'];
500
501 let chart = null;
502 let inFlight = null;
503 let lastDetail = null;
504
505 function setState(state) {
506 STATE_CLASSES.forEach(function (cls) {
507 section.classList.toggle(cls, cls === `is-${state}`);
508 });
509 }
510
511 function setOverlay(text) {
512 if (overlayEl) {
513 overlayEl.textContent = text || '';
514 }
515 }
516
517 function setSubtitle(text) {
518 if (dateRangeEl) {
519 dateRangeEl.textContent = text || '';
520 }
521 }
522
523 /**
524 * Build the visually-hidden screen-reader data table for the chart.
525 *
526 * @param {Array} rows Bucket rows from the payload.
527 * @param {string} fromLabel Range start (Y-m-d).
528 * @param {string} toLabel Range end (Y-m-d).
529 */
530 function renderDataTable(rows, fromLabel, toLabel) {
531 if (!dataTableEl) {
532 return;
533 }
534
535 const captionText = __(
536 'List performance over time: views, submissions, and conversion rate per bucket for %1$s to %2$s.',
537 'mailchimp',
538 )
539 .replace('%1$s', fromLabel)
540 .replace('%2$s', toLabel);
541
542 const headerCells = [
543 __('Period', 'mailchimp'),
544 __('Form Views', 'mailchimp'),
545 __('Submissions', 'mailchimp'),
546 __('Conversion Rate', 'mailchimp'),
547 ];
548
549 const table = document.createElement('table');
550
551 const caption = document.createElement('caption');
552 caption.textContent = captionText;
553 table.appendChild(caption);
554
555 const thead = document.createElement('thead');
556 const headRow = document.createElement('tr');
557 headerCells.forEach(function (text) {
558 const th = document.createElement('th');
559 th.scope = 'col';
560 th.textContent = text;
561 headRow.appendChild(th);
562 });
563 thead.appendChild(headRow);
564 table.appendChild(thead);
565
566 const tbody = document.createElement('tbody');
567 rows.forEach(function (row) {
568 const tr = document.createElement('tr');
569
570 const rowHeader = document.createElement('th');
571 rowHeader.scope = 'row';
572 rowHeader.textContent = row.label || '';
573 tr.appendChild(rowHeader);
574
575 [
576 String(row.views || 0),
577 String(row.submissions || 0),
578 `${Number(row.conversion_rate || 0).toFixed(2)}%`,
579 ].forEach(function (text) {
580 const td = document.createElement('td');
581 td.textContent = text;
582 tr.appendChild(td);
583 });
584
585 tbody.appendChild(tr);
586 });
587 table.appendChild(tbody);
588
589 dataTableEl.innerHTML = '';
590 dataTableEl.appendChild(table);
591 }
592
593 /**
594 * Empty the screen-reader data table
595 */
596 function clearDataTable() {
597 if (dataTableEl) {
598 dataTableEl.innerHTML = '';
599 }
600 }
601
602 function destroyCharts() {
603 if (chart) {
604 chart.destroy();
605 chart = null;
606 }
607 }
608
609 function formatRangeLabel(from, to) {
610 try {
611 const fromDate = new Date(`${from}T00:00:00`);
612 const toDate = new Date(`${to}T00:00:00`);
613 const fmt = new Intl.DateTimeFormat(undefined, {
614 month: 'short',
615 day: 'numeric',
616 year: 'numeric',
617 });
618 return `\u2066${fmt.format(fromDate)} – ${fmt.format(toDate)}\u2069`;
619 } catch (err) {
620 return `\u2066${from} – ${to}\u2069`;
621 }
622 }
623
624 function setErrorBanner(visible, message, errorCode) {
625 if (!errorBannerEl) {
626 return;
627 }
628 lastErrorCode = visible ? errorCode || '' : '';
629 if (visible) {
630 if (errorMessageEl) {
631 errorMessageEl.textContent = message || STRINGS.errorDefault;
632 }
633 if (retryBtnEl) {
634 const settingsUrl =
635 (window.mailchimpSFAnalytics && window.mailchimpSFAnalytics.settingsUrl) ||
636 '';
637 if (lastErrorCode === 'not_connected' && settingsUrl) {
638 retryBtnEl.textContent = STRINGS.reconnect;
639 retryBtnEl.hidden = false;
640 } else {
641 retryBtnEl.hidden = true;
642 }
643 }
644 errorBannerEl.hidden = false;
645 } else {
646 if (retryBtnEl) {
647 retryBtnEl.hidden = true;
648 }
649 errorBannerEl.hidden = true;
650 }
651 }
652
653 function showLoading() {
654 destroyCharts();
655 clearDataTable();
656 setErrorBanner(false);
657 setOverlay(STRINGS.loadingOverlay);
658 setSubtitle(STRINGS.loadingSubtitle);
659 setState('loading');
660 }
661
662 function showEmpty() {
663 destroyCharts();
664 clearDataTable();
665 setErrorBanner(false);
666 setOverlay(STRINGS.emptyOverlay);
667 setSubtitle(STRINGS.emptySubtitle);
668 setState('empty');
669 }
670
671 function showError(message, errorCode) {
672 destroyCharts();
673 clearDataTable();
674 setOverlay('');
675 if (lastDetail && lastDetail.from && lastDetail.to) {
676 setSubtitle(formatRangeLabel(lastDetail.from, lastDetail.to));
677 }
678 setErrorBanner(true, message, errorCode);
679 setState('error');
680 }
681
682 /**
683 * Render the bar+line chart from API rows.
684 *
685 * @param {Array} rows Payload `data` rows from the API.
686 */
687 function renderChart(rows) {
688 if (!chartCanvas || typeof window.Chart === 'undefined') {
689 return;
690 }
691
692 const labels = rows.map(function (r) {
693 return r.label;
694 });
695 const views = rows.map(function (r) {
696 return r.views || 0;
697 });
698 const submissions = rows.map(function (r) {
699 return r.submissions || 0;
700 });
701 const rate = rows.map(function (r) {
702 return r.conversion_rate || 0;
703 });
704
705 chart = new window.Chart(chartCanvas.getContext('2d'), {
706 type: 'bar',
707 data: {
708 labels,
709 datasets: [
710 {
711 type: 'bar',
712 label: STRINGS.views,
713 data: views,
714 backgroundColor: COLORS.viewsFill,
715 borderColor: COLORS.viewsBorder,
716 borderWidth: 0,
717 borderRadius: 0,
718 maxBarThickness: 22,
719 order: 2,
720 yAxisID: 'y',
721 },
722 {
723 type: 'bar',
724 label: STRINGS.submissions,
725 data: submissions,
726 backgroundColor: COLORS.submissionsFill,
727 borderColor: COLORS.submissionsBorder,
728 borderWidth: 0,
729 borderRadius: 0,
730 maxBarThickness: 22,
731 order: 2,
732 yAxisID: 'y',
733 },
734 {
735 type: 'line',
736 label: STRINGS.conversionRate,
737 data: rate,
738 borderColor: COLORS.rateBorder,
739 backgroundColor: COLORS.rateBorder,
740 borderWidth: 2,
741 pointBackgroundColor: COLORS.rateBorder,
742 pointBorderColor: COLORS.rateBorder,
743 pointRadius: 3,
744 pointHoverRadius: 5,
745 tension: 0.1,
746 fill: false,
747 order: 1,
748 yAxisID: 'y1',
749 },
750 ],
751 },
752 options: {
753 responsive: true,
754 maintainAspectRatio: false,
755 animation: PREFERS_REDUCED_MOTION ? false : undefined,
756 interaction: { mode: 'index', intersect: false },
757 plugins: {
758 legend: {
759 position: 'top',
760 align: 'center',
761 labels: {
762 usePointStyle: true,
763 pointStyleWidth: 36,
764 boxHeight: 20,
765 padding: 24,
766 color: COLORS.text,
767 generateLabels(ci) {
768 const legendFills = [
769 COLORS.viewsLegendFill,
770 COLORS.submissionsLegendFill,
771 ];
772 return ci.data.datasets.map(function (dataset, i) {
773 const isLine = dataset.type === 'line';
774 return {
775 text: dataset.label,
776 fillStyle: isLine
777 ? 'transparent'
778 : legendFills[i] || dataset.backgroundColor,
779 strokeStyle: isLine
780 ? dataset.borderColor
781 : dataset.backgroundColor,
782 lineWidth: 2,
783 pointStyle: isLine ? 'line' : 'rect',
784 hidden: !ci.isDatasetVisible(i),
785 datasetIndex: i,
786 };
787 });
788 },
789 },
790 },
791 tooltip: {
792 callbacks: {
793 label(ctx) {
794 const value = ctx.parsed.y || 0;
795 if (ctx.dataset.yAxisID === 'y1') {
796 return `${ctx.dataset.label}: ${value.toFixed(1)}%`;
797 }
798 return `${ctx.dataset.label}: ${value}`;
799 },
800 },
801 },
802 },
803 scales: {
804 x: {
805 grid: {
806 color: COLORS.gridLine,
807 drawBorder: false,
808 drawTicks: false,
809 },
810 ticks: { color: COLORS.text },
811 },
812 y: {
813 type: 'linear',
814 position: 'left',
815 beginAtZero: true,
816 grid: { color: COLORS.gridLine, drawBorder: false },
817 ticks: { color: COLORS.text, precision: 0 },
818 },
819 y1: {
820 type: 'linear',
821 position: 'right',
822 beginAtZero: true,
823 max: 110,
824 grid: { drawOnChartArea: false },
825 ticks: {
826 color: COLORS.text,
827 stepSize: 10,
828 callback(value) {
829 return value > 100 ? '' : `${value}%`;
830 },
831 },
832 },
833 },
834 },
835 });
836 }
837
838 function render(payload, fromLabel, toLabel) {
839 destroyCharts();
840 setErrorBanner(false);
841
842 const rows = Array.isArray(payload.data) ? payload.data : [];
843 const totalViews = payload.total_views || 0;
844 const totalSubs = payload.total_submissions || 0;
845
846 // Empty when there's literally no tracked activity for the range.
847 if (rows.length === 0 || (totalViews === 0 && totalSubs === 0)) {
848 showEmpty();
849 return;
850 }
851
852 setSubtitle(formatRangeLabel(fromLabel, toLabel));
853 setOverlay('');
854 setState('ready');
855 renderChart(rows);
856 renderDataTable(rows, fromLabel, toLabel);
857 }
858
859 /**
860 * Custom event so other analytics modules (Audience
861 * Overview, etc.) can render from the same fetch without making
862 * their own AJAX call.
863 *
864 * @param {string} name Event suffix — appended to `mailchimp-analytics-`.
865 * @param {object} eventDetail Payload passed as the event's `detail`.
866 */
867 function broadcast(name, eventDetail) {
868 document.dispatchEvent(
869 new CustomEvent(`mailchimp-analytics-${name}`, { detail: eventDetail }),
870 );
871 }
872
873 function fetchPerformance(detail) {
874 if (!window.mailchimpSFAnalytics || !window.mailchimpSFAnalytics.ajax_url) {
875 showError();
876 broadcast('error', { message: STRINGS.errorDefault });
877 return;
878 }
879 if (!detail || !detail.listId || !detail.from || !detail.to) {
880 showEmpty();
881 return;
882 }
883
884 lastDetail = {
885 listId: detail.listId,
886 from: detail.from,
887 to: detail.to,
888 };
889
890 if (inFlight && typeof inFlight.abort === 'function') {
891 inFlight.abort();
892 }
893
894 const controller =
895 typeof window.AbortController !== 'undefined' ? new AbortController() : null;
896 inFlight = controller;
897
898 const formData = new FormData();
899 formData.append('action', 'mailchimp_sf_get_form_performance');
900 formData.append('nonce', window.mailchimpSFAnalytics.nonce);
901 formData.append('list_id', detail.listId);
902 formData.append('date_from', detail.from);
903 formData.append('date_to', detail.to);
904
905 showLoading();
906 broadcast('loading', { from: detail.from, to: detail.to });
907
908 fetch(window.mailchimpSFAnalytics.ajax_url, {
909 method: 'POST',
910 body: formData,
911 credentials: 'same-origin',
912 signal: controller ? controller.signal : undefined,
913 })
914 .then(function (response) {
915 return response.json().catch(function () {
916 return null;
917 });
918 })
919 .then(function (body) {
920 inFlight = null;
921 if (!body || body.success !== true || !body.data) {
922 const message =
923 body && body.data && body.data.message ? body.data.message : '';
924 const errorCode =
925 body && body.data && body.data.error_code ? body.data.error_code : '';
926 showError(message, errorCode);
927 broadcast('error', {
928 message: message || STRINGS.errorDefault,
929 errorCode,
930 });
931 return;
932 }
933 render(body.data, detail.from, detail.to);
934 broadcast('loaded', {
935 data: body.data,
936 from: detail.from,
937 to: detail.to,
938 });
939 })
940 .catch(function (err) {
941 if (err && err.name === 'AbortError') {
942 return;
943 }
944 inFlight = null;
945 showError();
946 broadcast('error', { message: STRINGS.errorDefault });
947 });
948 }
949
950 if (retryBtnEl) {
951 retryBtnEl.addEventListener('click', function () {
952 if (lastErrorCode === 'not_connected') {
953 const settingsUrl =
954 (window.mailchimpSFAnalytics && window.mailchimpSFAnalytics.settingsUrl) ||
955 '';
956 if (settingsUrl) {
957 window.location.href = settingsUrl;
958 }
959 return;
960 }
961 if (lastDetail) {
962 fetchPerformance(lastDetail);
963 }
964 });
965 }
966
967 document.addEventListener('mailchimp-analytics-refresh', function (e) {
968 fetchPerformance(e.detail);
969 });
970 })();
971
972 /**
973 * Subscriber change over time — diverging bar + totals donut.
974 * Loads independently from other analytics sections so an API error in
975 * this section does not affect KPIs or Form Performance.
976 */
977 (function subscriberActivityModule() {
978 const section = document.querySelector('[data-section="subscriber-activity"]');
979 if (!section) {
980 return;
981 }
982
983 const barCanvas = document.getElementById('mailchimp-sf-sa-bar');
984 const donutCanvas = document.getElementById('mailchimp-sf-sa-donut');
985 const netEl = document.getElementById('mailchimp-sf-sa-net');
986 const totalNewEl = document.getElementById('mailchimp-sf-sa-total-new');
987 const totalUnsubsEl = document.getElementById('mailchimp-sf-sa-total-unsubs');
988 const dateRangeEl = document.getElementById('mailchimp-sf-sa-daterange');
989 const noticeEl = document.getElementById('mailchimp-sf-sa-notice');
990 const overlayEl = document.getElementById('mailchimp-sf-sa-overlay');
991 const errorBannerEl = document.getElementById('mailchimp-sf-sa-error-banner');
992 const errorMessageEl = document.getElementById('mailchimp-sf-sa-error-message');
993 const retryBtnEl = document.getElementById('mailchimp-sf-sa-error-retry');
994 const dataTableEl = document.getElementById('mailchimp-sf-sa-data-table');
995
996 const COLORS = {
997 newFill: '#2b72fb',
998 newBorder: '#2b72fb',
999 unsubFill: '#fa4b42',
1000 unsubBorder: '#fa4b42',
1001 gridLine: 'rgba(0, 0, 0, 0.06)',
1002 zeroLine: 'rgba(0, 0, 0, 0.25)',
1003 text: '#6B7280',
1004 };
1005
1006 const EM_DASH = '\u2014';
1007
1008 const STRINGS = {
1009 loadingSubtitle: __('Loading subscriber activity…', 'mailchimp'),
1010 loadingOverlay: __('Loading subscriber activity…', 'mailchimp'),
1011 emptySubtitle: __('No data available for the selected date range', 'mailchimp'),
1012 emptyOverlay: __('No data available for this date range', 'mailchimp'),
1013 errorDefault: __(
1014 'Unable to load data for the selected date range. Please check your connection and try again.',
1015 'mailchimp',
1016 ),
1017 limited: __(
1018 'Mailchimp subscriber activity is only available for the last 180 days. Showing available data.',
1019 'mailchimp',
1020 ),
1021 reconnect: __('Reconnect Mailchimp Account', 'mailchimp'),
1022 newSubscribers: __('New Subscribers', 'mailchimp'),
1023 unsubscribes: __('Unsubscribes', 'mailchimp'),
1024 };
1025
1026 const STATE_CLASSES = ['is-loading', 'is-ready', 'is-empty', 'is-error'];
1027
1028 let barChart = null;
1029 let donutChart = null;
1030 let inFlight = null;
1031 let lastDetail = null;
1032 let lastErrorCode = '';
1033
1034 function setState(state) {
1035 STATE_CLASSES.forEach(function (cls) {
1036 section.classList.toggle(cls, cls === `is-${state}`);
1037 });
1038 }
1039
1040 function setPlaceholderTotals() {
1041 if (netEl) {
1042 netEl.textContent = EM_DASH;
1043 netEl.classList.remove('is-positive', 'is-negative');
1044 }
1045 if (totalNewEl) {
1046 totalNewEl.textContent = EM_DASH;
1047 }
1048 if (totalUnsubsEl) {
1049 totalUnsubsEl.textContent = EM_DASH;
1050 }
1051 }
1052
1053 function showNotice(message) {
1054 if (!noticeEl) {
1055 return;
1056 }
1057 if (message) {
1058 noticeEl.textContent = message;
1059 noticeEl.hidden = false;
1060 } else {
1061 noticeEl.textContent = '';
1062 noticeEl.hidden = true;
1063 }
1064 }
1065
1066 function setOverlay(text) {
1067 if (overlayEl) {
1068 overlayEl.textContent = text || '';
1069 }
1070 }
1071
1072 function setSubtitle(text) {
1073 if (dateRangeEl) {
1074 dateRangeEl.textContent = text || '';
1075 }
1076 }
1077
1078 /**
1079 * Build the visually-hidden screen-reader data table for the
1080 * subscriber activity chart
1081 *
1082 * @param {Array} rows Bucket rows from the payload.
1083 * @param {string} fromLabel Range start (Y-m-d).
1084 * @param {string} toLabel Range end (Y-m-d).
1085 */
1086 function renderDataTable(rows, fromLabel, toLabel) {
1087 if (!dataTableEl) {
1088 return;
1089 }
1090
1091 const captionText = __(
1092 'Subscriber change over time: new subscribers and unsubscribes per bucket for %1$s to %2$s.',
1093 'mailchimp',
1094 )
1095 .replace('%1$s', fromLabel)
1096 .replace('%2$s', toLabel);
1097
1098 const headerCells = [
1099 __('Period', 'mailchimp'),
1100 __('New Subscribers', 'mailchimp'),
1101 __('Unsubscribes', 'mailchimp'),
1102 ];
1103
1104 const table = document.createElement('table');
1105
1106 const caption = document.createElement('caption');
1107 caption.textContent = captionText;
1108 table.appendChild(caption);
1109
1110 const thead = document.createElement('thead');
1111 const headRow = document.createElement('tr');
1112 headerCells.forEach(function (text) {
1113 const th = document.createElement('th');
1114 th.scope = 'col';
1115 th.textContent = text;
1116 headRow.appendChild(th);
1117 });
1118 thead.appendChild(headRow);
1119 table.appendChild(thead);
1120
1121 const tbody = document.createElement('tbody');
1122 rows.forEach(function (row) {
1123 const tr = document.createElement('tr');
1124
1125 const rowHeader = document.createElement('th');
1126 rowHeader.scope = 'row';
1127 rowHeader.textContent = row.label || '';
1128 tr.appendChild(rowHeader);
1129
1130 [String(row.new_subscribers || 0), String(row.unsubscribes || 0)].forEach(
1131 function (text) {
1132 const td = document.createElement('td');
1133 td.textContent = text;
1134 tr.appendChild(td);
1135 },
1136 );
1137
1138 tbody.appendChild(tr);
1139 });
1140 table.appendChild(tbody);
1141
1142 dataTableEl.innerHTML = '';
1143 dataTableEl.appendChild(table);
1144 }
1145
1146 /**
1147 * Empty the screen-reader data table
1148 */
1149 function clearDataTable() {
1150 if (dataTableEl) {
1151 dataTableEl.innerHTML = '';
1152 }
1153 }
1154
1155 function destroyCharts() {
1156 if (barChart) {
1157 barChart.destroy();
1158 barChart = null;
1159 }
1160 if (donutChart) {
1161 donutChart.destroy();
1162 donutChart = null;
1163 }
1164 }
1165
1166 function formatRangeLabel(from, to) {
1167 try {
1168 const fromDate = new Date(`${from}T00:00:00`);
1169 const toDate = new Date(`${to}T00:00:00`);
1170 const fmt = new Intl.DateTimeFormat(undefined, {
1171 month: 'short',
1172 day: 'numeric',
1173 year: 'numeric',
1174 });
1175 return `\u2066${fmt.format(fromDate)} – ${fmt.format(toDate)}\u2069`;
1176 } catch (err) {
1177 return `\u2066${from} – ${to}\u2069`;
1178 }
1179 }
1180
1181 function setErrorBanner(visible, message, errorCode) {
1182 if (!errorBannerEl) {
1183 return;
1184 }
1185 lastErrorCode = visible ? errorCode || '' : '';
1186 if (visible) {
1187 if (errorMessageEl) {
1188 errorMessageEl.textContent = message || STRINGS.errorDefault;
1189 }
1190 if (retryBtnEl) {
1191 const settingsUrl =
1192 (window.mailchimpSFAnalytics && window.mailchimpSFAnalytics.settingsUrl) ||
1193 '';
1194 if (lastErrorCode === 'not_connected' && settingsUrl) {
1195 retryBtnEl.textContent = STRINGS.reconnect;
1196 retryBtnEl.hidden = false;
1197 } else {
1198 retryBtnEl.hidden = true;
1199 }
1200 }
1201 errorBannerEl.hidden = false;
1202 } else {
1203 if (retryBtnEl) {
1204 retryBtnEl.hidden = true;
1205 }
1206 errorBannerEl.hidden = true;
1207 }
1208 }
1209
1210 function showLoading() {
1211 destroyCharts();
1212 clearDataTable();
1213 showNotice('');
1214 setErrorBanner(false);
1215 setOverlay(STRINGS.loadingOverlay);
1216 setSubtitle(STRINGS.loadingSubtitle);
1217 setPlaceholderTotals();
1218 setState('loading');
1219 }
1220
1221 function showEmpty() {
1222 destroyCharts();
1223 clearDataTable();
1224 setErrorBanner(false);
1225 setOverlay(STRINGS.emptyOverlay);
1226 setSubtitle(STRINGS.emptySubtitle);
1227 setPlaceholderTotals();
1228 setState('empty');
1229 }
1230
1231 function showError(message, errorCode) {
1232 destroyCharts();
1233 clearDataTable();
1234 showNotice('');
1235 setOverlay('');
1236 // Keep subtitle showing the last attempted date range if we have one.
1237 if (lastDetail && lastDetail.from && lastDetail.to) {
1238 setSubtitle(formatRangeLabel(lastDetail.from, lastDetail.to));
1239 }
1240 setPlaceholderTotals();
1241 setErrorBanner(true, message, errorCode);
1242 setState('error');
1243 }
1244
1245 function renderBar(data) {
1246 if (!barCanvas || typeof window.Chart === 'undefined') {
1247 return;
1248 }
1249
1250 const labels = data.map(function (row) {
1251 return row.label;
1252 });
1253 const newSeries = data.map(function (row) {
1254 return row.new_subscribers || 0;
1255 });
1256 const unsubSeries = data.map(function (row) {
1257 return -Math.abs(row.unsubscribes || 0);
1258 });
1259
1260 const config = {
1261 type: 'bar',
1262 data: {
1263 labels,
1264 datasets: [
1265 {
1266 label: STRINGS.unsubscribes,
1267 data: unsubSeries,
1268 backgroundColor: COLORS.unsubFill,
1269 borderColor: COLORS.unsubBorder,
1270 borderWidth: 0,
1271 borderRadius: 0,
1272 borderSkipped: false,
1273 maxBarThickness: 32,
1274 },
1275 {
1276 label: STRINGS.newSubscribers,
1277 data: newSeries,
1278 backgroundColor: COLORS.newFill,
1279 borderColor: COLORS.newBorder,
1280 borderWidth: 0,
1281 borderRadius: 0,
1282 borderSkipped: false,
1283 maxBarThickness: 32,
1284 },
1285 ],
1286 },
1287 options: {
1288 responsive: true,
1289 maintainAspectRatio: false,
1290 animation: PREFERS_REDUCED_MOTION ? false : undefined,
1291 interaction: { mode: 'index', intersect: false },
1292 plugins: {
1293 legend: {
1294 position: 'top',
1295 align: 'center',
1296 labels: {
1297 usePointStyle: true,
1298 pointStyle: 'rectRounded',
1299 boxWidth: 10,
1300 boxHeight: 10,
1301 padding: 16,
1302 color: COLORS.text,
1303 },
1304 },
1305 tooltip: {
1306 callbacks: {
1307 label(ctx) {
1308 const value = Math.abs(ctx.parsed.y || 0);
1309 return `${ctx.dataset.label}: ${value}`;
1310 },
1311 },
1312 },
1313 },
1314 scales: {
1315 x: {
1316 grid: {
1317 color: COLORS.gridLine,
1318 drawBorder: false,
1319 drawOnChartArea: true,
1320 drawTicks: false,
1321 },
1322 ticks: { color: COLORS.text },
1323 },
1324 y: {
1325 beginAtZero: true,
1326 grid: {
1327 color(ctx) {
1328 return ctx.tick && ctx.tick.value === 0
1329 ? COLORS.zeroLine
1330 : COLORS.gridLine;
1331 },
1332 drawBorder: false,
1333 drawOnChartArea: true,
1334 },
1335 ticks: {
1336 color: COLORS.text,
1337 callback(value) {
1338 return value;
1339 },
1340 },
1341 },
1342 },
1343 },
1344 };
1345
1346 barChart = new window.Chart(barCanvas.getContext('2d'), config);
1347 }
1348
1349 function renderDonut(totalNew, totalUnsubs) {
1350 if (!donutCanvas || typeof window.Chart === 'undefined') {
1351 return;
1352 }
1353 const total = (totalNew || 0) + (totalUnsubs || 0);
1354 const data = total > 0 ? [totalNew || 0, totalUnsubs || 0] : [1, 0];
1355 const colors =
1356 total > 0
1357 ? [COLORS.newBorder, COLORS.unsubBorder]
1358 : ['rgba(0, 0, 0, 0.08)', 'rgba(0, 0, 0, 0.08)'];
1359
1360 donutChart = new window.Chart(donutCanvas.getContext('2d'), {
1361 type: 'doughnut',
1362 data: {
1363 labels: [STRINGS.newSubscribers, STRINGS.unsubscribes],
1364 datasets: [
1365 {
1366 data,
1367 backgroundColor: colors,
1368 borderWidth: 0,
1369 cutout: '78%',
1370 },
1371 ],
1372 },
1373 options: {
1374 responsive: true,
1375 maintainAspectRatio: false,
1376 animation: PREFERS_REDUCED_MOTION ? false : undefined,
1377 plugins: {
1378 legend: { display: false },
1379 tooltip: { enabled: total > 0 },
1380 },
1381 },
1382 });
1383 }
1384
1385 function renderTotals(payload) {
1386 const net = payload.net_change || 0;
1387 if (netEl) {
1388 const sign = net > 0 ? '+' : '';
1389 netEl.textContent = `${sign}${net}`;
1390 netEl.classList.toggle('is-positive', net > 0);
1391 netEl.classList.toggle('is-negative', net < 0);
1392 }
1393 if (totalNewEl) {
1394 totalNewEl.textContent = String(payload.total_new || 0);
1395 }
1396 if (totalUnsubsEl) {
1397 totalUnsubsEl.textContent = String(payload.total_unsubs || 0);
1398 }
1399 }
1400
1401 function render(payload, fromLabel, toLabel) {
1402 destroyCharts();
1403 setErrorBanner(false);
1404
1405 const rows = Array.isArray(payload.data) ? payload.data : [];
1406 const totalNew = payload.total_new || 0;
1407 const totalUnsubs = payload.total_unsubs || 0;
1408
1409 // Match the Form Performance card's empty-state behavior
1410 if (rows.length === 0 || (totalNew === 0 && totalUnsubs === 0)) {
1411 showEmpty();
1412 return;
1413 }
1414
1415 showNotice(payload.limited ? STRINGS.limited : '');
1416 setSubtitle(formatRangeLabel(fromLabel, toLabel));
1417 setOverlay('');
1418 setState('ready');
1419 renderBar(rows);
1420 renderDonut(totalNew, totalUnsubs);
1421 renderTotals(payload);
1422 renderDataTable(rows, fromLabel, toLabel);
1423 }
1424
1425 function fetchActivity(detail) {
1426 if (!window.mailchimpSFAnalytics || !window.mailchimpSFAnalytics.ajax_url) {
1427 showError();
1428 return;
1429 }
1430 if (!detail || !detail.listId || !detail.from || !detail.to) {
1431 showEmpty();
1432 return;
1433 }
1434
1435 lastDetail = {
1436 listId: detail.listId,
1437 from: detail.from,
1438 to: detail.to,
1439 };
1440
1441 if (inFlight && typeof inFlight.abort === 'function') {
1442 inFlight.abort();
1443 }
1444
1445 const controller =
1446 typeof window.AbortController !== 'undefined' ? new AbortController() : null;
1447 inFlight = controller;
1448
1449 const formData = new FormData();
1450 formData.append('action', 'mailchimp_sf_get_subscriber_activity');
1451 formData.append('nonce', window.mailchimpSFAnalytics.nonce);
1452 formData.append('list_id', detail.listId);
1453 formData.append('date_from', detail.from);
1454 formData.append('date_to', detail.to);
1455
1456 showLoading();
1457
1458 fetch(window.mailchimpSFAnalytics.ajax_url, {
1459 method: 'POST',
1460 body: formData,
1461 credentials: 'same-origin',
1462 signal: controller ? controller.signal : undefined,
1463 })
1464 .then(function (response) {
1465 return response.json().catch(function () {
1466 return null;
1467 });
1468 })
1469 .then(function (body) {
1470 inFlight = null;
1471 if (!body || body.success !== true || !body.data) {
1472 const message =
1473 body && body.data && body.data.message ? body.data.message : '';
1474 const errorCode =
1475 body && body.data && body.data.error_code ? body.data.error_code : '';
1476 showError(message, errorCode);
1477 return;
1478 }
1479 render(body.data, detail.from, detail.to);
1480 })
1481 .catch(function (err) {
1482 if (err && err.name === 'AbortError') {
1483 return;
1484 }
1485 inFlight = null;
1486 showError();
1487 });
1488 }
1489
1490 if (retryBtnEl) {
1491 retryBtnEl.addEventListener('click', function () {
1492 if (lastErrorCode === 'not_connected') {
1493 const settingsUrl =
1494 (window.mailchimpSFAnalytics && window.mailchimpSFAnalytics.settingsUrl) ||
1495 '';
1496 if (settingsUrl) {
1497 window.location.href = settingsUrl;
1498 }
1499 return;
1500 }
1501 if (lastDetail) {
1502 fetchActivity(lastDetail);
1503 }
1504 });
1505 }
1506
1507 document.addEventListener('mailchimp-analytics-refresh', function (e) {
1508 fetchActivity(e.detail);
1509 });
1510 })();
1511
1512 /**
1513 * Audience Overview KPI block — Total subscribers, Form views, New submissions, Conversion rate.
1514 */
1515 (function audienceOverviewModule() {
1516 const section = document.querySelector('[data-section="audience-overview"]');
1517 if (!section) {
1518 return;
1519 }
1520
1521 const subscribersEl = document.getElementById('mailchimp-sf-ao-total-subscribers');
1522 const viewsEl = document.getElementById('mailchimp-sf-ao-views');
1523 const submissionsEl = document.getElementById('mailchimp-sf-ao-submissions');
1524 const rateEl = document.getElementById('mailchimp-sf-ao-rate');
1525 const dateRangeEl = document.getElementById('mailchimp-sf-ao-daterange');
1526 const errorBannerEl = document.getElementById('mailchimp-sf-ao-error-banner');
1527 const errorMessageEl = document.getElementById('mailchimp-sf-ao-error-message');
1528 const retryBtnEl = document.getElementById('mailchimp-sf-ao-error-retry');
1529
1530 const STRINGS = {
1531 loadingSubtitle: __('Loading audience overview…', 'mailchimp'),
1532 errorDefault: __(
1533 'Unable to load audience overview. Please check your connection and try again.',
1534 'mailchimp',
1535 ),
1536 reconnect: __('Reconnect Mailchimp Account', 'mailchimp'),
1537 };
1538
1539 const STATE_CLASSES = ['is-loading', 'is-ready', 'is-error'];
1540
1541 let lastDetail = null;
1542 let lastErrorCode = '';
1543
1544 function setState(state) {
1545 STATE_CLASSES.forEach(function (cls) {
1546 section.classList.toggle(cls, cls === `is-${state}`);
1547 });
1548 }
1549
1550 function setSubtitle(text) {
1551 if (dateRangeEl) {
1552 dateRangeEl.textContent = text || '';
1553 }
1554 }
1555
1556 function setErrorBanner(visible, message, errorCode) {
1557 if (!errorBannerEl) {
1558 return;
1559 }
1560 lastErrorCode = visible ? errorCode || '' : '';
1561 if (visible) {
1562 if (errorMessageEl) {
1563 errorMessageEl.textContent = message || STRINGS.errorDefault;
1564 }
1565 if (retryBtnEl) {
1566 const settingsUrl =
1567 (window.mailchimpSFAnalytics && window.mailchimpSFAnalytics.settingsUrl) ||
1568 '';
1569 if (lastErrorCode === 'not_connected' && settingsUrl) {
1570 retryBtnEl.textContent = STRINGS.reconnect;
1571 retryBtnEl.hidden = false;
1572 } else {
1573 retryBtnEl.hidden = true;
1574 }
1575 }
1576 errorBannerEl.hidden = false;
1577 } else {
1578 if (retryBtnEl) {
1579 retryBtnEl.hidden = true;
1580 }
1581 errorBannerEl.hidden = true;
1582 }
1583 }
1584
1585 function setPlaceholders() {
1586 [subscribersEl, viewsEl, submissionsEl, rateEl].forEach(function (el) {
1587 if (el) {
1588 el.textContent = '-';
1589 }
1590 });
1591 }
1592
1593 function formatRangeLabel(from, to) {
1594 try {
1595 const fromDate = new Date(`${from}T00:00:00`);
1596 const toDate = new Date(`${to}T00:00:00`);
1597 const fmt = new Intl.DateTimeFormat(undefined, {
1598 month: 'short',
1599 day: 'numeric',
1600 year: 'numeric',
1601 });
1602 return `\u2066${fmt.format(fromDate)} – ${fmt.format(toDate)}\u2069`;
1603 } catch (err) {
1604 return `\u2066${from} – ${to}\u2069`;
1605 }
1606 }
1607
1608 function formatNumber(n) {
1609 if (n === null || typeof n === 'undefined') {
1610 return '-';
1611 }
1612 try {
1613 return new Intl.NumberFormat().format(n);
1614 } catch (err) {
1615 return String(n);
1616 }
1617 }
1618
1619 function showLoading() {
1620 setErrorBanner(false);
1621 setSubtitle(STRINGS.loadingSubtitle);
1622 setPlaceholders();
1623 setState('loading');
1624 }
1625
1626 function showError(message, errorCode) {
1627 if (lastDetail && lastDetail.from && lastDetail.to) {
1628 setSubtitle(formatRangeLabel(lastDetail.from, lastDetail.to));
1629 }
1630 setPlaceholders();
1631 setErrorBanner(true, message, errorCode);
1632 setState('error');
1633 }
1634
1635 function render(data, fromLabel, toLabel) {
1636 setErrorBanner(false);
1637 setSubtitle(formatRangeLabel(fromLabel, toLabel));
1638
1639 if (subscribersEl) {
1640 subscribersEl.textContent = formatNumber(data.total_subscribers);
1641 }
1642 if (viewsEl) {
1643 viewsEl.textContent = formatNumber(data.total_views);
1644 }
1645 if (submissionsEl) {
1646 submissionsEl.textContent = formatNumber(data.total_submissions);
1647 }
1648 if (rateEl) {
1649 const rate = data.total_conversion_rate;
1650 rateEl.textContent =
1651 rate === null || typeof rate === 'undefined'
1652 ? '-'
1653 : `${Number(rate).toFixed(2)}%`;
1654 }
1655
1656 setState('ready');
1657 }
1658
1659 document.addEventListener('mailchimp-analytics-refresh', function (e) {
1660 if (e.detail) {
1661 lastDetail = {
1662 listId: e.detail.listId,
1663 from: e.detail.from,
1664 to: e.detail.to,
1665 };
1666 }
1667 });
1668
1669 document.addEventListener('mailchimp-analytics-loading', function () {
1670 showLoading();
1671 });
1672
1673 document.addEventListener('mailchimp-analytics-loaded', function (e) {
1674 if (e.detail && e.detail.data) {
1675 render(e.detail.data, e.detail.from, e.detail.to);
1676 }
1677 });
1678
1679 document.addEventListener('mailchimp-analytics-error', function (e) {
1680 showError(e.detail && e.detail.message, e.detail && e.detail.errorCode);
1681 });
1682
1683 if (retryBtnEl) {
1684 retryBtnEl.addEventListener('click', function () {
1685 if (lastErrorCode === 'not_connected') {
1686 const settingsUrl =
1687 (window.mailchimpSFAnalytics && window.mailchimpSFAnalytics.settingsUrl) ||
1688 '';
1689 if (settingsUrl) {
1690 window.location.href = settingsUrl;
1691 }
1692 return;
1693 }
1694 if (!lastDetail) {
1695 return;
1696 }
1697
1698 document.dispatchEvent(
1699 new CustomEvent('mailchimp-analytics-refresh', { detail: lastDetail }),
1700 );
1701 });
1702 }
1703 })();
1704
1705 // Initialize.
1706 updateTriggerLabel();
1707 syncDateInputs();
1708 refreshAnalytics();
1709 })();
1710