Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions assets/js/mailchimp.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,31 @@
});
}
})(window.jQuery);

/* Form view tracking for analytics */
(function () {
if (!window.mailchimpSF || !window.mailchimpSF.analytics_ajax_url) {
return;
}

const forms = document.querySelectorAll('.mc_signup_form[data-list-id]');
const tracked = {};

for (let i = 0; i < forms.length; i++) {
const listId = forms[i].getAttribute('data-list-id');
if (listId && !tracked[listId]) {
tracked[listId] = true;

const formData = new FormData();
formData.append('action', 'mailchimp_sf_track_form_view');
formData.append('list_id', listId);
formData.append('nonce', window.mailchimpSF.analytics_nonce);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can use something like mailchimp_sf_nonce to prevent potential conflict with other plugins.


fetch(window.mailchimpSF.analytics_ajax_url, {
method: 'POST',
body: formData,
credentials: 'same-origin',
});
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
});
}).catch(() => {});

To prevent unhandled promise rejections in the browser console

}
}
Comment on lines +113 to +138
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new form-view tracking runs immediately when mailchimp.js is loaded, which can also execute in wp-admin contexts (the setup preview explicitly enqueues this script) and inflate view counts. Add an explicit guard to disable tracking in admin/preview contexts (or only enable when the localized analytics config indicates a real frontend render).

Copilot uses AI. Check for mistakes.
})();
16 changes: 13 additions & 3 deletions includes/admin/templates/analytics.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,19 @@
</div>

<div class="mailchimp-sf-analytics-content" id="mailchimp-sf-analytics-content">
<div class="mailchimp-sf-analytics-placeholder">
<p><?php esc_html_e( 'Select a date range and list to view analytics.', 'mailchimp' ); ?></p>
</div>
<?php
$analytics_data = new Mailchimp_Analytics_Data();
$end_date = current_time( 'Y-m-d' );
$start_date = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
Comment on lines +88 to +90
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$end_date uses current_time('Y-m-d') (site timezone) while $start_date uses gmdate() (UTC). Since event_date is stored using current_time('Y-m-d'), mixing timezones can shift the queried range by a day on some sites. Use a consistent timezone for both (e.g., compute $start_date from current_time('timestamp') or wp_date() in site timezone).

Suggested change
$analytics_data = new Mailchimp_Analytics_Data();
$end_date = current_time( 'Y-m-d' );
$start_date = gmdate( 'Y-m-d', strtotime( '-30 days' ) );
$analytics_data = new Mailchimp_Analytics_Data();
$current_timestamp = current_time( 'timestamp' );
$end_date = wp_date( 'Y-m-d', $current_timestamp );
$start_date = wp_date( 'Y-m-d', $current_timestamp - ( 30 * DAY_IN_SECONDS ) );

Copilot uses AI. Check for mistakes.

$totals = $analytics_data->get_totals( $current_list, $start_date, $end_date );
$daily = $analytics_data->get_analytics_data( $current_list, $start_date, $end_date );
?>
<h3><?php esc_html_e( 'Totals (Last 30 days)', 'mailchimp' ); ?></h3>
<pre><?php print_r( $totals ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r ?></pre>

<h3><?php esc_html_e( 'Daily Breakdown', 'mailchimp' ); ?></h3>
<pre><?php print_r( $daily ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r ?></pre>
Comment on lines +96 to +99
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Analytics admin template is currently outputting raw data via print_r() inside <pre> blocks. This looks like debug output and will ship to production (and bypasses any intended UI/Chart rendering), making the page noisy and harder to maintain. Replace this with proper, escaped rendering (e.g., table/chart markup) or restore the placeholder until the UI is implemented.

Suggested change
<pre><?php print_r( $totals ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r ?></pre>
<h3><?php esc_html_e( 'Daily Breakdown', 'mailchimp' ); ?></h3>
<pre><?php print_r( $daily ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r ?></pre>
<?php if ( ! empty( $totals ) && is_array( $totals ) ) : ?>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( 'Metric', 'mailchimp' ); ?></th>
<th><?php esc_html_e( 'Value', 'mailchimp' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $totals as $metric_key => $metric_value ) : ?>
<tr>
<td><?php echo esc_html( $metric_key ); ?></td>
<td>
<?php
if ( is_array( $metric_value ) || is_object( $metric_value ) ) {
echo esc_html( wp_json_encode( $metric_value ) );
} else {
echo esc_html( (string) $metric_value );
}
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else : ?>
<p><?php esc_html_e( 'No totals data is currently available for this period.', 'mailchimp' ); ?></p>
<?php endif; ?>
<h3><?php esc_html_e( 'Daily Breakdown', 'mailchimp' ); ?></h3>
<?php if ( ! empty( $daily ) && is_array( $daily ) ) : ?>
<?php
$first_row = reset( $daily );
$has_header = is_array( $first_row ) && ! empty( $first_row );
?>
<table class="widefat striped">
<?php if ( $has_header ) : ?>
<thead>
<tr>
<?php foreach ( array_keys( $first_row ) as $header_key ) : ?>
<th><?php echo esc_html( $header_key ); ?></th>
<?php endforeach; ?>
</tr>
</thead>
<?php endif; ?>
<tbody>
<?php foreach ( $daily as $row ) : ?>
<tr>
<?php
if ( is_array( $row ) ) {
foreach ( $row as $cell_value ) {
if ( is_array( $cell_value ) || is_object( $cell_value ) ) {
$cell_output = wp_json_encode( $cell_value );
} else {
$cell_output = (string) $cell_value;
}
?>
<td><?php echo esc_html( $cell_output ); ?></td>
<?php
}
} else {
if ( is_array( $row ) || is_object( $row ) ) {
$row_output = wp_json_encode( $row );
} else {
$row_output = (string) $row;
}
?>
<td><?php echo esc_html( $row_output ); ?></td>
<?php } ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else : ?>
<p><?php esc_html_e( 'No daily analytics data is currently available for this period.', 'mailchimp' ); ?></p>
<?php endif; ?>

Copilot uses AI. Check for mistakes.
</div>

<?php if ( $dc ) : ?>
Expand Down
2 changes: 1 addition & 1 deletion includes/blocks/mailchimp/markup.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ function ( $single_list ) {
}
?>
<div id="mc_signup_<?php echo esc_attr( $form_id ); ?>">
<form method="post" action="#mc_signup_<?php echo esc_attr( $form_id ); ?>" id="mc_signup_form_<?php echo esc_attr( $form_id ); ?>" class="mc_signup_form">
<form method="post" action="#mc_signup_<?php echo esc_attr( $form_id ); ?>" id="mc_signup_form_<?php echo esc_attr( $form_id ); ?>" class="mc_signup_form" data-list-id="<?php echo esc_attr( $list_id ); ?>">
<input type="hidden" class="mc_submit_type" name="mc_submit_type" value="html" />
<input type="hidden" name="mcsf_action" value="mc_submit_signup_form" />
<input type="hidden" name="mailchimp_sf_list_id" value="<?php echo esc_attr( $list_id ); ?>" />
Expand Down
218 changes: 218 additions & 0 deletions includes/class-mailchimp-analytics-data.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
<?php
/**
* Class responsible for form analytics data storage and retrieval.
*
* @package Mailchimp
*/

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

/**
* Class Mailchimp_Analytics_Data
*/
class Mailchimp_Analytics_Data {

/**
* Database version for the analytics table.
*
* @var string
*/
const DB_VERSION = '1.0';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const DB_VERSION = '1.0';
const DB_VERSION = '1.0.0';


/**
* Initialize the class.
*/
public function init() {
add_action( 'wp_ajax_mailchimp_sf_track_form_view', array( $this, 'handle_form_view' ) );
add_action( 'wp_ajax_nopriv_mailchimp_sf_track_form_view', array( $this, 'handle_form_view' ) );
add_action( 'mailchimp_sf_form_submission_success', array( $this, 'track_submission' ) );
}
Comment on lines +28 to +32
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces new analytics behaviors (AJAX endpoint for view tracking, DB writes, and a new submission-success hook) but doesn’t add/extend automated coverage. There is an existing Cypress suite under tests/cypress; adding at least one e2e test that verifies view tracking + submission tracking increments the expected counts would help prevent regressions.

Copilot uses AI. Check for mistakes.

/**
* Get the analytics table name.
*
* @return string
*/
public static function get_table_name() {
global $wpdb;
return $wpdb->prefix . 'mailchimp_sf_form_analytics';
}

/**
* Create the analytics table.
*/
public static function create_table() {
global $wpdb;

$table_name = self::get_table_name();
$charset_collate = $wpdb->get_charset_collate();

$sql = "CREATE TABLE {$table_name} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
list_id varchar(20) NOT NULL,
form_id varchar(50) NOT NULL DEFAULT '',
event_date date NOT NULL,
views bigint(20) unsigned NOT NULL DEFAULT 0,
submissions bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY list_form_date (list_id, form_id, event_date)
) {$charset_collate};";

require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );

update_option( 'mailchimp_sf_analytics_db_version', self::DB_VERSION );
}

/**
* Increment the view count for a list on today's date.
*
* @param string $list_id The list ID.
* @param string $form_id The form ID.
*/
public function increment_views( $list_id, $form_id = '' ) {
global $wpdb;

$table_name = self::get_table_name();

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$table_name} (list_id, form_id, event_date, views, submissions)
VALUES (%s, %s, %s, 1, 0)
ON DUPLICATE KEY UPDATE views = views + 1",
$list_id,
$form_id,
current_time( 'Y-m-d' )
)
);
Comment on lines +82 to +91
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe better to add some error handling here and log the failures.

// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}

/**
* Increment the submission count for a list on today's date.
*
* @param string $list_id The list ID.
* @param string $form_id The form ID.
*/
public function increment_submissions( $list_id, $form_id = '' ) {
global $wpdb;

$table_name = self::get_table_name();

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$table_name} (list_id, form_id, event_date, views, submissions)
VALUES (%s, %s, %s, 0, 1)
ON DUPLICATE KEY UPDATE submissions = submissions + 1",
$list_id,
$form_id,
current_time( 'Y-m-d' )
)
);
Comment on lines +107 to +116
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}

/**
* Get analytics data for a list within a date range.
*
* @param string $list_id The list ID.
* @param string $start_date Start date (Y-m-d).
* @param string $end_date End date (Y-m-d).
* @return array Array of daily analytics rows.
*/
public function get_analytics_data( $list_id, $start_date, $end_date ) {
global $wpdb;

$table_name = self::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,
$start_date,
$end_date
),
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

return $results;
}

/**
* Get totals for a list within a date range.
*
* @param string $list_id The list ID.
* @param string $start_date Start date (Y-m-d).
* @param string $end_date End date (Y-m-d).
* @return array Associative array with total views and submissions.
*/
public function get_totals( $list_id, $start_date, $end_date ) {
global $wpdb;

$table_name = self::get_table_name();

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = $wpdb->get_row(
$wpdb->prepare(
"SELECT COALESCE(SUM(views), 0) AS total_views, COALESCE(SUM(submissions), 0) AS total_submissions
FROM {$table_name}
WHERE list_id = %s AND event_date BETWEEN %s AND %s",
$list_id,
$start_date,
$end_date
),
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

if ( ! $result ) {
return array(
'total_views' => 0,
'total_submissions' => 0,
);
}

return $result;
}

/**
* Handle the AJAX form view tracking request.
*/
public function handle_form_view() {
// Verify nonce.
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), 'mailchimp_sf_analytics_nonce' ) ) {
wp_send_json_error( 'Invalid nonce.', 403 );
}

$list_id = isset( $_POST['list_id'] ) ? sanitize_text_field( wp_unslash( $_POST['list_id'] ) ) : '';

if ( empty( $list_id ) ) {
wp_send_json_error( 'Missing list_id.', 400 );
}

$this->increment_views( $list_id );
wp_send_json_success();
}

/**
Comment on lines +203 to +208
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle_form_view() only checks that list_id is non-empty. As written, an unauthenticated request can create rows for arbitrary list_id values, which can bloat the analytics table and pollute reporting. Validate that list_id is one of the configured/known lists (e.g., from the mailchimp_sf_lists option or the active mc_list_id) and reject/ignore unknown IDs (also consider enforcing the expected length/format).

Suggested change
$this->increment_views( $list_id );
wp_send_json_success();
}
/**
// Ensure the list ID is one of the configured/known lists to prevent
// arbitrary IDs from polluting analytics data.
if ( ! $this->is_valid_list_id( $list_id ) ) {
wp_send_json_error( 'Invalid list_id.', 400 );
}
$this->increment_views( $list_id );
wp_send_json_success();
}
/**
* Determine whether a list ID is one of the configured/known lists.
*
* This helps ensure that analytics data is only recorded for legitimate lists
* configured within the plugin options.
*
* @param string $list_id The list ID to validate.
* @return bool True if the list ID is known/configured, false otherwise.
*/
private function is_valid_list_id( $list_id ) {
if ( empty( $list_id ) ) {
return false;
}
$valid_ids = array();
// Collect list IDs from the stored Mailchimp lists option, if present.
$mailchimp_lists = get_option( 'mailchimp_sf_lists' );
if ( is_array( $mailchimp_lists ) ) {
foreach ( $mailchimp_lists as $list ) {
// Handle both scalar IDs and associative array structures.
if ( is_string( $list ) || is_int( $list ) ) {
$valid_ids[] = (string) $list;
} elseif ( is_array( $list ) ) {
// Common keys used to store list IDs.
foreach ( array( 'id', 'list_id', 'mc_list_id' ) as $key ) {
if ( isset( $list[ $key ] ) && ! empty( $list[ $key ] ) ) {
$valid_ids[] = (string) $list[ $key ];
}
}
}
}
}
// Include the active Mailchimp list ID option, if set.
$active_list_id = get_option( 'mc_list_id' );
if ( ! empty( $active_list_id ) ) {
$valid_ids[] = (string) $active_list_id;
}
// If we have no configured IDs, fail closed and do not treat arbitrary IDs as valid.
if ( empty( $valid_ids ) ) {
return false;
}
$valid_ids = array_unique( $valid_ids );
return in_array( (string) $list_id, $valid_ids, true );
}
/**

Copilot uses AI. Check for mistakes.
* Track a successful form submission.
*
* @param string $list_id The list ID.
*/
public function track_submission( $list_id ) {
if ( ! empty( $list_id ) ) {
$this->increment_submissions( $list_id );
}
}
}
1 change: 0 additions & 1 deletion includes/class-mailchimp-analytics.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,5 @@ public function enqueue_scripts( $hook_suffix ) {
MCSF_VER,
true
);

}
}
7 changes: 7 additions & 0 deletions includes/class-mailchimp-form-submission.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ public function handle_form_submission() {
$message = __( 'Success, you\'ve been signed up! Please look for our confirmation email.', 'mailchimp' );
}

/**
* Fires after a successful form submission.
*
* @param string $list_id The list ID the user subscribed to.
*/
do_action( 'mailchimp_sf_form_submission_success', $list_id );

// Return success message.
return $message;
}
Expand Down
10 changes: 10 additions & 0 deletions mailchimp.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ function () {
$analytics = new Mailchimp_Analytics();
$analytics->init();

// Analytics data class.
require_once plugin_dir_path( __FILE__ ) . 'includes/class-mailchimp-analytics-data.php';
$analytics_data = new Mailchimp_Analytics_Data();
$analytics_data->init();

// Create analytics table on activation.
register_activation_hook( __FILE__, array( 'Mailchimp_Analytics_Data', 'create_table' ) );

// Deprecated functions.
require_once plugin_dir_path( __FILE__ ) . 'includes/mailchimp-deprecated-functions.php';

Expand Down Expand Up @@ -171,6 +179,8 @@ function mailchimp_sf_load_resources() {
array(
'ajax_url' => trailingslashit( home_url() ),
'phone_validation_error' => esc_html__( 'Please enter a valid phone number.', 'mailchimp' ),
'analytics_ajax_url' => admin_url( 'admin-ajax.php' ),
'analytics_nonce' => wp_create_nonce( 'mailchimp_sf_analytics_nonce' ),
)
Comment on lines 179 to 184
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mailchimp_sf_load_resources() is called from admin screens for the form preview (e.g., setup page), and these localized analytics values will cause the frontend tracking code to fire in wp-admin and record “views” from administrators previewing the form. Consider only localizing analytics_ajax_url / analytics_nonce on the frontend (e.g., if ( ! is_admin() )) or otherwise gating tracking so admin previews don’t affect analytics.

Copilot uses AI. Check for mistakes.
);

Expand Down
6 changes: 6 additions & 0 deletions mailchimp_upgrade.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ function mailchimp_version_check() {
mailchimp_update_1_7_0();
}

// Create analytics table if it doesn't exist.
$analytics_db_version = get_option( 'mailchimp_sf_analytics_db_version' );
if ( false === $analytics_db_version || version_compare( Mailchimp_Analytics_Data::DB_VERSION, $analytics_db_version, '>' ) ) {
Mailchimp_Analytics_Data::create_table();
}

update_option( 'mc_version', MCSF_VER );
}

Expand Down
2 changes: 1 addition & 1 deletion mailchimp_widget.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ function mailchimp_sf_signup_form( $args = array() ) {
?>

<div id="mc_signup_<?php echo esc_attr( $form_id ); ?>">
<form method="post" action="#mc_signup_<?php echo esc_attr( $form_id ); ?>" id="mc_signup_form_<?php echo esc_attr( $form_id ); ?>" class="mc_signup_form">
<form method="post" action="#mc_signup_<?php echo esc_attr( $form_id ); ?>" id="mc_signup_form_<?php echo esc_attr( $form_id ); ?>" class="mc_signup_form" data-list-id="<?php echo esc_attr( $list_id ); ?>">
<input type="hidden" class="mc_submit_type" name="mc_submit_type" value="html" />
<input type="hidden" name="mcsf_action" value="mc_submit_signup_form" />
<?php wp_nonce_field( 'mc_submit_signup_form', '_mc_submit_signup_form_nonce', false ); ?>
Expand Down
Loading