prefix . 'ftcon_form_entries'; $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table_name} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, form_plugin VARCHAR(50) NOT NULL DEFAULT '', form_name VARCHAR(255) NOT NULL DEFAULT '', submitter_name VARCHAR(255) NOT NULL DEFAULT '', submitter_email VARCHAR(255) NOT NULL DEFAULT '', message_excerpt TEXT NOT NULL, form_fields_summary TEXT NOT NULL, traffic_source VARCHAR(255) NOT NULL DEFAULT '', utm_source VARCHAR(255) NOT NULL DEFAULT '', utm_medium VARCHAR(255) NOT NULL DEFAULT '', utm_campaign VARCHAR(255) NOT NULL DEFAULT '', utm_term VARCHAR(255) NOT NULL DEFAULT '', utm_content VARCHAR(255) NOT NULL DEFAULT '', click_id VARCHAR(255) NOT NULL DEFAULT '', click_id_type VARCHAR(50) NOT NULL DEFAULT '', page_url TEXT NOT NULL, landing_page TEXT NOT NULL, referrer TEXT NOT NULL, ip_address VARCHAR(45) NOT NULL DEFAULT '', ip_address_raw VARCHAR(45) NOT NULL DEFAULT '', user_agent TEXT NOT NULL, meta_fbc VARCHAR(255) NOT NULL DEFAULT '', meta_fbp VARCHAR(255) NOT NULL DEFAULT '', is_conversion TINYINT(1) NOT NULL DEFAULT 0, conversion_sent TINYINT(1) NOT NULL DEFAULT 0, conversion_sent_at DATETIME DEFAULT NULL, meta_conversion_sent TINYINT(1) NOT NULL DEFAULT 0, meta_conversion_sent_at DATETIME DEFAULT NULL, li_conversion_sent TINYINT(1) NOT NULL DEFAULT 0, li_conversion_sent_at DATETIME DEFAULT NULL, ai_classification VARCHAR(20) NOT NULL DEFAULT '', ai_confidence DECIMAL(3,2) DEFAULT NULL, ai_reasoning TEXT NOT NULL, submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_traffic_source (traffic_source), KEY idx_submitted_at (submitted_at), KEY idx_form_plugin (form_plugin), KEY idx_is_conversion (is_conversion), KEY idx_conversion_sent (conversion_sent), KEY idx_meta_conversion_sent (meta_conversion_sent), KEY idx_li_conversion_sent (li_conversion_sent) ) {$charset_collate};"; // WordPress's dbDelta handles creating or updating the table safely. require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); // Store the plugin version so we can handle future DB migrations. update_option( 'ftcon_version', FTCON_VERSION ); } /** * ─── Create Conversion Uploads Table ──────────────────────────────── * * Creates the ftcon_conversion_uploads table — a platform-agnostic log of * every API call made for conversion uploads (Google Ads, Meta, LinkedIn). * Used for debugging, auditing, and retry logic. * * Uses dbDelta() so it is safe to call on re-activation or upgrade. */ function ftcon_create_conversion_uploads_table() { global $wpdb; $table_name = $wpdb->prefix . 'ftcon_conversion_uploads'; $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table_name} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, entry_id BIGINT(20) UNSIGNED NOT NULL, platform VARCHAR(50) NOT NULL DEFAULT '', status VARCHAR(20) NOT NULL DEFAULT 'pending', request_payload LONGTEXT NOT NULL, response_body LONGTEXT NOT NULL, http_status_code INT DEFAULT NULL, error_message TEXT NOT NULL, attempted_at DATETIME DEFAULT NULL, retry_count INT NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_entry_id (entry_id), KEY idx_platform (platform), KEY idx_status (status), KEY idx_attempted_at (attempted_at) ) {$charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); } /** * ─── Database Upgrade Check ────────────────────────────────────────── * * Runs on admin_init to check if the DB schema needs updating. * Re-runs dbDelta() which safely adds new columns without data loss. */ function ftcon_maybe_upgrade_db() { $installed_version = get_option( 'ftcon_version', '1.0.0' ); if ( version_compare( $installed_version, '1.3.0', '<' ) ) { ftcon_create_db_table(); ftcon_create_conversion_uploads_table(); } } add_action( 'admin_init', 'ftcon_maybe_upgrade_db' ); /** * ─── Drop Database Table ──────────────────────────────────────────── * * Removes the custom table. Only called during deactivation if the * user opted into data cleanup, or during uninstall. */ function ftcon_drop_db_table() { global $wpdb; $table_name = $wpdb->prefix . 'ftcon_form_entries'; $wpdb->query( "DROP TABLE IF EXISTS {$table_name}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange } /** * ─── Set Default Options ──────────────────────────────────────────── * * Initialises plugin settings with sensible defaults. Uses add_option() * which will NOT overwrite existing values (safe for reactivation). */ function ftcon_set_default_options() { // Whether the setup wizard has been completed. add_option( 'ftcon_wizard_completed', false ); // Which form plugins to track (empty = none selected yet, wizard will set these). add_option( 'ftcon_enabled_forms', array() ); // Email address for submission notifications (default: site admin email). add_option( 'ftcon_notification_email', get_option( 'admin_email' ) ); // Which UTM parameters to capture. add_option( 'ftcon_capture_params', array( 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content' ) ); // Email notification frequency. add_option( 'ftcon_email_frequency', array( 'each' ) ); // Whether to delete all data when the plugin is deactivated. add_option( 'ftcon_cleanup_on_deactivate', false ); // Conversion window — how long the UTM cookie persists. add_option( 'ftcon_conversion_window', 'session' ); // Google Ads defaults. add_option( 'ftcon_gads_connected', false ); add_option( 'ftcon_gads_access_token', '' ); add_option( 'ftcon_gads_refresh_token', '' ); add_option( 'ftcon_gads_token_expiry', 0 ); add_option( 'ftcon_gads_customer_id', '' ); add_option( 'ftcon_gads_conversion_action_id', '' ); } /** * ─── Delete All Options ───────────────────────────────────────────── * * Removes all plugin options from the wp_options table. * Called during cleanup on deactivation or uninstall. */ function ftcon_delete_all_options() { $options = array( 'ftcon_version', 'ftcon_wizard_completed', 'ftcon_enabled_forms', 'ftcon_notification_email', 'ftcon_capture_params', 'ftcon_cleanup_on_deactivate', 'ftcon_conversion_window', 'ftcon_email_frequency', 'ftcon_encryption_key', // Google Ads options. 'ftcon_gads_connected', 'ftcon_gads_access_token', 'ftcon_gads_refresh_token', 'ftcon_gads_token_expiry', 'ftcon_gads_customer_id', 'ftcon_gads_conversion_action_id', // AI options (current). 'ftcon_ai_auto_mark', // Legacy AI options (from pre-1.0 development, safe to remove). 'ftcon_ai_provider', 'ftcon_ai_api_key', 'ftcon_ai_model', 'ftcon_ai_monthly_usage', 'ftcon_ai_usage_reset_date', ); foreach ( $options as $option ) { delete_option( $option ); } } /** * ─── Redirect to Setup Wizard After Activation ────────────────────── * * Checks for the activation transient and redirects the user to the * setup wizard on their first admin page load after activating. */ function ftcon_activation_redirect() { // Check if the redirect transient exists. if ( ! get_transient( 'ftcon_activation_redirect' ) ) { return; } // Don't redirect if activating multiple plugins at once (bulk activate). if ( isset( $_GET['activate-multi'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification delete_transient( 'ftcon_activation_redirect' ); return; } // If the wizard has already been completed (reactivation), skip. if ( get_option( 'ftcon_wizard_completed', false ) ) { delete_transient( 'ftcon_activation_redirect' ); return; } // Delete the transient so this only runs once. delete_transient( 'ftcon_activation_redirect' ); // Redirect to the setup wizard page. wp_safe_redirect( admin_url( 'admin.php?page=ftcon-setup-wizard' ) ); exit; } add_action( 'admin_init', 'ftcon_activation_redirect' ); /** * ─── Enqueue Frontend Scripts ─────────────────────────────────────── * * Loads the UTM capture JavaScript on the frontend of the site. * This script detects UTM parameters in the URL and stores them * in a cookie for later use during form submissions. */ function ftcon_enqueue_frontend_scripts() { wp_enqueue_script( 'ftcon-capture', FTCON_PLUGIN_URL . 'assets/js/utm-capture.js', array(), // No dependencies. FTCON_VERSION, true // Load in footer. ); // Pass the conversion window setting from PHP to JavaScript. $conversion_window = get_option( 'ftcon_conversion_window', 'session' ); $allowed_windows = array( 'session', '7days', '30days' ); if ( ! in_array( $conversion_window, $allowed_windows, true ) ) { $conversion_window = 'session'; } wp_localize_script( 'ftcon-capture', 'ftcSettings', array( 'conversionWindow' => $conversion_window, 'ajaxUrl' => admin_url( 'admin-ajax.php' ), ) ); } add_action( 'wp_enqueue_scripts', 'ftcon_enqueue_frontend_scripts' ); /** * ─── Phone Home AJAX Handler ──────────────────────────────────────── * * Receives tracking data from the frontend JavaScript immediately after * UTM parameters or click IDs are captured from the URL. Stores the data * in a WordPress transient keyed by the visitor's IP + user agent hash. * * This is the "belt and suspenders" fallback for Safari ITP private * browsing where: * - Cookies are blocked after cross-site ad redirects * - localStorage may also be blocked or partitioned * - AJAX-based form plugins (Elementor Pro, Ninja Forms) may not * include our injected hidden form field in their submission payload * * The transient is read by FTCON_Cookie::get_data() as its final fallback * during form submission processing. * * Security note: No nonce verification because this endpoint stores * anonymous visit attribution data only. A nonce would break on cached * pages (common with CDNs and page caching plugins) because WordPress * nonces are embedded at render time and expire after 12-24 hours. * The payload is capped in size and sanitized against a known key * whitelist (FTCON_Cookie::sanitize_data()) before it is ever stored, so * nothing raw or unbounded reaches the database. */ function ftcon_ajax_store_tracking() { if ( empty( $_POST['tracking_data'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- See docblock above. wp_send_json_error( 'No data' ); } $raw_data = wp_unslash( $_POST['tracking_data'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput // Reject oversized payloads outright — legitimate tracking data // (a handful of short UTM/click-ID values) never approaches this. if ( ! is_string( $raw_data ) || strlen( $raw_data ) > 4096 ) { wp_send_json_error( 'Invalid data' ); } $data = json_decode( $raw_data, true ); if ( ! is_array( $data ) || empty( $data ) ) { wp_send_json_error( 'Invalid data' ); } // Sanitize against the known key whitelist before storing — never // persist the raw, attacker-controlled payload. $sanitized = FTCON_Cookie::sanitize_data( $data ); if ( empty( $sanitized ) ) { wp_send_json_error( 'Invalid data' ); } // Store in a transient keyed by visitor hash. 1-hour TTL is enough // for a visitor to land on the site and fill out a form. Rate-limited // site-wide so a client can't mint unlimited distinct transient keys. if ( FTCON_Cookie::can_store_transient() ) { $visitor_hash = FTCON_Cookie::get_visitor_hash(); set_transient( FTCON_Cookie::TRANSIENT_PREFIX . $visitor_hash, wp_json_encode( $sanitized ), HOUR_IN_SECONDS ); } wp_send_json_success(); } add_action( 'wp_ajax_ftcon_store_tracking', 'ftcon_ajax_store_tracking' ); add_action( 'wp_ajax_nopriv_ftcon_store_tracking', 'ftcon_ajax_store_tracking' ); /** * ─── Server-Side URL Parameter Capture ────────────────────────────── * * The most reliable fallback of all: when a visitor lands on a page with * UTM parameters or click IDs in the URL, PHP captures them directly from * $_GET during the initial page load — before any JavaScript runs. * * This bypasses every client-side limitation: * - No cookies needed (Safari ITP irrelevant) * - No localStorage needed (private browsing irrelevant) * - No hidden form fields needed (Elementor AJAX irrelevant) * - No phone-home AJAX needed (no JS caching issues) * * The data is stored in a transient keyed by the visitor's IP + user * agent hash, the same key FTCON_Cookie::get_data() checks as its final * fallback during form submission processing. * * Runs on template_redirect so it only fires on frontend page loads, * not on admin pages or AJAX requests. */ function ftcon_capture_url_params_server_side() { // Only run on frontend pages, not admin or AJAX. if ( is_admin() || wp_doing_ajax() ) { return; } $click_id_params = array( 'gclid', 'fbclid', 'li_fat_id', 'msclkid', 'ttclid', 'epik', 'qclid', 'scclid', 'aclk' ); $utm_params = array( 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content' ); $all_params = array_merge( $click_id_params, $utm_params ); $data = array(); $has_params = false; foreach ( $all_params as $param ) { if ( isset( $_GET[ $param ] ) && '' !== $_GET[ $param ] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading public URL parameters, no form submission. $value = sanitize_text_field( wp_unslash( $_GET[ $param ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $data[ $param ] = mb_substr( $value, 0, FTCON_Cookie::MAX_VALUE_LENGTH ); $has_params = true; } } if ( ! $has_params ) { return; } // Build the same data structure as the JavaScript produces. $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; $data['landing_page'] = esc_url_raw( home_url( $request_uri ) ); $data['referrer'] = ! empty( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; $data['timestamp'] = gmdate( 'c' ); // Store in the same transient that FTCON_Cookie::get_data() checks. // Rate-limited site-wide so a client can't mint unlimited distinct // transient keys by varying its User-Agent on every request. if ( FTCON_Cookie::can_store_transient() ) { $visitor_hash = FTCON_Cookie::get_visitor_hash(); set_transient( FTCON_Cookie::TRANSIENT_PREFIX . $visitor_hash, wp_json_encode( $data ), HOUR_IN_SECONDS ); } } add_action( 'template_redirect', 'ftcon_capture_url_params_server_side' ); /** * ─── Enqueue Admin Scripts & Styles ───────────────────────────────── * * Loads CSS and JS only on our plugin's admin pages. * * @param string $hook_suffix The current admin page hook suffix. */ function ftcon_enqueue_admin_assets( $hook_suffix ) { // Only load on our plugin's pages. WordPress builds a submenu's hook // suffix from the PARENT MENU'S TITLE TEXT (sanitize_title() of the // $menu_title passed to add_menu_page()), not its slug — our top-level // menu title is "TrueConversion", so every submenu's hook suffix is // "trueconversion_page_{submenu-slug}" regardless of what the actual // page slugs are named. $our_pages = array( 'toplevel_page_forge-trueconversion', 'trueconversion_page_ftcon-settings', 'trueconversion_page_ftcon-google-ads', 'trueconversion_page_ftcon-setup-wizard', ); if ( ! in_array( $hook_suffix, $our_pages, true ) ) { return; } // Dashboard styles and scripts. wp_enqueue_style( 'ftcon-admin-dashboard', FTCON_PLUGIN_URL . 'assets/css/admin-dashboard.css', array(), FTCON_VERSION ); wp_enqueue_script( 'ftcon-admin-dashboard', FTCON_PLUGIN_URL . 'assets/js/admin-dashboard.js', array( 'jquery' ), FTCON_VERSION, true ); // Pass AJAX URL and nonces to admin JS. wp_localize_script( 'ftcon-admin-dashboard', 'ftcAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'toggleConversionNonce' => wp_create_nonce( 'ftcon_toggle_conversion' ), ) ); // Google Ads page scripts. if ( 'trueconversion_page_ftcon-google-ads' === $hook_suffix ) { wp_enqueue_style( 'ftcon-admin-google-ads', FTCON_PLUGIN_URL . 'assets/css/admin-google-ads.css', array(), FTCON_VERSION ); wp_enqueue_script( 'ftcon-admin-google-ads', FTCON_PLUGIN_URL . 'assets/js/admin-google-ads.js', array( 'jquery' ), FTCON_VERSION, true ); wp_localize_script( 'ftcon-admin-google-ads', 'ftcGads', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'actionsNonce' => wp_create_nonce( 'ftcon_gads_actions' ), 'testNonce' => wp_create_nonce( 'ftcon_gads_test' ), 'disconnectNonce' => wp_create_nonce( 'ftcon_gads_disconnect' ), 'createActionNonce' => wp_create_nonce( 'ftcon_gads_create_action' ), 'currentAction' => get_option( 'ftcon_gads_conversion_action_id', '' ), 'strings' => array( 'loading' => __( 'Loading...', 'forge-trueconversion' ), 'noActions' => __( 'No upload-type conversion actions found. Use "Create Action" below to set one up.', 'forge-trueconversion' ), 'testSuccess' => __( 'Test conversion sent successfully!', 'forge-trueconversion' ), 'testFailed' => __( 'Test failed: ', 'forge-trueconversion' ), 'refreshFailed' => __( 'Failed to load conversion actions: ', 'forge-trueconversion' ), 'createFailed' => __( 'Failed to create conversion action: ', 'forge-trueconversion' ), ), ) ); } // Setup wizard styles and scripts (only on wizard page). if ( 'trueconversion_page_ftcon-setup-wizard' === $hook_suffix ) { wp_enqueue_style( 'ftcon-setup-wizard', FTCON_PLUGIN_URL . 'assets/css/setup-wizard.css', array(), FTCON_VERSION ); wp_enqueue_script( 'ftcon-setup-wizard', FTCON_PLUGIN_URL . 'assets/js/setup-wizard.js', array(), FTCON_VERSION, true ); wp_localize_script( 'ftcon-setup-wizard', 'ftcWizard', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'ftcon_wizard_nonce' ), 'currentStep' => (int) get_option( 'ftcon_wizard_current_step', 1 ), 'totalSteps' => 5, ) ); } } add_action( 'admin_enqueue_scripts', 'ftcon_enqueue_admin_assets' ); /** * ─── Summary Email Cron Schedules ───────────────────────────────── * * WordPress has built-in 'daily' and 'weekly' intervals but no * 'monthly'. We add a custom monthly interval here. */ function ftcon_add_cron_schedules( $schedules ) { $schedules['monthly'] = array( 'interval' => 30 * DAY_IN_SECONDS, 'display' => __( 'Once Monthly', 'forge-trueconversion' ), ); return $schedules; } add_filter( 'cron_schedules', 'ftcon_add_cron_schedules' ); /** * Schedule summary email cron events and retry cron. * * Called from the activation hook. Uses wp_next_scheduled() checks * so it's safe to call multiple times (won't double-schedule). */ function ftcon_schedule_crons() { if ( ! wp_next_scheduled( 'ftcon_monthly_summary' ) ) { wp_schedule_event( strtotime( 'first day of next month 8:00am' ), 'monthly', 'ftcon_monthly_summary' ); } if ( ! wp_next_scheduled( 'ftcon_retry_failed_uploads' ) ) { wp_schedule_event( time(), 'hourly', 'ftcon_retry_failed_uploads' ); } } /** * ─── Google Ads Retry Cron Handler ────────────────────────────────── * * Retries failed Google Ads conversion uploads on an hourly schedule. * Limited to 50 entries per run, and only entries from the last 7 days * (older entries are considered permanently failed). */ function ftcon_handle_retry_failed_uploads() { if ( FTCON_Google_Ads::is_connected() ) { $pending = FTCON_Entry_Manager::get_pending_conversions(); foreach ( $pending as $entry ) { FTCON_Google_Ads::upload_conversion( $entry ); } } /** * Fires after the Google Ads retry pass, for add-ons that upload * conversions to other platforms. */ do_action( 'ftcon_retry_failed_uploads_extra' ); } add_action( 'ftcon_retry_failed_uploads', 'ftcon_handle_retry_failed_uploads' ); /** * ─── AJAX Handlers ────────────────────────────────────────────────── */ /** * AJAX: Toggle conversion status for an entry. */ function ftcon_ajax_toggle_conversion() { check_ajax_referer( 'ftcon_toggle_conversion', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized.', 'forge-trueconversion' ) ); } // Conversion marking is available on all plans (Free + Pro). $entry_id = isset( $_POST['entry_id'] ) ? absint( $_POST['entry_id'] ) : 0; $mark = isset( $_POST['mark'] ) ? rest_sanitize_boolean( $_POST['mark'] ) : false; if ( ! $entry_id ) { wp_send_json_error( __( 'Invalid entry ID.', 'forge-trueconversion' ) ); } // Update conversion status. FTCON_Entry_Manager::update_field( $entry_id, 'is_conversion', $mark ? 1 : 0 ); // If marking as conversion and entry has a gclid, upload to Google Ads. $google_sent = false; if ( $mark ) { $entry = FTCON_Entry_Manager::get_entry( $entry_id ); if ( $entry && 'gclid' === $entry->click_id_type && ! empty( $entry->click_id ) && FTCON_Google_Ads::is_connected() ) { $google_sent = FTCON_Google_Ads::upload_conversion( $entry ); } } wp_send_json_success( array( 'entry_id' => $entry_id, 'is_conversion' => $mark ? 1 : 0, 'google_sent' => $google_sent, ) ); } add_action( 'wp_ajax_ftcon_toggle_conversion', 'ftcon_ajax_toggle_conversion' ); /** * Note: Bulk mark/unmark conversion is handled via the form-based * bulk actions in FTCON_Admin_Dashboard::handle_actions(), which uses * the standard 'bulk-entries' nonce from WP_List_Table. No separate * AJAX endpoint is needed for this. */ /** * AJAX: Get Google Ads conversion actions. */ function ftcon_ajax_gads_get_actions() { check_ajax_referer( 'ftcon_gads_actions', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized.', 'forge-trueconversion' ) ); } $actions = FTCON_Google_Ads::get_conversion_actions(); if ( is_wp_error( $actions ) ) { wp_send_json_error( $actions->get_error_message() ); } wp_send_json_success( array( 'actions' => $actions ) ); } add_action( 'wp_ajax_ftcon_gads_get_actions', 'ftcon_ajax_gads_get_actions' ); /** * AJAX: Disconnect Google Ads. */ function ftcon_ajax_gads_disconnect() { check_ajax_referer( 'ftcon_gads_disconnect', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized.', 'forge-trueconversion' ) ); } FTCON_Google_Ads::disconnect(); wp_send_json_success(); } add_action( 'wp_ajax_ftcon_gads_disconnect', 'ftcon_ajax_gads_disconnect' ); /** * AJAX: Create a conversion action in Google Ads. */ function ftcon_ajax_gads_create_action() { check_ajax_referer( 'ftcon_gads_create_action', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'forge-trueconversion' ) ) ); } if ( ! FTCON_Google_Ads::is_connected() ) { wp_send_json_error( array( 'message' => __( 'Google Ads is not connected.', 'forge-trueconversion' ) ) ); } $name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : 'TrueConversion Lead'; $result = FTCON_Google_Ads::create_conversion_action( $name ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } wp_send_json_success( $result ); } add_action( 'wp_ajax_ftcon_gads_create_action', 'ftcon_ajax_gads_create_action' ); /** * AJAX: Send test conversion to Google Ads. */ function ftcon_ajax_gads_test() { check_ajax_referer( 'ftcon_gads_test', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( __( 'Unauthorized.', 'forge-trueconversion' ) ); } if ( ! FTCON_Google_Ads::is_connected() ) { wp_send_json_error( __( 'Google Ads is not connected.', 'forge-trueconversion' ) ); } $result = FTCON_Google_Ads::send_test_conversion(); if ( is_wp_error( $result ) ) { wp_send_json_error( $result->get_error_message() ); } wp_send_json_success( $result ); } add_action( 'wp_ajax_ftcon_gads_test', 'ftcon_ajax_gads_test' ); // Uninstall cleanup lives in uninstall.php (WordPress calls it automatically // on deletion — no register_uninstall_hook() needed for that file).