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, }; }