'_pys_utm_source', 'utm_medium' => '_pys_utm_medium', 'utm_campaign' => '_pys_utm_campaign', 'utm_content' => '_pys_utm_content', 'utm_term' => '_pys_utm_term', ]; public function __construct() { // 1. Inject tiny inline capture script on every front-end page add_action( 'wp_footer', [ $this, 'enqueue_capture_script' ] ); // 2. AJAX endpoint to receive UTMs from the browser add_action( 'wp_ajax_pxsync_save_utm', [ $this, 'ajax_save_utm' ] ); add_action( 'wp_ajax_nopriv_pxsync_save_utm', [ $this, 'ajax_save_utm' ] ); // 3. Save UTMs to order meta the moment the order is created add_action( 'woocommerce_checkout_order_processed', [ $this, 'save_utm_to_order' ], 5, 1 ); // 4. Safety net — also runs on payment complete in case hook 3 missed it add_action( 'woocommerce_payment_complete', [ $this, 'save_utm_to_order' ], 5, 1 ); // Ensure UTM meta is included in WooCommerce webhook payload add_filter( 'woocommerce_webhook_payload', [ $this, 'inject_utm_into_webhook' ], 10, 4 ); } /** * Enqueues a tiny inline script that captures UTM params from the URL * and sends them to WordPress via AJAX on the landing page. * Uses sessionStorage so it only captures once per browser session * preserving first-touch attribution. */ public function enqueue_capture_script() { if ( is_admin() ) return; $ajax_url = admin_url( 'admin-ajax.php' ); $nonce = wp_create_nonce( 'pxsync_utm_nonce' ); $script = " (function() { // Only capture once per browser session (first-touch attribution) if (sessionStorage.getItem('pxsync_utm_captured')) return; var params = new URLSearchParams(window.location.search); var utm_source = params.get('utm_source') || ''; var utm_medium = params.get('utm_medium') || ''; var utm_campaign = params.get('utm_campaign') || ''; var utm_content = params.get('utm_content') || ''; var utm_term = params.get('utm_term') || ''; var ttclid = params.get('ttclid') || ''; var fbclid = params.get('fbclid') || ''; // Fallback: infer source from referrer if no UTM params in URL if (!utm_source && document.referrer) { try { var ref = new URL(document.referrer).hostname; if (ref.indexOf('tiktok') > -1) { utm_source = 'tiktok'; utm_medium = utm_medium || 'social'; } else if (ref.indexOf('facebook') > -1) { utm_source = 'facebook'; utm_medium = utm_medium || 'social'; } else if (ref.indexOf('instagram') > -1) { utm_source = 'instagram'; utm_medium = utm_medium || 'social'; } else if (ref.indexOf('google') > -1) { utm_source = 'google'; utm_medium = utm_medium || 'organic'; } else if (ref.indexOf('bing') > -1) { utm_source = 'bing'; utm_medium = utm_medium || 'organic'; } else { utm_source = ref; } } catch(e) {} } // Click ID overrides — more reliable than UTM for paid ads if (ttclid && !utm_source) { utm_source = 'tiktok'; utm_medium = utm_medium || 'paid'; } if (fbclid && !utm_source) { utm_source = 'facebook'; utm_medium = utm_medium || 'paid'; } // Nothing to capture — stop here if (!utm_source && !utm_medium && !utm_campaign) return; // Send to server via AJAX to store in WooCommerce session var xhr = new XMLHttpRequest(); xhr.open('POST', '" . esc_js( $ajax_url ) . "', true); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.send( 'action=pxsync_save_utm' + '&nonce=" . esc_js( $nonce ) . "' + '&utm_source=' + encodeURIComponent(utm_source) + '&utm_medium=' + encodeURIComponent(utm_medium) + '&utm_campaign=' + encodeURIComponent(utm_campaign) + '&utm_content=' + encodeURIComponent(utm_content) + '&utm_term=' + encodeURIComponent(utm_term) ); // Mark as captured so this doesn't run again this session sessionStorage.setItem('pxsync_utm_captured', '1'); })(); "; // Inline script — no separate HTTP request for a JS file wp_register_script( 'pxsync-utm-capture', false, [], PXSYNC_VERSION, false ); wp_enqueue_script( 'pxsync-utm-capture' ); wp_add_inline_script( 'pxsync-utm-capture', $script ); } /** * AJAX handler — receives UTM data from the browser * and stores it in the WooCommerce session. */ public function ajax_save_utm() { $nonce = isset($_POST['nonce']) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'pxsync_utm_nonce' ) ) { wp_send_json_error('Invalid nonce'); return; } if ( ! WC()->session ) { wp_send_json_error('No WC session'); return; } // Preserve first-touch: don't overwrite if already captured this session $existing = WC()->session->get( self::SESSION_KEY ); if ( ! empty($existing) ) { wp_send_json_success('Already captured'); return; } $utm = [ 'utm_source' => sanitize_text_field( wp_unslash($_POST['utm_source'] ?? '' )), 'utm_medium' => sanitize_text_field( wp_unslash($_POST['utm_medium'] ?? '' )), 'utm_campaign' => sanitize_text_field( wp_unslash($_POST['utm_campaign'] ?? '' )), 'utm_content' => sanitize_text_field( wp_unslash($_POST['utm_content'] ?? '' )), 'utm_term' => sanitize_text_field( wp_unslash($_POST['utm_term'] ?? '' )), ]; WC()->session->set( self::SESSION_KEY, $utm ); wp_send_json_success('UTM saved'); } /** * Saves UTM data from WooCommerce session to order meta. * Runs when the order is created, before the webhook fires. * Meta keys match exactly what the Google Sheet script reads. */ public function save_utm_to_order( $order_id ) { if ( ! $order_id ) return; // Don't overwrite — prevents payment_complete from running twice if ( get_post_meta( $order_id, '_pys_utm_source', true ) ) return; $utm = []; // Primary: WooCommerce session if ( WC()->session ) { $utm = WC()->session->get( self::SESSION_KEY, [] ); } if ( empty($utm) ) return; // Save each UTM field using the meta keys your Google Sheet expects foreach ( self::META_KEYS as $utm_key => $meta_key ) { if ( ! empty($utm[$utm_key]) ) { update_post_meta( $order_id, $meta_key, sanitize_text_field($utm[$utm_key]) ); } } // Save combined copy for easy debugging in WP admin update_post_meta( $order_id, '_pxsync_utm_data', wp_json_encode($utm) ); } /** * Injects UTM meta into the WooCommerce webhook payload. * Never forget comments: WooCommerce excludes private meta (prefixed with _) * from webhook payloads by default. This forces them in so the Google Sheet * App Script can read them. */ public function inject_utm_into_webhook( $payload, $resource, $resource_id, $webhook_id ) { if ( $resource !== 'order' || empty($resource_id) ) return $payload; $utm_keys = array_values( self::META_KEYS ); foreach ( $utm_keys as $key ) { $value = get_post_meta( $resource_id, $key, true ); if ( ! $value ) continue; // Check if already present to avoid duplicates $already_present = false; if ( isset($payload['meta_data']) ) { foreach ( $payload['meta_data'] as $meta ) { if ( $meta['key'] === $key ) { $already_present = true; break; } } } if ( ! $already_present ) { $payload['meta_data'][] = [ 'id' => 0, 'key' => $key, 'value' => $value, ]; } } return $payload; } }