diff --git a/assets/css/analytics.scss b/assets/css/analytics.scss index f4470f9..3a974fe 100644 --- a/assets/css/analytics.scss +++ b/assets/css/analytics.scss @@ -312,24 +312,11 @@ $mq-xs: 600px; } // ----------------------------------------------------------------------------- -// Subscriber activity section +// Shared analytics chart-card // ----------------------------------------------------------------------------- +.mailchimp-sf-fp, .mailchimp-sf-sa { - // Body grid — chart column + totals column - &__body { - align-items: stretch; - display: grid; - gap: 32px; - grid-template-columns: minmax(0, 1fr) 320px; - margin-top: 32px; - - &[hidden] { - display: none; - } - } - - // Chart column &__chart { min-width: 0; } @@ -349,7 +336,6 @@ $mq-xs: 600px; margin: 0 0 16px; } - // Canvas area (hosts the real canvas + skeleton + overlay + background grid) &__canvas-wrap { height: 340px; position: relative; @@ -368,6 +354,199 @@ $mq-xs: 600px; z-index: 1; } + &__skeleton-bars { + align-items: flex-end; + bottom: 42%; + display: none; + gap: 16px; + justify-content: center; + left: 0; + padding: 4px 0 0; + pointer-events: none; + position: absolute; + right: 0; + top: 0; + + span { + background: var(--mc-sa-border); + border-radius: 2px; + display: block; + width: 18px; + } + + span:nth-child(1) { height: 96px; } + span:nth-child(2) { height: 53px; } + span:nth-child(3) { height: 122px; } + span:nth-child(4) { height: 80px; } + span:nth-child(5) { height: 40px; } + } + + &__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; + } +} + +// ----------------------------------------------------------------------------- +// Form performance — card-specific rules +// ----------------------------------------------------------------------------- +.mailchimp-sf-fp { + + &__body { + margin-top: 24px; + + &[hidden] { + display: none; + } + } + + &__chart-heading { + margin-bottom: 8px; + } + + // Centered overlay (used for empty / error). Loading state below + // repositions it below the skeleton bars. + &__overlay { + align-items: center; + bottom: 0; + color: #666666; + display: none; + font-size: 14px; + font-weight: 500; + justify-content: center; + left: 0; + padding: 16px; + pointer-events: none; + position: absolute; + right: 0; + text-align: center; + top: 0; + z-index: 2; + } + + &.is-loading, + &.is-empty, + &.is-error { + + .mailchimp-sf-fp__canvas { + opacity: 0; + } + + .mailchimp-sf-fp__canvas-wrap { + background-image: + linear-gradient(to right, var(--mc-sa-skeleton-grid) 1px, transparent 1px), + linear-gradient(to bottom, var(--mc-sa-skeleton-grid) 1px, transparent 1px); + background-position: 0 100%; + background-size: calc(100% / 7) calc(100% / 8); + } + } + + &.is-loading, + &.is-empty { + + .mailchimp-sf-fp__overlay { + display: flex; + } + } + + &.is-loading { + + .mailchimp-sf-fp__skeleton-bars { + display: flex; + } + + .mailchimp-sf-fp__overlay { + align-items: flex-start; + padding-top: 28px; + top: 60%; + } + + .mailchimp-sf-fp__skeleton-bars span, + .mailchimp-sf-fp__overlay { + animation: mailchimp-sf-sa-pulse 1.4s ease-in-out infinite; + } + } + + button#mailchimp-sf-fp-error-retry { + background-color: #fff; + border: 1px solid #ccd6dc; + + &:hover, + &:focus, + &:active { + background-color: #f6f7f7; + } + } +} + +@media screen and (max-width: $mq-xs) { + + .mailchimp-sf-fp__canvas-wrap { + height: 240px; + } +} + +// ----------------------------------------------------------------------------- +// Subscriber activity section +// ----------------------------------------------------------------------------- +.mailchimp-sf-sa { + + // Body grid — chart column + totals column + &__body { + align-items: stretch; + display: grid; + gap: 32px; + grid-template-columns: minmax(0, 1fr) 320px; + margin-top: 32px; + + &[hidden] { + display: none; + } + } + // Totals column &__totals { border-left: 1px solid var(--mc-sa-border); @@ -474,52 +653,6 @@ $mq-xs: 600px; padding: 10px 14px; } - /* - * Skeleton bars — live in the top ~60% of the canvas-wrap so the overlay - * copy below isn't covered. No background grid here; the grid lives on - * canvas-wrap itself so it spans the full chart area, not just the bars. - */ - &__skeleton-bars { - align-items: flex-end; - bottom: 42%; - display: none; - gap: 16px; - justify-content: center; - left: 0; - padding: 4px 0 0; - pointer-events: none; - position: absolute; - right: 0; - top: 0; - - span { - background: var(--mc-sa-border); - border-radius: 2px; - display: block; - width: 18px; - } - - span:nth-child(1) { - height: 96px; - } - - span:nth-child(2) { - height: 53px; - } - - span:nth-child(3) { - height: 122px; - } - - span:nth-child(4) { - height: 80px; - } - - span:nth-child(5) { - height: 40px; - } - } - // Skeleton donut — outlined grey ring, same size as the real donut &__skeleton-donut { border: 20px solid var(--mc-sa-border); @@ -556,57 +689,6 @@ $mq-xs: 600px; z-index: 2; } - // ------------------------------------------------------------------------- - // Error banner (shown in place of the chart overlay in the error state). - // ------------------------------------------------------------------------- - &__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; - } - // ------------------------------------------------------------------------- // State modifiers: loading / empty / error // Loading + empty share the same skeleton presentation; error shows the @@ -696,6 +778,8 @@ $mq-xs: 600px; @media (prefers-reduced-motion: reduce) { + .mailchimp-sf-fp.is-loading .mailchimp-sf-fp__skeleton-bars span, + .mailchimp-sf-fp.is-loading .mailchimp-sf-fp__overlay, .mailchimp-sf-sa.is-loading .mailchimp-sf-sa__skeleton-bars span, .mailchimp-sf-sa.is-loading .mailchimp-sf-sa__skeleton-donut, .mailchimp-sf-sa.is-loading .mailchimp-sf-sa__overlay { diff --git a/assets/js/analytics.js b/assets/js/analytics.js index 31c0a7e..85209e7 100644 --- a/assets/js/analytics.js +++ b/assets/js/analytics.js @@ -359,50 +359,383 @@ import { __ } from '@wordpress/i18n'; }); /** - * Fetch analytics data via AJAX and update the content area. - * - * @param {object} detail Event detail with from, to, listId. + * Forms performance over time */ - function fetchAnalyticsData(detail) { - if (!window.mailchimpSFAnalytics || !window.mailchimpSFAnalytics.ajax_url) { + (function formPerformanceModule() { + const section = document.querySelector('[data-section="form-performance"]'); + if (!section) { return; } - const contentArea = document.getElementById('mailchimp-sf-analytics-content'); - if (!contentArea) { - return; + const chartCanvas = document.getElementById('mailchimp-sf-fp-line'); + const dateRangeEl = document.getElementById('mailchimp-sf-fp-daterange'); + const overlayEl = document.getElementById('mailchimp-sf-fp-overlay'); + const errorBannerEl = document.getElementById('mailchimp-sf-fp-error-banner'); + const errorMessageEl = document.getElementById('mailchimp-sf-fp-error-message'); + const retryBtnEl = document.getElementById('mailchimp-sf-fp-error-retry'); + + const COLORS = { + viewsFill: '#3B82F6', + viewsBorder: '#2563EB', + submissionsFill: '#2DD4BF', + submissionsBorder: '#14B8A6', + rateBorder: '#EAB308', + gridLine: 'rgba(0, 0, 0, 0.06)', + text: '#6B7280', + // Legend chip fills — translucent version of each bar color so the + // legend markers match the outlined-chip style from the Figma spec. + viewsLegendFill: 'rgba(59, 130, 246, 0.35)', + submissionsLegendFill: 'rgba(45, 212, 191, 0.35)', + }; + + const STRINGS = { + loadingSubtitle: __('Loading form performance…', 'mailchimp'), + loadingOverlay: __('Loading form performance…', 'mailchimp'), + emptySubtitle: __('No submissions recorded for the selected date range', 'mailchimp'), + emptyOverlay: __('No data available for this date range', 'mailchimp'), + errorDefault: __( + 'Unable to load data for the selected date range. Please check your connection and try again.', + 'mailchimp', + ), + views: __('Form Views', 'mailchimp'), + submissions: __('Submissions', 'mailchimp'), + conversionRate: __('Conversion Rate', 'mailchimp'), + }; + + const STATE_CLASSES = ['is-loading', 'is-ready', 'is-empty', 'is-error']; + + let chart = null; + let inFlight = null; + let lastDetail = null; + + function setState(state) { + STATE_CLASSES.forEach(function (cls) { + section.classList.toggle(cls, cls === `is-${state}`); + }); } - const formData = new FormData(); - formData.append('action', 'mailchimp_sf_get_analytics'); - formData.append('nonce', window.mailchimpSFAnalytics.nonce); - formData.append('list_id', detail.listId); - formData.append('start_date', detail.from); - formData.append('end_date', detail.to); - - fetch(window.mailchimpSFAnalytics.ajax_url, { - method: 'POST', - body: formData, - credentials: 'same-origin', - }) - .then(function (response) { - return response.json(); - }) - .then(function (response) { - if (!response.success) { - return; + function setOverlay(text) { + if (overlayEl) { + overlayEl.textContent = text || ''; + } + } + + function setSubtitle(text) { + if (dateRangeEl) { + dateRangeEl.textContent = text || ''; + } + } + + function destroyCharts() { + if (chart) { + chart.destroy(); + chart = null; + } + } + + 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 setErrorBanner(visible, message) { + if (!errorBannerEl) { + return; + } + if (visible) { + if (errorMessageEl) { + errorMessageEl.textContent = message || STRINGS.errorDefault; } + errorBannerEl.hidden = false; + } else { + errorBannerEl.hidden = true; + } + } + + function showLoading() { + destroyCharts(); + setErrorBanner(false); + setOverlay(STRINGS.loadingOverlay); + setSubtitle(STRINGS.loadingSubtitle); + setState('loading'); + } + + function showEmpty() { + destroyCharts(); + setErrorBanner(false); + setOverlay(STRINGS.emptyOverlay); + setSubtitle(STRINGS.emptySubtitle); + setState('empty'); + } - const { data } = response; - contentArea.innerHTML = JSON.stringify(data); + function showError(message) { + destroyCharts(); + setOverlay(''); + if (lastDetail && lastDetail.from && lastDetail.to) { + setSubtitle(formatRangeLabel(lastDetail.from, lastDetail.to)); + } + setErrorBanner(true, message); + setState('error'); + } + + /** + * @param {Array} rows Payload `data` rows from the API. + */ + function renderChart(rows) { + if (!chartCanvas || typeof window.Chart === 'undefined') { + return; + } + + const labels = rows.map(function (r) { + return r.label; + }); + const views = rows.map(function (r) { + return r.views || 0; + }); + const submissions = rows.map(function (r) { + return r.submissions || 0; + }); + const rate = rows.map(function (r) { + return r.conversion_rate || 0; + }); + + chart = new window.Chart(chartCanvas.getContext('2d'), { + type: 'bar', + data: { + labels, + datasets: [ + { + type: 'bar', + label: STRINGS.views, + data: views, + backgroundColor: COLORS.viewsFill, + borderColor: COLORS.viewsBorder, + borderWidth: 0, + borderRadius: 0, + maxBarThickness: 22, + order: 2, + yAxisID: 'y', + }, + { + type: 'bar', + label: STRINGS.submissions, + data: submissions, + backgroundColor: COLORS.submissionsFill, + borderColor: COLORS.submissionsBorder, + borderWidth: 0, + borderRadius: 0, + maxBarThickness: 22, + order: 2, + yAxisID: 'y', + }, + { + type: 'line', + label: STRINGS.conversionRate, + data: rate, + borderColor: COLORS.rateBorder, + backgroundColor: COLORS.rateBorder, + borderWidth: 2, + pointBackgroundColor: COLORS.rateBorder, + pointBorderColor: COLORS.rateBorder, + pointRadius: 3, + pointHoverRadius: 5, + tension: 0.1, + fill: false, + order: 1, + yAxisID: 'y1', + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { + position: 'top', + align: 'center', + labels: { + usePointStyle: true, + pointStyleWidth: 36, + boxHeight: 20, + padding: 24, + color: COLORS.text, + generateLabels(ci) { + const legendFills = [ + COLORS.viewsLegendFill, + COLORS.submissionsLegendFill, + ]; + return ci.data.datasets.map(function (dataset, i) { + const isLine = dataset.type === 'line'; + return { + text: dataset.label, + fillStyle: isLine + ? 'transparent' + : legendFills[i] || dataset.backgroundColor, + strokeStyle: isLine + ? dataset.borderColor + : dataset.backgroundColor, + lineWidth: 2, + pointStyle: isLine ? 'line' : 'rect', + hidden: !ci.isDatasetVisible(i), + datasetIndex: i, + }; + }); + }, + }, + }, + tooltip: { + callbacks: { + label(ctx) { + const value = ctx.parsed.y || 0; + if (ctx.dataset.yAxisID === 'y1') { + return `${ctx.dataset.label}: ${value.toFixed(1)}%`; + } + return `${ctx.dataset.label}: ${value}`; + }, + }, + }, + }, + scales: { + x: { + grid: { + color: COLORS.gridLine, + drawBorder: false, + drawTicks: false, + }, + ticks: { color: COLORS.text }, + }, + y: { + type: 'linear', + position: 'left', + beginAtZero: true, + grid: { color: COLORS.gridLine, drawBorder: false }, + ticks: { color: COLORS.text, precision: 0 }, + }, + y1: { + type: 'linear', + position: 'right', + beginAtZero: true, + max: 110, + grid: { drawOnChartArea: false }, + ticks: { + color: COLORS.text, + stepSize: 10, + callback(value) { + return value > 100 ? '' : `${value}%`; + }, + }, + }, + }, + }, + }); + } + + function render(payload, fromLabel, toLabel) { + destroyCharts(); + setErrorBanner(false); + + const rows = Array.isArray(payload.data) ? payload.data : []; + const totalViews = payload.total_views || 0; + const totalSubs = payload.total_submissions || 0; + + // Empty when there's literally no tracked activity for the range. + if (rows.length === 0 || (totalViews === 0 && totalSubs === 0)) { + showEmpty(); + return; + } + + setSubtitle(formatRangeLabel(fromLabel, toLabel)); + setOverlay(''); + setState('ready'); + renderChart(rows); + } + + function fetchPerformance(detail) { + if (!window.mailchimpSFAnalytics || !window.mailchimpSFAnalytics.ajax_url) { + showError(); + return; + } + if (!detail || !detail.listId || !detail.from || !detail.to) { + showEmpty(); + return; + } + + lastDetail = { + listId: detail.listId, + from: detail.from, + to: detail.to, + }; + + if (inFlight && typeof inFlight.abort === 'function') { + inFlight.abort(); + } + + const controller = + typeof window.AbortController !== 'undefined' ? new AbortController() : null; + inFlight = controller; + + const formData = new FormData(); + formData.append('action', 'mailchimp_sf_get_form_performance'); + formData.append('nonce', window.mailchimpSFAnalytics.nonce); + formData.append('list_id', detail.listId); + formData.append('date_from', detail.from); + formData.append('date_to', detail.to); + + showLoading(); + + fetch(window.mailchimpSFAnalytics.ajax_url, { + method: 'POST', + body: formData, + credentials: 'same-origin', + signal: controller ? controller.signal : undefined, }) - .catch(function () {}); - } + .then(function (response) { + return response.json().catch(function () { + return null; + }); + }) + .then(function (body) { + inFlight = null; + if (!body || body.success !== true || !body.data) { + const message = + body && body.data && body.data.message ? body.data.message : ''; + showError(message); + return; + } + render(body.data, detail.from, detail.to); + }) + .catch(function (err) { + if (err && err.name === 'AbortError') { + return; + } + inFlight = null; + showError(); + }); + } - // Listen for analytics refresh events. - document.addEventListener('mailchimp-analytics-refresh', function (e) { - fetchAnalyticsData(e.detail); - }); + if (retryBtnEl) { + retryBtnEl.addEventListener('click', function () { + if (lastDetail) { + fetchPerformance(lastDetail); + } + }); + } + + document.addEventListener('mailchimp-analytics-refresh', function (e) { + fetchPerformance(e.detail); + }); + })(); /** * Subscriber change over time — diverging bar + totals donut. diff --git a/composer.json b/composer.json index ef422f8..0bb5ded 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ ], "prefer-stable": true, "require": { - "php": ">=7.0", + "php": ">=7.4", "woocommerce/action-scheduler": "3.8.2" }, "require-dev": { diff --git a/includes/admin/templates/analytics.php b/includes/admin/templates/analytics.php index efd1bd4..b944399 100644 --- a/includes/admin/templates/analytics.php +++ b/includes/admin/templates/analytics.php @@ -100,6 +100,91 @@ +
+
+

+ +

+

+
+ +
+

+ +

+

+ +

+
+ + + +
+
+
+ + +
+
+
+
+
+
esc_html__( 'Unauthorized.', 'mailchimp' ) ), 403 ); + } + + check_ajax_referer( 'mailchimp_sf_analytics_admin_nonce', 'nonce' ); + + $list_id = isset( $_POST['list_id'] ) ? sanitize_text_field( wp_unslash( $_POST['list_id'] ) ) : ''; + $date_from = isset( $_POST['date_from'] ) ? sanitize_text_field( wp_unslash( $_POST['date_from'] ) ) : ''; + $date_to = isset( $_POST['date_to'] ) ? sanitize_text_field( wp_unslash( $_POST['date_to'] ) ) : ''; + + if ( empty( $list_id ) ) { + wp_send_json_error( array( 'message' => esc_html__( 'Please select a list.', 'mailchimp' ) ), 400 ); + } + + if ( ! $this->is_valid_date( $date_from ) || ! $this->is_valid_date( $date_to ) ) { + wp_send_json_error( array( 'message' => esc_html__( 'Invalid date range.', 'mailchimp' ) ), 400 ); + } + + if ( strtotime( $date_from ) > strtotime( $date_to ) ) { + wp_send_json_error( array( 'message' => esc_html__( 'Start date must be before end date.', 'mailchimp' ) ), 400 ); + } + + $rows = $this->fetch_rows( $list_id, $date_from, $date_to ); + + if ( null === $rows ) { + wp_send_json_error( array( 'message' => esc_html__( 'Unable to load form analytics.', 'mailchimp' ) ), 500 ); + } + + $response = $this->aggregate( $rows, $date_from, $date_to ); + + wp_send_json_success( $response ); + } + + /** + * Fetch daily totals from the analytics table for the selected list/range. + * + * @param string $list_id List ID. + * @param string $date_from `Y-m-d`. + * @param string $date_to `Y-m-d`. + * @return array|null Rows of `{ event_date, views, submissions }`, or null on DB error. + */ + public function fetch_rows( string $list_id, string $date_from, string $date_to ): ?array { + global $wpdb; + + $table_name = Mailchimp_Analytics_Data::get_table_name(); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT event_date, SUM(views) AS views, SUM(submissions) AS submissions + FROM {$table_name} + WHERE list_id = %s AND event_date BETWEEN %s AND %s + GROUP BY event_date + ORDER BY event_date ASC", + $list_id, + $date_from, + $date_to + ), + ARRAY_A + ); + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ( null === $results || ! empty( $wpdb->last_error ) ) { + return null; + } + + return is_array( $results ) ? $results : array(); + } + + /** + * Bucket daily rows into the chart's interval, back-filling missing days + * with zeros so every bucket on the x-axis has a value + * + * @param array $rows Rows from `fetch_rows()`. + * @param string $date_from `Y-m-d`. + * @param string $date_to `Y-m-d`. + * @return array Response payload. + */ + public function aggregate( array $rows, string $date_from, string $date_to ): array { + $tz = wp_timezone(); + $from_dt = new DateTimeImmutable( $date_from, $tz ); + $to_dt = new DateTimeImmutable( $date_to, $tz ); + + $requested_days = (int) $from_dt->diff( $to_dt )->days + 1; + $interval = $this->get_interval( $requested_days ); + + // Build the complete ordered set of bucket keys covering the range. + $buckets = array(); + $one_day = new DateInterval( 'P1D' ); + $cursor = $from_dt; + while ( $cursor <= $to_dt ) { + $date = $cursor->format( 'Y-m-d' ); + $key = $this->get_bucket_key( $date, $interval, $tz ); + if ( ! isset( $buckets[ $key ] ) ) { + $buckets[ $key ] = array( + 'key' => $key, + 'label' => $this->get_bucket_label( $date, $interval, $tz ), + 'views' => 0, + 'submissions' => 0, + ); + } + $cursor = $cursor->add( $one_day ); + } + ksort( $buckets ); + + $total_views = 0; + $total_submissions = 0; + + foreach ( $rows as $row ) { + $date = isset( $row['event_date'] ) ? (string) $row['event_date'] : ''; + $views = isset( $row['views'] ) ? (int) $row['views'] : 0; + $submissions = isset( $row['submissions'] ) ? (int) $row['submissions'] : 0; + + if ( '' === $date ) { + continue; + } + + $key = $this->get_bucket_key( $date, $interval, $tz ); + if ( ! isset( $buckets[ $key ] ) ) { + continue; + } + + $buckets[ $key ]['views'] += $views; + $buckets[ $key ]['submissions'] += $submissions; + $total_views += $views; + $total_submissions += $submissions; + } + + $data = array(); + foreach ( $buckets as $bucket ) { + $bucket['conversion_rate'] = $this->conversion_rate( $bucket['submissions'], $bucket['views'] ); + $data[] = $bucket; + } + + return array( + 'interval' => $interval, + 'data' => $data, + 'total_views' => $total_views, + 'total_submissions' => $total_submissions, + 'total_conversion_rate' => $this->conversion_rate( $total_submissions, $total_views ), + ); + } + + /** + * 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/class-mailchimp-subscriber-activity.php b/includes/class-mailchimp-subscriber-activity.php index 593e8e8..c4b957a 100644 --- a/includes/class-mailchimp-subscriber-activity.php +++ b/includes/class-mailchimp-subscriber-activity.php @@ -22,6 +22,8 @@ */ class Mailchimp_Subscriber_Activity { + use Mailchimp_Analytics_Bucketing; + /** * Transient key prefix for the cached raw API response. */ @@ -229,100 +231,4 @@ public function filter_and_aggregate( array $raw, string $date_from, string $dat ); } - /** - * Pick an aggregation interval based on the requested range. - * - * @param int $days Inclusive day count of the requested range. - * @return string One of `daily|weekly|monthly|quarterly|yearly`. - */ - public function get_interval( int $days ): string { - if ( $days <= 30 ) { - return 'daily'; - } - if ( $days <= 90 ) { - return 'weekly'; - } - if ( $days <= 365 ) { - return 'monthly'; - } - if ( $days <= 365 * 3 ) { - return 'quarterly'; - } - return 'yearly'; - } - - /** - * Build a stable sort key for the bucket a given date falls into. - * - * @param string $date `Y-m-d`. - * @param string $interval Interval name. - * @param DateTimeZone|null $tz Timezone. - * @return string - */ - public function get_bucket_key( string $date, string $interval, $tz = null ): string { - $tz = $tz instanceof DateTimeZone ? $tz : wp_timezone(); - $dt = new DateTimeImmutable( $date, $tz ); - - switch ( $interval ) { - case 'weekly': - // ISO week starts on Monday. - return $dt->format( 'o-\WW' ); - case 'monthly': - return $dt->format( 'Y-m' ); - case 'quarterly': - $quarter = (int) ceil( (int) $dt->format( 'n' ) / 3 ); - return $dt->format( 'Y' ) . '-Q' . $quarter; - case 'yearly': - return $dt->format( 'Y' ); - case 'daily': - default: - return $dt->format( 'Y-m-d' ); - } - } - - /** - * Build a human-readable label for the bucket a given date falls into. - * - * @param string $date `Y-m-d`. - * @param string $interval Interval name. - * @param DateTimeZone|null $tz Timezone. - * @return string - */ - public function get_bucket_label( string $date, string $interval, $tz = null ): string { - $tz = $tz instanceof DateTimeZone ? $tz : wp_timezone(); - $dt = new DateTimeImmutable( $date, $tz ); - - switch ( $interval ) { - case 'weekly': - $monday = $dt->modify( 'monday this week' ); - if ( $monday > $dt ) { - $monday = $dt->modify( 'monday last week' ); - } - return wp_date( 'M j', $monday->getTimestamp(), $tz ); - case 'monthly': - return wp_date( 'M Y', $dt->getTimestamp(), $tz ); - case 'quarterly': - $quarter = (int) ceil( (int) $dt->format( 'n' ) / 3 ); - return 'Q' . $quarter . ' ' . $dt->format( 'Y' ); - case 'yearly': - return $dt->format( 'Y' ); - case 'daily': - default: - return wp_date( 'M j', $dt->getTimestamp(), $tz ); - } - } - - /** - * Validate a `Y-m-d` date string. - * - * @param string $date Candidate date string. - * @return bool - */ - private function is_valid_date( string $date ): bool { - if ( '' === $date ) { - return false; - } - $dt = DateTimeImmutable::createFromFormat( 'Y-m-d', $date ); - return $dt && $dt->format( 'Y-m-d' ) === $date; - } } diff --git a/includes/trait-mailchimp-analytics-bucketing.php b/includes/trait-mailchimp-analytics-bucketing.php new file mode 100644 index 0000000..613fe03 --- /dev/null +++ b/includes/trait-mailchimp-analytics-bucketing.php @@ -0,0 +1,113 @@ +format( 'o-\WW' ); + case 'monthly': + return $dt->format( 'Y-m' ); + case 'quarterly': + $quarter = (int) ceil( (int) $dt->format( 'n' ) / 3 ); + return $dt->format( 'Y' ) . '-Q' . $quarter; + case 'yearly': + return $dt->format( 'Y' ); + case 'daily': + default: + return $dt->format( 'Y-m-d' ); + } + } + + /** + * Build a human-readable label for the bucket a given date falls into. + * + * @param string $date `Y-m-d`. + * @param string $interval Interval name. + * @param DateTimeZone|null $tz Timezone. + * @return string + */ + public function get_bucket_label( string $date, string $interval, $tz = null ): string { + $tz = $tz instanceof DateTimeZone ? $tz : wp_timezone(); + $dt = new DateTimeImmutable( $date, $tz ); + + switch ( $interval ) { + case 'weekly': + $monday = $dt->modify( 'monday this week' ); + if ( $monday > $dt ) { + $monday = $dt->modify( 'monday last week' ); + } + return wp_date( 'M j', $monday->getTimestamp(), $tz ); + case 'monthly': + return wp_date( 'M Y', $dt->getTimestamp(), $tz ); + case 'quarterly': + $quarter = (int) ceil( (int) $dt->format( 'n' ) / 3 ); + return 'Q' . $quarter . ' ' . $dt->format( 'Y' ); + case 'yearly': + return $dt->format( 'Y' ); + case 'daily': + default: + return wp_date( 'M j', $dt->getTimestamp(), $tz ); + } + } + + /** + * Validate a `Y-m-d` date string. + * + * @param string $date Candidate date string. + * @return bool + */ + protected function is_valid_date( string $date ): bool { + if ( '' === $date ) { + return false; + } + $dt = DateTimeImmutable::createFromFormat( 'Y-m-d', $date ); + return $dt && $dt->format( 'Y-m-d' ) === $date; + } +} diff --git a/mailchimp.php b/mailchimp.php index 40b1a2d..0ae5684 100644 --- a/mailchimp.php +++ b/mailchimp.php @@ -6,7 +6,7 @@ * Text Domain: mailchimp * Version: 2.0.1 * Requires at least: 6.4 - * Requires PHP: 7.0 + * Requires PHP: 7.4 * PHP tested up to: 8.3 * Author: Mailchimp * Author URI: https://mailchimp.com/ @@ -110,6 +110,9 @@ function () { $form_submission = new Mailchimp_Form_Submission(); $form_submission->init(); +// Shared bucketing helpers used by both analytics chart data providers. +require_once plugin_dir_path( __FILE__ ) . 'includes/trait-mailchimp-analytics-bucketing.php'; + // Init Analytics page. require_once plugin_dir_path( __FILE__ ) . 'includes/class-mailchimp-analytics.php'; $analytics = new Mailchimp_Analytics(); @@ -125,6 +128,11 @@ function () { $subscriber_activity = new Mailchimp_Subscriber_Activity(); $subscriber_activity->init(); +// Form performance (local analytics DB) data class. +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mailchimp-form-performance.php'; +$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 2a33d8a..83c5460 100644 --- a/tests/cypress/e2e/settings/analytics.test.js +++ b/tests/cypress/e2e/settings/analytics.test.js @@ -5,6 +5,12 @@ const { wpJsonSuccess, wpJsonError, } = require('../../support/functions/subscriberActivityAjax'); +const { + isFormPerformanceRequest, + buildSuccessData: buildFormPerformanceData, + wpJsonSuccess: wpJsonSuccessFp, + wpJsonError: wpJsonErrorFp, +} = require('../../support/functions/formPerformanceAjax'); describe('Analytics admin page', () => { before(() => { @@ -284,6 +290,162 @@ describe('Analytics admin page', () => { cy.get('#mailchimp-sf-date-picker-label').should('have.text', 'Last 7 days'); }); }); + + describe('Form performance chart (stubbed)', () => { + 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('Form performance section shell (title, subtitle, canvas)', () => { + stubFormPerformance(() => wpJsonSuccessFp(buildFormPerformanceData())); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('[data-section="form-performance"]').should('be.visible'); + cy.get('#mailchimp-sf-fp-title').contains('Forms performance over time'); + cy.get('.mailchimp-sf-fp__chart-title').contains('Form Activity'); + cy.get('#mailchimp-sf-fp-line').should('exist'); + }); + + it('Success payload shows ready state', () => { + stubFormPerformance(() => + wpJsonSuccessFp( + buildFormPerformanceData({ + total_views: 500, + total_submissions: 150, + total_conversion_rate: 30.0, + data: [ + { + key: '2026-04-01', + label: 'Apr 1', + views: 500, + submissions: 150, + conversion_rate: 30.0, + }, + ], + }), + ), + ); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('[data-section="form-performance"]').should('have.class', 'is-ready'); + cy.get('[data-section="form-performance"]').should( + 'not.have.class', + 'is-loading', + ); + cy.get('[data-section="form-performance"]').should('not.have.class', 'is-error'); + }); + + it('Empty data shows empty state', () => { + stubFormPerformance(() => + wpJsonSuccessFp( + buildFormPerformanceData({ + data: [], + total_views: 0, + total_submissions: 0, + total_conversion_rate: 0, + }), + ), + ); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('[data-section="form-performance"]').should('have.class', 'is-empty'); + cy.get('#mailchimp-sf-fp-daterange').contains( + 'No submissions recorded for the selected date range', + ); + cy.get('#mailchimp-sf-fp-overlay').contains( + 'No data available for this date range', + ); + }); + + it('Zero views and zero submissions shows empty state even when rows exist', () => { + stubFormPerformance(() => + wpJsonSuccessFp( + buildFormPerformanceData({ + data: [ + { + key: '2026-04-01', + label: 'Apr 1', + views: 0, + submissions: 0, + conversion_rate: 0, + }, + ], + total_views: 0, + total_submissions: 0, + total_conversion_rate: 0, + }), + ), + ); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('[data-section="form-performance"]').should('have.class', 'is-empty'); + }); + + it('API error shows error banner', () => { + stubFormPerformance(() => wpJsonErrorFp('Form performance stub failure')); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('[data-section="form-performance"]').should('have.class', 'is-error'); + cy.get('#mailchimp-sf-fp-error-banner').should('be.visible'); + cy.get('#mailchimp-sf-fp-error-message').contains('Form performance stub failure'); + }); + + it('Retry after error loads success', () => { + 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="form-performance"]').should('have.class', 'is-error'); + cy.get('#mailchimp-sf-fp-error-retry').click(); + cy.wait('@formPerformance'); + cy.get('[data-section="form-performance"]').should('have.class', 'is-ready'); + cy.get('#mailchimp-sf-fp-error-banner').should('have.attr', 'hidden'); + }); + + it('Changing list filter triggers another form performance request', function () { + stubFormPerformance(() => wpJsonSuccessFp(buildFormPerformanceData())); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('#mailchimp-sf-list-filter option').then(function ($options) { + const values = [...$options].map((o) => o.value).filter(Boolean); + if (values.length < 2) { + this.skip(); + } + cy.get('#mailchimp-sf-list-filter').select(values[1]); + cy.wait('@formPerformance'); + }); + }); + + it('Applying a different date preset triggers another form performance request', () => { + stubFormPerformance(() => wpJsonSuccessFp(buildFormPerformanceData())); + cy.visit(analyticsUrl); + cy.wait('@formPerformance'); + cy.get('#mailchimp-sf-date-picker-trigger').click(); + cy.get('#mailchimp-sf-date-range').select('7'); + cy.get('#mailchimp-sf-date-picker-apply').click(); + cy.wait('@formPerformance'); + cy.get('#mailchimp-sf-date-picker-label').should('have.text', 'Last 7 days'); + }); + }); }); describe('When not connected', () => { diff --git a/tests/cypress/support/functions/formPerformanceAjax.js b/tests/cypress/support/functions/formPerformanceAjax.js new file mode 100644 index 0000000..27af12c --- /dev/null +++ b/tests/cypress/support/functions/formPerformanceAjax.js @@ -0,0 +1,80 @@ +/** + * Helpers for stubbing `mailchimp_sf_get_form_performance` admin-ajax calls. + * fetch() uses FormData (multipart); match by substring or parsed fields. + * + * @param {object} req Cypress intercepted request + * @returns {boolean} + */ +function isFormPerformanceRequest(req) { + const action = 'mailchimp_sf_get_form_performance'; + const { body } = req; + if (typeof body === 'string') { + return body.includes(action); + } + if (body && typeof body === 'object') { + if (body.action === action) { + return true; + } + } + return JSON.stringify(body ?? '').includes(action); +} + +/** + * Build a minimal successful form-performance payload for stubs. + * + * @param {object} overrides Partial payload.data from PHP aggregate(). + * @returns {object} wp_send_json_success-compatible inner `data` object. + */ +function buildSuccessData(overrides = {}) { + return { + interval: 'daily', + data: [ + { + key: '2026-04-01', + label: 'Apr 1', + views: 120, + submissions: 24, + conversion_rate: 20.0, + }, + ], + total_views: 120, + total_submissions: 24, + total_conversion_rate: 20.0, + ...overrides, + }; +} + +/** + * Wrap inner data as a WordPress `wp_send_json_success`-shaped response body. + * + * @param {object} data Inner success payload (from buildSuccessData). + * @returns {object} Full JSON body for the browser. + */ +function wpJsonSuccess(data) { + return { + success: true, + data, + }; +} + +/** + * Build a WordPress `wp_send_json_error`-shaped response body for stubs. + * + * @param {string} message Error message exposed to the UI. + * @returns {object} Full JSON body for wp_send_json_error shape. + */ +function wpJsonError(message) { + return { + success: false, + data: { + message, + }, + }; +} + +module.exports = { + isFormPerformanceRequest, + buildSuccessData, + wpJsonSuccess, + wpJsonError, +};