diff --git a/assets/css/analytics.scss b/assets/css/analytics.scss
index 3a974fe..242cabe 100644
--- a/assets/css/analytics.scss
+++ b/assets/css/analytics.scss
@@ -311,6 +311,141 @@ $mq-xs: 600px;
}
}
+// -----------------------------------------------------------------------------
+// Audience Overview KPI block
+// -----------------------------------------------------------------------------
+.mailchimp-sf-ao {
+
+ &__metrics {
+ display: grid;
+ gap: 24px;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ margin: 0 0 16px;
+ }
+
+ &__metric {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ }
+
+ &__metric-label {
+ align-self: flex-start;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+ color: var(--mc-sa-text-strong);
+ font-size: 14px;
+ font-weight: 600;
+ margin: 0;
+
+ &::after {
+ background-image: linear-gradient(
+ 90deg,
+ color-mix(in srgb, var(--mailchimp-color-link, #017e89) 45%, #ffffff) 5px,
+ transparent 5px
+ );
+ background-repeat: repeat-x;
+ background-size: 9px 2px;
+ content: "";
+ display: block;
+ height: 2px;
+ margin-top: 6px;
+ }
+ }
+
+ &__metric-value {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+ color: var(--mc-sa-text-strong);
+ font-size: 32px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ line-height: 1.1;
+ margin: 8px 0 0;
+ }
+
+ &.is-loading &__metric-value,
+ &.is-error &__metric-value {
+ color: var(--mc-sa-grey);
+ opacity: 0.85;
+ }
+
+ &__error-banner {
+ align-items: center;
+ background: var(--mc-sa-error-bg);
+ border: 1px solid var(--mc-sa-error-border);
+ border-radius: 8px;
+ display: flex;
+ gap: 12px;
+ margin: 4px 0 16px;
+ padding: 12px 14px;
+
+ &[hidden] {
+ display: none;
+ }
+ }
+
+ &__error-banner-icon {
+ align-items: center;
+ color: var(--mc-sa-error-text);
+ display: inline-flex;
+ flex-shrink: 0;
+ justify-content: center;
+ line-height: 0;
+ }
+
+ &__error-banner-body {
+ flex: 1 1 auto;
+ min-width: 0;
+ }
+
+ &__error-banner-title {
+ color: var(--mc-sa-error-title);
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 1.3;
+ margin: 0 0 2px;
+ }
+
+ &__error-banner-message {
+ color: var(--mc-sa-error-text);
+ font-size: 13px;
+ line-height: 1.4;
+ margin: 0;
+ }
+
+ &__error-banner-action {
+ flex-shrink: 0;
+ }
+
+ button#mailchimp-sf-ao-error-retry {
+ background-color: #fff;
+ border: 1px solid #ccd6dc;
+
+ &:hover,
+ &:focus,
+ &:active {
+ background-color: #f6f7f7;
+ }
+ }
+}
+
+@media screen and (max-width: $mq-medium) {
+
+ .mailchimp-sf-ao__metrics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media screen and (max-width: $mq-xs) {
+
+ .mailchimp-sf-ao__metrics {
+ grid-template-columns: 1fr;
+ }
+
+ .mailchimp-sf-ao__metric-value {
+ font-size: 28px;
+ }
+}
+
// -----------------------------------------------------------------------------
// Shared analytics chart-card
// -----------------------------------------------------------------------------
diff --git a/assets/js/analytics.js b/assets/js/analytics.js
index 85209e7..0958260 100644
--- a/assets/js/analytics.js
+++ b/assets/js/analytics.js
@@ -489,6 +489,8 @@ import { __ } from '@wordpress/i18n';
}
/**
+ * Render the bar+line chart from API rows.
+ *
* @param {Array} rows Payload `data` rows from the API.
*/
function renderChart(rows) {
@@ -661,9 +663,24 @@ import { __ } from '@wordpress/i18n';
renderChart(rows);
}
+ /**
+ * Custom event so other analytics modules (Audience
+ * Overview, etc.) can render from the same fetch without making
+ * their own AJAX call.
+ *
+ * @param {string} name Event suffix — appended to `mailchimp-analytics-`.
+ * @param {object} eventDetail Payload passed as the event's `detail`.
+ */
+ function broadcast(name, eventDetail) {
+ document.dispatchEvent(
+ new CustomEvent(`mailchimp-analytics-${name}`, { detail: eventDetail }),
+ );
+ }
+
function fetchPerformance(detail) {
if (!window.mailchimpSFAnalytics || !window.mailchimpSFAnalytics.ajax_url) {
showError();
+ broadcast('error', { message: STRINGS.errorDefault });
return;
}
if (!detail || !detail.listId || !detail.from || !detail.to) {
@@ -693,6 +710,7 @@ import { __ } from '@wordpress/i18n';
formData.append('date_to', detail.to);
showLoading();
+ broadcast('loading', { from: detail.from, to: detail.to });
fetch(window.mailchimpSFAnalytics.ajax_url, {
method: 'POST',
@@ -711,9 +729,15 @@ import { __ } from '@wordpress/i18n';
const message =
body && body.data && body.data.message ? body.data.message : '';
showError(message);
+ broadcast('error', { message: message || STRINGS.errorDefault });
return;
}
render(body.data, detail.from, detail.to);
+ broadcast('loaded', {
+ data: body.data,
+ from: detail.from,
+ to: detail.to,
+ });
})
.catch(function (err) {
if (err && err.name === 'AbortError') {
@@ -721,6 +745,7 @@ import { __ } from '@wordpress/i18n';
}
inFlight = null;
showError();
+ broadcast('error', { message: STRINGS.errorDefault });
});
}
@@ -1160,6 +1185,173 @@ import { __ } from '@wordpress/i18n';
});
})();
+ /**
+ * Audience Overview KPI block — Total subscribers, Form views, New submissions, Conversion rate.
+ */
+ (function audienceOverviewModule() {
+ const section = document.querySelector('[data-section="audience-overview"]');
+ if (!section) {
+ return;
+ }
+
+ const subscribersEl = document.getElementById('mailchimp-sf-ao-total-subscribers');
+ const viewsEl = document.getElementById('mailchimp-sf-ao-views');
+ const submissionsEl = document.getElementById('mailchimp-sf-ao-submissions');
+ const rateEl = document.getElementById('mailchimp-sf-ao-rate');
+ const dateRangeEl = document.getElementById('mailchimp-sf-ao-daterange');
+ const errorBannerEl = document.getElementById('mailchimp-sf-ao-error-banner');
+ const errorMessageEl = document.getElementById('mailchimp-sf-ao-error-message');
+ const retryBtnEl = document.getElementById('mailchimp-sf-ao-error-retry');
+
+ const STRINGS = {
+ loadingSubtitle: __('Loading audience overview…', 'mailchimp'),
+ errorDefault: __(
+ 'Unable to load audience overview. Please check your connection and try again.',
+ 'mailchimp',
+ ),
+ };
+
+ const STATE_CLASSES = ['is-loading', 'is-ready', 'is-error'];
+
+ let lastDetail = null;
+
+ function setState(state) {
+ STATE_CLASSES.forEach(function (cls) {
+ section.classList.toggle(cls, cls === `is-${state}`);
+ });
+ }
+
+ function setSubtitle(text) {
+ if (dateRangeEl) {
+ dateRangeEl.textContent = text || '';
+ }
+ }
+
+ function setErrorBanner(visible, message) {
+ if (!errorBannerEl) {
+ return;
+ }
+ if (visible) {
+ if (errorMessageEl) {
+ errorMessageEl.textContent = message || STRINGS.errorDefault;
+ }
+ errorBannerEl.hidden = false;
+ } else {
+ errorBannerEl.hidden = true;
+ }
+ }
+
+ function setPlaceholders() {
+ [subscribersEl, viewsEl, submissionsEl, rateEl].forEach(function (el) {
+ if (el) {
+ el.textContent = '-';
+ }
+ });
+ }
+
+ function formatRangeLabel(from, to) {
+ try {
+ const fromDate = new Date(`${from}T00:00:00`);
+ const toDate = new Date(`${to}T00:00:00`);
+ const fmt = new Intl.DateTimeFormat(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+ return `${fmt.format(fromDate)} – ${fmt.format(toDate)}`;
+ } catch (err) {
+ return `${from} – ${to}`;
+ }
+ }
+
+ function formatNumber(n) {
+ if (n === null || typeof n === 'undefined') {
+ return '-';
+ }
+ try {
+ return new Intl.NumberFormat().format(n);
+ } catch (err) {
+ return String(n);
+ }
+ }
+
+ function showLoading() {
+ setErrorBanner(false);
+ setSubtitle(STRINGS.loadingSubtitle);
+ setPlaceholders();
+ setState('loading');
+ }
+
+ function showError(message) {
+ if (lastDetail && lastDetail.from && lastDetail.to) {
+ setSubtitle(formatRangeLabel(lastDetail.from, lastDetail.to));
+ }
+ setPlaceholders();
+ setErrorBanner(true, message);
+ setState('error');
+ }
+
+ function render(data, fromLabel, toLabel) {
+ setErrorBanner(false);
+ setSubtitle(formatRangeLabel(fromLabel, toLabel));
+
+ if (subscribersEl) {
+ subscribersEl.textContent = formatNumber(data.total_subscribers);
+ }
+ if (viewsEl) {
+ viewsEl.textContent = formatNumber(data.total_views);
+ }
+ if (submissionsEl) {
+ submissionsEl.textContent = formatNumber(data.total_submissions);
+ }
+ if (rateEl) {
+ const rate = data.total_conversion_rate;
+ rateEl.textContent =
+ rate === null || typeof rate === 'undefined'
+ ? '-'
+ : `${Number(rate).toFixed(2)}%`;
+ }
+
+ setState('ready');
+ }
+
+ document.addEventListener('mailchimp-analytics-refresh', function (e) {
+ if (e.detail) {
+ lastDetail = {
+ listId: e.detail.listId,
+ from: e.detail.from,
+ to: e.detail.to,
+ };
+ }
+ });
+
+ document.addEventListener('mailchimp-analytics-loading', function () {
+ showLoading();
+ });
+
+ document.addEventListener('mailchimp-analytics-loaded', function (e) {
+ if (e.detail && e.detail.data) {
+ render(e.detail.data, e.detail.from, e.detail.to);
+ }
+ });
+
+ document.addEventListener('mailchimp-analytics-error', function (e) {
+ showError(e.detail && e.detail.message);
+ });
+
+ if (retryBtnEl) {
+ retryBtnEl.addEventListener('click', function () {
+ if (!lastDetail) {
+ return;
+ }
+
+ document.dispatchEvent(
+ new CustomEvent('mailchimp-analytics-refresh', { detail: lastDetail }),
+ );
+ });
+ }
+ })();
+
// Initialize.
updateTriggerLabel();
syncDateInputs();
diff --git a/includes/admin/templates/analytics.php b/includes/admin/templates/analytics.php
index b944399..825091e 100644
--- a/includes/admin/templates/analytics.php
+++ b/includes/admin/templates/analytics.php
@@ -100,6 +100,96 @@
+
+
+
+
+
+
+
+
-
+
+
+ - —
+
+
+
+
+
+ —
+
+
+
+
+
+ —
+
+
+
+
+
+ —
+
+
+
+
+
aggregate( $rows, $date_from, $date_to );
+ $response['total_subscribers'] = $this->fetch_total_subscribers( $list_id );
+
wp_send_json_success( $response );
}
+ /**
+ * Fetch (and cache) the current total subscriber count for a list.
+ *
+ * @param string $list_id List ID.
+ * @return int|null
+ */
+ public function fetch_total_subscribers( string $list_id ): ?int {
+ $cache_key = self::SUBSCRIBERS_CACHE_PREFIX . md5( $list_id );
+ $cached = get_transient( $cache_key );
+
+ if ( false !== $cached ) {
+ return (int) $cached;
+ }
+
+ $api = mailchimp_sf_get_api();
+ if ( ! $api ) {
+ return null;
+ }
+
+ $response = $api->get(
+ 'lists/' . rawurlencode( $list_id ),
+ 1,
+ array( 'stats.member_count' )
+ );
+
+ if ( is_wp_error( $response ) || ! is_array( $response ) ) {
+ return null;
+ }
+
+ if ( ! isset( $response['stats']['member_count'] ) ) {
+ return null;
+ }
+
+ $count = (int) $response['stats']['member_count'];
+ set_transient( $cache_key, $count, self::SUBSCRIBERS_CACHE_TTL );
+
+ return $count;
+ }
+
/**
* Fetch daily totals from the analytics table for the selected list/range.
*
@@ -175,19 +226,4 @@ public function aggregate( array $rows, string $date_from, string $date_to ): ar
);
}
- /**
- * Submissions ÷ views, as a percentage (0–100, two decimals).
- *
- * @param int $submissions Submission count.
- * @param int $views View count.
- * @return float
- */
- private function conversion_rate( int $submissions, int $views ): float {
- if ( $views <= 0 ) {
- return 0.0;
- }
- $rate = ( $submissions / $views ) * 100;
- return round( min( 100.0, $rate ), 2 );
- }
-
}
diff --git a/includes/trait-mailchimp-analytics-bucketing.php b/includes/trait-mailchimp-analytics-bucketing.php
index 613fe03..b3815d9 100644
--- a/includes/trait-mailchimp-analytics-bucketing.php
+++ b/includes/trait-mailchimp-analytics-bucketing.php
@@ -97,6 +97,23 @@ public function get_bucket_label( string $date, string $interval, $tz = null ):
}
}
+ /**
+ * Submissions ÷ views, expressed as a percentage (0–100, two decimals).
+ *
+ * Returns 0 when there were no views
+ *
+ * @param int $submissions Submission count.
+ * @param int $views View count.
+ * @return float Conversion rate percentage in the range [0, 100].
+ */
+ protected function conversion_rate( int $submissions, int $views ): float {
+ if ( $views <= 0 ) {
+ return 0.0;
+ }
+ $rate = ( $submissions / $views ) * 100;
+ return round( min( 100.0, $rate ), 2 );
+ }
+
/**
* Validate a `Y-m-d` date string.
*
diff --git a/mailchimp.php b/mailchimp.php
index 0ae5684..db2fa1e 100644
--- a/mailchimp.php
+++ b/mailchimp.php
@@ -133,7 +133,6 @@ function () {
$form_performance = new Mailchimp_Form_Performance();
$form_performance->init();
-
// Deprecated functions.
require_once plugin_dir_path( __FILE__ ) . 'includes/mailchimp-deprecated-functions.php';
diff --git a/tests/cypress/e2e/settings/analytics.test.js b/tests/cypress/e2e/settings/analytics.test.js
index 83c5460..75afd50 100644
--- a/tests/cypress/e2e/settings/analytics.test.js
+++ b/tests/cypress/e2e/settings/analytics.test.js
@@ -446,6 +446,116 @@ describe('Analytics admin page', () => {
cy.get('#mailchimp-sf-date-picker-label').should('have.text', 'Last 7 days');
});
});
+
+ describe('Audience Overview KPI block', () => {
+ const analyticsUrl = '/wp-admin/admin.php?page=mailchimp_sf_analytics';
+
+ function stubFormPerformance(replyFn) {
+ cy.intercept('POST', '**/admin-ajax.php', (req) => {
+ if (!isFormPerformanceRequest(req)) {
+ req.continue();
+ return;
+ }
+ const payload = typeof replyFn === 'function' ? replyFn(req) : replyFn;
+ req.reply({
+ statusCode: 200,
+ headers: { 'content-type': 'application/json; charset=UTF-8' },
+ body: payload,
+ });
+ }).as('formPerformance');
+ }
+
+ it('Audience Overview section shell (title, four KPIs)', () => {
+ stubFormPerformance(() => wpJsonSuccessFp(buildFormPerformanceData()));
+ cy.visit(analyticsUrl);
+ cy.wait('@formPerformance');
+ cy.get('[data-section="audience-overview"]').should('be.visible');
+ cy.get('#mailchimp-sf-ao-title').contains('Audience Overview');
+ cy.get('#mailchimp-sf-ao-total-subscribers').should('exist');
+ cy.get('#mailchimp-sf-ao-views').should('exist');
+ cy.get('#mailchimp-sf-ao-submissions').should('exist');
+ cy.get('#mailchimp-sf-ao-rate').should('exist');
+ });
+
+ it('Success payload renders all four KPIs from the shared response', () => {
+ stubFormPerformance(() =>
+ wpJsonSuccessFp(
+ buildFormPerformanceData({
+ total_subscribers: 5082,
+ total_views: 3138,
+ total_submissions: 938,
+ total_conversion_rate: 29.89,
+ }),
+ ),
+ );
+ cy.visit(analyticsUrl);
+ cy.wait('@formPerformance');
+ cy.get('[data-section="audience-overview"]').should('have.class', 'is-ready');
+ cy.get('#mailchimp-sf-ao-total-subscribers').should('contain', '5,082');
+ cy.get('#mailchimp-sf-ao-views').should('contain', '3,138');
+ cy.get('#mailchimp-sf-ao-submissions').should('contain', '938');
+ cy.get('#mailchimp-sf-ao-rate').should('contain', '29.89%');
+ });
+
+ it('Missing total_subscribers (API failure) renders an em dash', () => {
+ stubFormPerformance(() =>
+ wpJsonSuccessFp(
+ buildFormPerformanceData({
+ total_subscribers: null,
+ total_views: 100,
+ total_submissions: 25,
+ total_conversion_rate: 25.0,
+ }),
+ ),
+ );
+ cy.visit(analyticsUrl);
+ cy.wait('@formPerformance');
+ cy.get('#mailchimp-sf-ao-total-subscribers').should('contain', '-');
+ cy.get('#mailchimp-sf-ao-views').should('contain', '100');
+ });
+
+ it('API error shows error banner on the Audience Overview card', () => {
+ stubFormPerformance(() => wpJsonErrorFp('Audience overview stub failure'));
+ cy.visit(analyticsUrl);
+ cy.wait('@formPerformance');
+ cy.get('[data-section="audience-overview"]').should('have.class', 'is-error');
+ cy.get('#mailchimp-sf-ao-error-banner').should('be.visible');
+ cy.get('#mailchimp-sf-ao-error-message').contains('Audience overview stub failure');
+ });
+
+ it('Retry on Audience Overview triggers another shared request', () => {
+ let n = 0;
+ stubFormPerformance(() => {
+ n += 1;
+ if (n === 1) {
+ return wpJsonErrorFp('First request fails');
+ }
+ return wpJsonSuccessFp(buildFormPerformanceData());
+ });
+ cy.visit(analyticsUrl);
+ cy.wait('@formPerformance');
+ cy.get('[data-section="audience-overview"]').should('have.class', 'is-error');
+ cy.get('#mailchimp-sf-ao-error-retry').click();
+ cy.wait('@formPerformance');
+ cy.get('[data-section="audience-overview"]').should('have.class', 'is-ready');
+ cy.get('#mailchimp-sf-ao-error-banner').should('have.attr', 'hidden');
+ });
+
+ it('Audience Overview does not fire its own AJAX action', () => {
+ stubFormPerformance(() => wpJsonSuccessFp(buildFormPerformanceData()));
+ cy.intercept('POST', '**/admin-ajax.php', (req) => {
+ if (
+ typeof req.body === 'string' &&
+ req.body.includes('mailchimp_sf_get_audience_overview')
+ ) {
+ throw new Error('Audience Overview should not fire its own AJAX call');
+ }
+ });
+ cy.visit(analyticsUrl);
+ cy.wait('@formPerformance');
+ cy.get('[data-section="audience-overview"]').should('have.class', 'is-ready');
+ });
+ });
});
describe('When not connected', () => {
diff --git a/tests/cypress/support/functions/formPerformanceAjax.js b/tests/cypress/support/functions/formPerformanceAjax.js
index 27af12c..8bcec85 100644
--- a/tests/cypress/support/functions/formPerformanceAjax.js
+++ b/tests/cypress/support/functions/formPerformanceAjax.js
@@ -40,6 +40,10 @@ function buildSuccessData(overrides = {}) {
total_views: 120,
total_submissions: 24,
total_conversion_rate: 20.0,
+ // `total_subscribers` is sourced from the Mailchimp List API and
+ // included in the same response so the Audience Overview KPI card
+ // can render from a single fetch shared with Form Performance.
+ total_subscribers: 5082,
...overrides,
};
}